diff --git a/.agents/skills/llmobs-integration/SKILL.md b/.agents/skills/llmobs-integration/SKILL.md index e7b684f871..78b628f8a6 100644 --- a/.agents/skills/llmobs-integration/SKILL.md +++ b/.agents/skills/llmobs-integration/SKILL.md @@ -1,65 +1,31 @@ --- name: llmobs-integration description: | - This skill should be used when the user asks to "add LLMObs support", "create an LLMObs plugin", - "instrument an LLM library", "add LLM Observability", "add llmobs", "add llm observability", - "instrument chat completions", "instrument streaming", "instrument embeddings", - "instrument agent runs", "instrument orchestration", "instrument LLM", - "LLMObsPlugin", "LlmObsPlugin", "getLLMObsSpanRegisterOptions", "setLLMObsTags", - "tagLLMIO", "tagEmbeddingIO", "tagRetrievalIO", "tagTextIO", "tagMetrics", "tagMetadata", - "tagSpanTags", "tagPrompt", "LlmObsCategory", "LlmObsSpanKind", - "span kind llm", "span kind workflow", "span kind agent", "span kind embedding", - "span kind tool", "span kind retrieval", - "openai llmobs", "anthropic llmobs", "genai llmobs", "google llmobs", - "langchain llmobs", "langgraph llmobs", "ai-sdk llmobs", - "llm span", "llmobs span event", "model provider", "model name", - "CompositePlugin llmobs", "llmobs tracing", "VCR cassettes", - or needs to build, modify, or debug an LLMObs plugin for any LLM library in dd-trace-js. + Use when adding, debugging, or modifying LLMObs plugins for an LLM library + in dd-trace-js. Triggers: "add LLMObs support", "instrument chat + completions / streaming / embeddings / agent runs / orchestration / tool + calls / retrieval", "LLMObsPlugin", "getLLMObsSpanRegisterOptions", + "setLLMObsTags", "LlmObsCategory", "LlmObsSpanKind", any provider tag + ("openai" / "anthropic" / "genai" / "google" / "langchain" / "langgraph" / + "ai-sdk" llmobs), "VCR cassettes". --- # LLM Observability Integration Skill -## Purpose - -This skill helps you create LLMObs plugins that instrument LLM library operations and emit proper span events for LLM observability in dd-trace-js. Supported operation types include: - -- **Chat completions** — standard request/response LLM calls -- **Streaming chat completions** — streamed token-by-token responses -- **Embeddings** — vector embedding generation -- **Agent runs** — autonomous LLM agent execution loops -- **Orchestration** — multi-step workflow and graph execution (langgraph, etc.) -- **Tool calls** — tool/function invocations -- **Retrieval** — vector DB / RAG operations - -## When to Use - -- Creating a new LLMObs plugin for an LLM library -- Adding LLMObs support to an existing tracing integration -- Understanding LLMObsPlugin architecture and patterns -- Determining how to instrument a new LLM package +This skill covers creating LLMObs plugins that instrument LLM library operations and emit span events. Supported operations: chat completions (streaming and non-streaming), embeddings, agent runs, orchestration (workflows / graphs), tool calls, retrieval (RAG / vector DB). ## Core Concepts ### 1. LLMObsPlugin Base Class -All LLMObs plugins extend the `LLMObsPlugin` base class, which provides the core instrumentation framework. - -**Key responsibilities:** -- **Span registration**: Define span metadata (model provider, model name, span kind) -- **Tag extraction**: Extract and tag LLM-specific data (messages, metrics, metadata) -- **Context management**: Handle span lifecycle and parent context +All LLMObs plugins extend `LLMObsPlugin`. Two methods must be implemented: -**Required methods to implement:** -- `getLLMObsSpanRegisterOptions(ctx)` - Returns span registration options (modelProvider, modelName, kind, name) -- `setLLMObsTags(ctx)` - Extracts and tags LLM data (input/output messages, metrics, metadata) +- `getLLMObsSpanRegisterOptions(ctx)` — returns `{ modelProvider, modelName, kind, name }`. +- `setLLMObsTags(ctx)` — extracts and tags input / output messages, token metrics, and model metadata. -**Plugin lifecycle:** -1. `start(ctx)` - Registers span with LLMObs, captures context -2. Operation executes (chat completion call) -3. `asyncEnd(ctx)` - Calls `setLLMObsTags()` to extract and tag data -4. `end(ctx)` - Restores parent context +Lifecycle: `start(ctx)` registers the span and captures context; the wrapped operation runs; `asyncEnd(ctx)` calls `setLLMObsTags()`; `end(ctx)` restores the parent. -See [references/plugin-architecture.md](references/plugin-architecture.md) for complete implementation details. +See [references/plugin-architecture.md](references/plugin-architecture.md) for the full implementation surface. ### 2. Package Category System @@ -166,15 +132,6 @@ See [references/message-extraction.md](references/message-extraction.md) for pro See [references/plugin-architecture.md](references/plugin-architecture.md) for step-by-step implementation guide. -## Common Patterns - -Based on category: - -- **LLM_CLIENT**: Messages in array, straightforward extraction from `result.choices[0]` or equivalent -- **MULTI_PROVIDER**: Handle multiple provider formats with provider detection logic -- **ORCHESTRATION**: May use `'workflow'` span kind instead of `'llm'`, focus on lifecycle events -- **INFRASTRUCTURE**: Protocol-specific instrumentation, may not have traditional messages - ## Plugin Registration All plugins must export an array: @@ -192,12 +149,3 @@ For detailed information, see: - [references/category-detection.md](references/category-detection.md) - Package classification heuristics and detection process - [references/message-extraction.md](references/message-extraction.md) - Provider-specific message format patterns - [references/reference-implementations.md](references/reference-implementations.md) - Working plugin examples (Anthropic, Google GenAI) - -## Key Principles - -1. **Category determines approach** - Always detect category first using decision tree -2. **Use enum values** - Reference `LlmObsCategory` and `LlmObsSpanKind` enums from models -3. **Standard message format** - Always convert to `[{content, role}]` format -4. **Complete metadata** - Extract all available model parameters and token metrics -5. **Error handling** - Handle failures gracefully (empty messages on error) -6. **Test strategy follows category** - VCR for clients, pure functions for orchestration diff --git a/.agents/skills/llmobs-testing/SKILL.md b/.agents/skills/llmobs-testing/SKILL.md index b2cc093815..f8034ea81e 100644 --- a/.agents/skills/llmobs-testing/SKILL.md +++ b/.agents/skills/llmobs-testing/SKILL.md @@ -1,56 +1,27 @@ --- name: llmobs-testing description: | - This skill should be used when the user asks to "write LLMObs tests", "add tests for LLM Observability", - "test an LLMObs plugin", "llmobs test", "llmobs spec", "test llm observability", - "assertLlmObsSpanEvent", "useLlmObs", "getEvents", - "MOCK_STRING", "MOCK_NOT_NULLISH", "MOCK_NUMBER", "MOCK_OBJECT", - "VCR cassette", "record cassette", "replay cassette", "vcr proxy", "llmobs cassette", - "test chat completions", "test streaming", "test embeddings", "test agent runs", - "test orchestration", "test workflow", "llmobs span event", - "LLMObs test strategy", "LlmObsCategory test", - "LLM_CLIENT test", "MULTI_PROVIDER test", "ORCHESTRATION test", "INFRASTRUCTURE test", - "span kind llm test", "span kind workflow test", - "inputMessages", "outputMessages", "token metrics", "llmobs span validation", - "cassette not generated", "re-record cassette", "127.0.0.1:9126", - or needs to write, modify, or debug tests for any LLMObs plugin in dd-trace-js. + Use when writing, modifying, or debugging tests for an LLMObs plugin in + dd-trace-js. Triggers: "write LLMObs tests", "test an LLMObs plugin", + "assertLlmObsSpanEvent", "useLlmObs", "getEvents", any MOCK_* matcher + ("MOCK_STRING" / "MOCK_NOT_NULLISH" / "MOCK_NUMBER" / "MOCK_OBJECT"), + "VCR cassette", "vcr proxy", "127.0.0.1:9126", any LlmObsCategory test + ("LLM_CLIENT" / "MULTI_PROVIDER" / "ORCHESTRATION" / "INFRASTRUCTURE"). --- # LLM Observability Testing Skill -## ⚠️ CRITICAL: Read This First ⚠️ +## Determine the package category first -**BEFORE writing any test, you MUST determine the package category.** +**Before writing any test, determine the package's `LlmObsCategory`.** Category picks the test strategy (VCR or not), the span kind, and the test structure. The wrong category produces tests that pass against the wrong contract — VCR cassettes for a workflow library produce empty recordings; pure-function tests for an HTTP-call wrapper miss the network surface entirely. -**The category determines EVERYTHING:** -- Whether to use VCR or not -- What spanKind to use -- What test structure to follow -- What examples to study +Quick check: -**IF YOU USE THE WRONG CATEGORY STRATEGY, THE TEST WILL FAIL.** +- Direct HTTP calls to an LLM provider? → `LLM_CLIENT` or `MULTI_PROVIDER` — VCR. +- Workflow / graph orchestration with state? → `ORCHESTRATION` — no VCR, pure functions, real LLM as the orchestration node. +- Protocol / server implementation? → `INFRASTRUCTURE` — mock server. -**Categories are defined in the `LlmObsCategory` enum.** - -**Quick check:** -- Does package make HTTP calls to LLM APIs? → `LLM_CLIENT` or `MULTI_PROVIDER` (use VCR) -- Does package orchestrate workflows/graphs? → `ORCHESTRATION` (NO VCR, pure functions) -- Does package implement protocols/servers? → `INFRASTRUCTURE` (mock servers) - -**See [references/category-strategies.md](references/category-strategies.md) for FORBIDDEN vs REQUIRED patterns per category.** - ---- - -## Purpose - -This skill helps you write comprehensive LLMObs tests that validate span events, messages, tokens, and metadata using category-appropriate strategies. - -## When to Use - -- Writing tests for a new LLMObs plugin (ALWAYS check category first) -- Understanding category-specific test strategies -- Learning VCR cassettes (for LLM_CLIENT/MULTI_PROVIDER only) -- Learning assertion patterns for LLMObs spans +See [references/category-strategies.md](references/category-strategies.md) for the FORBIDDEN-vs-REQUIRED matrix per category. ## Core Testing Concepts @@ -98,52 +69,13 @@ See [references/vcr-cassettes.md](references/vcr-cassettes.md) for recording pro ### 3. Category-Specific Test Strategies -Test strategy is determined by the `LlmObsCategory` enum. - -#### LlmObsCategory.LLM_CLIENT & LlmObsCategory.MULTI_PROVIDER - -**Strategy:** VCR with real API calls via proxy - -**Characteristics:** -- Use VCR proxy baseURL -- Record cassettes with real API keys -- Tests make actual HTTP calls (recorded once) -- Validate LLM-specific data (messages, tokens, model info) - -**Span kind:** Usually `'llm'` for chat completions +The category-determination block at the top maps category to strategy. Non-obvious bits per category: -See [references/category-strategies.md](references/category-strategies.md) for detailed patterns. +- **LLM_CLIENT / MULTI_PROVIDER**: VCR proxy baseURL is `http://127.0.0.1:9126/vcr/{provider}`. Span kind: `'llm'`. Cassettes record once with real API keys; CI replays them. +- **ORCHESTRATION**: Span kind: `'workflow'` or `'agent'`, never `'llm'`. No VCR, no real API calls — the orchestrator itself doesn't make HTTP calls, it coordinates libraries that do. Mock LLM responses as plain return values from the node so the test exercises the workflow execution, not the provider API. +- **INFRASTRUCTURE**: Mock server, protocol-specific validation, no VCR. -#### LlmObsCategory.ORCHESTRATION - -**Strategy:** Pure function tests, NO VCR, NO real API calls - -**Characteristics:** -- NO VCR cassettes -- NO HTTP calls to LLM providers -- Use library's native APIs with mock/test LLM responses -- Focus on workflow lifecycle, not API calls -- **CRITICAL:** Still test with actual LLM as orchestration node (not mocked completely) - -**Span kind:** Usually `'workflow'` or `'agent'`, NOT `'llm'` - -**Example concept:** -- LangGraph invokes nodes that call LLMs -- LangGraph itself doesn't make HTTP calls -- Test LangGraph's workflow execution, not the underlying LLM API - -See [references/category-strategies.md](references/category-strategies.md) for orchestration test patterns. - -#### LlmObsCategory.INFRASTRUCTURE - -**Strategy:** Mock server tests - -**Characteristics:** -- Mock server implementation -- Protocol-specific validation -- NO VCR - -See [references/category-strategies.md](references/category-strategies.md) for infrastructure test patterns. +See [references/category-strategies.md](references/category-strategies.md) for per-category patterns. ### 4. Assertion Patterns @@ -221,38 +153,6 @@ On errors, validate: - Error object exists: `error: MOCK_OBJECT` - Span still created (not dropped) -## Common Patterns by Category - -### LLM_CLIENT / MULTI_PROVIDER Pattern -- Use VCR proxy baseURL -- Test chat completions with various parameters -- Validate real API response structure -- Test streaming (if supported) -- Test error responses - -### ORCHESTRATION Pattern -- NO VCR -- Test workflow lifecycle methods (invoke, stream, run) -- Use mock LLM responses within workflow -- Focus on workflow span, not LLM spans -- Validate workflow-specific metadata (state, nodes, edges) - -### INFRASTRUCTURE Pattern -- Mock server setup -- Protocol-specific validation -- Connection/transport testing - -## Best Practices - -1. **Use MOCK_* for non-deterministic values** - Output text, token counts, error objects -2. **Use exact values for inputs** - You control input messages and parameters -3. **Always validate spanKind** - Required for every span -4. **Match category to test strategy** - VCR for clients, pure functions for orchestration -5. **Test error paths** - Verify empty outputs and error objects on failures -6. **Group by method** - Organize tests by instrumented method -7. **Load modules fresh** - Use beforeEach() to avoid state leakage -8. **Cover edge cases** - Empty messages, missing metadata, streaming - ## References For detailed information, see: @@ -261,11 +161,3 @@ For detailed information, see: - [references/vcr-cassettes.md](references/vcr-cassettes.md) - VCR recording process, cassette management, troubleshooting - [references/assertion-helpers.md](references/assertion-helpers.md) - Complete assertLlmObsSpanEvent API, matchers, patterns - [references/category-strategies.md](references/category-strategies.md) - Detailed test strategies for each LlmObsCategory - -## Key Principles - -1. **Category determines strategy** - Always check `LlmObsCategory` to pick test approach -2. **Orchestrators don't use VCR** - They don't make direct API calls -3. **Use matchers for variance** - Real API responses vary, use MOCK_* matchers -4. **Validate message format** - Always check `{content, role}` structure -5. **Test with real behavior** - For orchestrators, use actual LLM as node (not fully mocked) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 43dccd9ab1..fe011fc7c8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -201,6 +201,7 @@ /packages/dd-trace/src/remote_config/ @DataDog/apm-sdk-capabilities-js /packages/dd-trace/test/remote_config/ @DataDog/apm-sdk-capabilities-js /packages/dd-trace/src/baggage.js @DataDog/apm-sdk-capabilities-js +/packages/dd-trace/test/baggage.spec.js @DataDog/apm-sdk-capabilities-js /packages/dd-trace/src/sampler.js @DataDog/apm-sdk-capabilities-js /packages/dd-trace/test/sampler.spec.js @DataDog/apm-sdk-capabilities-js /packages/dd-trace/src/priority_sampler.js @DataDog/apm-sdk-capabilities-js @@ -238,6 +239,8 @@ /.github/actions/dd-sts-app-key/action.yml @Datadog/lang-platform-js /.github/actions/dd-sts-api-key/action.yml @Datadog/lang-platform-js /.github/actions/push_to_test_optimization/ @DataDog/ci-app-libraries +/.github/playwright/ @DataDog/ci-app-libraries +/.github/selenium/ @DataDog/ci-app-libraries /.github/actions/upload-node-reports/action.yml @Datadog/lang-platform-js /.github/chainguard @DataDog/sdlc-security /.github/codeql_config.yml @DataDog/sdlc-security @@ -259,6 +262,7 @@ /.github/workflows/serverless.yml @DataDog/serverless-aws @DataDog/apm-serverless /.github/workflows/llmobs.yml @DataDog/ml-observability /.github/workflows/openfeature.yml @DataDog/feature-flagging-and-experimentation-sdk +/.github/workflows/pr-title.yml @DataDog/lang-platform-js /.github/workflows/profiling.yml @DataDog/profiling-js /.github/workflows/system-tests.yml @DataDog/asm-js /.github/workflows/test-optimization.yml @DataDog/ci-app-libraries diff --git a/.github/actions/coverage/action.yml b/.github/actions/coverage/action.yml index b504f1302d..5ce4adcfc8 100644 --- a/.github/actions/coverage/action.yml +++ b/.github/actions/coverage/action.yml @@ -16,40 +16,21 @@ inputs: runs: using: composite steps: - - name: Verify coverage output - shell: bash - run: node scripts/verify-coverage.js --flags "${{ inputs.flags }}" - - # `master-coverage` is the flag .codecov.yml gates codecov/patch on. Attach - # it only on PRs targeting master so release-branch PRs auto-pass. - - name: Compute Codecov flags - id: codecov-flags - shell: bash - env: - JOB_FLAGS: ${{ inputs.flags }} - EVENT_NAME: ${{ github.event_name }} - BASE_REF: ${{ github.base_ref }} - run: | - flags="$JOB_FLAGS" - if [ "$EVENT_NAME" = "pull_request" ] && [ "$BASE_REF" = "master" ]; then - flags="${flags:+$flags,}master-coverage" - fi - echo "value=$flags" >> "$GITHUB_OUTPUT" - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 - with: - flags: ${{ steps.codecov-flags.outputs.value }} - - - name: Install datadog-ci - if: always() - uses: ./.github/actions/datadog-ci - - - name: Upload coverage to Datadog - if: always() + # Retry once on failure to work around transient issues (e.g. flaky + # Codecov upload network calls). + - id: attempt + uses: ./.github/actions/coverage/upload continue-on-error: true + with: + flags: ${{ inputs.flags }} + report-dir: ${{ inputs.report-dir }} + dd_api_key: ${{ inputs.dd_api_key }} + - if: steps.attempt.outcome == 'failure' shell: bash - run: datadog-ci coverage upload ${FLAGS:+--flags "$FLAGS"} . - env: - DD_API_KEY: ${{ inputs.dd_api_key }} - FLAGS: ${{ inputs.flags }} + run: sleep 60 + - if: steps.attempt.outcome == 'failure' + uses: ./.github/actions/coverage/upload + with: + flags: ${{ inputs.flags }} + report-dir: ${{ inputs.report-dir }} + dd_api_key: ${{ inputs.dd_api_key }} diff --git a/.github/actions/coverage/upload/action.yml b/.github/actions/coverage/upload/action.yml new file mode 100644 index 0000000000..a64b75a384 --- /dev/null +++ b/.github/actions/coverage/upload/action.yml @@ -0,0 +1,60 @@ +name: Coverage Upload +description: Internal implementation; verify and upload coverage. Use `./.github/actions/coverage` instead. + +inputs: + flags: + description: "Codecov flags value" + required: false + report-dir: + description: "Coverage report directory (defaults to 'coverage')" + required: false + default: coverage + dd_api_key: + description: "Datadog API key for coverage upload" + required: true + +runs: + using: composite + steps: + - name: Verify coverage output + shell: bash + run: node scripts/verify-coverage.js --flags "${{ inputs.flags }}" + + # `master-coverage` is the flag .codecov.yml gates codecov/patch on. Attach + # it only on PRs targeting master so release-branch PRs auto-pass. + - name: Compute Codecov flags + id: codecov-flags + shell: bash + env: + JOB_FLAGS: ${{ inputs.flags }} + EVENT_NAME: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + run: | + flags="$JOB_FLAGS" + if [ "$EVENT_NAME" = "pull_request" ] && [ "$BASE_REF" = "master" ]; then + flags="${flags:+$flags,}master-coverage" + fi + echo "value=$flags" >> "$GITHUB_OUTPUT" + + - name: Install gpg for Codecov validation + if: runner.os == 'Linux' + shell: bash + run: command -v gpg || sudo apt-get install -y gpg + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + with: + flags: ${{ steps.codecov-flags.outputs.value }} + + - name: Install datadog-ci + if: always() + uses: ./.github/actions/datadog-ci + + - name: Upload coverage to Datadog + if: always() + continue-on-error: true + shell: bash + run: datadog-ci coverage upload ${FLAGS:+--flags "$FLAGS"} . + env: + DD_API_KEY: ${{ inputs.dd_api_key }} + FLAGS: ${{ inputs.flags }} diff --git a/.github/actions/datadog-ci/action.yml b/.github/actions/datadog-ci/action.yml index 528d18e7b2..13924ed071 100644 --- a/.github/actions/datadog-ci/action.yml +++ b/.github/actions/datadog-ci/action.yml @@ -4,17 +4,18 @@ description: Install @datadog/datadog-ci from npm and add it to PATH. runs: using: composite steps: - - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: ${{ github.workspace }}/.github/actions/datadog-ci/node_modules - key: datadog-ci-${{ hashFiles('.github/actions/datadog-ci/yarn.lock') }} - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '20' + - uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + max_attempts: 3 + timeout_minutes: 5 + retry_wait_seconds: 30 + command: | + cd ${{ github.workspace }}/.github/actions/datadog-ci + yarn install --frozen-lockfile + - shell: bash - run: | - yarn install --frozen-lockfile || (sleep 30 && yarn install --frozen-lockfile) - echo "$(pwd)/node_modules/.bin" >> $GITHUB_PATH - working-directory: ${{ github.workspace }}/.github/actions/datadog-ci + run: echo "${{ github.workspace }}/.github/actions/datadog-ci/node_modules/.bin" >> $GITHUB_PATH diff --git a/.github/actions/datadog-ci/package.json b/.github/actions/datadog-ci/package.json index 17aca88798..96b032effe 100644 --- a/.github/actions/datadog-ci/package.json +++ b/.github/actions/datadog-ci/package.json @@ -3,6 +3,6 @@ "version": "1.0.0", "private": true, "dependencies": { - "@datadog/datadog-ci": "5.16.0" + "@datadog/datadog-ci": "5.17.0" } } diff --git a/.github/actions/datadog-ci/yarn.lock b/.github/actions/datadog-ci/yarn.lock index 0a1c6bbdea..ded93d71b0 100644 --- a/.github/actions/datadog-ci/yarn.lock +++ b/.github/actions/datadog-ci/yarn.lock @@ -2,7 +2,7 @@ # yarn lockfile v1 -"@datadog/datadog-ci@5.16.0": - version "5.16.0" - resolved "https://registry.npmjs.org/@datadog/datadog-ci/-/datadog-ci-5.16.0.tgz#54c40e97ff0ba14d661beaec4f8efec1808b8bbe" - integrity sha512-0ykDASeq6cM9LAZibf/n8VxyVYLFGSCGmepuY0tLzcFHZuob2hszN++C8R91afW3cOTy/fKWHdDADFKv4qD1RQ== +"@datadog/datadog-ci@5.17.0": + version "5.17.0" + resolved "https://registry.npmjs.org/@datadog/datadog-ci/-/datadog-ci-5.17.0.tgz#88f68eff837d9988564592e0c52a859cd9de7836" + integrity sha512-Orwju9h/kLQnuNr7VoHW/JABbKsK8/Lhh8zDacjR1CqwJBmgwejgmcL3ut9/Vbi6hC5KYlgXybeeMpsdJEWvQQ== diff --git a/.github/actions/dd-sts-api-key/action.yml b/.github/actions/dd-sts-api-key/action.yml index 6957d1c7a1..504e9a60a7 100644 --- a/.github/actions/dd-sts-api-key/action.yml +++ b/.github/actions/dd-sts-api-key/action.yml @@ -4,13 +4,18 @@ description: Exchange GitHub OIDC token for a Datadog API key via dd-sts. outputs: api_key: description: "Datadog API key" - value: ${{ steps.dd-sts.outputs.api_key }} + value: ${{ steps.attempt.outputs.api_key || steps.retry.outputs.api_key }} runs: using: composite steps: - - name: Get Datadog API key - id: dd-sts - uses: DataDog/dd-sts-action@cf22dd37b6a6355fd2dcd4b7a1634ef5f837e6fc # v1.0.1 - with: - policy: dd-trace-js-api-key + # Retry once on failure to work around transient dd-sts exchange errors. + - id: attempt + uses: ./.github/actions/dd-sts-api-key/exchange + continue-on-error: true + - if: steps.attempt.outcome == 'failure' + shell: bash + run: sleep 60 + - if: steps.attempt.outcome == 'failure' + id: retry + uses: ./.github/actions/dd-sts-api-key/exchange diff --git a/.github/actions/dd-sts-api-key/exchange/action.yml b/.github/actions/dd-sts-api-key/exchange/action.yml new file mode 100644 index 0000000000..0ebe9ffbec --- /dev/null +++ b/.github/actions/dd-sts-api-key/exchange/action.yml @@ -0,0 +1,25 @@ +name: Get Datadog API key (exchange) +description: Internal implementation; exchange OIDC token for API key. Use `./.github/actions/dd-sts-api-key` instead. + +outputs: + api_key: + description: "Datadog API key" + value: ${{ steps.dd-sts.outputs.api_key }} + +runs: + using: composite + steps: + - name: Get Datadog API key + id: dd-sts + uses: DataDog/dd-sts-action@1f350ca511be980515cf08b0bd64182b9c6e5d32 # v1.0.3 + with: + policy: dd-trace-js-api-key + - name: Verify API key was returned + shell: bash + env: + API_KEY: ${{ steps.dd-sts.outputs.api_key }} + run: | + if [ -z "$API_KEY" ]; then + echo "dd-sts succeeded but returned no API key" + exit 1 + fi diff --git a/.github/actions/dd-sts-app-key/action.yml b/.github/actions/dd-sts-app-key/action.yml index 52e4329cae..918ea35e32 100644 --- a/.github/actions/dd-sts-app-key/action.yml +++ b/.github/actions/dd-sts-app-key/action.yml @@ -11,6 +11,6 @@ runs: steps: - name: Get Datadog App key id: dd-sts - uses: DataDog/dd-sts-action@cf22dd37b6a6355fd2dcd4b7a1634ef5f837e6fc # v1.0.1 + uses: DataDog/dd-sts-action@1f350ca511be980515cf08b0bd64182b9c6e5d32 # v1.0.3 with: policy: dd-trace-js diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 8d31ce9af8..4dbb54dd6d 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -3,9 +3,11 @@ description: Install dependencies runs: using: composite steps: - # Retry in case of server error from registry. - # Wait 60 seconds to give the registry server time to heal. - - run: bun install --linker=hoisted --trust --network-concurrency 8 || (sleep 60 && bun install --linker=hoisted --trust --network-concurrency 8) - shell: bash + - uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 env: _DD_IGNORE_ENGINES: 'true' + with: + max_attempts: 3 + timeout_minutes: 5 + retry_wait_seconds: 30 + command: bun install --linker=hoisted --trust --network-concurrency 8 diff --git a/.github/actions/instrumentations/test/action.yml b/.github/actions/instrumentations/test/action.yml index faf48e5cc0..6e3de5bf46 100644 --- a/.github/actions/instrumentations/test/action.yml +++ b/.github/actions/instrumentations/test/action.yml @@ -1,11 +1,19 @@ name: Instrumentation Tests description: Run instrumentation tests +inputs: + node-floor: + description: 'Lower Node alias: oldest-maintenance-lts or newest-maintenance-lts.' + required: false + default: oldest-maintenance-lts runs: using: composite steps: - uses: ./.github/actions/dd-sts-api-key id: dd-sts - - uses: ./.github/actions/node/oldest-maintenance-lts + - if: ${{ inputs.node-floor == 'oldest-maintenance-lts' }} + uses: ./.github/actions/node/oldest-maintenance-lts + - if: ${{ inputs.node-floor == 'newest-maintenance-lts' }} + uses: ./.github/actions/node/newest-maintenance-lts - uses: ./.github/actions/install - run: yarn test:instrumentations:ci shell: bash diff --git a/.github/actions/node/action.yml b/.github/actions/node/action.yml index 0fc3690169..7fd4acecc0 100644 --- a/.github/actions/node/action.yml +++ b/.github/actions/node/action.yml @@ -8,54 +8,17 @@ inputs: runs: using: composite steps: - # Resolve the version from the input alias so we can use it in cache keys. - - name: Resolve Node.js version - id: node-version - env: - NODE_VERSION: ${{ - inputs.version == 'eol' && '16' || - inputs.version == 'oldest' && '18' || - inputs.version == 'maintenance' && '20' || - inputs.version == 'active' && '22' || - inputs.version == 'latest' && (env.LATEST_VERSION || '24') || - inputs.version }} - shell: bash - run: echo "version=$NODE_VERSION" >> "$GITHUB_OUTPUT" - - # Cache a tiny file containing the exact Node.js version resolved by a previous run. - # Key rotates every 60 minutes (epoch / 3600), capping setup-node manifest API - # calls at one per (os, arch, major) per hour. On cache hit, install the cached - # patch directly and skip the manifest lookup. - - name: Compute cache key - id: cache-key - shell: bash - run: echo "block=$(( $(date -u +%s) / 3600 ))" >> "$GITHUB_OUTPUT" - - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - id: node-version-cache - with: - path: /tmp/.node-resolved-version-${{ steps.node-version.outputs.version }} - key: node-resolved-${{ runner.os }}-${{ runner.arch }}-v${{ steps.node-version.outputs.version }}-${{ steps.cache-key.outputs.block }} - - name: Read cached version - id: cached - shell: bash - run: | - if [ -f /tmp/.node-resolved-version-${{ steps.node-version.outputs.version }} ]; then - echo "version=$(cat /tmp/.node-resolved-version-${{ steps.node-version.outputs.version }})" >> "$GITHUB_OUTPUT" - fi - - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + # Retry once on failure to work around transient runner issues (e.g. flaky + # setup-node or setup-bun network calls). + - id: attempt + uses: ./.github/actions/node/setup + continue-on-error: true with: - # Cache hit installs the cached patch directly. Otherwise pass the major; the cached - # patch is a semver-exact spec, so check-latest would not upgrade it. - node-version: ${{ steps.cached.outputs.version || steps.node-version.outputs.version }} - check-latest: ${{ steps.cached.outputs.version == '' }} - registry-url: ${{ inputs.registry-url || 'https://registry.npmjs.org' }} - - # Persist the resolved version so subsequent runs within this 60-minute window can reuse it. - - name: Save resolved version - if: steps.node-version-cache.outputs.cache-hit != 'true' + version: ${{ inputs.version }} + - if: steps.attempt.outcome == 'failure' shell: bash - run: node -v | tr -d 'v' > /tmp/.node-resolved-version-${{ steps.node-version.outputs.version }} - - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + run: sleep 60 + - if: steps.attempt.outcome == 'failure' + uses: ./.github/actions/node/setup with: - bun-version: 1.3.1 + version: ${{ inputs.version }} diff --git a/.github/actions/node/setup/action.yml b/.github/actions/node/setup/action.yml new file mode 100644 index 0000000000..1f82d4b665 --- /dev/null +++ b/.github/actions/node/setup/action.yml @@ -0,0 +1,57 @@ +name: Node.js Setup +description: Internal implementation; install Node.js and Bun. Use `./.github/actions/node` instead. +inputs: + version: + description: "Version identifier of the version to use." + required: false + default: 'latest' +runs: + using: composite + steps: + # Resolve the version from the input alias. Exact patch versions come from + # packages/dd-trace/test/plugins/versions/package.json, kept up-to-date by + # Dependabot. Falls back to the bare major when the file is absent + # (e.g. sparse checkouts that only include .github/). + - name: Resolve Node.js version + id: node-version + env: + VERSION: ${{ inputs.version }} + LATEST_VERSION: ${{ env.LATEST_VERSION }} + shell: bash + run: | + node_version() { + local file="packages/dd-trace/test/plugins/versions/package.json" + [ -f "$file" ] && node -p \ + "require('./${file}').dependencies['node-${1}'].replace('npm:node@','')" \ + || echo "${1}" + } + case "$VERSION" in + eol) version=16 ;; + oldest) version=$(node_version 18) ;; + maintenance) version=$(node_version 20) ;; + active) version=$(node_version 24) ;; + latest) version=${LATEST_VERSION:-$(node_version 26)} ;; + *) version=$VERSION ;; + esac + echo "version=$version" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: ${{ steps.node-version.outputs.version }} + registry-url: ${{ inputs.registry-url || 'https://registry.npmjs.org' }} + + # Persist the resolved version so subsequent runs within this 60-minute window can reuse it. + - name: Save resolved version + if: steps.node-version-cache.outputs.cache-hit != 'true' + shell: bash + run: node -v | tr -d 'v' > "$RUNNER_TEMP/.node-resolved-version-${{ steps.node-version.outputs.version }}" + - name: Check Node.js version for Bun + id: bun-check + shell: bash + run: | + MAJOR=$(node -e "process.stdout.write(String(parseInt(process.versions.node)))") + echo "supported=$([ "$MAJOR" -ge 12 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + - name: Install Bun + if: steps.bun-check.outputs.supported == 'true' + shell: bash + run: npm install -g bun@1.3.1 --prefer-offline --no-audit --no-fund diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c39729bb90..37735a3eca 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -143,7 +143,9 @@ updates: - "/packages/dd-trace/test/plugins/versions" schedule: interval: "daily" - open-pull-requests-limit: 1 + # One slot per cohort group below plus headroom; with the cohort design a + # single blocked group otherwise stalls every other group. + open-pull-requests-limit: 10 cooldown: default-days: 3 exclude: @@ -157,7 +159,68 @@ updates: - dependency-name: "office-addin-mock" # Pinned to 2.x due to compatibility issues with newer major versions update-types: ["version-update:semver-major"] + # Cohort-based groups so a single breaking bump only blocks its own PR. groups: + ai-and-llm: + patterns: + - "@ai-sdk/*" + - "@anthropic-ai/sdk" + - "@google-cloud/vertexai" + - "@google/genai" + - "@langchain/*" + - "@modelcontextprotocol/sdk" + - "@openai/*" + - "ai" + - "langchain" + - "openai" + serverless: + patterns: + - "@aws-sdk/*" + - "@azure/*" + - "@smithy/*" + - "aws-sdk" + - "azure-functions-core-tools" + - "durable-functions" + opentelemetry: + patterns: + - "@opentelemetry/*" + - "opentracing" + test-optimization: + patterns: + - "@fast-check/jest" + - "@happy-dom/jest-environment" + - "@jest/*" + - "@playwright/test" + - "@vitest/*" + - "babel-jest" + - "jest-*" + - "jest" + - "mocha-each" + - "mocha" + - "nyc" + - "playwright-core" + - "playwright" + - "tinypool" + - "vitest" + databases: + patterns: + - "@node-redis/client" + - "@prisma/*" + - "@redis/client" + - "ioredis" + - "mongodb-core" + - "mongodb" + - "mongoose" + - "mysql" + - "mysql2" + - "pg-cursor" + - "pg-native" + - "pg-query-stream" + - "pg" + - "prisma" + - "redis" + - "sqlite3" + - "tedious" test-versions: patterns: - "*" diff --git a/.github/playwright/Dockerfile b/.github/playwright/Dockerfile new file mode 100644 index 0000000000..dedc2a942b --- /dev/null +++ b/.github/playwright/Dockerfile @@ -0,0 +1,75 @@ +FROM oven/bun:1.3.1@sha256:c1526bc496336087e5bdfaf519746c12ac4d5ebdda6d8a99d27ebd5d9ad7304c AS bun +FROM node:24.14.1-bookworm-slim@sha256:e484ae3f1e3c378021c967fd42254f343c302a9263e412280eac32bf5bca7008 +ARG PLAYWRIGHT_VERSION + +ENV DEBIAN_FRONTEND=noninteractive +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright + +COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + gpg \ + libatomic1 \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +# Playwright 1.18.x tries obsolete Ubuntu package names on Debian Bookworm +# (`ttf-unifont`, `xfonts-cyrillic`, `ttf-ubuntu-font-family`) and exits 0 +# even though apt fails. Install Chromium's runtime dependencies directly. +RUN npm install --prefix /tmp/pw @playwright/test@${PLAYWRIGHT_VERSION} \ + && case "$PLAYWRIGHT_VERSION" in \ + 1.18|1.18.*) \ + echo "Installing Playwright 1.18 Chromium dependencies for Debian Bookworm" \ + && apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + fonts-ipafont-gothic \ + fonts-liberation \ + fonts-noto-color-emoji \ + fonts-tlwg-loma-otf \ + fonts-unifont \ + fonts-wqy-zenhei \ + libasound2 \ + libatk-bridge2.0-0 \ + libatk1.0-0 \ + libatspi2.0-0 \ + libcairo2 \ + libcups2 \ + libdbus-1-3 \ + libdrm2 \ + libegl1 \ + libfontconfig1 \ + libfreetype6 \ + libgbm1 \ + libglib2.0-0 \ + libgtk-3-0 \ + libnspr4 \ + libnss3 \ + libpango-1.0-0 \ + libx11-6 \ + libx11-xcb1 \ + libxcb1 \ + libxcomposite1 \ + libxdamage1 \ + libxext6 \ + libxfixes3 \ + libxrandr2 \ + libxshmfence1 \ + xfonts-scalable \ + xvfb \ + && rm -rf /var/lib/apt/lists/* \ + && /tmp/pw/node_modules/.bin/playwright install chromium \ + ;; \ + *) \ + echo "Installing Chromium through Playwright dependency installer" \ + && /tmp/pw/node_modules/.bin/playwright install --with-deps chromium \ + ;; \ + esac \ + && rm -rf /tmp/pw \ + # Remove node in the same RUN so it is invisible to the container at runtime. + # Deletions in a later layer would still bloat the image with the unreachable binary. + # setup-node is the sole provider of node at runtime. + && rm -f /usr/local/bin/node /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \ + && rm -rf /usr/local/lib/node_modules /usr/local/include/node diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f0a1ed3d8d..0f74378701 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,6 +2,9 @@ + + + ### What does this PR do? diff --git a/.github/selenium/Dockerfile b/.github/selenium/Dockerfile new file mode 100644 index 0000000000..0556b5f218 --- /dev/null +++ b/.github/selenium/Dockerfile @@ -0,0 +1,36 @@ +FROM node:24.14.1-bookworm-slim@sha256:e484ae3f1e3c378021c967fd42254f343c302a9263e412280eac32bf5bca7008 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y curl git gnupg libatomic1 unzip wget \ + && rm -rf /var/lib/apt/lists/* + +# Install Google Chrome stable +RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub \ + | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \ + && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" \ + > /etc/apt/sources.list.d/google-chrome.list \ + && apt-get update \ + && apt-get install -y google-chrome-stable \ + && rm -rf /var/lib/apt/lists/* + +# Install ChromeDriver matching the installed Chrome version +RUN CHROME_VER=$(google-chrome --version | awk '{print $3}' | cut -d. -f1-3) \ + && curl -sf https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json \ + > /tmp/chrome-versions.json \ + && DRIVER_URL=$(CHROME_VER="$CHROME_VER" node -e " \ + const d = JSON.parse(require('fs').readFileSync('/tmp/chrome-versions.json', 'utf8')); \ + const prefix = process.env.CHROME_VER; \ + const matches = d.versions.filter(v => v.version.startsWith(prefix)); \ + const latest = matches[matches.length - 1]; \ + const entry = latest.downloads.chromedriver.find(e => e.platform === 'linux64'); \ + console.log(entry.url);") \ + && wget -q "$DRIVER_URL" -O /tmp/chromedriver.zip \ + && unzip /tmp/chromedriver.zip -d /tmp/chromedriver-dir \ + && mv /tmp/chromedriver-dir/chromedriver-linux64/chromedriver /usr/bin/chromedriver \ + && chmod +x /usr/bin/chromedriver \ + && rm -rf /tmp/chrome-versions.json /tmp/chromedriver.zip /tmp/chromedriver-dir + +# Remove node since it will be injected at runtime by setup-node +RUN rm -f /usr/local/bin/node /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \ + && rm -rf /usr/local/lib/node_modules /usr/local/include/node diff --git a/.github/workflows/aiguard.yml b/.github/workflows/aiguard.yml index 24c7eefe8f..b09d0030c0 100644 --- a/.github/workflows/aiguard.yml +++ b/.github/workflows/aiguard.yml @@ -62,7 +62,7 @@ jobs: windows: name: ${{ github.workflow }} / windows - runs-on: windows-latest + runs-on: windows-2022 permissions: id-token: write steps: diff --git a/.github/workflows/all-green.yml b/.github/workflows/all-green.yml index 92ff448243..38c3f35d76 100644 --- a/.github/workflows/all-green.yml +++ b/.github/workflows/all-green.yml @@ -34,9 +34,9 @@ jobs: - run: yarn add @actions/core @actions/github octokit - run: node scripts/all-green.mjs env: - DELAY: ${{ github.run_attempt == 1 && '5' || '0' }} # 5 minutes on first attempt, no delay on reruns + DELAY: ${{ github.run_attempt == 1 && '1' || '0' }} # 1 minute on first attempt, no delay on reruns RUN_ATTEMPT: ${{ github.run_attempt }} GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }} GITHUB_TOKEN: ${{ steps.octo-sts.outputs.token }} POLLING_INTERVAL: 1 - RETRIES: 30 + RETRIES: 20 diff --git a/.github/workflows/apm-capabilities.yml b/.github/workflows/apm-capabilities.yml index fc3906d93e..51f521a7b5 100644 --- a/.github/workflows/apm-capabilities.yml +++ b/.github/workflows/apm-capabilities.yml @@ -68,7 +68,7 @@ jobs: dd_api_key: ${{ steps.dd-sts.outputs.api_key }} tracing-windows: - runs-on: windows-latest + runs-on: windows-2022 permissions: id-token: write steps: diff --git a/.github/workflows/apm-integrations.yml b/.github/workflows/apm-integrations.yml index 2988dc3dc1..14f1acb543 100644 --- a/.github/workflows/apm-integrations.yml +++ b/.github/workflows/apm-integrations.yml @@ -157,6 +157,10 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/plugins/upstream + # `plugins/upstream` only runs `test:plugins:upstream`, which is a non-glob suite runner; + # the in-tree `test/integration-test/*.spec.js` files would otherwise have no CI invocation + # whose glob expands to reach them. + - uses: ./.github/actions/plugins/integration-test body-parser: runs-on: ubuntu-latest @@ -167,6 +171,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/plugins/upstream + - uses: ./.github/actions/plugins/integration-test bullmq: runs-on: ubuntu-latest @@ -450,7 +455,9 @@ jobs: - uses: ./.github/actions/dd-sts-api-key id: dd-sts - uses: ./.github/actions/testagent/start - - uses: ./.github/actions/node/latest + - uses: ./.github/actions/node + with: + version: '24.15' - uses: ./.github/actions/install # Ubuntu 24.04 tightened AppArmor defaults and now blocks unprivileged user # namespaces, which Electron's Chromium sandbox requires to run. Setting @@ -845,6 +852,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/plugins/test + with: + node-floor: newest-maintenance-lts mongodb-core: runs-on: ubuntu-latest @@ -862,6 +871,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/plugins/test + with: + node-floor: newest-maintenance-lts mongoose: runs-on: ubuntu-latest @@ -878,6 +889,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/plugins/test + with: + node-floor: newest-maintenance-lts multer: runs-on: ubuntu-latest @@ -927,6 +940,22 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/plugins/test + nats: + runs-on: ubuntu-latest + permissions: + id-token: write + services: + nats: + image: nats@sha256:7f430e429d0a90444b38bd40ab7812fd3afcc49a51f6b03a931f9becd5aeb280 # 2.14.1 + ports: + - 4222:4222 + env: + PLUGINS: nats + SERVICES: nats + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/plugins/test + net: runs-on: ubuntu-latest permissions: @@ -1275,10 +1304,8 @@ jobs: - uses: ./.github/actions/dd-sts-api-key id: dd-sts - uses: ./.github/actions/testagent/start - - uses: ./.github/actions/node/active-lts - - uses: ./.github/actions/install - - run: yarn test:plugins:ci - uses: ./.github/actions/node/oldest-maintenance-lts + - uses: ./.github/actions/install - run: yarn test:plugins:ci - uses: ./.github/actions/coverage with: diff --git a/.github/workflows/appsec.yml b/.github/workflows/appsec.yml index 4f23228f4e..523a90a428 100644 --- a/.github/workflows/appsec.yml +++ b/.github/workflows/appsec.yml @@ -71,7 +71,7 @@ jobs: windows: name: ${{ github.workflow }} / windows - runs-on: windows-latest + runs-on: windows-2022 permissions: id-token: write steps: @@ -321,7 +321,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/dd-sts-api-key id: dd-sts - - uses: ./.github/actions/node/oldest-maintenance-lts + - uses: ./.github/actions/node/newest-maintenance-lts - uses: ./.github/actions/install - run: yarn test:appsec:plugins:ci - uses: ./.github/actions/node/latest diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4f582cc1ed..bfecf2e50b 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@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 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@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 + uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 with: token: ${{ github.token }} wait-for-processing: false diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index b9e254fa64..e983f828f7 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -21,7 +21,9 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/dd-sts-api-key id: dd-sts - - uses: ./.github/actions/node/latest + - uses: ./.github/actions/node + with: + version: '24.15' - uses: ./.github/actions/install - run: yarn test:integration:electron - uses: ./.github/actions/push_to_test_optimization @@ -37,7 +39,9 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/dd-sts-api-key id: dd-sts - - uses: ./.github/actions/node/latest + - uses: ./.github/actions/node + with: + version: '24.15' - uses: ./.github/actions/install # Electron needs a display even for headless (show: false) windows. # xvfb-run provides a virtual framebuffer so the test can run without a physical display. diff --git a/.github/workflows/instrumentation.yml b/.github/workflows/instrumentation.yml index e786d252d1..618c9f470d 100644 --- a/.github/workflows/instrumentation.yml +++ b/.github/workflows/instrumentation.yml @@ -57,6 +57,26 @@ jobs: flags: platform-webpack dd_api_key: ${{ steps.dd-sts.outputs.api_key }} + instrumentation-ai: + runs-on: ubuntu-latest + permissions: + id-token: write + env: + PLUGINS: ai + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/instrumentations/test + + instrumentation-aws-sdk: + runs-on: ubuntu-latest + permissions: + id-token: write + env: + PLUGINS: aws-sdk + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/instrumentations/test + instrumentation-bluebird: runs-on: ubuntu-latest permissions: @@ -97,6 +117,55 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/instrumentations/test + # The couchbase native binding ships libcouchbase: the 3.x line has no + # prebuilt binary for Node 18+ and its bundled sources do not compile under + # modern gcc (missing for std::uint8_t). Pin one Node version per + # range instead of using the composite action that runs both oldest+latest, + # mirroring the existing plugin job's matrix. + instrumentation-couchbase: + strategy: + fail-fast: false + matrix: + include: + - node-version: eol + range: "^3.0.7" + - node-version: 18 + range: ">=4.2.0" + runs-on: ubuntu-latest + permissions: + id-token: write + env: + PLUGINS: couchbase + PACKAGE_VERSION_RANGE: ${{ matrix.range }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/dd-sts-api-key + id: dd-sts + - uses: ./.github/actions/node + with: + version: ${{ matrix.node-version }} + - uses: ./.github/actions/install + - run: yarn config set ignore-engines true + - run: yarn test:instrumentations:ci --ignore-engines + - uses: ./.github/actions/coverage + with: + flags: instrumentations-${{ github.job }}-${{ matrix.node-version }} + dd_api_key: ${{ steps.dd-sts.outputs.api_key }} + - uses: ./.github/actions/push_to_test_optimization + if: "!cancelled()" + with: + dd_api_key: ${{ steps.dd-sts.outputs.api_key }} + + instrumentation-crypto: + runs-on: ubuntu-latest + permissions: + id-token: write + env: + PLUGINS: crypto + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/instrumentations/test + instrumentation-express-mongo-sanitize: runs-on: ubuntu-latest permissions: @@ -133,6 +202,36 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/instrumentations/test + instrumentation-express-multi-version: + runs-on: ubuntu-latest + permissions: + id-token: write + env: + PLUGINS: express-multi-version + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/instrumentations/test + + instrumentation-fastify: + runs-on: ubuntu-latest + permissions: + id-token: write + env: + PLUGINS: fastify + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/instrumentations/test + + instrumentation-fetch: + runs-on: ubuntu-latest + permissions: + id-token: write + env: + PLUGINS: fetch + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/instrumentations/test + instrumentation-fs: runs-on: ubuntu-latest permissions: @@ -153,6 +252,16 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/instrumentations/test + instrumentation-hono: + runs-on: ubuntu-latest + permissions: + id-token: write + env: + PLUGINS: hono + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/instrumentations/test + # TODO: Retries below work around a flaky bug in Node.js http code. Revert to using # ./.github/actions/instrumentations/test once fixed upstream. instrumentation-http: @@ -191,6 +300,26 @@ jobs: with: dd_api_key: ${{ steps.dd-sts.outputs.api_key }} + instrumentation-http-client-options: + runs-on: ubuntu-latest + permissions: + id-token: write + env: + PLUGINS: http-client-options + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/instrumentations/test + + instrumentation-kafkajs: + runs-on: ubuntu-latest + permissions: + id-token: write + env: + PLUGINS: kafkajs + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/instrumentations/test + instrumentation-knex: runs-on: ubuntu-latest permissions: @@ -226,6 +355,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/instrumentations/test + with: + node-floor: newest-maintenance-lts instrumentation-multer: runs-on: ubuntu-latest @@ -256,6 +387,26 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/instrumentations/test + instrumentation-openai-aiguard: + runs-on: ubuntu-latest + permissions: + id-token: write + env: + PLUGINS: openai-aiguard + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/instrumentations/test + + instrumentation-otel-sdk-trace: + runs-on: ubuntu-latest + permissions: + id-token: write + env: + PLUGINS: otel-sdk-trace + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/instrumentations/test + instrumentation-passport: runs-on: ubuntu-latest permissions: @@ -335,6 +486,26 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/instrumentations/test + instrumentation-stripe: + runs-on: ubuntu-latest + permissions: + id-token: write + env: + PLUGINS: stripe + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/instrumentations/test + + instrumentation-router: + runs-on: ubuntu-latest + permissions: + id-token: write + env: + PLUGINS: router + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/instrumentations/test + instrumentation-url: runs-on: ubuntu-latest permissions: @@ -355,6 +526,16 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/instrumentations/test + instrumentation-zlib: + runs-on: ubuntu-latest + permissions: + id-token: write + env: + PLUGINS: zlib + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/instrumentations/test + instrumentations-misc: runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/openfeature.yml b/.github/workflows/openfeature.yml index cbdd45f170..21d28b2bd4 100644 --- a/.github/workflows/openfeature.yml +++ b/.github/workflows/openfeature.yml @@ -89,7 +89,7 @@ jobs: windows: name: ${{ github.workflow }} / windows - runs-on: windows-latest + runs-on: windows-2022 permissions: id-token: write steps: diff --git a/.github/workflows/platform.yml b/.github/workflows/platform.yml index d616335a14..346daf4482 100644 --- a/.github/workflows/platform.yml +++ b/.github/workflows/platform.yml @@ -42,7 +42,13 @@ jobs: - name: yarn install: yarn add - name: yarn-berry - install: (command -v corepack >/dev/null 2>&1 || npm install -g corepack) && yarn set version stable && yarn config set nodeLinker node-modules && yarn add + install: >- + (command -v corepack >/dev/null 2>&1 || npm install -g corepack) + && yarn set version stable + && yarn config set nodeLinker node-modules + && yarn config set enableScripts true + && yarn config set --json npmPreapprovedPackages '["@datadog/*"]' + && yarn add - name: bun install: bun add --linker=hoisted runs-on: ubuntu-latest diff --git a/.github/workflows/pr-labels.yml b/.github/workflows/pr-labels.yml deleted file mode 100644 index 4e1001ab79..0000000000 --- a/.github/workflows/pr-labels.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: Pull Request Labels -on: - pull_request_target: - types: [opened, labeled, unlabeled, synchronize] - branches: - - "master" -jobs: - label: - runs-on: ubuntu-latest - steps: - - uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5.5.2 - with: - mode: exactly - count: 1 - labels: "semver-patch, semver-minor, semver-major" diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000000..9449ad1cc7 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,117 @@ +name: Pull Request Title + +on: + pull_request_target: + types: [opened, edited, reopened] + branches: + - "master" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + conventional-commit: + runs-on: ubuntu-latest + permissions: + pull-requests: write + env: + # Shared between both steps. Must stay portable across bash ERE and JS + # regex (no lookarounds, named groups, or other JS-only features). + # Revert PRs nest the reverted commit's type, e.g. `revert: feat(api): undo X`, + # so the semver label can track the magnitude of the original change. + PR_TITLE_PATTERN: '^(revert(!)?: )?(feat|fix|docs|style|refactor|perf|test|bench|build|ci|chore)(\(([^)]+)\))?(!)?: .+' + steps: + - name: Validate PR title against Conventional Commits + if: github.event.action != 'edited' || github.event.changes.title != null + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + if [[ ! "$PR_TITLE" =~ $PR_TITLE_PATTERN ]]; then + echo "::error::PR title does not follow Conventional Commits format." + echo "Got: $PR_TITLE" + echo "Expected: ()?(!)?: " + echo " revert(!)?: ()?(!)?: (for reverts)" + echo "Allowed types: feat, fix, docs, style, refactor, perf, test, bench, build, ci, chore" + echo "Revert PRs must embed the original commit's type so the semver impact can" + echo "be determined (e.g. 'revert: feat(scope): original title')." + echo "Reverts of reverts re-apply the original change, so use the original" + echo "title (e.g. 'feat: x', not 'revert: revert: feat: x')." + exit 1 + fi + echo "PR title OK: $PR_TITLE" + + - name: Sync labels with PR title + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const pattern = new RegExp(process.env.PR_TITLE_PATTERN) + const parse = (title) => { + const m = (title || '').match(pattern) + if (!m) return {} + const isRevert = !!m[1] + return { + type: isRevert ? 'revert' : m[3], + revertedType: isRevert ? m[3] : undefined, + scope: m[5], + breaking: m[2] === '!' || m[6] === '!', + } + } + + // For reverts, the semver bump tracks the magnitude of the change + // being undone, parsed from the nested type in the title. + const semverFor = ({ type, revertedType, breaking }) => { + if (!type) return undefined + if (breaking) return 'semver-major' + const effective = type === 'revert' ? revertedType : type + if (effective === 'feat') return 'semver-minor' + return 'semver-patch' + } + + const pr = context.payload.pull_request + const next = parse(pr.title) + + // Prefetch all existing repo labels once to avoid per-label API calls. + const repoLabels = new Set() + for await (const page of github.paginate.iterator(github.rest.issues.listLabelsForRepo, { ...context.repo, per_page: 100 })) { + for (const label of page.data) repoLabels.add(label.name) + } + + // Returns the set of labels derived from a parsed title. + // Type and scope are only added if they exist in the repo. + const titledLabels = (parsed) => { + const labels = new Set() + if (!parsed.type) return labels + if (repoLabels.has(parsed.type)) labels.add(parsed.type) + if (parsed.scope && repoLabels.has(parsed.scope)) labels.add(parsed.scope) + const semver = semverFor(parsed) + if (semver) labels.add(semver) + return labels + } + + const nextLabels = titledLabels(next) + const current = new Set((pr.labels || []).map(l => l.name)) + + // When the title changes, remove labels from the old title that no + // longer apply so stale type/scope/semver labels don't linger. + const titleChanged = context.payload.action === 'edited' && + context.payload.changes?.title?.from != null + const toRemove = new Set() + if (titleChanged) { + const prev = parse(context.payload.changes.title.from) + for (const label of titledLabels(prev)) { + if (!nextLabels.has(label)) toRemove.add(label) + } + } + + const desired = new Set([...current].filter(l => !toRemove.has(l))) + for (const label of nextLabels) desired.add(label) + + const unchanged = current.size === desired.size && [...current].every(l => desired.has(l)) + if (!unchanged) { + await github.rest.issues.setLabels({ + ...context.repo, + issue_number: pr.number, + labels: [...desired], + }) + } diff --git a/.github/workflows/profiling.yml b/.github/workflows/profiling.yml index 28d9eac878..2514ce6243 100644 --- a/.github/workflows/profiling.yml +++ b/.github/workflows/profiling.yml @@ -76,7 +76,7 @@ jobs: windows: name: ${{ github.workflow }} / windows - runs-on: windows-latest + runs-on: windows-2022 permissions: id-token: write steps: @@ -87,10 +87,58 @@ jobs: with: version: 24.14.1 # TODO: remove pin when https://github.com/nodejs/node/issues/62991 is fixed - uses: ./.github/actions/install + # Enable Windows Error Reporting LocalDumps for node.exe so __fastfail / + # RaiseFailFastException crashes (e.g. STATUS_STACK_BUFFER_OVERRUN / + # 0xC0000409) — which bypass V8 and process.report — still produce a + # minidump. See PROF-14469. + - name: Enable WER LocalDumps for node.exe + shell: pwsh + run: | + $dumpDir = Join-Path $env:RUNNER_TEMP 'windows-crash-dumps' + New-Item -ItemType Directory -Force -Path $dumpDir | Out-Null + $key = 'HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\node.exe' + New-Item -Path $key -Force | Out-Null + Set-ItemProperty -Path $key -Name DumpFolder -Type ExpandString -Value $dumpDir + # DumpType=0 means "use CustomDumpFlags". Flags chosen for diagnosing native-binding + # teardown crashes (PROF-14469) without including heap memory: + # 0x0001 MiniDumpWithDataSegs — module .data segments (V8/libc globals) + # 0x0004 MiniDumpWithHandleData — process handle table + # 0x0020 MiniDumpWithUnloadedModules — modules unloaded before the crash + # 0x1000 MiniDumpWithThreadInfo — per-thread CPU/start-address/affinity + Set-ItemProperty -Path $key -Name DumpType -Type DWord -Value 0 + Set-ItemProperty -Path $key -Name CustomDumpFlags -Type DWord -Value 0x1025 + Set-ItemProperty -Path $key -Name DumpCount -Type DWord -Value 20 + "WER_DUMP_DIR=$dumpDir" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + # Defense-in-depth: blank known-sensitive env vars before tests so that + # if any of them ever do end up in the test process env (today they + # don't, but it's one workflow edit away), they cannot reach a published + # minidump artifact via module data segments or static state. + - name: Scrub sensitive env before tests + shell: pwsh + run: | + @( + 'DD_API_KEY', + 'DD_APP_KEY', + 'DD_APPLICATION_KEY', + 'GITHUB_TOKEN', + 'NODE_AUTH_TOKEN', + 'NPM_TOKEN' + ) | ForEach-Object { "$_=" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 } - run: yarn test:profiler:ci - run: yarn test:integration:profiler:coverage - uses: ./.github/actions/node-crash-report if: failure() + # Upload any WER minidumps that landed during this job, even on + # mocha-retry-recovered success — those are exactly the flake signatures + # we want to inspect. + - name: Upload Windows crash dumps + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-crash-dumps-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ env.WER_DUMP_DIR }}/*.dmp + if-no-files-found: ignore + retention-days: 14 - uses: ./.github/actions/coverage with: flags: profiling-windows diff --git a/.github/workflows/project.yml b/.github/workflows/project.yml index 0c71450eab..2e2812d3b6 100644 --- a/.github/workflows/project.yml +++ b/.github/workflows/project.yml @@ -42,15 +42,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: ./.github/actions/node/latest - uses: ./.github/actions/install - - run: npm run lint && npm run lint:codeowners:ci - - verify-exercised-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/node/latest - - uses: ./.github/actions/install - - run: npm run verify-exercised-tests + - run: npm run lint generated-config-types: runs-on: ubuntu-latest @@ -139,100 +131,6 @@ jobs: # - uses: ./.github/actions/install # - run: node scripts/verify-ci-config.js - supported-integrations: - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - has_changes: ${{ steps.diff.outputs.has_changes }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - uses: ./.github/actions/node/latest - - uses: ./.github/actions/install - - run: npm run generate:supported-integrations - - id: diff - env: - EVENT_NAME: ${{ github.event_name }} - HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - BASE_REPO: ${{ github.repository }} - run: | - set -euo pipefail - - if git diff --quiet; then - echo "has_changes=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - changed="$(git diff --name-only | sort -u)" - expected="$(printf 'supported_versions_output.json\nsupported_versions_table.csv')" - if [ "$changed" != "$expected" ]; then - echo "Unexpected paths changed during regeneration:" >&2 - echo "$changed" >&2 - exit 1 - fi - - if [ "$EVENT_NAME" != "pull_request" ] || [ "$HEAD_REPO" != "$BASE_REPO" ]; then - echo "Out of date. Run 'npm run generate:supported-integrations' locally and commit." >&2 - exit 1 - fi - - mkdir -p "${RUNNER_TEMP}/supported-integrations" - cp supported_versions_output.json supported_versions_table.csv "${RUNNER_TEMP}/supported-integrations/" - echo "has_changes=true" >> "$GITHUB_OUTPUT" - - if: steps.diff.outputs.has_changes == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: supported-integrations - path: ${{ runner.temp }}/supported-integrations - if-no-files-found: error - - supported-integrations-push: - # See yarn-dedupe-push: skip the bot's own re-trigger after it pushes. - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && needs.supported-integrations.outputs.has_changes == 'true' && github.actor != 'dd-octo-sts[bot]' - runs-on: ubuntu-latest - needs: supported-integrations - 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: supported-integrations - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: supported-integrations - path: ${{ runner.temp }}/supported-integrations - - env: - GH_TOKEN: ${{ steps.octo-sts.outputs.token }} - BRANCH: ${{ github.event.pull_request.head.ref }} - EXPECTED: ${{ github.event.pull_request.head.sha }} - DIR: ${{ runner.temp }}/supported-integrations - run: | - set -euo pipefail - # gh's `-f variables=` does not parse the value as JSON; build - # the `{query, variables}` body with jq and pipe via `--input -`. - jq -nc \ - --arg repo "$GITHUB_REPOSITORY" \ - --arg branch "$BRANCH" \ - --arg expected "$EXPECTED" \ - --arg json "$(base64 -w 0 "$DIR/supported_versions_output.json")" \ - --arg csv "$(base64 -w 0 "$DIR/supported_versions_table.csv")" \ - '{ - query: "mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { url } } }", - variables: { input: { - branch: { repositoryNameWithOwner: $repo, branchName: $branch }, - message: { headline: "chore: update supported-integrations" }, - expectedHeadOid: $expected, - fileChanges: { additions: [ - { path: "supported_versions_output.json", contents: $json }, - { path: "supported_versions_table.csv", contents: $csv } - ] } - } } - }' | gh api graphql --input - >/dev/null - yarn-dedupe: runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/serverless.yml b/.github/workflows/serverless.yml index 61f3bb3fa9..d986082bd0 100644 --- a/.github/workflows/serverless.yml +++ b/.github/workflows/serverless.yml @@ -163,6 +163,7 @@ jobs: - eventhubs - client - servicebus + - cosmosdb runs-on: ubuntu-latest permissions: id-token: write @@ -198,12 +199,24 @@ jobs: ACCEPT_EULA: "Y" MSSQL_SA_PASSWORD: "Localtestpass1!" SQL_SERVER: azuresqledge + azurecosmosemulator: + image: ghcr.io/datadog/dd-trace-js/mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator@sha256:bf3bd3cbe3fae2005a0745f77b01d1fc8cd04112193f3ee32289ee15ee9ae5fa # vnext-preview + ports: + - "127.0.0.1:8081:8081" + - "127.0.0.1:8080:8080" + - "127.0.0.1:1234:1234" + env: + ACCEPT_EULA: "Y" + BLOB_SERVER: azurite + METADATA_SERVER: azurite env: PLUGINS: azure-functions - SERVICES: azuresqledge,azureservicebusemulator,azurite,azureeventhubsemulator + SERVICES: azuresqledge,azureservicebusemulator,azurite,azureeventhubsemulator,azurecosmosemulator SPEC: ${{ matrix.spec }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Wait for Cosmos emulator to be ready + run: timeout 120 bash -c 'until curl -sf -o /dev/null --max-time 5 http://127.0.0.1:8080/ready; do sleep 3; done' - name: Copy emulator config files run: | docker cp \ @@ -278,6 +291,39 @@ jobs: with: flags: serverless-azure-durable-functions + azure-cosmos: + runs-on: ubuntu-latest + permissions: + id-token: write + services: + azurite: + image: ghcr.io/datadog/dd-trace-js/mcr.microsoft.com/azure-storage/azurite@sha256:647c63a91102a9d8e8000aab803436e1fc85fbb285e7ce830a82ee5d6661cf37 # 3.35.0 + ports: + - "127.0.0.1:10000:10000" + - "127.0.0.1:10001:10001" + - "127.0.0.1:10002:10002" + azurecosmosemulator: + image: ghcr.io/datadog/dd-trace-js/mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator@sha256:bf3bd3cbe3fae2005a0745f77b01d1fc8cd04112193f3ee32289ee15ee9ae5fa # vnext-preview + ports: + - "127.0.0.1:8081:8081" + - "127.0.0.1:8080:8080" + - "127.0.0.1:1234:1234" + env: + ACCEPT_EULA: "Y" + BLOB_SERVER: azurite + METADATA_SERVER: azurite + env: + PLUGINS: azure-cosmos + SERVICES: azurite,azurecosmosemulator + NODE_OPTIONS: '--experimental-global-webcrypto' + NODE_TLS_REJECT_UNAUTHORIZED: 0 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Wait for Cosmos emulator to be ready + run: timeout 120 bash -c 'until curl -sf -o /dev/null --max-time 5 http://127.0.0.1:8080/ready; do sleep 3; done' + - uses: ./.github/actions/plugins/test + + google-cloud-pubsub: runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 61dd4f873b..1ae067ed56 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -15,7 +15,7 @@ jobs: with: scope: DataDog/dd-trace-js policy: stale - - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 + - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 with: repo-token: ${{ steps.octo-sts.outputs.token }} days-before-issue-stale: -1 # disabled for issues diff --git a/.github/workflows/test-optimization.yml b/.github/workflows/test-optimization.yml index ea0ec94655..e2cdb9f9a0 100644 --- a/.github/workflows/test-optimization.yml +++ b/.github/workflows/test-optimization.yml @@ -72,7 +72,59 @@ jobs: flags: test-optimization-testopt-${{ matrix.version }} dd_api_key: ${{ steps.dd-sts.outputs.api_key }} + playwright-image: + strategy: + fail-fast: false + matrix: + playwright-version: [oldest, latest] + name: Ensure Playwright Docker image (${{ matrix.playwright-version }}) + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + # Both matrix jobs set this to the same value, so the last-writer-wins + # behaviour of matrix outputs is safe here. + images: ${{ steps.versions.outputs.images }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Determine versions + id: versions + run: | + LATEST=$(node -p "require('./integration-tests/playwright/versions').latest") + OLDEST=$(node -p "require('./integration-tests/playwright/versions').oldest") + DOCKER_HASH=$(sha256sum .github/playwright/Dockerfile | cut -c1-8) + LATEST_TAG="${LATEST}-${DOCKER_HASH}" + OLDEST_TAG="${OLDEST}-${DOCKER_HASH}" + BASE="ghcr.io/datadog/dd-trace-js/playwright-tools" + IMAGES=$(printf '{"latest":"%s:%s","oldest":"%s:%s"}' "$BASE" "$LATEST_TAG" "$BASE" "$OLDEST_TAG") + PW_VERSION=$([ "${{ matrix.playwright-version }}" = "latest" ] && echo "$LATEST" || echo "$OLDEST") + IMAGE_TAG=$([ "${{ matrix.playwright-version }}" = "latest" ] && echo "$LATEST_TAG" || echo "$OLDEST_TAG") + echo "images=$IMAGES" >> $GITHUB_OUTPUT + echo "pw-version=$PW_VERSION" >> $GITHUB_OUTPUT + echo "image-tag=$IMAGE_TAG" >> $GITHUB_OUTPUT + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Ensure image + env: + PW_VERSION: ${{ steps.versions.outputs.pw-version }} + IMAGE_TAG: ${{ steps.versions.outputs.image-tag }} + run: | + IMAGE="ghcr.io/datadog/dd-trace-js/playwright-tools:${IMAGE_TAG}" + if docker manifest inspect "${IMAGE}" > /dev/null 2>&1; then + echo "Image ${IMAGE} already exists, skipping build" + else + docker build --build-arg PLAYWRIGHT_VERSION="${PW_VERSION}" \ + -t "${IMAGE}" .github/playwright + docker push "${IMAGE}" + fi + integration-playwright: + needs: playwright-image strategy: fail-fast: false matrix: @@ -91,7 +143,10 @@ jobs: permissions: id-token: write container: - image: ghcr.io/rochdev/playwright-tools@sha256:65b8161e23ede2354d04c8a242032d9ee8ca275359eac1764c027f073d38217c # 1.54.1-5 + image: ${{ fromJson(needs.playwright-image.outputs.images)[matrix.playwright-version] }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} env: DD_SERVICE: dd-trace-js-integration-tests DD_CIVISIBILITY_AGENTLESS_ENABLED: 1 @@ -105,25 +160,6 @@ jobs: with: version: ${{ matrix.node-version }} - uses: ./.github/actions/install - # We need this because the "oldest" playwright version depends on the major version of dd-trace - # For v5 it's 1.18.0 and for v6 it's 1.38.0 - # We don't cache "latest" because it changes based on playwright releases. - # We could do it, but we'd have to request GitHub API to get the latest version, - # and the cache hit rate would be way lower than for "oldest", which should be 100%. - - name: Get dd-trace major version - if: matrix.playwright-version == 'oldest' - id: dd-version - run: | - VERSION=$(node -p "require('./package.json').version") - MAJOR=$(echo $VERSION | cut -d. -f1) - echo "major=$MAJOR" >> $GITHUB_OUTPUT - echo "dd-trace major version: $MAJOR" - - name: Cache Playwright browsers - if: matrix.playwright-version == 'oldest' - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: /github/home/.cache/ms-playwright - key: playwright-browsers-oldest-dd${{ steps.dd-version.outputs.major }} - name: Configure Git safe directory # The Playwright job runs in a container where the checkout can be owned by a different UID. # Git rejects metadata commands in that checkout unless the workspace is marked as safe. @@ -178,7 +214,7 @@ jobs: jest-version: [oldest, latest] spec: - jest.core - - jest.itr-efd + - jest.tia-efd - jest.test-management name: integration-jest (${{ matrix.jest-version }}, node-${{ matrix.version }}, ${{ matrix.spec }}) runs-on: ubuntu-latest @@ -206,6 +242,19 @@ jobs: flags: test-optimization-jest-${{ matrix.version }}-${{ matrix.jest-version }} dd_api_key: ${{ steps.dd-sts.outputs.api_key }} + # Unit tests for `packages/datadog-plugin-jest`. The `integration-jest` matrix above only runs + # `integration-tests/jest/*.spec.js`, so this is the only CI invocation whose glob expands to + # cover `packages/datadog-plugin-jest/test/util.spec.js`. + jest: + runs-on: ubuntu-latest + permissions: + id-token: write + env: + PLUGINS: jest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/plugins/test + integration-cucumber: strategy: fail-fast: false @@ -236,7 +285,41 @@ jobs: flags: test-optimization-cucumber-${{ matrix.version }}-${{ matrix.cucumber-version }} dd_api_key: ${{ steps.dd-sts.outputs.api_key }} + selenium-image: + name: Ensure Selenium Docker image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.image.outputs.image }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Determine image tag + id: image + run: | + DOCKER_HASH=$(sha256sum .github/selenium/Dockerfile | cut -c1-8) + IMAGE="ghcr.io/datadog/dd-trace-js/selenium-tools:${DOCKER_HASH}" + echo "image=$IMAGE" >> $GITHUB_OUTPUT + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Ensure image + env: + IMAGE: ${{ steps.image.outputs.image }} + run: | + if docker manifest inspect "${IMAGE}" > /dev/null 2>&1; then + echo "Image ${IMAGE} already exists, skipping build" + else + docker build -t "${IMAGE}" .github/selenium + docker push "${IMAGE}" + fi + integration-selenium: + needs: selenium-image strategy: fail-fast: false matrix: @@ -244,6 +327,11 @@ jobs: runs-on: ubuntu-latest permissions: id-token: write + container: + image: ${{ needs.selenium-image.outputs.image }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} env: DD_SERVICE: dd-trace-js-integration-tests DD_CIVISIBILITY_AGENTLESS_ENABLED: 1 @@ -255,24 +343,11 @@ jobs: - uses: ./.github/actions/node with: version: ${{ matrix.version }} - - name: Install Google Chrome - run: | - sudo sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list' - wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | sudo apt-key add - - if [ $? -ne 0 ]; then echo "Failed to add Google key"; exit 1; fi - sudo apt-get update - sudo apt-get install -y google-chrome-stable - if [ $? -ne 0 ]; then echo "Failed to install Google Chrome"; exit 1; fi - - name: Install ChromeDriver - run: | - export CHROME_VERSION=$(google-chrome --version) - CHROME_DRIVER_DOWNLOAD_URL=$(node --experimental-fetch scripts/get-chrome-driver-download-url.js) - wget -q "$CHROME_DRIVER_DOWNLOAD_URL" - if [ $? -ne 0 ]; then echo "Failed to download ChromeDriver"; exit 1; fi - unzip chromedriver-linux64.zip - sudo mv chromedriver-linux64/chromedriver /usr/bin/chromedriver - sudo chmod +x /usr/bin/chromedriver - uses: ./.github/actions/install + - name: Configure Git safe directory + # The Selenium job runs in a container where the checkout can be owned by a different UID. + # Git rejects metadata commands in that checkout unless the workspace is marked as safe. + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - run: yarn test:integration:selenium:coverage env: NODE_OPTIONS: "-r ./ci/init" @@ -296,6 +371,7 @@ jobs: spec: - cypress-reporting - cypress-itr + - cypress-tia-code-coverage - cypress-efd - cypress-atr - cypress-test-management @@ -335,14 +411,20 @@ jobs: with: version: ${{ matrix.version }} - uses: ./.github/actions/install - # We cache Cypress binaries for fixed versions (6.7.0, 12.0.0, 14.5.4) but not for "latest" - # as that changes frequently and would have a low cache hit rate + - name: Resolve Cypress version + id: cypress-version + run: | + if [ "${{ matrix.cypress-version }}" = "latest" ]; then + RESOLVED=$(node -p "require('./packages/dd-trace/test/plugins/versions/package.json').dependencies.cypress") + else + RESOLVED="${{ matrix.cypress-version }}" + fi + echo "resolved=$RESOLVED" >> $GITHUB_OUTPUT - name: Cache Cypress binary - if: matrix.cypress-version != 'latest' uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.cache/Cypress - key: cypress-binary-${{ matrix.cypress-version }} + key: cypress-binary-${{ steps.cypress-version.outputs.resolved }} - run: yarn config set ignore-engines true - run: yarn test:integration:cypress:coverage --ignore-engines env: diff --git a/.gitignore b/.gitignore index a16e3f9249..9b41adb225 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,8 @@ lib-cov coverage/ !integration-tests/coverage/ !integration-tests/coverage/** +!.github/actions/coverage/ +!.github/actions/coverage/** *.lcov # nyc test coverage diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 07b34faf2b..bf56417038 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -55,6 +55,24 @@ variables: configure_system_tests: variables: SYSTEM_TESTS_SCENARIOS_GROUPS: "simple_onboarding,simple_onboarding_profiling,simple_onboarding_appsec,docker-ssi,lib-injection" + rules: + - if: $SKIP_SHARED_PIPELINE == "true" + when: never + - if: $DANGEROUSLY_SKIP_SHARED_PIPELINE_TESTS == "true" + when: never + - if: $CI_COMMIT_BRANCH == "master" || $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^v\d+\.\d+\.\d+-proposal$/ || $CI_MERGE_REQUEST_LABELS =~ /run-ssi-tests/ + when: on_success + - when: never + +system_tests: + rules: + - if: $SKIP_SHARED_PIPELINE == "true" + when: never + - if: $DANGEROUSLY_SKIP_SHARED_PIPELINE_TESTS == "true" + when: never + - if: $CI_COMMIT_BRANCH == "master" || $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^v\d+\.\d+\.\d+-proposal$/ || $CI_MERGE_REQUEST_LABELS =~ /run-ssi-tests/ + when: on_success + - when: never requirements_json_test: rules: diff --git a/.gitlab/benchmarks.yml b/.gitlab/benchmarks.yml index 9e4ec5bb12..3866d0d941 100644 --- a/.gitlab/benchmarks.yml +++ b/.gitlab/benchmarks.yml @@ -123,7 +123,7 @@ benchmark-serverless: needs: - benchmark-serverless-trigger script: - - curl -OL "binaries.ddbuild.io/dd-source/authanywhere/LATEST/authanywhere-linux-amd64" + - curl -OL "https://binaries.ddbuild.io/dd-source/authanywhere/LATEST/authanywhere-linux-amd64" - mv "authanywhere-linux-amd64" /bin/authanywhere - chmod +x /bin/authanywhere - BTI_CI_API_TOKEN=$(authanywhere --audience rapid-devex-ci) diff --git a/.gitlab/one-pipeline.locked.yml b/.gitlab/one-pipeline.locked.yml index c863cb6b6b..ccf6f04825 100644 --- a/.gitlab/one-pipeline.locked.yml +++ b/.gitlab/one-pipeline.locked.yml @@ -1,4 +1,4 @@ # DO NOT EDIT THIS FILE MANUALLY # This file is auto-generated by automation. include: - - remote: https://gitlab-templates.ddbuild.io/libdatadog/one-pipeline/ca/08b1c626970e6f9f6d0c1084b5f9ce92921646c3b349ee1a4af9bd3fc505f7b3/one-pipeline.yml + - remote: https://gitlab-templates.ddbuild.io/libdatadog/one-pipeline/ca/f9ee2fa697e8b0c440dc9762cc522cafbe295a9671d49067f38258fbee985c74/one-pipeline.yml diff --git a/.yarnrc b/.yarnrc index 123ac74a0a..31a4f99e02 100644 --- a/.yarnrc +++ b/.yarnrc @@ -1 +1,2 @@ ignore-engines true +network-timeout 60000 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c18fb3f31..65065d5b48 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,9 +71,9 @@ In the event that some existing functionality _does_ need to change, as much as ## Indicate intended release targets -When writing major changes we use a series of labels in the form of `dont-land-on-vN.x` where N is the major release line which a PR should not land in. Every PR marked as semver-major should include these tags. These tags allow our [branch-diff](https://github.com/bengl/branch-diff) tooling to work smoothly as we can exclude PRs not intended for the release line we're preparing a release proposal for. The `semver-major` labels on their own are not sufficient as they don't encode any indication of from _which_ releases they are a major change. +When writing changes that should only land on the next major release line (master) and not on any current stable release line, add the `only-land-on-next` label. This tells our [branch-diff](https://github.com/bengl/branch-diff) tooling to exclude those PRs when preparing a release proposal for a stable line. -For outside contributions we will have the relevant team add these labels when they review and determine when they plan to release it. +For outside contributions we will have the relevant team add this label when they review and determine the intended release target. ## Ensure all tests are green @@ -85,6 +85,44 @@ Eventually we plan to look into putting these permission-required tests behind a Always search the codebase first before creating new code to avoid duplicates. Check for existing utilities, helpers, or patterns that solve similar problems. Reuse existing code when possible rather than reinventing solutions. +## Pull Request Titles + +PR titles must follow the [Conventional Commits](https://www.conventionalcommits.org/) format: + +``` +type(scope): description +``` + +The `scope` is optional. Valid types are: + +| Type | When to use | +|------|-------------| +| `feat` | A new feature | +| `fix` | A bug fix | +| `docs` | Documentation changes only | +| `style` | Formatting, missing semicolons, etc. (no logic change) | +| `refactor` | Code change that neither fixes a bug nor adds a feature | +| `perf` | A code change that improves performance | +| `test` | Adding or updating tests | +| `bench` | Adding or updating benchmarks (e.g. under `benchmark/sirun/`) | +| `build` | Changes to build system or external dependencies | +| `ci` | Changes to CI configuration files and scripts | +| `chore` | Other changes that don't modify src or test files | +| `revert` | Reverts a previous commit | + +Revert PRs must embed the original commit's type so the semver impact can be +determined automatically: `revert: ()?: `. + +Examples: + +``` +feat(appsec): add new WAF rule +fix: handle cross section things +docs: update contributing guidelines +chore(deps): bump express to v5 +revert: fix(redis): handle connection timeout +``` + ## Sign your commits All commits in a pull request must be signed. We require commit signing to ensure the authenticity and integrity of contributions to the project. diff --git a/LICENSE-3rdparty.csv b/LICENSE-3rdparty.csv index bd25c04956..fb58e8f1b0 100644 --- a/LICENSE-3rdparty.csv +++ b/LICENSE-3rdparty.csv @@ -1,102 +1,90 @@ -"component","origin","license","copyright" -"@apm-js-collab/code-transformer","https://github.com/nodejs/orchestrion-js","['Apache-2.0']","['nodejs']" -"@datadog/flagging-core","https://github.com/DataDog/openfeature-js-client","['Apache-2.0']","['DataDog']" -"@datadog/libdatadog","https://github.com/DataDog/libdatadog-nodejs","['Apache-2.0']","['Datadog Inc.']" -"@datadog/native-appsec","https://github.com/DataDog/dd-native-appsec-js","['Apache-2.0']","['Datadog Inc.']" -"@datadog/native-iast-taint-tracking","https://github.com/DataDog/dd-native-iast-taint-tracking-js","['Apache-2.0']","['Datadog Inc.']" -"@datadog/native-metrics","https://github.com/DataDog/dd-native-metrics-js","['Apache-2.0']","['Datadog Inc.']" -"@datadog/openfeature-node-server","https://github.com/DataDog/openfeature-js-client","['Apache-2.0']","['DataDog']" -"@datadog/pprof","https://github.com/DataDog/pprof-nodejs","['Apache-2.0']","['Google Inc.']" -"@datadog/sketches-js","https://github.com/DataDog/sketches-js","['Apache-2.0']","['DataDog']" -"@datadog/wasm-js-rewriter","https://github.com/DataDog/dd-wasm-js-rewriter","['Apache-2.0']","['Datadog Inc.']" -"@emnapi/core","https://github.com/toyobayashi/emnapi","['MIT']","['toyobayashi']" -"@emnapi/runtime","https://github.com/toyobayashi/emnapi","['MIT']","['toyobayashi']" -"@emnapi/wasi-threads","https://github.com/toyobayashi/emnapi","['MIT']","['toyobayashi']" -"@isaacs/ttlcache","https://github.com/isaacs/ttlcache","['BlueOak-1.0.0']","['Isaac Z. Schlueter']" -"@jsep-plugin/assignment","https://github.com/EricSmekens/jsep","['MIT']","['Shelly']" -"@jsep-plugin/regex","https://github.com/EricSmekens/jsep","['MIT']","['Shelly']" -"@napi-rs/wasm-runtime","https://github.com/napi-rs/napi-rs","['MIT']","['LongYinan']" -"@opentelemetry/api","https://github.com/open-telemetry/opentelemetry-js","['Apache-2.0']","['OpenTelemetry Authors']" -"@opentelemetry/api-logs","https://github.com/open-telemetry/opentelemetry-js","['Apache-2.0']","['OpenTelemetry Authors']" -"@opentelemetry/core","https://github.com/open-telemetry/opentelemetry-js","['Apache-2.0']","['OpenTelemetry Authors']" -"@opentelemetry/resources","https://github.com/open-telemetry/opentelemetry-js","['Apache-2.0']","['OpenTelemetry Authors']" -"@opentelemetry/semantic-conventions","https://github.com/open-telemetry/opentelemetry-js","['Apache-2.0']","['OpenTelemetry Authors']" -"@oxc-parser/binding-android-arm-eabi","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-android-arm64","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-darwin-arm64","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-darwin-x64","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-freebsd-x64","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-linux-arm-gnueabihf","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-linux-arm-musleabihf","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-linux-arm64-gnu","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-linux-arm64-musl","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-linux-ppc64-gnu","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-linux-riscv64-gnu","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-linux-riscv64-musl","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-linux-s390x-gnu","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-linux-x64-gnu","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-linux-x64-musl","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-openharmony-arm64","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-wasm32-wasi","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-win32-arm64-msvc","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-win32-ia32-msvc","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-parser/binding-win32-x64-msvc","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@oxc-project/types","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"@protobufjs/aspromise","https://github.com/protobufjs/protobuf.js","['BSD-3-Clause']","['Daniel Wirtz']" -"@protobufjs/base64","https://github.com/protobufjs/protobuf.js","['BSD-3-Clause']","['Daniel Wirtz']" -"@protobufjs/codegen","https://github.com/protobufjs/protobuf.js","['BSD-3-Clause']","['Daniel Wirtz']" -"@protobufjs/eventemitter","https://github.com/protobufjs/protobuf.js","['BSD-3-Clause']","['Daniel Wirtz']" -"@protobufjs/fetch","https://github.com/protobufjs/protobuf.js","['BSD-3-Clause']","['Daniel Wirtz']" -"@protobufjs/float","https://github.com/protobufjs/protobuf.js","['BSD-3-Clause']","['Daniel Wirtz']" -"@protobufjs/inquire","https://github.com/protobufjs/protobuf.js","['BSD-3-Clause']","['Daniel Wirtz']" -"@protobufjs/path","https://github.com/protobufjs/protobuf.js","['BSD-3-Clause']","['Daniel Wirtz']" -"@protobufjs/pool","https://github.com/protobufjs/protobuf.js","['BSD-3-Clause']","['Daniel Wirtz']" -"@protobufjs/utf8","https://github.com/protobufjs/protobuf.js","['BSD-3-Clause']","['Daniel Wirtz']" -"@tybys/wasm-util","https://github.com/toyobayashi/wasm-util","['MIT']","['toyobayashi']" -"@types/estree","https://github.com/DefinitelyTyped/DefinitelyTyped","['MIT']","['DefinitelyTyped']" -"@types/node","https://github.com/DefinitelyTyped/DefinitelyTyped","['MIT']","['DefinitelyTyped']" -"acorn","https://github.com/acornjs/acorn","['MIT']","['acornjs']" -"acorn-import-attributes","https://github.com/xtuc/acorn-import-attributes","['MIT']","['Sven Sauleau']" -"argparse","https://github.com/nodeca/argparse","['Python-2.0']","['nodeca']" -"astring","https://github.com/davidbonnet/astring","['MIT']","['David Bonnet']" -"cjs-module-lexer","https://github.com/nodejs/cjs-module-lexer","['MIT']","['Guy Bedford']" -"crypto-randomuuid","npm:crypto-randomuuid","['MIT']","['Stephen Belanger']" -"dc-polyfill","https://github.com/DataDog/dc-polyfill","['MIT']","['Thomas Hunter II']" -"dd-trace","https://github.com/DataDog/dd-trace-js","['(Apache-2.0 OR BSD-3-Clause)']","['Datadog Inc. ']" -"detect-newline","https://github.com/sindresorhus/detect-newline","['MIT']","['Sindre Sorhus']" -"escape-string-regexp","https://github.com/sindresorhus/escape-string-regexp","['MIT']","['Sindre Sorhus']" -"esquery","https://github.com/estools/esquery","['BSD-3-Clause']","['Joel Feenstra']" -"estraverse","https://github.com/estools/estraverse","['BSD-2-Clause']","['estools']" -"fast-fifo","https://github.com/mafintosh/fast-fifo","['MIT']","['Mathias Buus']" -"import-in-the-middle","https://github.com/nodejs/import-in-the-middle","['Apache-2.0']","['Bryan English']" -"istanbul-lib-coverage","https://github.com/istanbuljs/istanbuljs","['BSD-3-Clause']","['Krishnan Anantheswaran']" -"jest-docblock","https://github.com/jestjs/jest","['MIT']","['jestjs']" -"js-yaml","https://github.com/nodeca/js-yaml","['MIT']","['Vladimir Zapparov']" -"jsep","https://github.com/EricSmekens/jsep","['MIT']","['Stephen Oney']" -"jsonpath-plus","https://github.com/JSONPath-Plus/JSONPath","['MIT']","['Stefan Goessner']" -"limiter","https://github.com/jhurliman/node-rate-limiter","['MIT']","['John Hurliman']" -"lodash.sortby","https://github.com/lodash/lodash","['MIT']","['John-David Dalton']" -"long","https://github.com/dcodeIO/long.js","['Apache-2.0']","['Daniel Wirtz']" -"lru-cache","https://github.com/isaacs/node-lru-cache","['ISC']","['Isaac Z. Schlueter']" -"meriyah","https://github.com/meriyah/meriyah","['ISC']","['Kenny F.']" -"module-details-from-path","https://github.com/watson/module-details-from-path","['MIT']","['Thomas Watson']" -"mutexify","https://github.com/mafintosh/mutexify","['MIT']","['Mathias Buus']" -"node-addon-api","https://github.com/nodejs/node-addon-api","['MIT']","['nodejs']" -"node-gyp-build","https://github.com/prebuild/node-gyp-build","['MIT']","['Mathias Buus']" -"opentracing","https://github.com/opentracing/opentracing-javascript","['Apache-2.0']","['opentracing']" -"oxc-parser","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" -"pprof-format","https://github.com/DataDog/pprof-format","['MIT']","['Datadog Inc.']" -"protobufjs","https://github.com/protobufjs/protobuf.js","['BSD-3-Clause']","['Daniel Wirtz']" -"queue-tick","https://github.com/mafintosh/queue-tick","['MIT']","['Mathias Buus']" -"retry","https://github.com/tim-kos/node-retry","['MIT']","['Tim Koschützki']" -"rfdc","https://github.com/davidmarkclements/rfdc","['MIT']","['David Mark Clements']" -"semifies","https://github.com/holepunchto/semifies","['Apache-2.0']","['Holepunch Inc']" -"shell-quote","https://github.com/ljharb/shell-quote","['MIT']","['James Halliday']" -"source-map","https://github.com/mozilla/source-map","['BSD-3-Clause']","['Nick Fitzgerald']" -"spark-md5","https://github.com/satazor/js-spark-md5","['(WTFPL OR MIT)']","['André Cruz']" -"tlhunter-sorted-set","https://github.com/tlhunter/node-sorted-set","['MIT']","['Thomas Hunter II']" -"tslib","https://github.com/microsoft/tslib","['0BSD']","['Microsoft Corp.']" -"ttl-set","https://github.com/watson/ttl-set","['MIT']","['Thomas Watson']" -"undici-types","https://github.com/nodejs/undici","['MIT']","['nodejs']" -"aws-lambda-nodejs-runtime-interface-client","https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/blob/v2.1.0/src/utils/UserFunction.ts","['Apache-2.0']","['Amazon.com Inc. or its affiliates']" -"is-git-url","https://github.com/jonschlinkert/is-git-url/blob/396965ffabf2f46656c8af4c47bef1d69f09292e/index.js#L9C15-L9C87","['MIT']","['Jon Schlinkert']" +"component","origin","license","copyright" +"@apm-js-collab/code-transformer","https://github.com/nodejs/orchestrion-js","['Apache-2.0']","['nodejs']" +"@datadog/flagging-core","https://github.com/DataDog/openfeature-js-client","['Apache-2.0']","['DataDog']" +"@datadog/libdatadog","https://github.com/DataDog/libdatadog-nodejs","['Apache-2.0']","['Datadog Inc.']" +"@datadog/native-appsec","https://github.com/DataDog/dd-native-appsec-js","['Apache-2.0']","['Datadog Inc.']" +"@datadog/native-iast-taint-tracking","https://github.com/DataDog/dd-native-iast-taint-tracking-js","['Apache-2.0']","['Datadog Inc.']" +"@datadog/native-metrics","https://github.com/DataDog/dd-native-metrics-js","['Apache-2.0']","['Datadog Inc.']" +"@datadog/openfeature-node-server","https://github.com/DataDog/openfeature-js-client","['Apache-2.0']","['DataDog']" +"@datadog/pprof","https://github.com/DataDog/pprof-nodejs","['Apache-2.0']","['Google Inc.']" +"@datadog/sketches-js","https://github.com/DataDog/sketches-js","['Apache-2.0']","['DataDog']" +"@datadog/wasm-js-rewriter","https://github.com/DataDog/dd-wasm-js-rewriter","['Apache-2.0']","['Datadog Inc.']" +"@emnapi/core","https://github.com/toyobayashi/emnapi","['MIT']","['toyobayashi']" +"@emnapi/runtime","https://github.com/toyobayashi/emnapi","['MIT']","['toyobayashi']" +"@emnapi/wasi-threads","https://github.com/toyobayashi/emnapi","['MIT']","['toyobayashi']" +"@isaacs/ttlcache","https://github.com/isaacs/ttlcache","['BlueOak-1.0.0']","['Isaac Z. Schlueter']" +"@jsep-plugin/assignment","https://github.com/EricSmekens/jsep","['MIT']","['Shelly']" +"@jsep-plugin/regex","https://github.com/EricSmekens/jsep","['MIT']","['Shelly']" +"@napi-rs/wasm-runtime","https://github.com/napi-rs/napi-rs","['MIT']","['LongYinan']" +"@opentelemetry/api","https://github.com/open-telemetry/opentelemetry-js","['Apache-2.0']","['OpenTelemetry Authors']" +"@opentelemetry/api-logs","https://github.com/open-telemetry/opentelemetry-js","['Apache-2.0']","['OpenTelemetry Authors']" +"@opentelemetry/core","https://github.com/open-telemetry/opentelemetry-js","['Apache-2.0']","['OpenTelemetry Authors']" +"@opentelemetry/resources","https://github.com/open-telemetry/opentelemetry-js","['Apache-2.0']","['OpenTelemetry Authors']" +"@opentelemetry/semantic-conventions","https://github.com/open-telemetry/opentelemetry-js","['Apache-2.0']","['OpenTelemetry Authors']" +"@oxc-parser/binding-android-arm-eabi","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-android-arm64","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-darwin-arm64","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-darwin-x64","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-freebsd-x64","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-linux-arm-gnueabihf","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-linux-arm-musleabihf","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-linux-arm64-gnu","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-linux-arm64-musl","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-linux-ppc64-gnu","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-linux-riscv64-gnu","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-linux-riscv64-musl","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-linux-s390x-gnu","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-linux-x64-gnu","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-linux-x64-musl","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-openharmony-arm64","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-wasm32-wasi","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-win32-arm64-msvc","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-win32-ia32-msvc","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-parser/binding-win32-x64-msvc","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@oxc-project/types","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"@tybys/wasm-util","https://github.com/toyobayashi/wasm-util","['MIT']","['toyobayashi']" +"@types/estree","https://github.com/DefinitelyTyped/DefinitelyTyped","['MIT']","['DefinitelyTyped']" +"acorn","https://github.com/acornjs/acorn","['MIT']","['acornjs']" +"acorn-import-attributes","https://github.com/xtuc/acorn-import-attributes","['MIT']","['Sven Sauleau']" +"argparse","https://github.com/nodeca/argparse","['Python-2.0']","['nodeca']" +"astring","https://github.com/davidbonnet/astring","['MIT']","['David Bonnet']" +"cjs-module-lexer","https://github.com/nodejs/cjs-module-lexer","['MIT']","['Guy Bedford']" +"crypto-randomuuid","npm:crypto-randomuuid","['MIT']","['Stephen Belanger']" +"dc-polyfill","https://github.com/DataDog/dc-polyfill","['MIT']","['Thomas Hunter II']" +"dd-trace","https://github.com/DataDog/dd-trace-js","['(Apache-2.0 OR BSD-3-Clause)']","['Datadog Inc. ']" +"detect-newline","https://github.com/sindresorhus/detect-newline","['MIT']","['Sindre Sorhus']" +"escape-string-regexp","https://github.com/sindresorhus/escape-string-regexp","['MIT']","['Sindre Sorhus']" +"esquery","https://github.com/estools/esquery","['BSD-3-Clause']","['Joel Feenstra']" +"estraverse","https://github.com/estools/estraverse","['BSD-2-Clause']","['estools']" +"fast-fifo","https://github.com/mafintosh/fast-fifo","['MIT']","['Mathias Buus']" +"import-in-the-middle","https://github.com/nodejs/import-in-the-middle","['Apache-2.0']","['Bryan English']" +"istanbul-lib-coverage","https://github.com/istanbuljs/istanbuljs","['BSD-3-Clause']","['Krishnan Anantheswaran']" +"jest-docblock","https://github.com/jestjs/jest","['MIT']","['jestjs']" +"js-yaml","https://github.com/nodeca/js-yaml","['MIT']","['Vladimir Zapparov']" +"jsep","https://github.com/EricSmekens/jsep","['MIT']","['Stephen Oney']" +"jsonpath-plus","https://github.com/JSONPath-Plus/JSONPath","['MIT']","['Stefan Goessner']" +"limiter","https://github.com/jhurliman/node-rate-limiter","['MIT']","['John Hurliman']" +"lodash.sortby","https://github.com/lodash/lodash","['MIT']","['John-David Dalton']" +"long","https://github.com/dcodeIO/long.js","['Apache-2.0']","['Daniel Wirtz']" +"lru-cache","https://github.com/isaacs/node-lru-cache","['ISC']","['Isaac Z. Schlueter']" +"meriyah","https://github.com/meriyah/meriyah","['ISC']","['Kenny F.']" +"module-details-from-path","https://github.com/watson/module-details-from-path","['MIT']","['Thomas Watson']" +"mutexify","https://github.com/mafintosh/mutexify","['MIT']","['Mathias Buus']" +"node-addon-api","https://github.com/nodejs/node-addon-api","['MIT']","['nodejs']" +"node-gyp-build","https://github.com/prebuild/node-gyp-build","['MIT']","['Mathias Buus']" +"opentracing","https://github.com/opentracing/opentracing-javascript","['Apache-2.0']","['opentracing']" +"oxc-parser","https://github.com/oxc-project/oxc","['MIT']","['Boshen and oxc contributors']" +"pprof-format","https://github.com/DataDog/pprof-format","['MIT']","['Datadog Inc.']" +"protobufjs","https://github.com/protobufjs/protobuf.js","['BSD-3-Clause']","['Daniel Wirtz']" +"queue-tick","https://github.com/mafintosh/queue-tick","['MIT']","['Mathias Buus']" +"retry","https://github.com/tim-kos/node-retry","['MIT']","['Tim Koschützki']" +"rfdc","https://github.com/davidmarkclements/rfdc","['MIT']","['David Mark Clements']" +"semifies","https://github.com/holepunchto/semifies","['Apache-2.0']","['Holepunch Inc']" +"shell-quote","https://github.com/ljharb/shell-quote","['MIT']","['James Halliday']" +"source-map","https://github.com/mozilla/source-map","['BSD-3-Clause']","['Nick Fitzgerald']" +"spark-md5","https://github.com/satazor/js-spark-md5","['(WTFPL OR MIT)']","['André Cruz']" +"tlhunter-sorted-set","https://github.com/tlhunter/node-sorted-set","['MIT']","['Thomas Hunter II']" +"tslib","https://github.com/microsoft/tslib","['0BSD']","['Microsoft Corp.']" +"ttl-set","https://github.com/watson/ttl-set","['MIT']","['Thomas Watson']" +"aws-lambda-nodejs-runtime-interface-client","https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/blob/v2.1.0/src/utils/UserFunction.ts","['Apache-2.0']","['Amazon.com Inc. or its affiliates']" +"is-git-url","https://github.com/jonschlinkert/is-git-url/blob/396965ffabf2f46656c8af4c47bef1d69f09292e/index.js#L9C15-L9C87","['MIT']","['Jon Schlinkert']" diff --git a/MIGRATING.md b/MIGRATING.md index 766af9b774..d21a0e3d80 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -19,6 +19,38 @@ The deprecated `whitelist` / `blacklist` plugin options on the `http`, `ioredis` surface. Use `allowlist` / `blocklist` instead — both have been the canonical names for several majors. +### `Span.addTags` only accepts plain objects + +`Span.addTags` historically dispatched on a `'key:val,key:val'` string +or an array (of strings, arrays, or objects, recursively) on top of the +documented `{ [key]: value }` form. Neither shape ever appeared in the +public TypeScript surface and no v6 caller passes one. v6 drops both +paths: `addTags` is now a thin `Object.assign` onto the span's tag map. +Convert string or array inputs to plain objects at the call site before +calling `addTags`. + +```js +// Before (still works on v5) +span.addTags('env:prod,version:1.2.3') + +// After +span.addTags({ env: 'prod', version: '1.2.3' }) +``` + +### `Span.addLink(spanContext, attributes)` legacy overload removed + +`Span.addLink` (both the OpenTracing-style API and the OpenTelemetry bridge) +no longer accepts a positional `(spanContext, attributes)` form. Pass the +single-argument shape instead: `addLink({ context, attributes })`. + +```js +// Before (still works on v5) +span.addLink(otherSpan.context(), { foo: 'bar' }) + +// After +span.addLink({ context: otherSpan.context(), attributes: { foo: 'bar' } }) +``` + ### `DD_TRACE_STARTUP_LOGS` defaults to `true` Startup configuration is logged to the console by default. Set diff --git a/benchmark/sirun/Dockerfile b/benchmark/sirun/Dockerfile index d033dc738e..e6f82fc1a7 100644 --- a/benchmark/sirun/Dockerfile +++ b/benchmark/sirun/Dockerfile @@ -40,7 +40,7 @@ RUN mkdir /opt/insecure-bank-js RUN git clone --depth 1 https://github.com/hdiv/insecure-bank-js.git /opt/insecure-bank-js WORKDIR /opt/insecure-bank-js -RUN git checkout 2003d9085a6e9a679e31fd88719e4de030d6855f +RUN git checkout 5755d091e6a5dc965a8e7c6d4fb79b2f0ea06d9a RUN . $NVM_DIR/nvm.sh \ && npm ci \ && npm cache clean --force diff --git a/benchmark/sirun/encoding/README.md b/benchmark/sirun/encoding/README.md index 957102dabc..6638142886 100644 --- a/benchmark/sirun/encoding/README.md +++ b/benchmark/sirun/encoding/README.md @@ -1,9 +1,36 @@ -This test sends a single trace many times to the encoder. Each trace is -pre-formatted (as the encoder requires) and consists of 30 spans with the same -content in each of them. The IDs are all randomized. A null writer is provided -to the encoder, so writing operations are not included here. +This bench sends a single pre-formatted trace through the encoder many times +with a null writer, so all I/O is excluded and the cost being measured is the +encoder itself. -The span content contains three metas, three metrics, and reasonable values for -everything else. +The trace shape (`trace-fixture.js`) mirrors a typical Node.js HTTP-service +request: one root Express server span plus a fan of internal middleware spans, +Postgres / Redis client spans, a few outbound HTTP client spans, DNS lookups, +and one error-bearing span with `error.message` / `error.stack`. The default +trace has 30 spans; `TRACE_SPANS=` scales the composition proportionally. -The two variants correspond to the v0.4 and v0.5 encoders. +Strings reuse the same keys (`span.kind`, `component`, `runtime-id`, …) and +the same hot values (`GET`, `server`, `client`, `internal`, `javascript`, …) +across spans, mirroring what the encoder's string cache sees in production. + +`tickTrace(trace, iteration)` runs before every encode and rewrites the +per-request dynamic fields in place: `start` nanos and `duration` +advance, the low half of every ID buffer is rewritten, `db.row_count` +on the Postgres spans jitters, the root span rotates through eight +coherent request shapes (route / URL / resource / status / client IP), +and the error span rotates through four type/message/stack variants +(each stack ~1.5 KB). Without that, every iteration encodes +byte-identical data and V8 collapses the integer magnitude branches +plus the stale-string cache hits the encoder is meant to exercise. +`attachFreshEvents` does the same for `span_events` (legacy path). + +The `+ iteration * 4096` step on `span.start` is well above the +IEEE-754 double's ULP at the ~1.7e18 nano-timestamp magnitude (256 +nanos); fixing that precision loss properly needs the span to carry +`start` as a `BigInt` instead of a `Number`, which is a separate +change on the tracer side, not here. + +Variants: + +- `0.4` / `0.5` — wire-format variants of the agent encoder. +- `0.4-events-native` / `0.4-events-legacy` / `0.5-events-legacy` — + exercise the span-event encoding paths (`WITH_SPAN_EVENTS`). diff --git a/benchmark/sirun/encoding/index.js b/benchmark/sirun/encoding/index.js index 04f3c74945..c1cdd55989 100644 --- a/benchmark/sirun/encoding/index.js +++ b/benchmark/sirun/encoding/index.js @@ -5,69 +5,21 @@ const assert = require('node:assert/strict') const { ENCODER_VERSION, WITH_SPAN_EVENTS = 'none', + TRACE_SPANS, } = process.env const { AgentEncoder } = require(`../../../packages/dd-trace/src/encode/${ENCODER_VERSION}`) -const id = require('../../../packages/dd-trace/src/id') +const { buildTrace, tickTrace, attachFreshEvents } = require('./trace-fixture') -const writer = { - flush: () => {}, -} - -function createSpan (parent) { - const spanId = id() - return { - trace_id: parent ? parent.trace_id : spanId, - span_id: spanId, - parent_id: parent ? parent.parent_id : id(0), - name: 'this is a name', - resource: 'this is a resource', - error: 0, - start: 1415926535897, - duration: 100, - meta: { - a: 'b', - hello: 'world', - and: 'this is a longer string, just because we want to test some longer strongs, got it? okay', - }, - metrics: { - b: 45, - something: 98764389, - afloaty: 203987465.756754, - }, - } -} - -const trace = [] -for (let parent = null, index = 0; index < 30; index++) { - const span = createSpan(parent) - trace.push(span) - parent = span -} - -const ATTR_TEMPLATE_HTTP_OK = { attempt: 1, ratio: 0.5, ok: true, kind: 'http.client', codes: [200, 204] } -const ATTR_TEMPLATE_HTTP_ERR = { attempt: 2, ratio: 0.6, ok: false, kind: 'http.server', codes: [500, 503] } -const ATTR_TEMPLATE_DB = { attempt: 3, ratio: 0.7, ok: true, kind: 'db.query', codes: [42] } - -// `encoder.encode` consumes its input: the legacy path deletes `span.span_events` -// after writing `meta.events`; the native path wraps each attribute primitive into -// a typed object that the next pass would then drop. Rebuilding per iteration is -// the only way to measure the same encoder work on every iteration. -function attachFreshEvents () { - for (const span of trace) { - span.span_events = [ - { name: 'http.attempt', time_unix_nano: 1_415_926_535_897, attributes: { ...ATTR_TEMPLATE_HTTP_OK } }, - { name: 'http.failure', time_unix_nano: 1_415_926_535_898, attributes: { ...ATTR_TEMPLATE_HTTP_ERR } }, - { name: 'db.query', time_unix_nano: 1_415_926_535_899, attributes: { ...ATTR_TEMPLATE_DB } }, - ] - } -} +const writer = { flush: () => {} } +const trace = buildTrace(TRACE_SPANS ? Number(TRACE_SPANS) : 30) const encoder = new AgentEncoder(writer) -// One pre-flight cycle to confirm encoder.encode actually advances state; catches a +// Pre-flight: one cycle to confirm encoder state actually advances; catches a // silent breakage where the fixture or loader skipped the encode path. -if (WITH_SPAN_EVENTS !== 'none') attachFreshEvents() +tickTrace(trace, 0) +if (WITH_SPAN_EVENTS !== 'none') attachFreshEvents(trace, 0) encoder.encode(trace) assert.equal(encoder.count(), 1) assert.ok(encoder._traceBytes.length > 0) @@ -75,11 +27,13 @@ encoder._reset() if (WITH_SPAN_EVENTS === 'none') { for (let iteration = 0; iteration < 5000; iteration++) { + tickTrace(trace, iteration) encoder.encode(trace) } } else { for (let iteration = 0; iteration < 5000; iteration++) { - attachFreshEvents() + tickTrace(trace, iteration) + attachFreshEvents(trace, iteration) encoder.encode(trace) } } diff --git a/benchmark/sirun/encoding/trace-fixture.js b/benchmark/sirun/encoding/trace-fixture.js new file mode 100644 index 0000000000..9c426f1beb --- /dev/null +++ b/benchmark/sirun/encoding/trace-fixture.js @@ -0,0 +1,701 @@ +'use strict' + +// Realistic post-`format()` trace fixture. The shape mirrors what a typical +// Node.js HTTP service sends: one Express server span at the root, a fan of +// internal middleware spans, a few Postgres / Redis client spans, a couple of +// outbound HTTP client spans, and a few low-level DNS/net spans. +// +// Strings deliberately reuse the same keys and values across spans because +// that is what the string cache sees in production (every span carries +// `span.kind`, `component`, `language`, `runtime-id`, env, version, etc.). +// Sizes (URLs, useragents, SQL statements, stack traces) are chosen to land +// in the same rough bucket as the production trace samples we benchmark +// against. +// +// The trace object is reused across iterations to keep allocation cost out +// of the measurement, but `tickTrace` mutates the per-request dynamic +// fields (timestamps, durations, ID bytes, event times, a handful of +// status codes / row counts) before every encode. Without that, every +// iteration encodes byte-identical data and V8 can collapse the integer +// magnitude branches the encoder is meant to exercise. + +const SERVICE = 'frontend-api' +const ENV = 'production' +const VERSION = '1.42.3' +const HOSTNAME = 'ip-10-0-12-83.ec2.internal' +const RUNTIME_ID = '01999999-1234-5678-90ab-cdef01234567' +const TRACE_TID_HIGH = '6634b8e500000000' +const PROCESS_ID = 12_345 +const USER_AGENT = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' + +// Realistic error stacks (~1.5 KB each). The formatter slices long strings at +// MAX_META_VALUE_LENGTH; in production `error.stack` frequently fills it. +// `tickTrace` rotates the error span through the pool so the encoder's +// large-string path (which bypasses the v0.4 cache and walks `_stringBytes` +// directly on the v0.5 wire) doesn't see one cached value forever. +const ERROR_VARIANTS = [ + { + type: 'Error', + message: 'connect ECONNREFUSED 10.0.5.42:6379', + stack: 'Error: connect ECONNREFUSED 10.0.5.42:6379\n' + + ' at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1495:16)\n' + + ' at Socket._final (node:net:480:12)\n' + + ' at callFinal (node:internal/streams/writable:701:27)\n' + + ' at prefinish (node:internal/streams/writable:734:7)\n' + + ' at finishMaybe (node:internal/streams/writable:744:5)\n' + + ' at Writable.end (node:internal/streams/writable:642:5)\n' + + ' at RedisSocket.connect (/app/node_modules/@redis/client/dist/lib/client/socket.js:122:14)\n' + + ' at RedisClient.connect (/app/node_modules/@redis/client/dist/lib/client/index.js:241:21)\n' + + ' at Object. (/app/dist/services/cache.js:18:12)\n' + + ' at Module._compile (node:internal/modules/cjs/loader:1830:14)', + }, + { + type: 'TimeoutError', + message: 'Query timeout exceeded after 5000ms', + stack: 'TimeoutError: Query timeout exceeded after 5000ms\n' + + ' at Timeout._onTimeout (/app/node_modules/pg-pool/index.js:184:25)\n' + + ' at listOnTimeout (node:internal/timers:573:17)\n' + + ' at process.processTimers (node:internal/timers:514:7)\n' + + ' at PostgresAdapter.query (/app/dist/db/postgres.js:124:18)\n' + + ' at async UserRepository.findFeed (/app/dist/repos/user.js:71:24)\n' + + ' at async FeedController.get (/app/dist/controllers/feed.js:32:21)\n' + + ' at async dispatch (/app/node_modules/express/lib/router/route.js:128:14)\n' + + ' at async Layer.handleRequest (/app/node_modules/express/lib/router/layer.js:95:5)\n' + + ' at async next (/app/node_modules/express/lib/router/route.js:144:13)\n' + + ' at async Function.handle (/app/node_modules/express/lib/router/index.js:284:10)', + }, + { + type: 'ValidationError', + message: 'invalid request body: field "user_id" is required', + stack: 'ValidationError: invalid request body: field "user_id" is required\n' + + ' at Validator.validate (/app/node_modules/joi/lib/validator.js:91:14)\n' + + ' at SessionService.create (/app/dist/services/session.js:48:18)\n' + + ' at AuthController.login (/app/dist/controllers/auth.js:62:34)\n' + + ' at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n' + + ' at async dispatch (/app/node_modules/express/lib/router/route.js:128:14)\n' + + ' at async Layer.handleRequest (/app/node_modules/express/lib/router/layer.js:95:5)\n' + + ' at async next (/app/node_modules/express/lib/router/route.js:144:13)\n' + + ' at async Function.handle (/app/node_modules/express/lib/router/index.js:284:10)\n' + + ' at async tracedHandler (/app/node_modules/dd-trace/lib/plugins/express.js:43:18)', + }, + { + type: 'HTTPError', + message: 'Request to upstream "billing-service" failed: 503 Service Unavailable', + stack: 'HTTPError: Request to upstream "billing-service" failed: 503 Service Unavailable\n' + + ' at HttpClient.handleResponse (/app/dist/clients/http.js:217:13)\n' + + ' at Object.onceWrapper (node:events:631:28)\n' + + ' at IncomingMessage.emit (node:events:517:28)\n' + + ' at endReadableNT (node:internal/streams/readable:1421:12)\n' + + ' at process.processTicksAndRejections (node:internal/process/task_queues:82:21)\n' + + ' at async BillingService.recordUsage (/app/dist/services/billing.js:71:14)\n' + + ' at async UsageMiddleware.afterRequest (/app/dist/middleware/usage.js:38:7)\n' + + ' at async dispatch (/app/node_modules/express/lib/router/route.js:128:14)\n' + + ' at async Layer.handleRequest (/app/node_modules/express/lib/router/layer.js:95:5)\n' + + ' at async next (/app/node_modules/express/lib/router/route.js:144:13)', + }, +] + +// Per-request server-span variance. Each entry is a coherent "request +// shape": the route, the URL that hits it, the resource name we report, +// the client IP it came from, and the status code it returns. Rotating +// through these on every encode keeps the encoder's string cache seeing +// the production pattern of "most requests are 200 on a small handful +// of routes, occasional 4xx/5xx, cold values appear regularly". +const REQUEST_VARIANTS = [ + { + route: '/api/users/:id/feed', + url: 'https://api.example.com/api/users/123/feed?include=posts,profile&limit=20', + resource: 'GET /api/users/:id/feed', + clientIp: '10.0.5.42', + status: '200', + }, + { + route: '/api/users/:id/feed', + url: 'https://api.example.com/api/users/8472/feed?include=posts', + resource: 'GET /api/users/:id/feed', + clientIp: '10.0.6.118', + status: '200', + }, + { + route: '/api/posts/:id/comments', + url: 'https://api.example.com/api/posts/74201/comments?page=2&limit=50', + resource: 'GET /api/posts/:id/comments', + clientIp: '10.0.7.91', + status: '200', + }, + { + route: '/api/sessions', + url: 'https://api.example.com/api/sessions', + resource: 'POST /api/sessions', + clientIp: '10.0.5.42', + status: '201', + }, + { + route: '/api/notifications', + url: 'https://api.example.com/api/notifications?unread_only=true', + resource: 'GET /api/notifications', + clientIp: '10.0.8.13', + status: '200', + }, + { + route: '/api/search', + url: 'https://api.example.com/api/search?q=node.js+tracing&page=1', + resource: 'GET /api/search', + clientIp: '10.0.5.77', + status: '200', + }, + { + route: '/api/users/:id', + url: 'https://api.example.com/api/users/9988', + resource: 'GET /api/users/:id', + clientIp: '10.0.4.201', + status: '404', + }, + { + route: '/api/billing/usage', + url: 'https://api.example.com/api/billing/usage?from=2026-05-01&to=2026-05-27', + resource: 'POST /api/billing/usage', + clientIp: '10.0.3.55', + status: '500', + }, +] + +const MIDDLEWARE_NAMES = [ + 'helmet', 'cors', 'compression', 'bodyParser.json', 'cookieParser', + 'session', 'passport.initialize', 'passport.session', 'csurf', 'authenticate', + 'rateLimiter', 'requestLogger', 'tenantResolver', +] + +const SQL_STATEMENTS = [ + 'SELECT u.id, u.email, u.name, u.created_at, p.bio, p.avatar_url FROM users u ' + + 'LEFT JOIN profiles p ON p.user_id = u.id WHERE u.id = $1 LIMIT 1', + 'SELECT id, title, body, author_id, published_at FROM posts ' + + 'WHERE author_id = $1 AND published_at IS NOT NULL ORDER BY published_at DESC LIMIT 20', + 'UPDATE sessions SET last_seen_at = NOW(), ip_address = $2 WHERE id = $1', + 'INSERT INTO audit_log (actor_id, action, target_id, payload) VALUES ($1, $2, $3, $4::jsonb) RETURNING id', + 'SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL', + 'SELECT 1', +] + +const REDIS_COMMANDS = [ + 'GET user:123:profile', + 'SETEX session:abcd1234 3600', + 'HMGET feature_flags:tenant:42 dark_mode billing_v2', + 'INCR ratelimit:ip:10.0.5.42:GET:/api/users', + 'EXPIRE ratelimit:ip:10.0.5.42:GET:/api/users 60', +] + +const HTTP_DOWNSTREAMS = [ + { method: 'GET', url: 'https://auth.internal.example.com/v1/sessions/abcd1234/validate', service: 'auth-service' }, + { method: 'POST', url: 'https://billing.internal.example.com/v2/usage/record', service: 'billing-service' }, + { method: 'GET', url: 'https://search.internal.example.com/v3/index/posts?q=node.js&limit=20', service: 'search-service' }, +] + +// `MutableIdentifier` shadows the public surface of `packages/dd-trace/src/id.js` +// that the encoder actually uses (`toBuffer`, `toArray`), but exposes the +// backing `Uint8Array` so `tickTrace` can rewrite per-request bytes without +// touching the real `Identifier`'s private cache fields (which would go stale +// after the first `toString` / `toBigInt` call). The encoder never calls +// `toString` on these in either v0.4 or v0.5, so the shorter contract is fine. +class MutableIdentifier { + /** @param {number} seed deterministic per-span seed so spans are distinguishable before the first tick. */ + constructor (seed) { + const buffer = new Uint8Array(8) + // Knuth multiplier (2654435761) gives well-spread bytes across small seeds. + let x = (seed * 2_654_435_761) >>> 0 + // Force the top bit clear so the int64 stays positive, matching the + // production `pseudoRandom` shape in id.js. + buffer[0] = (x >>> 24) & 0x7F + buffer[1] = (x >>> 16) & 0xFF + buffer[2] = (x >>> 8) & 0xFF + buffer[3] = x & 0xFF + x = ((x ^ (x >>> 16)) * 0x85_eb_ca_6b) >>> 0 + buffer[4] = (x >>> 24) & 0xFF + buffer[5] = (x >>> 16) & 0xFF + buffer[6] = (x >>> 8) & 0xFF + buffer[7] = x & 0xFF + this._buffer = buffer + } + + toBuffer () { return this._buffer } + toArray () { return this._buffer } +} + +// The agent's intake refuses negative parent_id, so the root parent is a +// zero buffer (matches `id('0')` in production). +const ZERO_ID = (() => { + const idObj = new MutableIdentifier(0) + idObj._buffer.fill(0) + return idObj +})() + +let idSeed = 1 +function newId () { + return new MutableIdentifier(idSeed++) +} + +function commonMeta () { + return { + language: 'javascript', + 'runtime-id': RUNTIME_ID, + env: ENV, + version: VERSION, + '_dd.p.dm': '-1', + '_dd.p.tid': TRACE_TID_HIGH, + } +} + +function commonMetrics () { + return { + _sampling_priority_v1: 1, + process_id: PROCESS_ID, + '_dd.tracer_kr': 1, + '_dd.agent_psr': 1, + } +} + +function makeServerSpan (traceId, parentId, startNs) { + const variant = REQUEST_VARIANTS[0] + return { + trace_id: traceId, + span_id: newId(), + parent_id: parentId, + name: 'express.request', + resource: variant.resource, + service: SERVICE, + type: 'web', + error: 0, + start: startNs, + duration: 47_321_456, + meta: { + ...commonMeta(), + 'span.kind': 'server', + component: 'express', + 'http.method': 'GET', + 'http.url': variant.url, + 'http.route': variant.route, + 'http.status_code': variant.status, + 'http.useragent': USER_AGENT, + 'http.client_ip': variant.clientIp, + 'http.host': 'api.example.com', + 'network.client.ip': variant.clientIp, + '_dd.base_service': SERVICE, + '_dd.origin': '', + '_dd.hostname': HOSTNAME, + }, + metrics: { + ...commonMetrics(), + '_dd.measured': 1, + '_dd.top_level': 1, + '_dd.rule_psr': 1, + '_dd.limit_psr': 1, + }, + } +} + +function makeMiddlewareSpan (traceId, parentId, startNs, index) { + const middlewareName = MIDDLEWARE_NAMES[index % MIDDLEWARE_NAMES.length] + return { + trace_id: traceId, + span_id: newId(), + parent_id: parentId, + name: 'express.middleware', + resource: middlewareName, + service: SERVICE, + type: 'web', + error: 0, + start: startNs, + duration: 213_456, + meta: { + ...commonMeta(), + 'span.kind': 'internal', + component: 'express', + 'express.type': 'middleware', + 'resource.name': middlewareName, + }, + metrics: { + ...commonMetrics(), + }, + } +} + +function makePostgresSpan (traceId, parentId, startNs, index) { + const statement = SQL_STATEMENTS[index % SQL_STATEMENTS.length] + return { + trace_id: traceId, + span_id: newId(), + parent_id: parentId, + name: 'pg.query', + resource: statement.slice(0, 60), + service: `${SERVICE}-postgres`, + type: 'sql', + error: 0, + start: startNs, + duration: 4_123_789, + meta: { + ...commonMeta(), + 'span.kind': 'client', + component: 'pg', + 'db.type': 'postgres', + 'db.name': 'production_app', + 'db.user': 'app_reader', + 'db.instance': 'production_app', + 'db.statement': statement, + 'out.host': 'db-replica-3.internal.example.com', + 'network.destination.name': 'db-replica-3.internal.example.com', + 'peer.service': 'production_app', + '_dd.peer.service.source': 'db.instance', + '_dd.base_service': SERVICE, + }, + metrics: { + ...commonMetrics(), + '_dd.measured': 1, + 'network.destination.port': 5432, + 'db.row_count': 17, + }, + } +} + +function makeRedisSpan (traceId, parentId, startNs, index) { + const command = REDIS_COMMANDS[index % REDIS_COMMANDS.length] + return { + trace_id: traceId, + span_id: newId(), + parent_id: parentId, + name: 'redis.command', + resource: command.split(' ', 1)[0], + service: `${SERVICE}-redis`, + type: 'redis', + error: 0, + start: startNs, + duration: 312_456, + meta: { + ...commonMeta(), + 'span.kind': 'client', + component: 'redis', + 'db.type': 'redis', + 'db.name': '0', + 'redis.raw_command': command, + 'out.host': 'cache-primary.internal.example.com', + 'network.destination.name': 'cache-primary.internal.example.com', + 'peer.service': 'cache-primary', + '_dd.peer.service.source': 'out.host', + '_dd.base_service': SERVICE, + }, + metrics: { + ...commonMetrics(), + '_dd.measured': 1, + 'network.destination.port': 6379, + }, + } +} + +function makeHttpClientSpan (traceId, parentId, startNs, index) { + const downstream = HTTP_DOWNSTREAMS[index % HTTP_DOWNSTREAMS.length] + const host = new URL(downstream.url).host + return { + trace_id: traceId, + span_id: newId(), + parent_id: parentId, + name: 'http.request', + resource: downstream.method, + service: `${SERVICE}-http-client`, + type: 'http', + error: 0, + start: startNs, + duration: 8_421_657, + meta: { + ...commonMeta(), + 'span.kind': 'client', + component: 'http', + 'http.method': downstream.method, + 'http.url': downstream.url, + 'http.status_code': '200', + 'out.host': host, + 'network.destination.name': host, + 'peer.service': downstream.service, + '_dd.peer.service.source': 'out.host', + '_dd.base_service': SERVICE, + }, + metrics: { + ...commonMetrics(), + '_dd.measured': 1, + 'network.destination.port': 443, + }, + } +} + +function makeDnsSpan (traceId, parentId, startNs, host) { + return { + trace_id: traceId, + span_id: newId(), + parent_id: parentId, + name: 'dns.lookup', + resource: host, + service: SERVICE, + type: 'dns', + error: 0, + start: startNs, + duration: 142_876, + meta: { + ...commonMeta(), + 'span.kind': 'internal', + component: 'dns', + 'dns.hostname': host, + }, + metrics: { + ...commonMetrics(), + }, + } +} + +function makeErrorSpan (traceId, parentId, startNs) { + const variant = ERROR_VARIANTS[0] + return { + trace_id: traceId, + span_id: newId(), + parent_id: parentId, + name: 'redis.command', + resource: 'CONNECT', + service: `${SERVICE}-redis`, + type: 'redis', + error: 1, + start: startNs, + duration: 1_004_211, + meta: { + ...commonMeta(), + 'span.kind': 'client', + component: 'redis', + 'db.type': 'redis', + 'out.host': 'cache-primary.internal.example.com', + 'error.type': variant.type, + 'error.message': variant.message, + 'error.stack': variant.stack, + '_dd.base_service': SERVICE, + }, + metrics: { + ...commonMetrics(), + '_dd.measured': 1, + }, + } +} + +/** + * Build a single realistic Node.js HTTP-request trace. + * + * Layout for the default 30-span trace: + * - 1 root `express.request` (server) + * - 13 `express.middleware` spans (internal, kind=internal) + * - 6 `pg.query` (sql/client) + * - 4 `redis.command` (cache/client) + * - 3 `http.request` outbound (client) + * - 2 `dns.lookup` (internal) + * - 1 error `redis.command` carrying error.message/error.stack + * + * The returned trace is meant to be reused across iterations; call + * `tickTrace(trace, iteration)` before each encode to refresh the + * per-request dynamic fields. + * + * @param {number} [spanCount] total number of spans in the trace (default 30). + * @returns {object[]} + */ +function buildTrace (spanCount = 30) { + const trace = [] + const rootStart = 1_715_926_535_897_000_000 + const traceId = newId() + const rootSpan = makeServerSpan(traceId, ZERO_ID, rootStart) + trace.push(rootSpan) + + // Composition is proportional, so callers can scale spanCount up or down. + const remaining = spanCount - 1 + const counts = { + middleware: Math.round(remaining * 0.45), + pg: Math.round(remaining * 0.21), + redis: Math.round(remaining * 0.14), + http: Math.round(remaining * 0.10), + dns: Math.round(remaining * 0.07), + } + counts.error = Math.max(0, remaining - counts.middleware - counts.pg - counts.redis - counts.http - counts.dns) + + let offsetNs = 200_000 + let middlewareIndex = 0 + + for (let i = 0; i < counts.middleware; i++) { + trace.push(makeMiddlewareSpan(traceId, rootSpan.span_id, rootStart + offsetNs, middlewareIndex++)) + offsetNs += 350_000 + } + for (let i = 0; i < counts.pg; i++) { + trace.push(makePostgresSpan(traceId, rootSpan.span_id, rootStart + offsetNs, i)) + offsetNs += 4_200_000 + } + for (let i = 0; i < counts.redis; i++) { + trace.push(makeRedisSpan(traceId, rootSpan.span_id, rootStart + offsetNs, i)) + offsetNs += 320_000 + } + for (let i = 0; i < counts.http; i++) { + const httpSpan = makeHttpClientSpan(traceId, rootSpan.span_id, rootStart + offsetNs, i) + trace.push(httpSpan) + offsetNs += 8_500_000 + if (counts.dns > 0) { + const dnsHost = new URL(HTTP_DOWNSTREAMS[i % HTTP_DOWNSTREAMS.length].url).host + trace.push(makeDnsSpan(traceId, httpSpan.span_id, rootStart + offsetNs, dnsHost)) + counts.dns-- + offsetNs += 150_000 + } + } + while (counts.dns > 0) { + trace.push(makeDnsSpan(traceId, rootSpan.span_id, rootStart + offsetNs, 'api.example.com')) + counts.dns-- + offsetNs += 150_000 + } + for (let i = 0; i < counts.error; i++) { + trace.push(makeErrorSpan(traceId, rootSpan.span_id, rootStart + offsetNs)) + offsetNs += 1_000_000 + } + + // Pin the base value of every per-request dynamic field so `tickTrace` + // can lay each iteration's delta on top of it without re-deriving the + // offset within the trace. + for (const span of trace) { + span._baseStart = span.start + span._baseDuration = span.duration + if (span.metrics['db.row_count'] !== undefined) span._baseRowCount = span.metrics['db.row_count'] + } + + // Cache the two spans whose string fields rotate per iteration so + // `tickTrace` doesn't walk the trace looking for them. + trace._errorSpan = trace.find((span) => span.error === 1) + + return trace +} + +/** + * Refresh the per-request dynamic fields on a reused trace so each encode + * sees production-shaped variance: monotonically advancing timestamps, a + * narrow duration jitter that stays inside the uint32 magnitude band, new + * low ID bytes (which collapses the encoder's per-span uint64 reads into + * a real load each time), rotating db row counts, a rotating server-span + * request shape (route / URL / resource / status / client IP), and a + * rotating error variant (type / message / multi-KB stack). Defeats V8's + * constant-folding on every dynamic field the encoder hot path touches. + * + * Cost target: a handful of register-bound integer ops per span plus a + * fixed-size string-pool rotation. Anything fancier shows up in the + * bench as setup overhead. + * + * @param {object[]} trace + * @param {number} iteration current iteration index. + */ +function tickTrace (trace, iteration) { + // Trace ID is shared across every span; rewriting it once propagates. + writeIdLowBytes(trace[0].trace_id._buffer, iteration, 0) + + for (let i = 0; i < trace.length; i++) { + const span = trace[i] + // start nano-timestamp climbs each iteration so V8 can't const-fold + // the uint64 path. The +4096 step is well above the IEEE-754 double's + // ULP at the ~1.7e18 base (256 nanos -- below that the value rounds + // back to its base), so every step is a distinct double; a real fix + // for that precision loss needs the span carry a BigInt, scoped to + // its own PR on the tracer side. + span.start = span._baseStart + iteration * 4096 + // Duration jitter stays in the bottom 14 bits so the value never leaves + // the uint32 wire band that production spans live in. + span.duration = span._baseDuration + (iteration & 0x3FFF) + + // Bumping span_id's low half changes the bytes `_encodeId` reads on + // every call. parent_id is a shared reference to the root's span_id + // for most spans, so we only rewrite the unique buffer once per span. + writeIdLowBytes(span.span_id._buffer, iteration, i) + + if (span._baseRowCount !== undefined) { + // db.row_count is a metric; metrics encode as numbers, so jittering + // the value drives both the encoder's number path and (for v0.4) the + // float64 encoding the inherited base class still uses. + span.metrics['db.row_count'] = span._baseRowCount + (iteration % 64) + } + } + + // Root server-span request shape rotates as a coherent unit: the + // status, the URL, the resource, and the client IP all change together, + // matching what one production request looks like across these fields. + const root = trace[0] + const reqVariant = REQUEST_VARIANTS[iteration % REQUEST_VARIANTS.length] + root.resource = reqVariant.resource + const rootMeta = root.meta + rootMeta['http.url'] = reqVariant.url + rootMeta['http.route'] = reqVariant.route + rootMeta['http.status_code'] = reqVariant.status + rootMeta['http.client_ip'] = reqVariant.clientIp + rootMeta['network.client.ip'] = reqVariant.clientIp + + // Error variant rotates the type/message/stack together. The stack is + // the multi-KB string the encoder either bypasses the cache for (v0.4) + // or writes into `_stringBytes` per encode (v0.5), so rotating it is + // the main signal-defeating change in tickTrace. + const errorSpan = trace._errorSpan + if (errorSpan !== undefined) { + const errVariant = ERROR_VARIANTS[iteration % ERROR_VARIANTS.length] + const errMeta = errorSpan.meta + errMeta['error.type'] = errVariant.type + errMeta['error.message'] = errVariant.message + errMeta['error.stack'] = errVariant.stack + } +} + +/** + * Rewrite the low 4 bytes of an 8-byte ID buffer. The top 4 bytes keep the + * per-span seed so spans remain distinguishable on the wire; the low 4 + * bytes carry the iteration index XOR'd with a span-local mixer so two + * spans never share the same low half on the same tick. + * + * @param {Uint8Array} buffer + * @param {number} iteration + * @param {number} mixer + */ +function writeIdLowBytes (buffer, iteration, mixer) { + const v = (iteration ^ (mixer * 0x9E_37_79_B1)) >>> 0 + buffer[4] = (v >>> 24) & 0xFF + buffer[5] = (v >>> 16) & 0xFF + buffer[6] = (v >>> 8) & 0xFF + buffer[7] = v & 0xFF +} + +const EVENT_ATTRIBUTES_HTTP_OK = { attempt: 1, ratio: 0.5, ok: true, kind: 'http.client', codes: [200, 204] } +const EVENT_ATTRIBUTES_HTTP_ERR = { attempt: 2, ratio: 0.6, ok: false, kind: 'http.server', codes: [500, 503] } +const EVENT_ATTRIBUTES_DB = { attempt: 3, ratio: 0.7, ok: true, kind: 'db.query', codes: [42] } + +const EVENT_TIME_BASE_OK = 1_715_926_535_897_000_000 +const EVENT_TIME_BASE_ERR = 1_715_926_535_898_000_000 +const EVENT_TIME_BASE_DB = 1_715_926_535_899_000_000 + +/** + * `encoder.encode` consumes `span_events`: the legacy path stringifies them + * into `meta.events` and clears the field; the native path mutates each + * attribute primitive into a typed wrapper. The trace is reused across + * iterations, so re-attach fresh events before every encode and step the + * event timestamps so they don't const-fold either. + * + * @param {object[]} trace + * @param {number} iteration + */ +function attachFreshEvents (trace, iteration) { + // `+ iteration * 4096` steps each event time by ~4 microseconds, well + // above the ~256-nano ULP of the double at this magnitude so every + // encode sees a fresh number. + const stepped = iteration * 4096 + const okTime = EVENT_TIME_BASE_OK + stepped + const errTime = EVENT_TIME_BASE_ERR + stepped + const dbTime = EVENT_TIME_BASE_DB + stepped + for (const span of trace) { + span.span_events = [ + { name: 'http.attempt', time_unix_nano: okTime, attributes: { ...EVENT_ATTRIBUTES_HTTP_OK } }, + { name: 'http.failure', time_unix_nano: errTime, attributes: { ...EVENT_ATTRIBUTES_HTTP_ERR } }, + { name: 'db.query', time_unix_nano: dbTime, attributes: { ...EVENT_ATTRIBUTES_DB } }, + ] + } +} + +module.exports = { buildTrace, tickTrace, attachFreshEvents } diff --git a/benchmark/sirun/exporting-pipeline/index.js b/benchmark/sirun/exporting-pipeline/index.js index f3ecdbfd0d..ac6ac5255d 100644 --- a/benchmark/sirun/exporting-pipeline/index.js +++ b/benchmark/sirun/exporting-pipeline/index.js @@ -48,6 +48,8 @@ function createSpan (parent) { something: 98764389, afloaty: 203987465.756754, }, + getTag (key) { return this._tags[key] }, + getTags () { return this._tags }, } const span = { context: () => context, diff --git a/benchmark/sirun/spans/spans.js b/benchmark/sirun/spans/spans.js index 51264c9717..e8edb85ad3 100644 --- a/benchmark/sirun/spans/spans.js +++ b/benchmark/sirun/spans/spans.js @@ -31,7 +31,7 @@ const FIELDS_WITH_TAGS_AND_LINKS = { // breakage where the construction shape stopped propagating. const sanitySpan = tracer.startSpan('sanity.span', FIELDS_WITH_TAGS_AND_LINKS) sanitySpan.addEvent('sanity-event', EVENT_ATTRIBUTES) -assert.equal(sanitySpan.context()._tags.service, 'svc') +assert.equal(sanitySpan.context().getTag('service'), 'svc') assert.equal(sanitySpan._links.length, 1) assert.equal(sanitySpan._events.length, 1) sanitySpan.finish() diff --git a/benchmark/sirun/startup/everything-fixture/package-lock.json b/benchmark/sirun/startup/everything-fixture/package-lock.json index 87e223ad13..86043ca550 100644 --- a/benchmark/sirun/startup/everything-fixture/package-lock.json +++ b/benchmark/sirun/startup/everything-fixture/package-lock.json @@ -33,7 +33,7 @@ "pg": "8.20.0", "pino": "10.3.1", "redis": "5.12.1", - "uuid": "9.0.1", + "uuid": "14.0.0", "winston": "3.19.0", "ws": "8.20.1" } @@ -4629,9 +4629,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -5327,17 +5327,16 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/vary": { diff --git a/benchmark/sirun/startup/everything-fixture/package.json b/benchmark/sirun/startup/everything-fixture/package.json index f86888ffb9..114fa90d99 100644 --- a/benchmark/sirun/startup/everything-fixture/package.json +++ b/benchmark/sirun/startup/everything-fixture/package.json @@ -30,7 +30,7 @@ "pg": "8.20.0", "pino": "10.3.1", "redis": "5.12.1", - "uuid": "9.0.1", + "uuid": "14.0.0", "winston": "3.19.0", "ws": "8.20.1" }, diff --git a/benchmark/stubs/span.js b/benchmark/stubs/span.js index 43973accbe..49c05d2b9f 100644 --- a/benchmark/stubs/span.js +++ b/benchmark/stubs/span.js @@ -32,6 +32,8 @@ const span = { _tags: tags, _sampling: {}, _name: 'operation', + getTag: (key) => tags[key], + getTags: () => tags, }), _startTime: 1500000000000.123, _duration: 100, diff --git a/docker-compose.yml b/docker-compose.yml index d80a11012e..75a4bf8b73 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,16 @@ services: - "127.0.0.1:10000:10000" - "127.0.0.1:10001:10001" - "127.0.0.1:10002:10002" + azurecosmosemulator: + image: ghcr.io/datadog/dd-trace-js/mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator@sha256:bf3bd3cbe3fae2005a0745f77b01d1fc8cd04112193f3ee32289ee15ee9ae5fa # vnext-preview + ports: + - "127.0.0.1:8081:8081" + - "127.0.0.1:8080:8080" + - "127.0.0.1:1234:1234" + environment: + ACCEPT_EULA: "Y" + BLOB_SERVER: azurite + METADATA_SERVER: azurite azureeventhubsemulator: image: ghcr.io/datadog/dd-trace-js/mcr.microsoft.com/azure-messaging/eventhubs-emulator@sha256:25ec4141efb69933a0c82e6a787fa147a3895519e7d236d4c41ba568e03100eb # 2.1.0 volumes: @@ -176,6 +186,10 @@ services: - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT - KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 - KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS=0 + nats: + image: nats@sha256:7f430e429d0a90444b38bd40ab7812fd3afcc49a51f6b03a931f9becd5aeb280 # 2.14.1 + ports: + - "127.0.0.1:4222:4222" opensearch: image: opensearchproject/opensearch@sha256:3a73623acf3cdd566ad7d0c6c06190a528e6b8a5d54fe1f4327258e32bd8df26 # 2 environment: diff --git a/docs/API.md b/docs/API.md index 888a113216..2fef0f4cb2 100644 --- a/docs/API.md +++ b/docs/API.md @@ -31,6 +31,7 @@ tracer.use('pg', {
+
@@ -80,6 +81,7 @@ tracer.use('pg', {
+
@@ -113,6 +115,7 @@ tracer.use('pg', { * [apollo](./interfaces/export_.plugins.apollo.html) * [avsc](./interfaces/export_.plugins.avsc.html) * [aws-sdk](./interfaces/export_.plugins.aws_sdk.html) +* [azure-cosmos](./interfaces/export_.plugins.azure_cosmos.html) * [azure-event-hubs](./interfaces/export_.plugins.azure_event_hubs.html) * [azure-functions](./interfaces/export_.plugins.azure_functions.html) * [azure-service-bus](./interfaces/export_.plugins.azure_service_bus.html) @@ -162,6 +165,7 @@ tracer.use('pg', { * [mongoose](./interfaces/export_.plugins.mongoose.html) * [mysql](./interfaces/export_.plugins.mysql.html) * [mysql2](./interfaces/export_.plugins.mysql2.html) +* [nats](./interfaces/export_.plugins.nats.html) * [net](./interfaces/export_.plugins.net.html) * [next](./interfaces/export_.plugins.next.html) * [nyc](./interfaces/export_.plugins.nyc.html) diff --git a/docs/test.ts b/docs/test.ts index efe34e00bc..32d72f75ec 100644 --- a/docs/test.ts +++ b/docs/test.ts @@ -288,6 +288,7 @@ tracer.use('anthropic'); tracer.use('avsc'); tracer.use('aws-sdk'); tracer.use('aws-sdk', awsSdkOptions); +tracer.use('azure-cosmos'); tracer.use('azure-event-hubs') tracer.use('azure-functions'); tracer.use('bullmq'); @@ -373,6 +374,7 @@ tracer.use('mysql'); tracer.use('mysql', { service: () => `my-custom-mysql` }); tracer.use('mysql2'); tracer.use('mysql2', { service: () => `my-custom-mysql2` }); +tracer.use('nats'); tracer.use('net'); tracer.use('next'); tracer.use('next', nextOptions); @@ -585,8 +587,6 @@ const otelTraceState: opentelemetry.TraceState = spanContext.traceState! otelSpan.addLink({ context: spanContext }) otelSpan.addLink({ context: spanContext, attributes: { foo: 'bar' } }) otelSpan.addLinks([{ context: spanContext }, { context: spanContext, attributes: { foo: 'bar' } }]) -otelSpan.addLink(spanContext) -otelSpan.addLink(spanContext, { foo: 'bar' }) // -- LLM Observability -- const llmobsEnableOptions = { diff --git a/eslint-rules/eslint-no-private-tags-access.mjs b/eslint-rules/eslint-no-private-tags-access.mjs new file mode 100644 index 0000000000..28fb1aaf8a --- /dev/null +++ b/eslint-rules/eslint-no-private-tags-access.mjs @@ -0,0 +1,110 @@ +const MESSAGE = 'Direct `_tags` access is forbidden; use `getTag()`, `setTag()`, `getTags()`, ' + + 'etc. on the span context instead.' + +// Convert a simple glob pattern into a RegExp. +// Supports `**` (any path), `*` (any non-slash run), and `?` (single non-slash char). +// Patterns are anchored at the end of the path; if the pattern does not contain a +// path separator, it matches against the basename. Otherwise it matches against +// any suffix of the path (so callers can write `packages/foo/bar.js` and have it +// match `/abs/path/to/packages/foo/bar.js`). +function patternToRegExp (pattern) { + // Escape regex metacharacters except for glob wildcards which we handle below. + // We use placeholder tokens for `**`, `*`, and `?` so the escape step doesn't + // touch them. + const DOUBLE_STAR = '\0DSTAR\0' + const SINGLE_STAR = '\0SSTAR\0' + const SINGLE_Q = '\0SQ\0' + + let p = pattern + .replaceAll('**', DOUBLE_STAR) + .replaceAll('*', SINGLE_STAR) + .replaceAll('?', SINGLE_Q) + + // Escape remaining regex metacharacters. + p = p.replaceAll(/[.+^${}()|[\]\\]/g, '\\$&') + + // Re-insert glob equivalents. + p = p + .replaceAll(DOUBLE_STAR, '.*') + .replaceAll(SINGLE_STAR, '[^/]*') + .replaceAll(SINGLE_Q, '[^/]') + + // Anchor: match either at the start of the path or after a `/`, through to + // the end. This works the same whether the pattern is a basename (no `/`) + // or a path suffix. + return new RegExp(`(?:^|/)${p}$`) +} + +export default { + meta: { + type: 'problem', + docs: { + description: + 'Disallow direct member access to the `_tags` field on span contexts. ' + + 'Use the public accessor API (`getTag`, `setTag`, `getTags`, `hasTag`, `deleteTag`, `clearTags`) instead.', + }, + messages: { + noPrivateTagsAccess: MESSAGE, + }, + schema: [ + { + type: 'object', + properties: { + allowFiles: { + type: 'array', + items: { type: 'string' }, + }, + }, + additionalProperties: false, + }, + ], + }, + + create (context) { + const options = context.options[0] || {} + const allowFiles = Array.isArray(options.allowFiles) ? options.allowFiles : [] + const filename = context.filename || context.getFilename?.() || '' + + // Normalize path separators so glob patterns using `/` match on every platform. + const normalizedFilename = filename.replaceAll('\\', '/') + + const compiledPatterns = allowFiles.map(patternToRegExp) + const isAllowed = compiledPatterns.some((re) => re.test(normalizedFilename)) + + if (isAllowed) return {} + + return { + MemberExpression (node) { + // Skip computed access (e.g. `foo['_tags']` — a string literal — or + // `foo[Symbol(...)]`). Only `.` access counts; the literal + // and symbol forms are explicitly excluded by the rule spec. + if (node.computed) return + + const prop = node.property + if (!prop || prop.type !== 'Identifier' || prop.name !== '_tags') return + + context.report({ + node, + messageId: 'noPrivateTagsAccess', + }) + }, + + // Catch destructuring access: `const { _tags } = ctx`, + // `const { _tags: alias } = ctx`, `function f({ _tags }) {}`, etc. + // Skip computed destructuring (`const { ['_tags']: x } = ctx`) for the + // same reason we skip computed MemberExpression — the dynamic form is + // explicitly outside the rule's scope. + 'ObjectPattern > Property' (node) { + if (node.computed) return + + const key = node.key + if (!key || key.type !== 'Identifier' || key.name !== '_tags') return + + context.report({ + node, + messageId: 'noPrivateTagsAccess', + }) + }, + } + }, +} diff --git a/eslint-rules/eslint-no-private-tags-access.test.mjs b/eslint-rules/eslint-no-private-tags-access.test.mjs new file mode 100644 index 0000000000..3220f2e49c --- /dev/null +++ b/eslint-rules/eslint-no-private-tags-access.test.mjs @@ -0,0 +1,140 @@ +import { RuleTester } from 'eslint' +import rule from './eslint-no-private-tags-access.mjs' + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + }, +}) + +ruleTester.run('eslint-no-private-tags-access', rule, { + valid: [ + // Public accessors are fine. + { code: 'span.context().getTag("k")' }, + { code: 'span.context().setTag("k", "v")' }, + { code: 'span.context().getTags()' }, + { code: 'span.context().hasTag("k")' }, + { code: 'span.context().deleteTag("k")' }, + { code: 'span.context().clearTags()' }, + + // Object-literal key named `_tags` is a shorthand/Property, not a MemberExpression. + { code: 'const x = { _tags: {} }' }, + + // String literal `'_tags'` is just a string literal. + { code: 'const key = "_tags"' }, + + // Computed access via bracket notation with a string literal should be ignored. + { code: 'const v = ctx["_tags"]' }, + + // Computed access via Symbol should also be ignored. + { code: 'const t = ctx[Symbol("_tags")]' }, + + // File on the allowlist may freely access `_tags`. + { + code: 'ctx._tags = {}', + options: [{ allowFiles: ['allowed.js'] }], + filename: '/path/to/allowed.js', + }, + { + code: 'ctx._tags = {}', + options: [{ allowFiles: ['packages/dd-trace/src/opentracing/span_context.js'] }], + filename: '/abs/repo/packages/dd-trace/src/opentracing/span_context.js', + }, + { + code: 'ctx._tags = {}', + options: [{ allowFiles: ['packages/dd-trace/test/opentracing/*.spec.js'] }], + filename: '/abs/repo/packages/dd-trace/test/opentracing/span_context.spec.js', + }, + + // Unrelated `_tags` reference on the basename glob — allowlist matches all. + { + code: 'ctx._tags["k"]', + options: [{ allowFiles: ['**/*.spec.js'] }], + filename: '/abs/repo/test/foo.spec.js', + }, + + // Computed destructuring (dynamic) — same exclusion as computed MemberExpression. + { code: 'const { ["_tags"]: x } = ctx' }, + + // The destructured *source* property is `tags`, not `_tags`; renaming the + // local binding to `_tags` is fine. + { code: 'const { tags: _tags } = ctx' }, + + // Destructuring inside an allowlisted file should be permitted. + { + code: 'const { _tags } = ctx', + options: [{ allowFiles: ['allowed.js'] }], + filename: '/path/to/allowed.js', + }, + { + code: 'const { _tags: aliased } = ctx', + options: [{ allowFiles: ['**/*.spec.js'] }], + filename: '/abs/repo/test/foo.spec.js', + }, + ], + + invalid: [ + { + code: 'const v = ctx._tags', + errors: [{ messageId: 'noPrivateTagsAccess' }], + }, + { + code: 'ctx._tags = {}', + errors: [{ messageId: 'noPrivateTagsAccess' }], + }, + { + code: 'span.context()._tags["k"]', + errors: [{ messageId: 'noPrivateTagsAccess' }], + }, + { + code: 'span.context()._tags.foo', + // `span.context()._tags` is one violation; the outer `.foo` access is + // separate and not a `_tags` access, so only one error. + errors: [{ messageId: 'noPrivateTagsAccess' }], + }, + + // Allowlist that doesn't match the current file should still error. + { + code: 'ctx._tags = {}', + options: [{ allowFiles: ['some/other/file.js'] }], + filename: '/abs/repo/packages/dd-trace/src/foo.js', + errors: [{ messageId: 'noPrivateTagsAccess' }], + }, + + // Destructuring access — shorthand. + { + code: 'const { _tags } = ctx', + errors: [{ messageId: 'noPrivateTagsAccess' }], + }, + + // Destructuring access — renamed. + { + code: 'const { _tags: aliased } = ctx', + errors: [{ messageId: 'noPrivateTagsAccess' }], + }, + + // Destructuring in a function parameter. + { + code: 'function f ({ _tags }) { return _tags }', + errors: [{ messageId: 'noPrivateTagsAccess' }], + }, + + // Nested destructuring still gets flagged. + { + code: 'const { context: { _tags } } = span', + errors: [{ messageId: 'noPrivateTagsAccess' }], + }, + + // Destructuring with a non-matching allowlist should still error. + { + code: 'const { _tags } = ctx', + options: [{ allowFiles: ['some/other/file.js'] }], + filename: '/abs/repo/packages/dd-trace/src/foo.js', + errors: [{ messageId: 'noPrivateTagsAccess' }], + }, + ], +}) + +// eslint-disable-next-line no-console +console.log('eslint-no-private-tags-access tests passed') diff --git a/eslint-rules/eslint-prefer-set-service-name.mjs b/eslint-rules/eslint-prefer-set-service-name.mjs new file mode 100644 index 0000000000..019c1a0019 --- /dev/null +++ b/eslint-rules/eslint-prefer-set-service-name.mjs @@ -0,0 +1,80 @@ +const SERVICE_KEYS = new Set(['service', 'service.name']) +// Constants exported by `ext/tags.js` / wrapper code that resolve to a service key. +// Catching these by name handles `setTag(SERVICE_NAME, x)` and `addTags({ [SERVICE_NAME]: x })`. +const SERVICE_KEY_IDENTIFIERS = new Set(['SERVICE_NAME', 'SERVICE_KEY']) + +function describeServiceKey (node) { + if (!node) return undefined + if (node.type === 'Literal') { + return typeof node.value === 'string' && SERVICE_KEYS.has(node.value) ? node.value : undefined + } + if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) { + const value = node.quasis[0].value.cooked + return SERVICE_KEYS.has(value) ? value : undefined + } + if (node.type === 'Identifier' && SERVICE_KEY_IDENTIFIERS.has(node.name)) { + return node.name + } + return undefined +} + +function describeStaticPropertyKey (property) { + if (property.computed) { + return describeServiceKey(property.key) + } + if (property.key.type === 'Identifier' && SERVICE_KEYS.has(property.key.name)) { + return property.key.name + } + if (property.key.type === 'Literal' && SERVICE_KEYS.has(property.key.value)) { + return property.key.value + } + return undefined +} + +export default { + meta: { + type: 'problem', + docs: { + description: + 'Forbid integration code from writing the `service`/`service.name` tag directly via ' + + '`setTag`/`addTags`. Use `Plugin#setServiceName(span, name)` so the integration\'s ' + + 'intended service is recorded and user overrides are detected at finish time.', + }, + schema: [], + messages: { + preferSetServiceName: + 'Use `this.setServiceName(span, name)` (TracingPlugin) instead of writing `{{key}}` ' + + 'via `{{method}}` directly. Direct writes bypass integration-source tracking and make ' + + 'user overrides indistinguishable from integration values.', + }, + }, + + create (context) { + return { + CallExpression (node) { + const callee = node.callee + if (callee.type !== 'MemberExpression' || callee.computed) return + + const method = callee.property.name + if (method !== 'setTag' && method !== 'addTags') return + + if (method === 'setTag') { + const key = describeServiceKey(node.arguments[0]) + if (key !== undefined) { + context.report({ node, messageId: 'preferSetServiceName', data: { key, method } }) + } + return + } + + const arg = node.arguments[0] + if (!arg || arg.type !== 'ObjectExpression') return + for (const prop of arg.properties) { + if (prop.type !== 'Property') continue + const key = describeStaticPropertyKey(prop) + if (key === undefined) continue + context.report({ node: prop, messageId: 'preferSetServiceName', data: { key, method } }) + } + }, + } + }, +} diff --git a/eslint-rules/eslint-prefer-set-service-name.test.mjs b/eslint-rules/eslint-prefer-set-service-name.test.mjs new file mode 100644 index 0000000000..4e7c002061 --- /dev/null +++ b/eslint-rules/eslint-prefer-set-service-name.test.mjs @@ -0,0 +1,66 @@ +import { RuleTester } from 'eslint' +import rule from './eslint-prefer-set-service-name.mjs' + +const ruleTester = new RuleTester({ + languageOptions: { ecmaVersion: 2022 }, +}) + +ruleTester.run('eslint-prefer-set-service-name', /** @type {import('eslint').Rule.RuleModule} */ (rule), { + valid: [ + // Unrelated tags + "span.setTag('http.status_code', 200)", + "span.setTag('component', 'http')", + "span.addTags({ 'http.method': 'GET' })", + // Computed property in addTags object with non-service key + "span.addTags({ [keyVar]: 'x' })", + // Method on something other than setTag/addTags + "span.set('service.name', 'x')", + // Template literal containing an expression (can't be statically resolved) + // eslint-disable-next-line no-template-curly-in-string + 'span.setTag(`service.${suffix}`, x)', + // Spread-only object — keys unknown + 'span.addTags({ ...meta })', + ], + invalid: [ + { + code: "span.setTag('service', 'my-svc')", + errors: [{ messageId: 'preferSetServiceName' }], + }, + { + code: "span.setTag('service.name', 'my-svc')", + errors: [{ messageId: 'preferSetServiceName' }], + }, + { + code: 'span.setTag(`service.name`, value)', + errors: [{ messageId: 'preferSetServiceName' }], + }, + { + code: "span.addTags({ 'service.name': 'my-svc' })", + errors: [{ messageId: 'preferSetServiceName' }], + }, + { + code: 'span.addTags({ service: name })', + errors: [{ messageId: 'preferSetServiceName' }], + }, + { + code: "span.addTags({ 'http.method': 'GET', 'service.name': 'svc' })", + errors: [{ messageId: 'preferSetServiceName' }], + }, + { + code: "this._tracer.scope().active().setTag('service.name', name)", + errors: [{ messageId: 'preferSetServiceName' }], + }, + { + code: 'span.setTag(SERVICE_NAME, name)', + errors: [{ messageId: 'preferSetServiceName' }], + }, + { + code: 'span.setTag(SERVICE_KEY, name)', + errors: [{ messageId: 'preferSetServiceName' }], + }, + { + code: 'span.addTags({ [SERVICE_NAME]: name })', + errors: [{ messageId: 'preferSetServiceName' }], + }, + ], +}) diff --git a/eslint-rules/eslint-require-boolean-assert-message.mjs b/eslint-rules/eslint-require-boolean-assert-message.mjs new file mode 100644 index 0000000000..2de9c43e77 --- /dev/null +++ b/eslint-rules/eslint-require-boolean-assert-message.mjs @@ -0,0 +1,327 @@ +// Boolean assertions like `assert(value)` and `assert.ok(value)` are usually fine — Node's +// AssertionError prints both the source line and the actual runtime value of the asserted +// expression, so `assert.ok(obj[KEY])` failing reveals which key, what was there, and that it was +// falsy. The failure is only useless when the expression *boolean-reduces*, hiding the operand +// values behind a plain `true`/`false`. For example: +// +// - `assert.ok(duration >= 1000)` — failure shows `actual: false`, not what `duration` was. +// - `assert.ok(text.includes('foo'))` — failure shows `actual: false`, not what `text` was. +// +// This rule flags only those boolean-reducing patterns: +// +// - Value comparisons: `<`, `<=`, `>`, `>=`, `===`, `!==`, `==`, `!=` +// - Logical combinations: `&&`, `||`, `??` +// - `in` / `instanceof` (only when an operand isn't a simple reference; with simple operands +// the source line fully describes the question, e.g. `'foo' in carrier`) +// - Boolean-returning predicate method calls (see `BOOLEAN_PREDICATE_METHODS` below), +// e.g. `arr.includes('foo')`, `Array.isArray(x)`, `Object.hasOwn(obj, 'k')`. String-matching +// predicates (`startsWith` / `endsWith` / `String#match` / `RegExp#test`) are handled by +// the more specific `eslint-prefer-assert-match` rule and intentionally omitted here. +// - `new` expressions and other shapes whose value isn't meaningful on its own +// +// Allowed without a message (Node's assertion error is informative on its own): +// +// - Truthy checks of values, including dynamic indexing: `isReady`, `obj.prop.sub`, `arr[i]`, +// `map[`key-${id}`]`, `obj?.prop` +// - Calls that may return data: `getResult()`, `arr.find(cb)`, `predicate(x)` +// - Getter-style navigation: `span.context()._tags` +// - Structural unary ops on a simple operand: `!isReady`, `typeof x`, `delete obj.k` +// - `in` / `instanceof` with simple operands: `'foo' in carrier`, `err instanceof Error` + +/** @typedef {import('estree').Node} Node */ +/** @typedef {import('estree').CallExpression} CallExpression */ + +const ASSERT_CALL_NAMES = ['assert', 'assert.ok'] + +// Unary operators that don't hide a "value of interest": they just transform a reference into a +// boolean/type/undefined. `+`, `-`, `~` are excluded — those hide numeric value differences. +const TRIVIAL_UNARY_OPERATORS = new Set(['!', 'typeof', 'void', 'delete']) + +// Binary operators that ask a structural yes/no question with both operands inspectable from +// source. Excludes value comparisons (`===`, `==`, `<`, `>`, etc.) which hide the actual value. +const TRIVIAL_BINARY_OPERATORS = new Set(['in', 'instanceof']) + +// Method names that conventionally return a boolean. When the callee of a CallExpression is a +// MemberExpression with one of these as its (non-computed) property name, the call's result is +// effectively a boolean — Node's `actual: false` then hides the real operand value, so we flag it. +// +// String-matching predicates (`startsWith`, `endsWith`, `match`, regex `test`) are intentionally +// NOT in this list: the dedicated `eslint-prefer-assert-match` rule handles those with a more +// specific suggestion (use `assert.match` / `assert.doesNotMatch`) and an autofixer. +const BOOLEAN_PREDICATE_METHODS = new Set([ + // Array containment (String containment is handled separately by `eslint-prefer-assert-match` + // via regex matching, but `.includes` is ambiguous between String and Array, so we keep it here) + 'includes', + // Iterable reductions (Array.prototype) + 'some', 'every', + // Property existence (Object.prototype, Object static) + 'hasOwnProperty', 'hasOwn', + // Type predicates (Array / Buffer / Number / Object / Reflect statics) + 'isArray', 'isBuffer', 'isNaN', 'isFinite', 'isInteger', 'isSafeInteger', + 'isFrozen', 'isSealed', 'isExtensible', + // Buffer / structural equality + 'equals', +]) + +// Comparison operators we can safely auto-fix by appending a message that interpolates the +// operand values. `===` and `!==` aren't here on purpose: `eslint-config.mjs` already nudges +// users toward `assert.strictEqual` / `assert.notStrictEqual` for those, which is the better +// migration. We keep loose `==` / `!=` because they're typically deliberate `x == null` checks +// and have no clean built-in replacement under `node:assert/strict`. +const AUTOFIXABLE_COMPARISON_OPERATORS = new Set(['<', '<=', '>', '>=', '==', '!=']) + +export default { + meta: { + type: 'problem', + docs: { + description: + 'Require a message argument on boolean assertions (`assert(value)` / `assert.ok(value)`) ' + + 'whose first argument is a non-trivial expression, so failure messages reveal what was asserted.', + recommended: true, + }, + schema: [], + fixable: 'code', + messages: { + missingMessage: + '`{{name}}(...)` with a non-trivial first argument should pass a descriptive message as the ' + + 'second argument. Without it, failures only report "Expected true, got false" without any ' + + 'context about the actual value. Include the runtime value in the message to make failures ' + + 'debuggable.', + }, + }, + + create (context) { + return { + CallExpression (node) { + const calleeName = getMatchedAssertName(node.callee) + if (calleeName === undefined) return + + if (node.arguments.length === 0) return + + const firstArg = node.arguments[0] + + if (firstArg.type === 'SpreadElement') return + + if (node.arguments.length >= 2) return + + if (isTrivialExpression(firstArg)) return + + const sourceCode = context.getSourceCode() + const fixMessage = buildAutofixMessage(firstArg, sourceCode) + + context.report({ + node, + messageId: 'missingMessage', + data: { name: calleeName }, + ...(fixMessage && { + fix (fixer) { + // `firstArg.range` excludes any surrounding parens (`assert.ok((x >= 1))`). Walk + // forward past balanced `)` tokens before inserting so the message ends up as a + // sibling argument, not a comma-sequence operand inside the parens. + const tokenAfterFirstArg = sourceCode.getTokenAfter(firstArg) + let insertAfterToken = firstArg + let token = tokenAfterFirstArg + while (token && token.type === 'Punctuator' && token.value === ')') { + const tokenBeforeOpen = sourceCode.getTokenBefore(insertAfterToken) + if (!tokenBeforeOpen || tokenBeforeOpen.value !== '(') break + // Only treat this `)` as wrapping `firstArg` if it sits inside the assert call's + // own argument list — stop at the call's closing `)`. + if (token.range[1] > node.range[1] - 1) break + insertAfterToken = token + token = sourceCode.getTokenAfter(token) + } + return fixer.insertTextAfter(insertAfterToken, `, ${fixMessage}`) + }, + }), + }) + }, + } + }, +} + +/** + * @param {Node} callee + * @returns {string | undefined} + */ +function getMatchedAssertName (callee) { + for (const name of ASSERT_CALL_NAMES) { + const parts = name.split('.') + + if (parts.length === 1) { + if (callee.type === 'Identifier' && callee.name === parts[0]) { + return name + } + } else if ( + callee.type === 'MemberExpression' && + !callee.computed && + !callee.optional && + callee.object.type === 'Identifier' && + callee.object.name === parts[0] && + callee.property.type === 'Identifier' && + callee.property.name === parts[1] + ) { + return name + } + } + + return undefined +} + +/** + * A "trivial" expression is one whose source text already describes what is being asserted on, + * so a failure of "Expected true, got false" is informative enough on its own. See the file + * header for the full taxonomy. + * + * @param {Node} node + * @returns {boolean} + */ +function isTrivialExpression (node) { + if (node.type === 'ChainExpression') { + return isTrivialExpression(node.expression) + } + + if ( + node.type === 'Literal' || + node.type === 'Identifier' || + node.type === 'ThisExpression' || + node.type === 'Super' + ) { + return true + } + + if (node.type === 'MemberExpression') { + // Both `obj.prop` and `obj[anything]` are trivial — Node's AssertionError will print the + // actual value at that key. (Dynamic subscripts are accepted: `arr[i]`, `map[`key-${id}`]`.) + return isTrivialExpression(node.object) + } + + if (node.type === 'UnaryExpression') { + return TRIVIAL_UNARY_OPERATORS.has(node.operator) && isTrivialExpression(node.argument) + } + + if (node.type === 'BinaryExpression') { + return TRIVIAL_BINARY_OPERATORS.has(node.operator) && + isTrivialExpression(node.left) && + isTrivialExpression(node.right) + } + + if (node.type === 'CallExpression') { + // Calls to known boolean-returning predicate methods (`arr.includes(x)`, `Array.isArray(x)`, + // `Object.hasOwn(o, k)`, …) reduce to a plain `true`/`false`, so flag them like comparisons. + if (isBooleanPredicateCall(node.callee)) return false + + // Any other call is trivial: its return value (whatever it is) will appear as `actual` in + // Node's AssertionError. We deliberately don't recurse into the arguments — they affect what + // the call returns, but not how informative the failure message is, and being strict about + // them only produces false positives on innocent calls like `arr.find(x => x.foo === 'bar')`. + return isTrivialExpression(node.callee) + } + + return false +} + +/** + * @param {Node} callee + * @returns {boolean} + */ +function isBooleanPredicateCall (callee) { + const target = callee.type === 'ChainExpression' ? callee.expression : callee + + return ( + target.type === 'MemberExpression' && + !target.computed && + target.property.type === 'Identifier' && + BOOLEAN_PREDICATE_METHODS.has(target.property.name) + ) +} + +/** + * Builds a value-interpolating template-literal message we can safely append as a second argument + * to `assert(...)` / `assert.ok(...)`. We only do this for plain comparison binary expressions + * whose operands are side-effect-free — interpolating values that came from a function call would + * evaluate the call twice. Returns null when an autofix isn't safe. + * + * @param {Node} firstArg + * @param {import('eslint').SourceCode} sourceCode + * @returns {string | null} + */ +function buildAutofixMessage (firstArg, sourceCode) { + if (firstArg.type !== 'BinaryExpression') return null + if (!AUTOFIXABLE_COMPARISON_OPERATORS.has(firstArg.operator)) return null + if (!isSideEffectFreeForInterpolation(firstArg.left)) return null + if (!isSideEffectFreeForInterpolation(firstArg.right)) return null + + const lhsText = sourceCode.getText(firstArg.left) + const rhsText = sourceCode.getText(firstArg.right) + + // A backtick anywhere in an operand would break the template literal we're synthesising — bail + // rather than try to escape it. + if (lhsText.includes('`') || rhsText.includes('`')) return null + + // For literal operands, the source text is embedded verbatim into the template's static + // segments — so any `${` inside it (e.g. `'${expected}'` or `'foo\${x}'`) would turn into an + // unintended interpolation, which at best changes the message and at worst throws a + // `ReferenceError` before `assert.ok` runs. Non-literal operands are wrapped in `${...}` and + // are expression text, so they don't need this check. + if (firstArg.left.type === 'Literal' && lhsText.includes('${')) return null + if (firstArg.right.type === 'Literal' && rhsText.includes('${')) return null + + const lhsPart = firstArg.left.type === 'Literal' ? lhsText : '${' + lhsText + '}' + const rhsPart = firstArg.right.type === 'Literal' ? rhsText : '${' + rhsText + '}' + + return '`Expected ' + lhsPart + ' ' + firstArg.operator + ' ' + rhsPart + '`' +} + +/** + * Conservatively decides whether an expression can be evaluated a second time (inside our message + * template) without side effects. Anything that could observe the world or mutate state — calls, + * `new`, assignments, `++`/`--`, `delete`, `void`, `await`, `yield`, tagged templates — is unsafe. + * + * @param {Node | null | undefined} node + * @returns {boolean} + */ +function isSideEffectFreeForInterpolation (node) { + if (!node) return false + + switch (node.type) { + case 'Literal': + case 'Identifier': + case 'ThisExpression': + case 'Super': + return true + + case 'ChainExpression': + return isSideEffectFreeForInterpolation(node.expression) + + case 'MemberExpression': + return isSideEffectFreeForInterpolation(node.object) && + (!node.computed || isSideEffectFreeForInterpolation(node.property)) + + case 'BinaryExpression': + case 'LogicalExpression': + return isSideEffectFreeForInterpolation(node.left) && + isSideEffectFreeForInterpolation(node.right) + + case 'UnaryExpression': + // `delete` mutates; `void` evaluates its operand only for its side effects. + if (node.operator === 'delete' || node.operator === 'void') return false + return isSideEffectFreeForInterpolation(node.argument) + + case 'ConditionalExpression': + return isSideEffectFreeForInterpolation(node.test) && + isSideEffectFreeForInterpolation(node.consequent) && + isSideEffectFreeForInterpolation(node.alternate) + + case 'TemplateLiteral': + return node.expressions.every(isSideEffectFreeForInterpolation) + + case 'ArrayExpression': + return node.elements.every((el) => el === null || isSideEffectFreeForInterpolation(el)) + + default: + // CallExpression, NewExpression, AssignmentExpression, UpdateExpression, + // SequenceExpression, AwaitExpression, YieldExpression, TaggedTemplateExpression, + // ObjectExpression (computed keys, getters), etc. + return false + } +} diff --git a/eslint-rules/eslint-require-boolean-assert-message.test.mjs b/eslint-rules/eslint-require-boolean-assert-message.test.mjs new file mode 100644 index 0000000000..17065f654e --- /dev/null +++ b/eslint-rules/eslint-require-boolean-assert-message.test.mjs @@ -0,0 +1,342 @@ +import { RuleTester } from 'eslint' +import rule from './eslint-require-boolean-assert-message.mjs' + +const ruleTester = new RuleTester({ + languageOptions: { ecmaVersion: 2022 }, +}) + +ruleTester.run('eslint-require-boolean-assert-message', /** @type {import('eslint').Rule.RuleModule} */ (rule), { + valid: [ + // Truthy checks of a value — Node's AssertionError prints the actual value. + 'assert(isReady)', + 'assert.ok(isReady)', + 'assert(this)', + 'assert.ok(true)', + 'assert.ok(obj.prop)', + 'assert.ok(a.b.c.d)', + 'assert.ok(arr[0])', + "assert.ok(obj['key'])", + + // Dynamic subscripts are also fine: Node shows the actual value at that key. + 'assert.ok(arr[i])', + 'assert.ok(obj[key])', + 'assert.ok(map[`key-${id}`])', // eslint-disable-line no-template-curly-in-string + 'assert.ok(testSpan.meta[TEST_FRAMEWORK_VERSION])', + + // Optional chains. + 'assert.ok(obj?.prop)', + 'assert.ok(obj?.prop?.sub)', + + // Structural unary ops on a trivial operand. + 'assert.ok(!isReady)', + 'assert.ok(!!isReady)', + 'assert(!obj.prop)', + 'assert.ok(typeof x)', + 'assert.ok(delete obj.foo)', + + // `in` and `instanceof` with trivial operands — the source line fully describes the question. + "assert.ok('foo' in carrier)", + "assert.ok(!('x-datadog-trace-id' in carrier))", + 'assert.ok(err instanceof Error)', + 'assert.ok(!(err instanceof TypeError))', + + // Non-predicate calls — whatever they return will appear as `actual`. Args don't matter: + // a complex argument can't make a value-returning call any less informative on failure. + 'assert.ok(getResult())', + 'assert.ok(predicate(x))', + 'assert.ok(getValue(a, b))', + 'assert.ok(arr.find(cb))', + 'assert.ok(arr.find(x => x.foo === "bar"))', + 'assert.ok(items.map(transform))', + 'assert.ok(arr.filter(cb))', + 'assert.ok(buildResult(a + b, foo()))', + + // Zero-arg method calls (getter-style navigation) and composed access. + 'assert.ok(span.context())', + 'assert.ok(span.context()._tags)', + 'assert.ok(arr.entries())', + + // `in` / `instanceof` whose operands are themselves trivial calls. + 'assert.ok(getKey() in carrier)', + 'assert.ok(make() instanceof Error)', + + // `!` of a trivial call (Node shows `actual: false`, but the intent — "should be falsy" — is + // captured by the surface form, same as `!isReady`). + 'assert.ok(!getResult())', + + // Non-trivial first argument with a message is fine. + 'assert(x > 5, `duration was ${x}`)', // eslint-disable-line no-template-curly-in-string + "assert.ok(x > 5, 'expected x > 5')", + "assert.ok(x === 'foo', 'x should be foo')", + "assert.ok(x && y, 'both should be truthy')", + "assert.ok(arr.includes('foo'), 'arr should contain foo')", + "assert.ok(Array.isArray(x), 'x should be an array')", + + // Calls we don't target. + 'assert.strictEqual(x, 5)', + 'assert.deepStrictEqual(x, { foo: 1 })', + 'assert.match(text, /foo/)', + "assert.fail('nope')", + 'foo.assert(x > 5)', + 'somethingElse(x > 5)', + + // String-matching predicates: intentionally allowed here — the dedicated + // `eslint-prefer-assert-match` rule handles these and steers users to `assert.match` / + // `assert.doesNotMatch`. Double-flagging would just produce noisier errors. + "assert.ok(text.startsWith('foo'))", + "assert.ok(text.endsWith('bar'))", + 'assert.ok(regex.test(text))', + 'assert.ok(text.match(/foo/))', + + // Spread first argument is opaque to us; don't flag. + 'assert(...args)', + 'assert.ok(...args)', + ], + invalid: [ + // Value comparisons hide the actual operand value — autofixed by interpolating the operands + // into a template-literal message. + { + code: 'assert.ok(duration >= 1000)', + // eslint-disable-next-line no-template-curly-in-string + output: 'assert.ok(duration >= 1000, `Expected ${duration} >= 1000`)', + errors: [{ messageId: 'missingMessage' }], + }, + { + code: 'assert.ok(duration < 1050)', + // eslint-disable-next-line no-template-curly-in-string + output: 'assert.ok(duration < 1050, `Expected ${duration} < 1050`)', + errors: [{ messageId: 'missingMessage' }], + }, + { + code: 'assert.ok(x > 5)', + // eslint-disable-next-line no-template-curly-in-string + output: 'assert.ok(x > 5, `Expected ${x} > 5`)', + errors: [{ messageId: 'missingMessage' }], + }, + { + code: 'assert.ok(arr.length > 0)', + // eslint-disable-next-line no-template-curly-in-string + output: 'assert.ok(arr.length > 0, `Expected ${arr.length} > 0`)', + errors: [{ messageId: 'missingMessage' }], + }, + { + code: 'assert.ok(value <= max)', + // eslint-disable-next-line no-template-curly-in-string + output: 'assert.ok(value <= max, `Expected ${value} <= ${max}`)', + errors: [{ messageId: 'missingMessage' }], + }, + // Surrounding parens — the autofix must place the message OUTSIDE them, otherwise the comma + // collapses into a sequence expression inside the parens and the assertion becomes a no-op. + { + code: 'assert.ok(((x) >= (1)))', + // eslint-disable-next-line no-template-curly-in-string + output: 'assert.ok(((x) >= (1)), `Expected ${x} >= 1`)', + errors: [{ messageId: 'missingMessage' }], + }, + { + code: 'assert.ok((x > 5))', + // eslint-disable-next-line no-template-curly-in-string + output: 'assert.ok((x > 5), `Expected ${x} > 5`)', + errors: [{ messageId: 'missingMessage' }], + }, + // Loose `==` / `!=` against `null` (intentional "is nullish?" check) — autofix preserves the + // operator while still surfacing the actual value. + { + code: 'assert.ok(x == null)', + // eslint-disable-next-line no-template-curly-in-string + output: 'assert.ok(x == null, `Expected ${x} == null`)', + errors: [{ messageId: 'missingMessage' }], + }, + { + code: 'assert.ok(x != 0)', + // eslint-disable-next-line no-template-curly-in-string + output: 'assert.ok(x != 0, `Expected ${x} != 0`)', + errors: [{ messageId: 'missingMessage' }], + }, + + // Strict equality / inequality — flagged but NOT autofixed: the better migration is to + // `assert.strictEqual` / `assert.notStrictEqual` (handled by `no-restricted-syntax` in the + // eslint config), so a mechanical message wrap here would just compete with that. + { + code: "assert(x === 'foo')", + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + code: 'assert.ok(x !== y)', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + + // Side-effectful or non-reproducible operands — autofix is unsafe because interpolating them + // re-evaluates the expression with possibly different results (or observable side effects). + { + // Plain function call. + code: 'assert.ok(getX() > 5)', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + // Function call on either side. + code: 'assert.ok(arr.length > size())', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + // Non-deterministic builtin — value would change between the call and the message. + code: 'assert.ok(timestamp > Date.now())', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + // `++` / `--` mutate state. + code: 'assert.ok(counter++ > 5)', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + // Inline assignment. + code: 'assert.ok((x = getValue()) > 5)', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + // `new` allocates and may run arbitrary constructor logic. + code: 'assert.ok(new Date() > startTime)', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + // Tagged template — the tag function may have side effects. + code: 'assert.ok(html`${x}` > 0)', // eslint-disable-line no-template-curly-in-string + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + // Comma operator runs both expressions for side effects. + code: 'assert.ok((a, b) > 5)', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + + // String literals containing `${...}` — copying them verbatim into the synthesised + // backtick template would turn the literal `${...}` into a real interpolation, silently + // changing the message (or throwing `ReferenceError` if the identifier isn't in scope). + // Bail rather than try to escape. + { + // eslint-disable-next-line no-template-curly-in-string + code: "assert.ok(value == '${expected}')", + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + // eslint-disable-next-line no-template-curly-in-string + code: "assert.ok(x > '${threshold}')", + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + // Escaped `${` inside the literal is just as dangerous — the synthesised template + // would still see `${...}` after the source backslash gets normalised by the parser. + // eslint-disable-next-line no-template-curly-in-string + code: "assert.ok(value == 'foo\\${expected}')", + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + + // Logical combinations — composite booleans hide which side was falsy, and there's no + // mechanical message that's reliably better than what the author would write. + { + code: 'assert.ok(x && y)', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + code: 'assert.ok(a || b)', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + code: "assert.ok(typeof x === 'object' && x !== null)", + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + + // Boolean-returning predicate methods — `actual: false` doesn't tell you the receiver's value. + // No autofix: producing a meaningful per-predicate message is fuzzy and would require + // `util.inspect`-style serialisation we can't always synthesise safely. + { + code: "assert.ok(text.includes('foo'))", + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + code: 'assert.ok(arr.some(cb))', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + code: 'assert.ok(arr.every(cb))', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + code: "assert.ok(carrier.hasOwnProperty('x-datadog-trace-id'))", + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + code: "assert.ok(Object.hasOwn(obj, 'k'))", + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + code: 'assert.ok(Array.isArray(x))', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + code: 'assert.ok(Buffer.isBuffer(x))', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + code: 'assert.ok(Number.isFinite(n))', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + { + code: 'assert.ok(buf.equals(other))', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + + // Negated predicate calls — same problem. + { + code: "assert.ok(!arr.includes('foo'))", + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + + // `!` of a comparison — also boolean-reducing. + { + code: 'assert.ok(!(x > 5))', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + + // `in` / `instanceof` with a non-trivial (e.g. binary-expression) operand. + { + code: 'assert.ok((a + b) in carrier)', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + + // NewExpression has no meaningful value to print on its own. + { + code: 'assert.ok(new Foo())', + output: null, + errors: [{ messageId: 'missingMessage' }], + }, + ], +}) diff --git a/eslint.config.mjs b/eslint.config.mjs index c1ecbc21b4..2ca45338f5 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,9 +18,12 @@ import globals from 'globals' import eslintConfigNamesSync from './eslint-rules/eslint-config-names-sync.mjs' 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 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 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' import eslintTimerUnref from './eslint-rules/eslint-timer-unref.mjs' @@ -383,8 +386,11 @@ export default [ 'eslint-config-names-sync': eslintConfigNamesSync, 'eslint-non-prefix-env-names': eslintNonPrefixEnvNames, '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-boolean-assert-message': eslintRequireBooleanAssertMessage, 'eslint-require-export-exists': eslintRequireExportExists, 'eslint-timer-unref': eslintTimerUnref, }, @@ -422,6 +428,37 @@ export default [ dynamicImports: 'always-multiline', }], 'eslint-rules/eslint-safe-typeof-object': 'error', + 'eslint-rules/eslint-no-private-tags-access': ['error', { + allowFiles: [ + // The span_context implementation defines and reads `_tags` directly. + 'packages/dd-trace/src/opentracing/span_context.js', + // Unrelated `_tags` fields on other classes (not span contexts). + 'packages/dd-trace/src/dogstatsd.js', + 'packages/dd-trace/src/datastreams/processor.js', + // `LLMObservabilitySpan` (internal LLM-Obs DTO) has its own `_tags` + // field unrelated to the APM span context. + 'packages/dd-trace/src/llmobs/span_processor.js', + // Test specs that intentionally mock the `_tags` field shape on a + // fake span context (their `getTag`/`getTags` mocks read `this._tags`). + 'packages/dd-trace/test/opentracing/span_context.spec.js', + 'packages/dd-trace/test/priority_sampler.spec.js', + 'packages/dd-trace/test/sampling_rule.spec.js', + 'packages/dd-trace/test/span_sampler.spec.js', + 'packages/dd-trace/test/span_format.spec.js', + 'packages/dd-trace/test/standalone/tracesource_priority_sampler.spec.js', + 'packages/dd-trace/test/appsec/reporter.spec.js', + 'packages/dd-trace/test/appsec/index.spec.js', + 'packages/dd-trace/test/plugins/database-dbm-hash.spec.js', + 'packages/dd-trace/test/plugins/outbound.spec.js', + 'packages/dd-trace/test/llmobs/tagger.spec.js', + 'packages/dd-trace/test/llmobs/span_processor.spec.js', + 'packages/dd-trace/test/profiling/profilers/wall.spec.js', + // Benchmark stubs that mock the `_tags` field shape on a fake span + // context (their `getTag`/`getTags` mocks read from `_tags`). + 'benchmark/stubs/span.js', + 'benchmark/sirun/exporting-pipeline/index.js', + ], + }], 'eslint-rules/eslint-require-export-exists': 'error', 'import/no-extraneous-dependencies': 'error', 'n/hashbang': 'error', @@ -506,6 +543,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-prefer-set-service-name': 'error', 'eslint-rules/eslint-timer-unref': 'error', 'no-restricted-syntax': ['error', { @@ -737,6 +775,8 @@ export default [ }, rules: { 'eslint-rules/eslint-prefer-assert-match': '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', 'mocha/max-top-level-suites': ['error', { limit: 1 }], 'mocha/no-mocha-arrows': 'off', @@ -802,6 +842,15 @@ export default [ 'mocha/no-pending-tests': 'off', }, }, + { + // jest-docblock's `@datadog {"unskippable": true}` tag reads as a malformed + // JSDoc type to `jsdoc/valid-types`. The shape is required by the plugin. + name: 'dd-trace/datadog-plugin-jest/fixtures', + files: ['packages/datadog-plugin-jest/test/fixtures/**/*.js'], + rules: { + 'jsdoc/valid-types': 'off', + }, + }, { // CI-visibility retry fixtures intentionally call `this.retries(N)` to // exercise the dd-trace test-optimization retry code paths. The fixtures diff --git a/index.d.ts b/index.d.ts index 9c941cab5f..403848509e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -230,6 +230,7 @@ interface Plugins { "apollo": tracer.plugins.apollo; "avsc": tracer.plugins.avsc; "aws-sdk": tracer.plugins.aws_sdk; + "azure-cosmos": tracer.plugins.azure_cosmos; "azure-event-hubs": tracer.plugins.azure_event_hubs; "azure-functions": tracer.plugins.azure_functions; "azure-service-bus": tracer.plugins.azure_service_bus; @@ -279,6 +280,7 @@ interface Plugins { "mongoose": tracer.plugins.mongoose; "mysql": tracer.plugins.mysql; "mysql2": tracer.plugins.mysql2; + "nats": tracer.plugins.nats; "net": tracer.plugins.net; "next": tracer.plugins.next; "nyc": tracer.plugins.nyc; @@ -353,17 +355,6 @@ declare namespace tracer { export interface Span extends opentracing.Span { context (): SpanContext; - /** - * Causally links another span to the current span - * - * @deprecated In favor of addLink(link: { context: SpanContext, attributes?: Object }). - * This will be removed in the next major version. - * @param {SpanContext} context The context of the span to link to. - * @param {Object} attributes An optional key value pair of arbitrary values. - * @returns {void} - */ - addLink (context: SpanContext, attributes?: Object): void; - /** * Adds a single link to the span. * @@ -805,6 +796,21 @@ declare namespace tracer { * Programmatic configuration takes precedence over the environment variables listed above. */ initializationTimeoutMs?: number + /** + * Configuration for span enrichment with feature flag evaluation data. + */ + spanEnrichment?: { + /** + * Whether to enable span enrichment with feature flag data. + * When enabled, feature flag evaluation metadata is attached to APM spans. + * Can be configured via DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED environment variable. + * + * @default false + * @env DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED + * Programmatic configuration takes precedence over the environment variables listed above. + */ + enabled?: boolean + } } }; @@ -852,11 +858,20 @@ declare namespace tracer { /** * Enables DBM to APM link using tag injection. + * + * - `disabled`: No SQL comment is injected (default). + * - `service`: Injects a SQL comment with service-level tags (database name, service, environment, + * host, tracer service, tracer version). Enables DBM–APM correlation without full trace linking. + * - `full`: Same as `service`, plus a W3C `traceparent` for full distributed trace correlation. + * - `dynamic_service`: Same as `service`, but also automatically injects the propagation hash + * (`ddsh`) when process tags are enabled (`DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED=true`). + * This is a convenience shorthand for `service` + `DD_DBM_INJECT_SQL_BASEHASH=true`. + * * @default 'disabled' * @env DD_DBM_PROPAGATION_MODE * Programmatic configuration takes precedence over the environment variables listed above. */ - dbmPropagationMode?: 'disabled' | 'service' | 'full' + dbmPropagationMode?: 'disabled' | 'service' | 'full' | 'dynamic_service' /** * Whether to enable Data Streams Monitoring. @@ -2236,6 +2251,12 @@ declare namespace tracer { [key: string]: boolean | Object | undefined; } + /** + * This plugin automatically instruments the + * @azure/cosmos module + */ + interface azure_cosmos extends Integration {} + /** * This plugin automatically instruments the * @azure/event-hubs module @@ -2819,6 +2840,13 @@ declare namespace tracer { */ interface mysql2 extends mysql {} + /** + * This plugin automatically instruments the + * [@nats-io/transport-node](https://github.com/nats-io/nats.js) and + * [@nats-io/nats-core](https://github.com/nats-io/nats.js) modules. + */ + interface nats extends Instrumentation {} + /** * This plugin automatically instruments the * [net](https://nodejs.org/api/net.html) module. @@ -3264,16 +3292,6 @@ declare namespace tracer { */ recordException(exception: Exception, time?: TimeInput): void; - /** - * Causally links another span to the current span - * - * @deprecated In favor of addLink(link: otel.Link). This will be removed in the next major version. - * @param {otel.SpanContext} context The context of the span to link to. - * @param {SpanAttributes} attributes An optional key value pair of arbitrary values. - * @returns {void} - */ - addLink(context: otel.SpanContext, attributes?: SpanAttributes): void; - /** * Adds a single link to the span. * diff --git a/index.d.v5.ts b/index.d.v5.ts index b122c66ae7..d554ea029f 100644 --- a/index.d.v5.ts +++ b/index.d.v5.ts @@ -230,6 +230,7 @@ interface Plugins { "apollo": tracer.plugins.apollo; "avsc": tracer.plugins.avsc; "aws-sdk": tracer.plugins.aws_sdk; + "azure-cosmos": tracer.plugins.azure_cosmos; "azure-event-hubs": tracer.plugins.azure_event_hubs; "azure-functions": tracer.plugins.azure_functions; "azure-service-bus": tracer.plugins.azure_service_bus; @@ -245,6 +246,7 @@ interface Plugins { "cypress": tracer.plugins.cypress; "dns": tracer.plugins.dns; "elasticsearch": tracer.plugins.elasticsearch; + "electron": tracer.plugins.electron; "express": tracer.plugins.express; "fastify": tracer.plugins.fastify; "fetch": tracer.plugins.fetch; @@ -278,6 +280,7 @@ interface Plugins { "mongoose": tracer.plugins.mongoose; "mysql": tracer.plugins.mysql; "mysql2": tracer.plugins.mysql2; + "nats": tracer.plugins.nats; "net": tracer.plugins.net; "next": tracer.plugins.next; "nyc": tracer.plugins.nyc; @@ -861,6 +864,21 @@ declare namespace tracer { * Programmatic configuration takes precedence over the environment variables listed above. */ initializationTimeoutMs?: number + /** + * Configuration for span enrichment with feature flag evaluation data. + */ + spanEnrichment?: { + /** + * Whether to enable span enrichment with feature flag data. + * When enabled, feature flag evaluation metadata is attached to APM spans. + * Can be configured via DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED environment variable. + * + * @default false + * @env DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED + * Programmatic configuration takes precedence over the environment variables listed above. + */ + enabled?: boolean + } } }; @@ -908,11 +926,20 @@ declare namespace tracer { /** * Enables DBM to APM link using tag injection. + * + * - `disabled`: No SQL comment is injected (default). + * - `service`: Injects a SQL comment with service-level tags (database name, service, environment, + * host, tracer service, tracer version). Enables DBM–APM correlation without full trace linking. + * - `full`: Same as `service`, plus a W3C `traceparent` for full distributed trace correlation. + * - `dynamic_service`: Same as `service`, but also automatically injects the propagation hash + * (`ddsh`) when process tags are enabled (`DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED=true`). + * This is a convenience shorthand for `service` + `DD_DBM_INJECT_SQL_BASEHASH=true`. + * * @default 'disabled' * @env DD_DBM_PROPAGATION_MODE * Programmatic configuration takes precedence over the environment variables listed above. */ - dbmPropagationMode?: 'disabled' | 'service' | 'full' + dbmPropagationMode?: 'disabled' | 'service' | 'full' | 'dynamic_service' /** * Whether to enable Data Streams Monitoring. @@ -1997,6 +2024,20 @@ declare namespace tracer { /** @hidden */ interface Instrumentation extends Integration, Analyzable {} + /** @hidden */ + interface DatabaseInstrumentation extends Instrumentation { + /** + * Truncate the resource name (e.g. the query) to the given length. + * When set to `true`, truncates to 5000 characters (matching the + * Datadog agent's default). When set to a number, truncates to that + * many characters. This can help prevent large queries from blocking + * the event loop during trace encoding. + * + * @default false + */ + truncate?: boolean | number; + } + /** @hidden */ interface Http extends Instrumentation { /** @@ -2217,7 +2258,7 @@ declare namespace tracer { } /** @hidden */ - interface Prisma extends Instrumentation {} + interface Prisma extends DatabaseInstrumentation {} /** @hidden */ interface PrismaClient extends Prisma {} @@ -2336,6 +2377,12 @@ declare namespace tracer { [key: string]: boolean | Object | undefined; } + /** + * This plugin automatically instruments the + * @azure/cosmos module + */ + interface azure_cosmos extends Integration {} + /** * This plugin automatically instruments the * @azure/event-hubs module @@ -2443,6 +2490,26 @@ declare namespace tracer { }; } + /** + * This plugin automatically instruments the + * [electron](https://github.com/electron/electron) module. + */ + interface electron extends Instrumentation { + /** + * Whether to enable instrumentation of ipc spans + * + * @default true + */ + ipc?: boolean; + + /** + * Whether to enable instrumentation of net spans + * + * @default true + */ + net?: boolean; + } + /** * This plugin automatically instruments the * [express](http://expressjs.com/) module. @@ -2931,6 +2998,13 @@ declare namespace tracer { */ interface mysql2 extends mysql {} + /** + * This plugin automatically instruments the + * [@nats-io/transport-node](https://github.com/nats-io/nats.js) and + * [@nats-io/nats-core](https://github.com/nats-io/nats.js) modules. + */ + interface nats extends Instrumentation {} + /** * This plugin automatically instruments the * [net](https://nodejs.org/api/net.html) module. @@ -3000,7 +3074,7 @@ declare namespace tracer { * This plugin automatically instruments the * [pg](https://node-postgres.com/) module. */ - interface pg extends Instrumentation { + interface pg extends DatabaseInstrumentation { /** * The service name to be used for this plugin. If a function is used, it will be passed the connection parameters and its return value will be used as the service name. */ @@ -3745,6 +3819,11 @@ declare namespace tracer { } interface LLMObservabilitySpan { + /** + * The span kind + */ + kind: spanKind, + /** * The input content associated with the span. */ diff --git a/integration-tests/aiguard/api-mock.js b/integration-tests/aiguard/api-mock.js index 0be211eab7..79c8d620ac 100644 --- a/integration-tests/aiguard/api-mock.js +++ b/integration-tests/aiguard/api-mock.js @@ -33,6 +33,15 @@ function startApiMock () { // Extract text content from the last message regardless of type const content = extractContent(lastMessage) + // Synthetic marker used by the never-break-clients integration test: + // returning a 503 simulates an unhealthy AI Guard service so we can verify + // the host OpenAI call still succeeds. + if (messages.some(msg => extractContent(msg).includes('[aiguard_unhealthy]'))) { + return res.status(503).type('application/json').json({ + errors: [{ status: '503', title: 'Service Unavailable' }], + }) + } + if (content.startsWith('You should not trust me')) { action = 'DENY' reason = 'I am feeling suspicious today' @@ -76,9 +85,15 @@ function startApiMock () { function extractContent (message) { if (typeof message.content === 'string') return message.content + if (Array.isArray(message.content)) { + return message.content + .map(part => (typeof part === 'string' ? part : part?.text ?? '')) + .join(' ') + } if (message.tool_calls) { return message.tool_calls.map(tc => tc.function?.arguments || '').join(' ') } + if (message.refusal) return message.refusal return '' } diff --git a/integration-tests/aiguard/index.spec.js b/integration-tests/aiguard/index.spec.js index 718fa3ab72..4d4a95cb80 100644 --- a/integration-tests/aiguard/index.spec.js +++ b/integration-tests/aiguard/index.spec.js @@ -8,11 +8,12 @@ const { after, afterEach, before, beforeEach, describe, it } = require('mocha') const { sandboxCwd, useSandbox, FakeAgent, spawnProc, stopProc } = require('../helpers') const { assertObjectContains } = require('../helpers') const startApiMock = require('./api-mock') +const startOpenAIMock = require('./openai-mock') const { executeRequest } = require('./util') function assertHasGuardSpan (payload, predicate) { const spans = payload[0].filter(span => span.name === 'ai_guard') - assert.ok(spans.length > 0) + assert.ok(spans.length > 0, `Expected ${spans.length} > 0`) const matching = spans.find(predicate) assert.notStrictEqual(matching, undefined) } @@ -28,18 +29,20 @@ function assertHasTags (metric, expectedTags) { } describe('AIGuard SDK integration tests', () => { - let cwd, appFile, agent, proc, api, url + let cwd, appFile, agent, proc, api, openaiApi, url - useSandbox(['express', 'ai@6.0.39']) + useSandbox(['express', 'ai@6.0.39', 'openai@6']) before(async function () { cwd = sandboxCwd() appFile = path.join(cwd, 'aiguard/server.js') api = await startApiMock() + openaiApi = await startOpenAIMock() }) after(async () => { await api.close() + await openaiApi.close() }) const baseEnv = () => ({ @@ -60,7 +63,10 @@ describe('AIGuard SDK integration tests', () => { agent = await new FakeAgent().start() proc = await spawnProc(appFile, { cwd, - env: baseEnv(), + env: { + ...baseEnv(), + OPENAI_BASE_URL: `http://127.0.0.1:${openaiApi.address().port}/v1`, + }, }) url = `${proc.url}` }) @@ -203,6 +209,175 @@ describe('AIGuard SDK integration tests', () => { }) } + const openaiSuite = [ + { endpoint: '/openai-chat', name: 'chat.completions.create' }, + { endpoint: '/openai-responses', name: 'responses.create' }, + ] + + for (const { endpoint, name } of openaiSuite) { + it(`allows safe OpenAI ${name} requests`, async () => { + const response = await executeRequest(`${url}${endpoint}?deny=false`) + assert.strictEqual(response.status, 200) + assert.strictEqual(response.body.blocked, false) + + await agent.assertMessageReceived(({ payload }) => { + const guardSpans = payload[0].filter(span => span.name === 'ai_guard') + // One span for Before Model, one for After Model. + assert.strictEqual(guardSpans.length, 2) + for (const span of guardSpans) { + assert.strictEqual(span.meta['ai_guard.action'], 'ALLOW') + } + }) + }) + + it(`blocks dangerous OpenAI ${name} requests at Before Model`, async () => { + const response = await executeRequest(`${url}${endpoint}?deny=true`) + assert.strictEqual(response.status, 403) + assert.deepStrictEqual(JSON.parse(response.body), { blocked: true, reason: 'Blocked by policy' }) + + await agent.assertMessageReceived(({ payload }) => { + assertHasGuardSpan(payload, span => + span.meta['ai_guard.action'] === 'DENY' && + span.meta['ai_guard.blocked'] === 'true' + ) + }) + }) + } + + const openaiAfterModelSuite = [ + { endpoint: '/openai-chat-after-deny', name: 'chat.completions.create' }, + { endpoint: '/openai-responses-after-deny', name: 'responses.create' }, + ] + + for (const { endpoint, name } of openaiAfterModelSuite) { + it(`blocks dangerous OpenAI ${name} responses at After Model`, async () => { + const response = await executeRequest(`${url}${endpoint}`) + assert.strictEqual(response.status, 403) + assert.deepStrictEqual(JSON.parse(response.body), { blocked: true, reason: 'Blocked by policy' }) + + await agent.assertMessageReceived(({ payload }) => { + assertHasGuardSpan(payload, span => + span.meta['ai_guard.action'] === 'DENY' && + span.meta['ai_guard.blocked'] === 'true' + ) + }) + }) + } + + it('evaluates tool_calls in the After Model span for chat.completions', async () => { + const response = await executeRequest(`${url}/openai-chat-tool?deny=false`) + assert.strictEqual(response.status, 200) + assert.strictEqual(response.body.blocked, false) + assert.ok( + Array.isArray(response.body.message.tool_calls), + `expected tool_calls array, got ${JSON.stringify(response.body.message)}` + ) + + await agent.assertMessageReceived(({ payload }) => { + const guardSpans = payload[0].filter(span => span.name === 'ai_guard') + assert.strictEqual(guardSpans.length, 2) + for (const span of guardSpans) { + assert.strictEqual(span.meta['ai_guard.action'], 'ALLOW') + } + }) + }) + + it('handles multimodal user content (text + image) without breaking the call', async () => { + const response = await executeRequest(`${url}/openai-chat-multimodal?deny=false`) + assert.strictEqual(response.status, 200) + assert.strictEqual(response.body.blocked, false) + + await agent.assertMessageReceived(({ payload }) => { + const guardSpans = payload[0].filter(span => span.name === 'ai_guard') + assert.strictEqual(guardSpans.length, 2) + for (const span of guardSpans) { + assert.strictEqual(span.meta['ai_guard.action'], 'ALLOW') + } + }) + }) + + it('blocks a multimodal user prompt when AI Guard denies the text part', async () => { + const response = await executeRequest(`${url}/openai-chat-multimodal?deny=true`) + assert.strictEqual(response.status, 403) + assert.deepStrictEqual(JSON.parse(response.body), { blocked: true, reason: 'Blocked by policy' }) + + await agent.assertMessageReceived(({ payload }) => { + assertHasGuardSpan(payload, span => + span.meta['ai_guard.action'] === 'DENY' && + span.meta['ai_guard.blocked'] === 'true' + ) + }) + }) + + it('passes a full multi-turn (system + user + assistant + tool) conversation through', async () => { + const response = await executeRequest(`${url}/openai-chat-multiturn?deny=false`) + assert.strictEqual(response.status, 200) + assert.strictEqual(response.body.blocked, false) + + await agent.assertMessageReceived(({ payload }) => { + const guardSpans = payload[0].filter(span => span.name === 'ai_guard') + assert.strictEqual(guardSpans.length, 2) + }) + }) + + it('handles responses.create with a multi-item input (function_call_output + message)', async () => { + const response = await executeRequest(`${url}/openai-responses-array-input?deny=false`) + assert.strictEqual(response.status, 200) + assert.strictEqual(response.body.blocked, false) + }) + + it('does not double-evaluate when the caller uses .withResponse()', async () => { + const response = await executeRequest(`${url}/openai-with-response`) + assert.strictEqual(response.status, 200) + assert.strictEqual(response.body.blocked, false) + assert.strictEqual(response.body.hasRawResponse, true) + + await agent.assertMessageReceived(({ payload }) => { + const guardSpans = payload[0].filter(span => span.name === 'ai_guard') + // Lazy memoization must coalesce the inputEval; we expect exactly Before+After + // even though .withResponse() may invoke .parse() multiple times internally. + assert.strictEqual(guardSpans.length, 2) + }) + }) + + it('returns the raw Response from .asResponse() after Before-Model resolves', async () => { + // Asserting only the user-visible outcome: AI Guard does not break the call when + // the caller consumes the raw HTTP response. Trace-level assertions are intentionally + // skipped here because the openai instrumentation does not publish `asyncEnd` for + // the asResponse-only path (pre-existing behavior, see openai.js handleUnwrappedAPIPromise), + // so the openai span never finalizes and the trace is not flushed during this test + // window. The companion deny test below covers the Before-Model rejection path. + const response = await executeRequest(`${url}/openai-as-response?deny=false`) + assert.strictEqual(response.status, 200) + assert.strictEqual(response.body.status, 200) + }) + + it('rejects .asResponse() with AIGuardAbortError when Before-Model denies', async () => { + const response = await executeRequest(`${url}/openai-as-response?deny=true`) + assert.strictEqual(response.status, 403) + assert.deepStrictEqual(JSON.parse(response.body), { blocked: true, reason: 'Blocked by policy' }) + }) + + it('does not break the OpenAI call when the AI Guard service is unhealthy (503)', async () => { + // The load-bearing never-break-clients gate. + const response = await executeRequest(`${url}/openai-aiguard-down`) + assert.strictEqual(response.status, 200) + assert.strictEqual(response.body.blocked, false) + assert.ok(response.body.message) + }) + + it('skips AI Guard for streaming chat.completions and consumes the stream cleanly', async () => { + const response = await executeRequest(`${url}/openai-stream`) + assert.strictEqual(response.status, 200) + assert.strictEqual(response.body.streamed, true) + assert.ok(response.body.chunks > 0, `expected > 0 chunks, got ${response.body.chunks}`) + + await agent.assertMessageReceived(({ payload }) => { + const guardSpans = payload[0].filter(span => span.name === 'ai_guard') + assert.strictEqual(guardSpans.length, 0, 'streaming requests must not produce AI Guard spans') + }) + }) + describe('telemetry metrics', () => { it('reports requests metric with sdk source on direct SDK call', async () => { await executeRequest(`${url}/allow`, 'GET') diff --git a/integration-tests/aiguard/openai-mock.js b/integration-tests/aiguard/openai-mock.js new file mode 100644 index 0000000000..844016d190 --- /dev/null +++ b/integration-tests/aiguard/openai-mock.js @@ -0,0 +1,106 @@ +'use strict' + +const express = require('express') + +/** + * Minimal OpenAI-compatible mock for integration tests. Serves `/v1/chat/completions` + * and `/v1/responses` with canned responses. The mock inspects `req.body` to pick + * the response shape (streaming, tool-call, deny-marker), but it does NOT make any + * AI Guard decisions itself — the AI Guard verdict comes from the separate AI Guard + * API mock, which recognizes the `[deny]` marker the tests inject into user prompts. + */ +function startOpenAIMock () { + return new Promise(resolve => { + const app = express() + app.use(express.json({ limit: '1mb' })) + + app.post('/v1/chat/completions', (req, res) => { + const model = req.body?.model ?? 'gpt-4o-mini' + const wantsToolCall = req.body?.messages?.some(m => m.content?.includes?.('use tool')) + const denyResponse = req.body?.metadata?.mock_response === 'deny' + + // Streaming branch: respond with a minimal SSE stream of two text deltas + // followed by [DONE]. This is enough for the openai SDK's stream consumer. + if (req.body?.stream) { + res.status(200) + .set({ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + const id = 'chatcmpl-mock' + const created = Math.floor(Date.now() / 1000) + const send = chunk => res.write(`data: ${JSON.stringify(chunk)}\n\n`) + const chunkBase = { id, object: 'chat.completion.chunk', created, model } + send({ + ...chunkBase, + choices: [{ index: 0, delta: { role: 'assistant', content: 'Hello' }, finish_reason: null }], + }) + send({ + ...chunkBase, + choices: [{ index: 0, delta: { content: ' world' }, finish_reason: null }], + }) + send({ + ...chunkBase, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + }) + res.write('data: [DONE]\n\n') + return res.end() + } + + const message = wantsToolCall + ? { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_mock', + type: 'function', + function: { + name: 'search', + arguments: '{"q":"example"}', + }, + }], + } + : { role: 'assistant', content: denyResponse ? 'Unsafe mock response [deny]' : 'Hello from the mock!' } + + res.status(200).json({ + id: 'chatcmpl-mock', + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model, + choices: [{ + index: 0, + message, + finish_reason: wantsToolCall ? 'tool_calls' : 'stop', + }], + usage: { prompt_tokens: 8, completion_tokens: 4, total_tokens: 12 }, + }) + }) + + app.post('/v1/responses', (req, res) => { + const model = req.body?.model ?? 'gpt-4o-mini' + const text = req.body?.metadata?.mock_response === 'deny' + ? 'Unsafe mock responses output [deny]' + : 'Hello from mock responses!' + res.status(200).json({ + id: 'resp_mock', + object: 'response', + created_at: Math.floor(Date.now() / 1000), + status: 'completed', + model, + output: [{ + id: 'msg_mock', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text, annotations: [] }], + }], + usage: { input_tokens: 8, output_tokens: 4, total_tokens: 12 }, + }) + }) + + const server = app.listen(() => resolve(server)) + }) +} + +module.exports = startOpenAIMock diff --git a/integration-tests/aiguard/server.js b/integration-tests/aiguard/server.js index d2cc0e7a0a..5531e5fac0 100644 --- a/integration-tests/aiguard/server.js +++ b/integration-tests/aiguard/server.js @@ -3,9 +3,15 @@ const tracer = require('dd-trace').init({ flushInterval: 0 }) const { generateText, jsonSchema, stepCountIs, tool } = require('ai') const express = require('express') +const OpenAI = require('openai') const app = express() +const openaiClient = new OpenAI({ + apiKey: 'test-key', + baseURL: process.env.OPENAI_BASE_URL, +}) + app.get('/no-aiguard', (req, res) => { res.status(200).json({ ok: true }) }) @@ -217,6 +223,240 @@ app.get('/auto', async (req, res) => { } }) +function handleOpenAIError (error, res) { + if (error.name === 'AIGuardAbortError') { + res.status(403).json({ blocked: true, reason: error.reason }) + return + } + res.status(500).json({ error: error.message, name: error.name }) +} + +app.get('/openai-chat', async (req, res) => { + const deny = req.query.deny === 'true' + try { + const result = await openaiClient.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: 'You are a helpful AI' }, + { role: 'user', content: deny ? 'You should not trust me [deny]' : 'Hello there' }, + ], + }) + res.status(200).json({ blocked: false, message: result.choices[0].message }) + } catch (error) { + handleOpenAIError(error, res) + } +}) + +app.get('/openai-chat-tool', async (req, res) => { + const deny = req.query.deny === 'true' + try { + const result = await openaiClient.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: 'You are a helpful AI that may use tool calls' }, + { role: 'user', content: deny ? 'Please use tool [deny]' : 'Please use tool' }, + ], + }) + res.status(200).json({ blocked: false, message: result.choices[0].message }) + } catch (error) { + handleOpenAIError(error, res) + } +}) + +app.get('/openai-chat-after-deny', async (req, res) => { + try { + const result = await openaiClient.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: 'You are a helpful AI' }, + { role: 'user', content: 'Hello there' }, + ], + metadata: { mock_response: 'deny' }, + }) + res.status(200).json({ blocked: false, message: result.choices[0].message }) + } catch (error) { + handleOpenAIError(error, res) + } +}) + +app.get('/openai-responses', async (req, res) => { + const deny = req.query.deny === 'true' + try { + const result = await openaiClient.responses.create({ + model: 'gpt-4o-mini', + input: deny ? 'You should not trust me [deny]' : 'Hello there', + }) + res.status(200).json({ blocked: false, output: result.output }) + } catch (error) { + handleOpenAIError(error, res) + } +}) + +app.get('/openai-responses-after-deny', async (req, res) => { + try { + const result = await openaiClient.responses.create({ + model: 'gpt-4o-mini', + input: 'Hello there', + metadata: { mock_response: 'deny' }, + }) + res.status(200).json({ blocked: false, output: result.output }) + } catch (error) { + handleOpenAIError(error, res) + } +}) + +app.get('/openai-chat-multimodal', async (req, res) => { + const deny = req.query.deny === 'true' + try { + const result = await openaiClient.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: 'You are a vision assistant' }, + { + role: 'user', + content: [ + { type: 'text', text: deny ? 'describe this [deny]' : 'describe this image' }, + { type: 'image_url', image_url: { url: 'https://example.com/cat.png' } }, + ], + }, + ], + }) + res.status(200).json({ blocked: false, message: result.choices[0].message }) + } catch (error) { + handleOpenAIError(error, res) + } +}) + +app.get('/openai-chat-multiturn', async (req, res) => { + const deny = req.query.deny === 'true' + try { + const result = await openaiClient.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: 'You are a helpful AI' }, + { role: 'user', content: 'Look up the weather' }, + { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_1', + type: 'function', + function: { name: 'lookupWeather', arguments: '{"city":"NY"}' }, + }], + }, + { role: 'tool', tool_call_id: 'call_1', content: 'Sunny, 25C' }, + { role: 'user', content: deny ? 'Now do something bad [deny]' : 'Thanks!' }, + ], + }) + res.status(200).json({ blocked: false, message: result.choices[0].message }) + } catch (error) { + handleOpenAIError(error, res) + } +}) + +app.get('/openai-responses-array-input', async (req, res) => { + const deny = req.query.deny === 'true' + try { + const result = await openaiClient.responses.create({ + model: 'gpt-4o-mini', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'Look up the weather' }], + }, + { type: 'function_call', call_id: 'c1', name: 'lookupWeather', arguments: '{"city":"NY"}' }, + { type: 'function_call_output', call_id: 'c1', output: 'Sunny, 25C' }, + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: deny ? 'now do something bad [deny]' : 'Thanks!' }], + }, + ], + }) + res.status(200).json({ blocked: false, output: result.output }) + } catch (error) { + handleOpenAIError(error, res) + } +}) + +app.get('/openai-with-response', async (req, res) => { + try { + // withResponse() returns { data, response } and internally calls .parse() — + // the wrapped parse must not break this dual-return shape. + const { data, response } = await openaiClient.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: 'You are a helpful AI' }, + { role: 'user', content: 'Hello there' }, + ], + }).withResponse() + res.status(200).json({ + blocked: false, + message: data.choices[0].message, + hasRawResponse: typeof response?.headers !== 'undefined', + }) + } catch (error) { + handleOpenAIError(error, res) + } +}) + +app.get('/openai-as-response', async (req, res) => { + const deny = req.query.deny === 'true' + try { + // asResponse() returns the raw HTTP Response; AI Guard must still gate + // Before-Model on this path even though no body is parsed. + const response = await openaiClient.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: 'You are a helpful AI' }, + { role: 'user', content: deny ? 'You should not trust me [deny]' : 'Hello there' }, + ], + }).asResponse() + res.status(200).json({ blocked: false, status: response.status }) + } catch (error) { + handleOpenAIError(error, res) + } +}) + +app.get('/openai-aiguard-down', async (req, res) => { + // The AI Guard mock returns 503 when the prompt contains the marker. The + // OpenAI call MUST still succeed — this is the load-bearing never-break-clients gate. + try { + const result = await openaiClient.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: 'You are a helpful AI' }, + { role: 'user', content: 'Hello [aiguard_unhealthy]' }, + ], + }) + res.status(200).json({ blocked: false, message: result.choices[0].message }) + } catch (error) { + handleOpenAIError(error, res) + } +}) + +app.get('/openai-stream', async (req, res) => { + // Streaming requests must skip AI Guard entirely (per openai.js:307); the + // stream consumption itself must not be affected by the wrapping. + try { + const stream = await openaiClient.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: 'You are a helpful AI' }, + { role: 'user', content: 'Hello there' }, + ], + stream: true, + }) + let chunks = 0 + // eslint-disable-next-line no-unused-vars + for await (const _chunk of stream) chunks++ + res.status(200).json({ blocked: false, streamed: true, chunks }) + } catch (error) { + handleOpenAIError(error, res) + } +}) + const server = app.listen(() => { const port = (/** @type {import('net').AddressInfo} */ (server.address())).port process.send({ port }) diff --git a/integration-tests/appsec/graphql.spec.js b/integration-tests/appsec/graphql.spec.js index 7511ac1474..3743e0eaaf 100644 --- a/integration-tests/appsec/graphql.spec.js +++ b/integration-tests/appsec/graphql.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const axios = require('axios') const { @@ -40,12 +41,15 @@ describe('graphql', () => { it('should not report any attack', async () => { const agentPromise = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 2) // Apollo server 5 is using Node.js http server instead of express assert.strictEqual(payload[1][0].name, 'web.request') assert.strictEqual(payload[1][0].metrics['_dd.appsec.enabled'], 1) - assert.ok(Object.hasOwn(payload[1][0].metrics, '_dd.appsec.waf.duration')) + assert.ok( + Object.hasOwn(payload[1][0].metrics, '_dd.appsec.waf.duration'), + `Available keys: ${inspect(Object.keys(payload[1][0].metrics))}` + ) assert.ok(!('_dd.appsec.event' in payload[1][0].meta)) assert.ok(!('_dd.appsec.json' in payload[1][0].meta)) }) @@ -102,14 +106,20 @@ describe('graphql', () => { const agentPromise = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 2) // Apollo server 5 is using Node.js http server instead of express assert.strictEqual(payload[1][0].name, 'web.request') assert.strictEqual(payload[1][0].metrics['_dd.appsec.enabled'], 1) - assert.ok(Object.hasOwn(payload[1][0].metrics, '_dd.appsec.waf.duration')) + assert.ok( + Object.hasOwn(payload[1][0].metrics, '_dd.appsec.waf.duration'), + `Available keys: ${inspect(Object.keys(payload[1][0].metrics))}` + ) assert.strictEqual(payload[1][0].meta['appsec.event'], 'true') - assert.ok(Object.hasOwn(payload[1][0].meta, '_dd.appsec.json')) + assert.ok( + Object.hasOwn(payload[1][0].meta, '_dd.appsec.json'), + `Available keys: ${inspect(Object.keys(payload[1][0].meta))}` + ) assert.deepStrictEqual(JSON.parse(payload[1][0].meta['_dd.appsec.json']), result) }) diff --git a/integration-tests/appsec/headers-collection.spec.js b/integration-tests/appsec/headers-collection.spec.js index 25895aef98..46f05eeefa 100644 --- a/integration-tests/appsec/headers-collection.spec.js +++ b/integration-tests/appsec/headers-collection.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const Axios = require('axios') const { @@ -56,7 +57,10 @@ describe('AppSec headers collection - Express', () => { requestHeaders.length ) requestHeaders.forEach((headerName) => { - assert.ok(Object.hasOwn(payload[0][0].meta, `http.request.headers.${headerName}`)) + assert.ok( + Object.hasOwn(payload[0][0].meta, `http.request.headers.${headerName}`), + `Available keys: ${inspect(Object.keys(payload[0][0].meta))}` + ) }) // Response headers @@ -65,7 +69,10 @@ describe('AppSec headers collection - Express', () => { responseHeaders.length ) responseHeaders.forEach((headerName) => { - assert.ok(Object.hasOwn(payload[0][0].meta, `http.response.headers.${headerName}`)) + assert.ok( + Object.hasOwn(payload[0][0].meta, `http.response.headers.${headerName}`), + `Available keys: ${inspect(Object.keys(payload[0][0].meta))}` + ) }) }) } diff --git a/integration-tests/appsec/iast-esbuild.spec.js b/integration-tests/appsec/iast-esbuild.spec.js index 14b6ab5d20..f5d678c3ab 100644 --- a/integration-tests/appsec/iast-esbuild.spec.js +++ b/integration-tests/appsec/iast-esbuild.spec.js @@ -6,7 +6,7 @@ const { setTimeout } = require('timers/promises') const childProcess = require('child_process') const fs = require('fs') const path = require('path') -const { promisify } = require('util') +const { promisify, inspect } = require('util') const Axios = require('axios') const msgpack = require('@msgpack/msgpack') @@ -46,7 +46,7 @@ describe('esbuild support for IAST', () => { return agent.assertMessageReceived(({ payload }) => { const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request')) spans.forEach(span => { - assert.ok(Object.hasOwn(span.meta, '_dd.iast.json')) + assert.ok(Object.hasOwn(span.meta, '_dd.iast.json'), `Available keys: ${inspect(Object.keys(span.meta))}`) const spanIastData = JSON.parse(span.meta['_dd.iast.json']) assert.strictEqual(spanIastData.vulnerabilities[0].type, 'COMMAND_INJECTION') assert.strictEqual(spanIastData.vulnerabilities[0].location.path, expectedPath) @@ -55,8 +55,11 @@ describe('esbuild support for IAST', () => { } const ddStack = msgpack.decode(span.meta_struct['_dd.stack']) - assert.ok(Object.hasOwn(ddStack.vulnerability[0], 'frames')) - assert.ok(ddStack.vulnerability[0].frames.length > 0) + assert.ok( + Object.hasOwn(ddStack.vulnerability[0], 'frames'), + `Available keys: ${inspect(Object.keys(ddStack.vulnerability[0]))}` + ) + assert.ok(ddStack.vulnerability[0].frames.length > 0, `Expected ${ddStack.vulnerability[0].frames.length} > 0`) }) }, null, 1, true) } diff --git a/integration-tests/appsec/iast-stack-traces-with-sourcemaps.spec.js b/integration-tests/appsec/iast-stack-traces-with-sourcemaps.spec.js index 4b31d63745..52c6d67d8e 100644 --- a/integration-tests/appsec/iast-stack-traces-with-sourcemaps.spec.js +++ b/integration-tests/appsec/iast-stack-traces-with-sourcemaps.spec.js @@ -4,6 +4,7 @@ const assert = require('node:assert/strict') const childProcess = require('child_process') const path = require('path') +const { inspect } = require('node:util') const Axios = require('axios') const { sandboxCwd, useSandbox, spawnProc, FakeAgent, stopProc } = require('../helpers') describe('IAST stack traces and vulnerabilities with sourcemaps', () => { @@ -64,7 +65,7 @@ describe('IAST stack traces and vulnerabilities with sourcemaps', () => { await agent.assertMessageReceived(({ payload }) => { const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request')) spans.forEach(span => { - assert.ok(Object.hasOwn(span.meta, '_dd.iast.json')) + assert.ok(Object.hasOwn(span.meta, '_dd.iast.json'), `Available keys: ${inspect(Object.keys(span.meta))}`) const iastJsonObject = JSON.parse(span.meta['_dd.iast.json']) assert.strictEqual(iastJsonObject.vulnerabilities.some(vulnerability => { @@ -96,7 +97,7 @@ describe('IAST stack traces and vulnerabilities with sourcemaps', () => { await agent.assertMessageReceived(({ payload }) => { const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request')) spans.forEach(span => { - assert.ok(Object.hasOwn(span.meta, '_dd.iast.json')) + assert.ok(Object.hasOwn(span.meta, '_dd.iast.json'), `Available keys: ${inspect(Object.keys(span.meta))}`) const iastJsonObject = JSON.parse(span.meta['_dd.iast.json']) assert.strictEqual(iastJsonObject.vulnerabilities.some(vulnerability => { diff --git a/integration-tests/appsec/iast.esm-security-controls.spec.js b/integration-tests/appsec/iast.esm-security-controls.spec.js index 43edc0c4e7..53fa527608 100644 --- a/integration-tests/appsec/iast.esm-security-controls.spec.js +++ b/integration-tests/appsec/iast.esm-security-controls.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const Axios = require('axios') const { sandboxCwd, useSandbox, spawnProc, FakeAgent, stopProc } = require('../helpers') describe('ESM Security controls', () => { @@ -51,7 +52,7 @@ describe('ESM Security controls', () => { await agent.assertMessageReceived(({ payload }) => { const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request')) spans.forEach(span => { - assert.ok(Object.hasOwn(span.meta, '_dd.iast.json')) + assert.ok(Object.hasOwn(span.meta, '_dd.iast.json'), `Available keys: ${inspect(Object.keys(span.meta))}`) assert.match(span.meta['_dd.iast.json'], /"COMMAND_INJECTION"/) }) }, null, 1, true) @@ -64,7 +65,10 @@ describe('ESM Security controls', () => { const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request')) spans.forEach(span => { assert.ok(!('_dd.iast.json' in span.meta)) - assert.ok(Object.hasOwn(span.metrics, '_dd.iast.telemetry.suppressed.vulnerabilities.command_injection')) + assert.ok( + Object.hasOwn(span.metrics, '_dd.iast.telemetry.suppressed.vulnerabilities.command_injection'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) }) }, null, 1, true) }) @@ -76,7 +80,10 @@ describe('ESM Security controls', () => { const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request')) spans.forEach(span => { assert.ok(!('_dd.iast.json' in span.meta)) - assert.ok(Object.hasOwn(span.metrics, '_dd.iast.telemetry.suppressed.vulnerabilities.command_injection')) + assert.ok( + Object.hasOwn(span.metrics, '_dd.iast.telemetry.suppressed.vulnerabilities.command_injection'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) }) }, null, 1, true) }) @@ -87,7 +94,7 @@ describe('ESM Security controls', () => { await agent.assertMessageReceived(({ payload }) => { const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request')) spans.forEach(span => { - assert.ok(Object.hasOwn(span.meta, '_dd.iast.json')) + assert.ok(Object.hasOwn(span.meta, '_dd.iast.json'), `Available keys: ${inspect(Object.keys(span.meta))}`) assert.match(span.meta['_dd.iast.json'], /"COMMAND_INJECTION"/) }) }, null, 1, true) @@ -100,7 +107,10 @@ describe('ESM Security controls', () => { const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request')) spans.forEach(span => { assert.ok(!('_dd.iast.json' in span.meta)) - assert.ok(Object.hasOwn(span.metrics, '_dd.iast.telemetry.suppressed.vulnerabilities.command_injection')) + assert.ok( + Object.hasOwn(span.metrics, '_dd.iast.telemetry.suppressed.vulnerabilities.command_injection'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) }) }, null, 1, true) }) @@ -112,7 +122,10 @@ describe('ESM Security controls', () => { const spans = payload.flatMap(p => p.filter(span => span.name === 'express.request')) spans.forEach(span => { assert.ok(!('_dd.iast.json' in span.meta)) - assert.ok(Object.hasOwn(span.metrics, '_dd.iast.telemetry.suppressed.vulnerabilities.command_injection')) + assert.ok( + Object.hasOwn(span.metrics, '_dd.iast.telemetry.suppressed.vulnerabilities.command_injection'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) }) }, null, 1, true) }) diff --git a/integration-tests/appsec/iast.esm.spec.js b/integration-tests/appsec/iast.esm.spec.js index 005e491089..4d0c59d476 100644 --- a/integration-tests/appsec/iast.esm.spec.js +++ b/integration-tests/appsec/iast.esm.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const Axios = require('axios') const { sandboxCwd, useSandbox, spawnProc, FakeAgent, stopProc } = require('../helpers') describe('ESM', () => { @@ -65,7 +66,7 @@ describe('ESM', () => { await agent.assertMessageReceived(({ payload }) => { verifySpan(payload, span => { - assert.ok(Object.hasOwn(span.meta, '_dd.iast.json')) + assert.ok(Object.hasOwn(span.meta, '_dd.iast.json'), `Available keys: ${inspect(Object.keys(span.meta))}`) assert.match(span.meta['_dd.iast.json'], /"COMMAND_INJECTION"/) }) }, null, 1, true) @@ -76,7 +77,7 @@ describe('ESM', () => { await agent.assertMessageReceived(({ payload }) => { verifySpan(payload, span => { - assert.ok(Object.hasOwn(span.meta, '_dd.iast.json')) + assert.ok(Object.hasOwn(span.meta, '_dd.iast.json'), `Available keys: ${inspect(Object.keys(span.meta))}`) assert.match(span.meta['_dd.iast.json'], /"COMMAND_INJECTION"/) }) }, null, 1, true) diff --git a/integration-tests/appsec/index.spec.js b/integration-tests/appsec/index.spec.js index 577ab15763..453d16d309 100644 --- a/integration-tests/appsec/index.spec.js +++ b/integration-tests/appsec/index.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const Axios = require('axios') const msgpack = require('@msgpack/msgpack') const { sandboxCwd, useSandbox, FakeAgent, spawnProc, stopProc } = require('../helpers') @@ -51,18 +52,27 @@ describe('RASP', () => { async function assertExploitDetected () { await agent.assertMessageReceived(({ headers, payload }) => { - assert.ok(Object.hasOwn(payload[0][0].meta, '_dd.appsec.json')) + assert.ok( + Object.hasOwn(payload[0][0].meta, '_dd.appsec.json'), + `Available keys: ${inspect(Object.keys(payload[0][0].meta))}` + ) assert.match(payload[0][0].meta['_dd.appsec.json'], /"test-rule-id-2"/) }) } async function assertBodyReported (expectedBody, truncated) { await agent.assertMessageReceived(({ headers, payload }) => { - assert.ok(Object.hasOwn(payload[0][0].meta_struct, 'http.request.body')) + assert.ok( + Object.hasOwn(payload[0][0].meta_struct, 'http.request.body'), + `Available keys: ${inspect(Object.keys(payload[0][0].meta_struct))}` + ) assert.deepStrictEqual(msgpack.decode(payload[0][0].meta_struct['http.request.body']), expectedBody) if (truncated) { - assert.ok(Object.hasOwn(payload[0][0].meta, '_dd.appsec.rasp.request_body_size.exceeded')) + assert.ok( + Object.hasOwn(payload[0][0].meta, '_dd.appsec.rasp.request_body_size.exceeded'), + `Available keys: ${inspect(Object.keys(payload[0][0].meta))}` + ) } }) } diff --git a/integration-tests/appsec/multer.spec.js b/integration-tests/appsec/multer.spec.js index 7dbb979227..06b25a79f0 100644 --- a/integration-tests/appsec/multer.spec.js +++ b/integration-tests/appsec/multer.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('node:path') +const { inspect } = require('node:util') const axios = require('axios') const { describe, it, beforeEach, afterEach, before } = require('mocha') @@ -96,13 +97,13 @@ describe('multer', () => { describe('IAST', () => { function assertCmdInjection ({ payload }) { - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 1) - assert.ok(Array.isArray(payload[0])) + assert.ok(Array.isArray(payload[0]), `Expected array, got ${inspect(payload[0])}`) const { meta } = payload[0][0] - assert.ok(Object.hasOwn(meta, '_dd.iast.json')) + assert.ok(Object.hasOwn(meta, '_dd.iast.json'), `Available keys: ${inspect(Object.keys(meta))}`) const iastJson = JSON.parse(meta['_dd.iast.json']) diff --git a/integration-tests/appsec/standalone-asm.spec.js b/integration-tests/appsec/standalone-asm.spec.js index 31b46136aa..ff88e30ff4 100644 --- a/integration-tests/appsec/standalone-asm.spec.js +++ b/integration-tests/appsec/standalone-asm.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const { sandboxCwd, @@ -69,9 +70,9 @@ describe('Standalone ASM', () => { it('should send correct headers and tags on first req', async () => { return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 1) - assert.ok(Array.isArray(payload[0])) + assert.ok(Array.isArray(payload[0]), `Expected array, got ${inspect(payload[0])}`) // express.request + router.middleware x 2 assert.strictEqual(payload[0].length, 3) @@ -83,7 +84,7 @@ describe('Standalone ASM', () => { it('should keep fifth req because RateLimiter allows 1 req/min', async () => { const promise = curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) if (payload.length === 4) { assertKeep(payload[0][0]) assertDrop(payload[1][0]) @@ -93,7 +94,7 @@ describe('Standalone ASM', () => { // req after a minute } else { const fifthReq = payload[0] - assert.ok(Array.isArray(fifthReq)) + assert.ok(Array.isArray(fifthReq), `Expected array, got ${inspect(fifthReq)}`) assert.strictEqual(fifthReq.length, 3) const { meta, metrics } = fifthReq[0] @@ -123,7 +124,7 @@ describe('Standalone ASM', () => { const urlAttack = proc.url + '?query=1 or 1=1' return curlAndAssertMessage(agent, urlAttack, ({ headers, payload }) => { assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 4) assertKeep(payload[3][0]) @@ -136,7 +137,7 @@ describe('Standalone ASM', () => { const url = proc.url + '/login?user=test' return curlAndAssertMessage(agent, url, ({ headers, payload }) => { assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 4) assertKeep(payload[3][0]) @@ -149,7 +150,7 @@ describe('Standalone ASM', () => { const url = proc.url + '/sdk' return curlAndAssertMessage(agent, url, ({ headers, payload }) => { assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 4) assertKeep(payload[3][0]) @@ -162,12 +163,15 @@ describe('Standalone ASM', () => { const url = proc.url + '/vulnerableHash' return curlAndAssertMessage(agent, url, ({ headers, payload }) => { assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 4) const expressReq4 = payload[3][0] assertKeep(expressReq4) - assert.ok(Object.hasOwn(expressReq4.meta, '_dd.iast.json')) + assert.ok( + Object.hasOwn(expressReq4.meta, '_dd.iast.json'), + `Available keys: ${inspect(Object.keys(expressReq4.meta))}` + ) assert.strictEqual(expressReq4.metrics['_dd.iast.enabled'], 1) }) }) @@ -197,7 +201,7 @@ describe('Standalone ASM', () => { const url = `${proc.url}/propagation-after-drop-and-call-sdk?port=${port2}` return curlAndAssertMessage(agent, url, ({ headers, payload }) => { assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) const innerReq = payload.find(p => p[0].resource === 'GET /sdk') assert.notStrictEqual(innerReq, undefined) @@ -214,7 +218,7 @@ describe('Standalone ASM', () => { const url = `${proc.url}/propagation-with-event?port=${port2}` return curlAndAssertMessage(agent, url, ({ headers, payload }) => { assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) const innerReq = payload.find(p => p[0].resource === 'GET /down') assert.notStrictEqual(innerReq, undefined) @@ -229,7 +233,7 @@ describe('Standalone ASM', () => { const url = `${proc.url}/propagation-without-event?port=${port2}` return curlAndAssertMessage(agent, url, ({ headers, payload }) => { assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) const innerReq = payload.find(p => p[0].resource === 'GET /down') assert.notStrictEqual(innerReq, undefined) @@ -243,11 +247,14 @@ describe('Standalone ASM', () => { const url = `${proc.url}/propagation-with-event?port=${port2}` return curlAndAssertMessage(agent, url, ({ headers, payload }) => { assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) const innerReq = payload.find(p => p[0].resource === 'GET /down') assert.notStrictEqual(innerReq, undefined) - assert.ok(Object.hasOwn(innerReq[0].meta, '_dd.p.other')) + assert.ok( + Object.hasOwn(innerReq[0].meta, '_dd.p.other'), + `Available keys: ${inspect(Object.keys(innerReq[0].meta))}` + ) }, undefined, undefined, true) }) }) @@ -276,7 +283,7 @@ describe('Standalone ASM', () => { it('should keep fifth req because of api security sampler', async () => { const promise = curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers['datadog-client-computed-stats'], 'yes') - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) if (payload.length === 4) { assertKeep(payload[0][0]) assertDrop(payload[1][0]) @@ -286,7 +293,7 @@ describe('Standalone ASM', () => { // req after 30s } else { const fifthReq = payload[0] - assert.ok(Array.isArray(fifthReq)) + assert.ok(Array.isArray(fifthReq), `Expected array, got ${inspect(fifthReq)}`) assert.strictEqual(fifthReq.length, 3) assertKeep(fifthReq[0]) } @@ -325,15 +332,18 @@ describe('Standalone ASM', () => { const url = proc.url + '/vulnerableHash' return curlAndAssertMessage(agent, url, ({ headers, payload }) => { assert.ok(!('datadog-client-computed-stats' in headers)) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 1) - assert.ok(Array.isArray(payload[0])) + assert.ok(Array.isArray(payload[0]), `Expected array, got ${inspect(payload[0])}`) // express.request + router.middleware x 2 assert.strictEqual(payload[0].length, 3) const { meta, metrics } = payload[0][0] - assert.ok(Object.hasOwn(meta, '_dd.iast.json')) // WEAK_HASH and XCONTENTTYPE_HEADER_MISSING reported + assert.ok( + Object.hasOwn(meta, '_dd.iast.json'), + `Available keys: ${inspect(Object.keys(meta))}` + ) // WEAK_HASH and XCONTENTTYPE_HEADER_MISSING reported assert.ok(!('_dd.p.ts' in meta)) assert.ok(!('_dd.apm.enabled' in metrics)) @@ -345,15 +355,18 @@ describe('Standalone ASM', () => { return curlAndAssertMessage(agent, urlAttack, ({ headers, payload }) => { assert.ok(!('datadog-client-computed-stats' in headers)) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 1) - assert.ok(Array.isArray(payload[0])) + assert.ok(Array.isArray(payload[0]), `Expected array, got ${inspect(payload[0])}`) // express.request + router.middleware x 2 assert.strictEqual(payload[0].length, 3) const { meta, metrics } = payload[0][0] - assert.ok(Object.hasOwn(meta, '_dd.appsec.json')) // crs-942-100 triggered + assert.ok( + Object.hasOwn(meta, '_dd.appsec.json'), + `Available keys: ${inspect(Object.keys(meta))}` + ) // crs-942-100 triggered assert.ok(!('_dd.p.ts' in meta)) assert.ok(!('_dd.apm.enabled' in metrics)) diff --git a/integration-tests/appsec/trace-tagging.spec.js b/integration-tests/appsec/trace-tagging.spec.js index 5dcabf7b6b..61dbcf0e4e 100644 --- a/integration-tests/appsec/trace-tagging.spec.js +++ b/integration-tests/appsec/trace-tagging.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const Axios = require('axios') const { @@ -49,9 +50,15 @@ describe('ASM Trace Tagging rules', () => { await axios.get('/', { headers: { 'User-Agent': 'TraceTaggingTest/v1' } }) await agent.assertMessageReceived(({ _, payload }) => { - assert.ok(Object.hasOwn(payload[0][0].meta, '_dd.appsec.trace.agent')) + assert.ok( + Object.hasOwn(payload[0][0].meta, '_dd.appsec.trace.agent'), + `Available keys: ${inspect(Object.keys(payload[0][0].meta))}` + ) assert.strictEqual(payload[0][0].meta['_dd.appsec.trace.agent'], 'TraceTaggingTest/v1') - assert.ok(Object.hasOwn(payload[0][0].metrics, '_dd.appsec.trace.integer')) + assert.ok( + Object.hasOwn(payload[0][0].metrics, '_dd.appsec.trace.integer'), + `Available keys: ${inspect(Object.keys(payload[0][0].metrics))}` + ) assert.strictEqual(payload[0][0].metrics['_dd.appsec.trace.integer'], 1234) }) }) @@ -82,9 +89,15 @@ describe('ASM Trace Tagging rules', () => { fastifyRequestReceived = true - assert.ok(Object.hasOwn(payload[0][0].meta, '_dd.appsec.trace.agent')) + assert.ok( + Object.hasOwn(payload[0][0].meta, '_dd.appsec.trace.agent'), + `Available keys: ${inspect(Object.keys(payload[0][0].meta))}` + ) assert.strictEqual(payload[0][0].meta['_dd.appsec.trace.agent'], 'TraceTaggingTest/v1') - assert.ok(Object.hasOwn(payload[0][0].metrics, '_dd.appsec.trace.integer')) + assert.ok( + Object.hasOwn(payload[0][0].metrics, '_dd.appsec.trace.integer'), + `Available keys: ${inspect(Object.keys(payload[0][0].metrics))}` + ) assert.strictEqual(payload[0][0].metrics['_dd.appsec.trace.integer'], 1234) }, 30000, 10, true) diff --git a/integration-tests/ci-visibility-intake.js b/integration-tests/ci-visibility-intake.js index 52802ec131..fb71abb62b 100644 --- a/integration-tests/ci-visibility-intake.js +++ b/integration-tests/ci-visibility-intake.js @@ -32,6 +32,7 @@ const DEFAULT_SETTINGS = { } const DEFAULT_SUITES_TO_SKIP = [] +const DEFAULT_SKIPPABLE_COVERAGE = {} const DEFAULT_GIT_UPLOAD_STATUS = 200 const DEFAULT_KNOWN_TESTS_RESPONSE_STATUS = 200 const DEFAULT_INFO_RESPONSE = { @@ -43,9 +44,19 @@ const DEFAULT_KNOWN_TESTS = ['test-suite1.js.test-name1', 'test-suite2.js.test-n const DEFAULT_TEST_MANAGEMENT_TESTS = {} const DEFAULT_TEST_MANAGEMENT_TESTS_RESPONSE_STATUS = 200 +function getSkippableResponse () { + const meta = { correlation_id: correlationId } + if (Object.keys(skippableCoverage).length) { + meta.coverage = skippableCoverage + } + + return { data: suitesToSkip, meta } +} + let settings = DEFAULT_SETTINGS let settingsResponseStatusCode = 200 let suitesToSkip = DEFAULT_SUITES_TO_SKIP +let skippableCoverage = DEFAULT_SKIPPABLE_COVERAGE let gitUploadStatus = DEFAULT_GIT_UPLOAD_STATUS let infoResponse = DEFAULT_INFO_RESPONSE let correlationId = DEFAULT_CORRELATION_ID @@ -78,6 +89,10 @@ class FakeCiVisIntake extends FakeAgent { suitesToSkip = newSuitesToSkip } + setSkippableCoverage (newSkippableCoverage) { + skippableCoverage = newSkippableCoverage + } + setItrCorrelationId (newCorrelationId) { correlationId = newCorrelationId } @@ -234,19 +249,15 @@ class FakeCiVisIntake extends FakeAgent { app.post([ '/api/v2/ci/tests/skippable', '/evp_proxy/:version/api/v2/ci/tests/skippable', - ], (req, res) => { + ], express.json(), (req, res) => { if (skippableSuitesResponseStatusCode < 200 || skippableSuitesResponseStatusCode >= 300) { res.status(skippableSuitesResponseStatusCode).send(JSON.stringify({ errors: ['error'] })) return } - res.status(skippableSuitesResponseStatusCode).send(JSON.stringify({ - data: suitesToSkip, - meta: { - correlation_id: correlationId, - }, - })) + res.status(skippableSuitesResponseStatusCode).send(JSON.stringify(getSkippableResponse())) this.emit('message', { headers: req.headers, + payload: req.body, url: req.url, }) }) @@ -354,6 +365,7 @@ class FakeCiVisIntake extends FakeAgent { settings = DEFAULT_SETTINGS settingsResponseStatusCode = 200 suitesToSkip = DEFAULT_SUITES_TO_SKIP + skippableCoverage = DEFAULT_SKIPPABLE_COVERAGE gitUploadStatus = DEFAULT_GIT_UPLOAD_STATUS knownTestsStatusCode = DEFAULT_KNOWN_TESTS_RESPONSE_STATUS knownTestsPageIndex = 0 diff --git a/integration-tests/ci-visibility/automatic-log-submission.spec.js b/integration-tests/ci-visibility/automatic-log-submission.spec.js index 72190ca764..faae2a6798 100644 --- a/integration-tests/ci-visibility/automatic-log-submission.spec.js +++ b/integration-tests/ci-visibility/automatic-log-submission.spec.js @@ -1,12 +1,13 @@ 'use strict' const assert = require('assert') -const { exec, execSync } = require('child_process') +const { exec } = require('child_process') const { once } = require('events') const { sandboxCwd, useSandbox, + installPlaywrightChromium, getCiVisAgentlessConfig, getCiVisEvpProxyConfig, assertObjectContains, @@ -29,11 +30,7 @@ describe('test optimization automatic log submission', () => { before(async () => { cwd = sandboxCwd() - const { NODE_OPTIONS, ...restOfEnv } = process.env - // Install chromium (configured in integration-tests/playwright.config.js) - // *Be advised*: this means that we'll only be using chromium for this test suite - // Must run in before hook: sandbox is created at test time so workflow can't install - execSync('npx playwright install chromium', { cwd, env: restOfEnv, stdio: 'inherit' }) + installPlaywrightChromium(cwd) await new Promise((resolve, reject) => { webAppServer.listen(0, () => { const address = webAppServer.address() diff --git a/integration-tests/ci-visibility/features-selenium/support/steps.js b/integration-tests/ci-visibility/features-selenium/support/steps.js index e7a5145777..02dca276cc 100644 --- a/integration-tests/ci-visibility/features-selenium/support/steps.js +++ b/integration-tests/ci-visibility/features-selenium/support/steps.js @@ -10,7 +10,7 @@ let title let helloWorldText const options = new chrome.Options() -options.addArguments('--headless') +options.addArguments('--headless', '--no-sandbox', '--disable-dev-shm-usage') Before(async function () { const build = new Builder().forBrowser('chrome').setChromeOptions(options) diff --git a/integration-tests/ci-visibility/git-cache.spec.js b/integration-tests/ci-visibility/git-cache.spec.js index 91a7b0808b..85ca471c22 100644 --- a/integration-tests/ci-visibility/git-cache.spec.js +++ b/integration-tests/ci-visibility/git-cache.spec.js @@ -5,6 +5,7 @@ const fs = require('fs') const assert = require('assert') const os = require('os') const path = require('path') +const { inspect } = require('node:util') const { assertObjectContains, sandboxCwd, useSandbox } = require('../helpers') @@ -187,7 +188,7 @@ describe('git-cache integration tests', () => { const cacheKey = defaultDirGitCache.getCacheKey('git', GET_COMMIT_MESSAGE_COMMAND_ARGS) const cacheFilePath = defaultDirGitCache.getCacheFilePath(cacheKey) - assert.ok(cacheFilePath.includes('dd-trace-git-cache')) + assert.ok(cacheFilePath.includes('dd-trace-git-cache'), `Got: ${inspect(cacheFilePath)}`) assert.strictEqual(fs.existsSync(cacheFilePath), true) removeGitFromPath() diff --git a/integration-tests/ci-visibility/jest-fast-check/jest-fast-check.js b/integration-tests/ci-visibility/jest-fast-check/jest-fast-check.js deleted file mode 100644 index ea994bc861..0000000000 --- a/integration-tests/ci-visibility/jest-fast-check/jest-fast-check.js +++ /dev/null @@ -1,9 +0,0 @@ -'use strict' - -const { test, fc } = require('@fast-check/jest') - -describe('fast check', () => { - test.prop([fc.string(), fc.string(), fc.string()])('will not include seed', (a, b, c) => { - expect([a, b, c]).toContain(b) - }) -}) diff --git a/integration-tests/ci-visibility/jest-seed-suffix/jest-describe-seed-suffix.js b/integration-tests/ci-visibility/jest-seed-suffix/jest-describe-seed-suffix.js new file mode 100644 index 0000000000..6d8b120cdc --- /dev/null +++ b/integration-tests/ci-visibility/jest-seed-suffix/jest-describe-seed-suffix.js @@ -0,0 +1,9 @@ +'use strict' + +const assert = require('assert') + +describe('seed suffix (with seed=12)', () => { + it('should preserve describe seed suffix', () => { + assert.deepStrictEqual(1 + 2, 3) + }) +}) diff --git a/integration-tests/ci-visibility/jest-fast-check/jest-no-fast-check.js b/integration-tests/ci-visibility/jest-seed-suffix/jest-seed-suffix.js similarity index 50% rename from integration-tests/ci-visibility/jest-fast-check/jest-no-fast-check.js rename to integration-tests/ci-visibility/jest-seed-suffix/jest-seed-suffix.js index fc895c0814..0fcea56055 100644 --- a/integration-tests/ci-visibility/jest-fast-check/jest-no-fast-check.js +++ b/integration-tests/ci-visibility/jest-seed-suffix/jest-seed-suffix.js @@ -2,8 +2,8 @@ const assert = require('assert') -describe('fast check with seed', () => { - it('should include seed (with seed=12)', () => { +describe('seed suffix', () => { + it('should strip seed (with seed=12)', () => { assert.deepStrictEqual(1 + 2, 3) }) }) diff --git a/integration-tests/ci-visibility/run-jest.js b/integration-tests/ci-visibility/run-jest.js index ea5d655f84..6d8839d459 100644 --- a/integration-tests/ci-visibility/run-jest.js +++ b/integration-tests/ci-visibility/run-jest.js @@ -2,6 +2,45 @@ const jest = require('jest') +function getJestRunArgs (options) { + const args = [ + '--no-cache', + '--runInBand', + ] + + if (process.env.USE_CONFIG_FILE) { + args.push('--config', require.resolve('../config-jest.js')) + } else { + args.push( + '--rootDir', process.cwd(), + '--testPathIgnorePatterns', options.testPathIgnorePatterns.join('|'), + '--modulePathIgnorePatterns', options.modulePathIgnorePatterns.join('|'), + '--testRegex', options.testRegex.source, + '--testRunner', options.testRunner, + '--testEnvironment', options.testEnvironment + ) + } + + if (options.coverage) { + args.push('--coverage') + } + if (options.collectCoverageFrom) { + for (const coveragePattern of options.collectCoverageFrom) { + args.push(`--collectCoverageFrom=${coveragePattern}`) + } + } + if (options._) { + args.push(...options._) + } + if (options.coverageReporters) { + for (const coverageReporter of options.coverageReporters) { + args.push(`--coverageReporters=${coverageReporter}`) + } + } + + return args +} + const options = { projects: [__dirname], testPathIgnorePatterns: ['/node_modules/'], @@ -43,6 +82,10 @@ if (process.env.COLLECT_COVERAGE_FROM) { options.collectCoverageFrom = process.env.COLLECT_COVERAGE_FROM.split(',') } +if (process.argv.length > 2) { + options._ = process.argv.slice(2) +} + if (process.env.COVERAGE_REPORTERS) { options.coverageReporters = process.env.COVERAGE_REPORTERS.split(',') } @@ -63,15 +106,22 @@ if (process.env.JEST_BAIL) { options.bail = true } -jest.runCLI( - options, - options.projects -).then((results) => { - if (process.send) { - process.send('finished') - } - if (process.env.SHOULD_CHECK_RESULTS) { - const exitCode = results.results.success ? 0 : 1 - process.exit(exitCode) - } -}) +if (process.env.USE_JEST_RUN) { + jest.run(getJestRunArgs(options)).catch((error) => { + // eslint-disable-next-line no-console + console.error(error) + }) +} else { + jest.runCLI( + options, + options.projects + ).then((results) => { + if (process.send) { + process.send('finished') + } + if (process.env.SHOULD_CHECK_RESULTS) { + const exitCode = results.results.success ? 0 : 1 + process.exit(exitCode) + } + }) +} diff --git a/integration-tests/ci-visibility/subproject/subproject-test-2.js b/integration-tests/ci-visibility/subproject/subproject-test-2.js new file mode 100644 index 0000000000..c9c0bdcca4 --- /dev/null +++ b/integration-tests/ci-visibility/subproject/subproject-test-2.js @@ -0,0 +1,11 @@ +'use strict' + +const assert = require('assert') + +const dependency = require('./dependency') + +describe('subproject-test-2', () => { + it('can run', () => { + assert.strictEqual(dependency(2, 3), 5) + }) +}) diff --git a/integration-tests/ci-visibility/test-optimization-wrong-init.spec.js b/integration-tests/ci-visibility/test-optimization-wrong-init.spec.js index bdda60fdf8..dee28c0084 100644 --- a/integration-tests/ci-visibility/test-optimization-wrong-init.spec.js +++ b/integration-tests/ci-visibility/test-optimization-wrong-init.spec.js @@ -2,6 +2,7 @@ const { once } = require('node:events') const assert = require('node:assert') +const { inspect } = require('node:util') const { exec } = require('child_process') const { sandboxCwd, useSandbox, getCiVisAgentlessConfig } = require('../helpers') @@ -119,11 +120,9 @@ testFrameworks.forEach(({ testFramework, command, expectedOutput, extraTestConte eventsPromise, ]) - assert.ok( - processOutput.includes( - `Plugin "${testFramework}" is not initialized because Test Optimization mode is not enabled.` - ) - ) + const reason = 'is not initialized because Test Optimization mode is not enabled.' + const expectedSubstring = `Plugin "${testFramework}" ${reason}` + assert.ok(processOutput.includes(expectedSubstring), `Got: ${inspect(processOutput)}`) assert.match(processOutput, new RegExp(expectedOutput)) }) }) diff --git a/integration-tests/ci-visibility/test/selenium-no-framework.js b/integration-tests/ci-visibility/test/selenium-no-framework.js index a99e081fec..f647f0b2c7 100644 --- a/integration-tests/ci-visibility/test/selenium-no-framework.js +++ b/integration-tests/ci-visibility/test/selenium-no-framework.js @@ -5,7 +5,7 @@ const chrome = require('selenium-webdriver/chrome') async function run () { const options = new chrome.Options() - options.addArguments('--headless') + options.addArguments('--headless', '--no-sandbox', '--disable-dev-shm-usage') const build = new Builder().forBrowser('chrome').setChromeOptions(options) const driver = await build.build() diff --git a/integration-tests/ci-visibility/test/selenium-test.js b/integration-tests/ci-visibility/test/selenium-test.js index 125943e8e2..bcaec26fad 100644 --- a/integration-tests/ci-visibility/test/selenium-test.js +++ b/integration-tests/ci-visibility/test/selenium-test.js @@ -5,7 +5,7 @@ const assert = require('assert') const { By, Builder } = require('selenium-webdriver') const chrome = require('selenium-webdriver/chrome') const options = new chrome.Options() -options.addArguments('--headless') +options.addArguments('--headless', '--no-sandbox', '--disable-dev-shm-usage') describe('selenium', function () { let driver diff --git a/integration-tests/ci-visibility/tia-code-coverage-cucumber.spec.js b/integration-tests/ci-visibility/tia-code-coverage-cucumber.spec.js new file mode 100644 index 0000000000..bb52696a96 --- /dev/null +++ b/integration-tests/ci-visibility/tia-code-coverage-cucumber.spec.js @@ -0,0 +1,396 @@ +'use strict' + +const assert = require('node:assert/strict') +const { exec } = require('node:child_process') +const { once } = require('node:events') +const path = require('node:path') + +const { + sandboxCwd, + useSandbox, + getCiVisAgentlessConfig, +} = require('../helpers') +const { FakeCiVisIntake } = require('../ci-visibility-intake') +const { + TEST_CODE_COVERAGE_LINES_PCT, + TEST_ITR_TESTS_SKIPPED, + TEST_SKIPPED_BY_ITR, + TEST_STATUS, + getLineCoverageBitmap, +} = require('../../packages/dd-trace/src/plugins/util/test') +const { NODE_MAJOR } = require('../../version') + +const FIXTURE_ROOT = 'ci-visibility/tia-code-coverage-cucumber' +const SUBDIRECTORY_FIXTURE_ROOT = 'tia-code-coverage-cucumber' +const SKIPPED_SUITE = `${FIXTURE_ROOT}/features/skipped.feature` +const SUBDIRECTORY_SKIPPED_SUITE = `${SUBDIRECTORY_FIXTURE_ROOT}/features/skipped.feature` +const RUN_SOURCE = `${FIXTURE_ROOT}/src/run-dependency.js` +const SKIPPED_SOURCE = `${FIXTURE_ROOT}/src/skipped-dependency.js` +const FEATURE_FILES = `${FIXTURE_ROOT}/features/run.feature ${FIXTURE_ROOT}/features/skipped.feature` +const LINE_PCT_RE = /Lines\s*:\s*(\d+(?:\.\d+)?)%/ +const CUCUMBER_COMMAND = './node_modules/nyc/bin/nyc.js --all ' + + `--include '${FIXTURE_ROOT}/src/**' -r=text-summary node ./node_modules/.bin/cucumber-js ${FEATURE_FILES}` +const MINIMUM_SUPPORTED_CUCUMBER_VERSION = '10.0.0' + +const CUCUMBER_VERSION_CONFIGS = [ + { + version: 'latest', + dependencies: ['@cucumber/cucumber', 'nyc'], + }, + { + version: MINIMUM_SUPPORTED_CUCUMBER_VERSION, + dependencies: [`@cucumber/cucumber@${MINIMUM_SUPPORTED_CUCUMBER_VERSION}`, 'nyc'], + }, +] + +function getLinesBitmapBase64 (startLine, endLine) { + const lineCoverage = {} + for (let line = startLine; line <= endLine; line++) { + lineCoverage[line] = 1 + } + return getLineCoverageBitmap(lineCoverage, true).toString('base64') +} + +function getCoverageEvents (payloads) { + return payloads + .flatMap(({ payload }) => payload) + .flatMap(({ content }) => content.coverages) +} + +function getLinePctFromOutput (output) { + const match = output.match(LINE_PCT_RE) + assert.ok(match, `coverage output did not include a lines percentage:\n${output}`) + return Number(match[1]) +} + +function getSubdirectoryCucumberCommand (cwd) { + const cucumberBin = path.join(cwd, 'node_modules/@cucumber/cucumber/bin/cucumber-js') + const script = "process.chdir('ci-visibility');" + + "process.argv.push('cucumber-js');" + + `process.argv.push('${SUBDIRECTORY_FIXTURE_ROOT}/features/run.feature');` + + `process.argv.push('${SUBDIRECTORY_FIXTURE_ROOT}/features/skipped.feature');` + + `require('${cucumberBin}')` + + return './node_modules/nyc/bin/nyc.js --all ' + + `--include '${FIXTURE_ROOT}/src/**' -r=text-summary node -e "${script}"` +} + +function describeCucumberVersion (cucumberVersion, dependencies) { + describe(`TIA code coverage cucumber@${cucumberVersion}`, function () { + if ((NODE_MAJOR === 18 || NODE_MAJOR === 23) && cucumberVersion === 'latest') return + + let cwd + let childProcess + + this.timeout(180_000) + + useSandbox(dependencies, true) + + before(() => { + cwd = sandboxCwd() + }) + + afterEach(() => { + if (childProcess?.exitCode === null) { + childProcess.kill() + } + }) + + async function runCucumber ({ + suitesToSkip = [], + skippableCoverage = {}, + settings = { + itr_enabled: true, + code_coverage: true, + coverage_report_upload_enabled: true, + tests_skipping: true, + }, + expectSuiteCoverage = true, + expectSessionCoverage = true, + expectCoveragePayloads = true, + command = CUCUMBER_COMMAND, + } = {}) { + const receiver = await new FakeCiVisIntake().start() + receiver.setSettings(settings) + receiver.setSuitesToSkip(suitesToSkip) + receiver.setSkippableCoverage(skippableCoverage) + + let eventsResult + let coverageResult + let output = '' + const coveragePayloads = [] + const coverageRequestListener = (message) => { + if (message.url.endsWith('/api/v2/citestcov')) { + coveragePayloads.push(message) + } + } + receiver.on('message', coverageRequestListener) + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + const testSessionEvent = events.find(event => event.type === 'test_session_end') + assert.ok(testSessionEvent, `test session event should be reported:\n${output}`) + const testSession = testSessionEvent.content + const skippedSuites = events + .filter(event => event.type === 'test_suite_end') + .map(event => event.content) + .filter(suite => suite.meta[TEST_SKIPPED_BY_ITR] === 'true') + + eventsResult = { + codeCoverageLinesPct: testSession.metrics[TEST_CODE_COVERAGE_LINES_PCT], + isTiaSkipped: testSession.meta[TEST_ITR_TESTS_SKIPPED], + skippedSuites, + } + }) + + const coveragePromise = expectCoveragePayloads + ? receiver.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcov'), (payloads) => { + const coverages = getCoverageEvents(payloads) + const suiteCoverage = coverages.find(coverage => coverage.test_suite_id) + const sessionCoverage = coverages.find(coverage => !coverage.test_suite_id) + const coveredFile = coverages + .flatMap(coverage => coverage.files) + .find(file => file.bitmap) + + if (expectSuiteCoverage) { + assert.ok(suiteCoverage, `suite code coverage should be reported:\n${output}`) + } else { + assert.strictEqual(suiteCoverage, undefined, `suite code coverage should not be reported:\n${output}`) + } + if (expectSessionCoverage) { + assert.ok(sessionCoverage, `session executable-line coverage should be reported:\n${output}`) + } else { + assert.strictEqual( + sessionCoverage, + undefined, + `session executable-line coverage should not be reported:\n${output}` + ) + } + assert.ok(coveredFile?.bitmap, `covered files should report line coverage bitmaps:\n${output}`) + + coverageResult = coverages + }) + : Promise.resolve() + + childProcess = exec( + command, + { + cwd, + env: getCiVisAgentlessConfig(receiver.port), + } + ) + childProcess.stdout?.on('data', chunk => { + output += chunk.toString() + }) + childProcess.stderr?.on('data', chunk => { + output += chunk.toString() + }) + + try { + const stdoutEndPromise = childProcess.stdout ? once(childProcess.stdout, 'end') : Promise.resolve() + const stderrEndPromise = childProcess.stderr ? once(childProcess.stderr, 'end') : Promise.resolve() + const [, , [exitCode]] = await Promise.all([ + eventsPromise, + coveragePromise, + once(childProcess, 'exit'), + stdoutEndPromise, + stderrEndPromise, + ]) + assert.strictEqual(exitCode, 0) + if (!expectCoveragePayloads) { + await new Promise(resolve => setTimeout(resolve, 500)) + assert.strictEqual(coveragePayloads.length, 0, `code coverage payloads should not be reported:\n${output}`) + } + + return { + ...eventsResult, + coverages: coverageResult, + output, + stdoutCodeCoverageLinesPct: getLinePctFromOutput(output), + } + } finally { + receiver.off('message', coverageRequestListener) + await receiver.stop() + } + } + + it('keeps total code coverage stable with skipped coverage', async () => { + const baseline = await runCucumber() + + assert.strictEqual(baseline.isTiaSkipped, 'false') + assert.strictEqual(baseline.codeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.ok(baseline.codeCoverageLinesPct > 0, `baseline coverage was ${baseline.codeCoverageLinesPct}`) + assert.ok(baseline.codeCoverageLinesPct < 100, `baseline coverage was ${baseline.codeCoverageLinesPct}`) + assert.ok(baseline.coverages.length > 0, 'baseline should report coverage payloads') + + const skippedWithoutCoverage = await runCucumber({ + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + }) + + assert.strictEqual(skippedWithoutCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedWithoutCoverage.skippedSuites.length, 1) + assert.strictEqual(skippedWithoutCoverage.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(skippedWithoutCoverage.codeCoverageLinesPct, undefined) + assert.ok( + skippedWithoutCoverage.stdoutCodeCoverageLinesPct < baseline.stdoutCodeCoverageLinesPct, + `expected ${skippedWithoutCoverage.stdoutCodeCoverageLinesPct} to be lower ` + + `than ${baseline.stdoutCodeCoverageLinesPct}` + ) + + const skippedWithCoverage = await runCucumber({ + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + skippableCoverage: { + [SKIPPED_SOURCE]: getLinesBitmapBase64(1, 20), + }, + }) + + assert.strictEqual(skippedWithCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedWithCoverage.skippedSuites.length, 1) + assert.strictEqual(skippedWithCoverage.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(skippedWithCoverage.stdoutCodeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.strictEqual(skippedWithCoverage.codeCoverageLinesPct, baseline.codeCoverageLinesPct) + }) + + it('backfills repository-relative skipped coverage when cucumber runs from a subdirectory', async () => { + const runFromSubdirectory = { + command: getSubdirectoryCucumberCommand(cwd), + } + const baseline = await runCucumber(runFromSubdirectory) + + const skippedWithCoverage = await runCucumber({ + ...runFromSubdirectory, + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SUBDIRECTORY_SKIPPED_SUITE, + }, + }], + skippableCoverage: { + [SKIPPED_SOURCE]: getLinesBitmapBase64(1, 20), + }, + }) + const sessionCoverage = skippedWithCoverage.coverages.find(coverage => !coverage.test_suite_id) + + assert.strictEqual(skippedWithCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedWithCoverage.skippedSuites.length, 1) + assert.strictEqual(skippedWithCoverage.stdoutCodeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.strictEqual(skippedWithCoverage.codeCoverageLinesPct, baseline.codeCoverageLinesPct) + assert.ok(sessionCoverage.files.some(file => file.filename === SKIPPED_SOURCE)) + }) + + it('reports total coverage when skipped coverage only overlaps covered lines', async () => { + const result = await runCucumber({ + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + skippableCoverage: { + [RUN_SOURCE]: getLinesBitmapBase64(1, 20), + }, + }) + + assert.strictEqual(result.isTiaSkipped, 'true') + assert.strictEqual(result.skippedSuites.length, 1) + assert.strictEqual(result.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(result.codeCoverageLinesPct, result.stdoutCodeCoverageLinesPct) + }) + + it('only uploads suite coverage when TIA is enabled but coverage report upload is disabled', async () => { + const result = await runCucumber({ + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + settings: { + itr_enabled: true, + code_coverage: true, + coverage_report_upload_enabled: false, + tests_skipping: true, + }, + expectSessionCoverage: false, + }) + + assert.strictEqual(result.isTiaSkipped, 'true') + assert.strictEqual(result.skippedSuites.length, 1) + assert.strictEqual(result.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(result.codeCoverageLinesPct, undefined) + assert.ok(result.stdoutCodeCoverageLinesPct > 0) + }) + + it('does not upload citestcov payloads when TIA code coverage is disabled', async () => { + const result = await runCucumber({ + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + settings: { + itr_enabled: true, + code_coverage: false, + coverage_report_upload_enabled: false, + tests_skipping: true, + }, + expectCoveragePayloads: false, + }) + + assert.strictEqual(result.isTiaSkipped, 'true') + assert.strictEqual(result.skippedSuites.length, 1) + assert.strictEqual(result.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(result.codeCoverageLinesPct, undefined) + assert.ok(result.stdoutCodeCoverageLinesPct > 0) + }) + + it('backfills and reports session coverage when coverage report upload is enabled', async () => { + const settings = { + itr_enabled: true, + code_coverage: false, + coverage_report_upload_enabled: true, + tests_skipping: true, + } + const baseline = await runCucumber({ + settings, + expectSuiteCoverage: false, + }) + + const skippedWithCoverage = await runCucumber({ + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + skippableCoverage: { + [SKIPPED_SOURCE]: getLinesBitmapBase64(1, 20), + }, + settings, + expectSuiteCoverage: false, + }) + + assert.strictEqual(skippedWithCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedWithCoverage.skippedSuites.length, 1) + assert.strictEqual(skippedWithCoverage.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(skippedWithCoverage.stdoutCodeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.strictEqual(skippedWithCoverage.codeCoverageLinesPct, baseline.codeCoverageLinesPct) + }) + }) +} + +for (const { version, dependencies } of CUCUMBER_VERSION_CONFIGS) { + describeCucumberVersion(version, dependencies) +} diff --git a/integration-tests/ci-visibility/tia-code-coverage-cucumber/features/run.feature b/integration-tests/ci-visibility/tia-code-coverage-cucumber/features/run.feature new file mode 100644 index 0000000000..d7ed46cd9a --- /dev/null +++ b/integration-tests/ci-visibility/tia-code-coverage-cucumber/features/run.feature @@ -0,0 +1,4 @@ +Feature: Run coverage + Scenario: Run dependency + When the run dependency is covered + Then the coverage result should be 3 diff --git a/integration-tests/ci-visibility/tia-code-coverage-cucumber/features/skipped.feature b/integration-tests/ci-visibility/tia-code-coverage-cucumber/features/skipped.feature new file mode 100644 index 0000000000..4381e6b67d --- /dev/null +++ b/integration-tests/ci-visibility/tia-code-coverage-cucumber/features/skipped.feature @@ -0,0 +1,4 @@ +Feature: Skipped coverage + Scenario: Skipped dependency + When the skipped dependency is covered + Then the coverage result should be 3 diff --git a/integration-tests/ci-visibility/tia-code-coverage-cucumber/features/support/steps.js b/integration-tests/ci-visibility/tia-code-coverage-cucumber/features/support/steps.js new file mode 100644 index 0000000000..d3aa598edd --- /dev/null +++ b/integration-tests/ci-visibility/tia-code-coverage-cucumber/features/support/steps.js @@ -0,0 +1,19 @@ +'use strict' + +const assert = require('node:assert/strict') +const { When, Then } = require('@cucumber/cucumber') + +const runDependency = require('../../src/run-dependency') +const skippedDependency = require('../../src/skipped-dependency') + +When('the run dependency is covered', function () { + this.coverageResult = runDependency(1, 2) +}) + +When('the skipped dependency is covered', function () { + this.coverageResult = skippedDependency(1, 2) +}) + +Then('the coverage result should be {int}', function (expected) { + assert.strictEqual(this.coverageResult, expected) +}) diff --git a/integration-tests/ci-visibility/tia-code-coverage-cucumber/src/run-dependency.js b/integration-tests/ci-visibility/tia-code-coverage-cucumber/src/run-dependency.js new file mode 100644 index 0000000000..99d88fa19c --- /dev/null +++ b/integration-tests/ci-visibility/tia-code-coverage-cucumber/src/run-dependency.js @@ -0,0 +1,5 @@ +'use strict' + +module.exports = function runDependency (a, b) { + return a + b +} diff --git a/integration-tests/ci-visibility/tia-code-coverage-cucumber/src/skipped-dependency.js b/integration-tests/ci-visibility/tia-code-coverage-cucumber/src/skipped-dependency.js new file mode 100644 index 0000000000..5342c74578 --- /dev/null +++ b/integration-tests/ci-visibility/tia-code-coverage-cucumber/src/skipped-dependency.js @@ -0,0 +1,5 @@ +'use strict' + +module.exports = function skippedDependency (a, b) { + return a + b +} diff --git a/integration-tests/ci-visibility/tia-code-coverage-cucumber/src/uncovered-dependency.js b/integration-tests/ci-visibility/tia-code-coverage-cucumber/src/uncovered-dependency.js new file mode 100644 index 0000000000..b793475439 --- /dev/null +++ b/integration-tests/ci-visibility/tia-code-coverage-cucumber/src/uncovered-dependency.js @@ -0,0 +1,5 @@ +'use strict' + +module.exports = function uncoveredDependency (a, b) { + return a + b +} diff --git a/integration-tests/ci-visibility/tia-code-coverage-mocha.spec.js b/integration-tests/ci-visibility/tia-code-coverage-mocha.spec.js new file mode 100644 index 0000000000..883e36d52c --- /dev/null +++ b/integration-tests/ci-visibility/tia-code-coverage-mocha.spec.js @@ -0,0 +1,386 @@ +'use strict' + +const assert = require('node:assert/strict') +const { exec } = require('node:child_process') +const { once } = require('node:events') +const path = require('node:path') + +const { + sandboxCwd, + useSandbox, + getCiVisAgentlessConfig, +} = require('../helpers') +const { FakeCiVisIntake } = require('../ci-visibility-intake') +const { + TEST_CODE_COVERAGE_LINES_PCT, + TEST_ITR_TESTS_SKIPPED, + TEST_SKIPPED_BY_ITR, + TEST_STATUS, + getLineCoverageBitmap, +} = require('../../packages/dd-trace/src/plugins/util/test') + +const FIXTURE_ROOT = 'ci-visibility/tia-code-coverage' +const SUBDIRECTORY_FIXTURE_ROOT = 'tia-code-coverage' +const SKIPPED_SUITE = `${FIXTURE_ROOT}/test-skipped.js` +const SUBDIRECTORY_SKIPPED_SUITE = `${SUBDIRECTORY_FIXTURE_ROOT}/test-skipped.js` +const SKIPPED_SOURCE = `${FIXTURE_ROOT}/src/skipped-dependency.js` +const LINE_PCT_RE = /Lines\s*:\s*(\d+(?:\.\d+)?)%/ +const TESTS_TO_RUN = JSON.stringify([ + './tia-code-coverage/test-run.js', + './tia-code-coverage/test-skipped.js', +]) +const MOCHA_COMMAND = './node_modules/nyc/bin/nyc.js --all ' + + `--include '${FIXTURE_ROOT}/src/**' -r=text-summary node ./ci-visibility/run-mocha.js` +const MINIMUM_SUPPORTED_MOCHA_VERSION = '8.0.0' + +const MOCHA_VERSION_CONFIGS = [ + { + version: 'latest', + dependencies: ['mocha', 'nyc'], + }, + { + version: MINIMUM_SUPPORTED_MOCHA_VERSION, + dependencies: [`mocha@${MINIMUM_SUPPORTED_MOCHA_VERSION}`, 'nyc'], + }, +] + +function getLinesBitmapBase64 (startLine, endLine) { + const lineCoverage = {} + for (let line = startLine; line <= endLine; line++) { + lineCoverage[line] = 1 + } + return getLineCoverageBitmap(lineCoverage, true).toString('base64') +} + +function getCoverageEvents (payloads) { + return payloads + .flatMap(({ payload }) => payload) + .flatMap(({ content }) => content.coverages) +} + +function getLinePctFromOutput (output) { + const match = output.match(LINE_PCT_RE) + assert.ok(match, `coverage output did not include a lines percentage:\n${output}`) + return Number(match[1]) +} + +function getSubdirectoryMochaCommand (cwd) { + const nycBin = path.join(cwd, 'node_modules/nyc/bin/nyc.js') + + return `${nycBin} --all --include '${FIXTURE_ROOT}/src/**' -r=text-summary ` + + 'node -e "process.chdir(\'ci-visibility\'); require(process.cwd() + \'/run-mocha.js\')"' +} + +function describeMochaVersion (mochaVersion, dependencies) { + describe(`TIA code coverage mocha@${mochaVersion}`, function () { + let cwd + let childProcess + + this.timeout(180_000) + + useSandbox(dependencies, true) + + before(() => { + cwd = sandboxCwd() + }) + + afterEach(() => { + if (childProcess?.exitCode === null) { + childProcess.kill() + } + }) + + async function runMocha ({ + suitesToSkip = [], + skippableCoverage = {}, + settings = { + itr_enabled: true, + code_coverage: true, + coverage_report_upload_enabled: true, + tests_skipping: true, + }, + expectSuiteCoverage = true, + expectSessionCoverage = true, + expectCoveragePayloads = true, + command = MOCHA_COMMAND, + runCwd = cwd, + testsToRun = TESTS_TO_RUN, + } = {}) { + const receiver = await new FakeCiVisIntake().start() + receiver.setSettings(settings) + receiver.setSuitesToSkip(suitesToSkip) + receiver.setSkippableCoverage(skippableCoverage) + + let eventsResult + let coverageResult + let output = '' + const coveragePayloads = [] + const coverageRequestListener = (message) => { + if (message.url.endsWith('/api/v2/citestcov')) { + coveragePayloads.push(message) + } + } + receiver.on('message', coverageRequestListener) + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + const testSessionEvent = events.find(event => event.type === 'test_session_end') + assert.ok(testSessionEvent, `test session event should be reported:\n${output}`) + const testSession = testSessionEvent.content + const skippedSuites = events + .filter(event => event.type === 'test_suite_end') + .map(event => event.content) + .filter(suite => suite.meta[TEST_SKIPPED_BY_ITR] === 'true') + + eventsResult = { + codeCoverageLinesPct: testSession.metrics[TEST_CODE_COVERAGE_LINES_PCT], + isTiaSkipped: testSession.meta[TEST_ITR_TESTS_SKIPPED], + skippedSuites, + } + }) + + const coveragePromise = expectCoveragePayloads + ? receiver.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcov'), (payloads) => { + const coverages = getCoverageEvents(payloads) + const suiteCoverage = coverages.find(coverage => coverage.test_suite_id) + const sessionCoverage = coverages.find(coverage => !coverage.test_suite_id) + const coveredFile = coverages + .flatMap(coverage => coverage.files) + .find(file => file.bitmap) + + if (expectSuiteCoverage) { + assert.ok(suiteCoverage, `suite code coverage should be reported:\n${output}`) + } else { + assert.strictEqual(suiteCoverage, undefined, `suite code coverage should not be reported:\n${output}`) + } + if (expectSessionCoverage) { + assert.ok(sessionCoverage, `session executable-line coverage should be reported:\n${output}`) + } else { + assert.strictEqual( + sessionCoverage, + undefined, + `session executable-line coverage should not be reported:\n${output}` + ) + } + assert.ok(coveredFile?.bitmap, `covered files should report line coverage bitmaps:\n${output}`) + + coverageResult = coverages + }) + : Promise.resolve() + + childProcess = exec( + command, + { + cwd: runCwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TESTS_TO_RUN: testsToRun, + }, + } + ) + childProcess.stdout?.on('data', chunk => { + output += chunk.toString() + }) + childProcess.stderr?.on('data', chunk => { + output += chunk.toString() + }) + + try { + const stdoutEndPromise = childProcess.stdout ? once(childProcess.stdout, 'end') : Promise.resolve() + const stderrEndPromise = childProcess.stderr ? once(childProcess.stderr, 'end') : Promise.resolve() + const [, , [exitCode]] = await Promise.all([ + eventsPromise, + coveragePromise, + once(childProcess, 'exit'), + stdoutEndPromise, + stderrEndPromise, + ]) + assert.strictEqual(exitCode, 0) + if (!expectCoveragePayloads) { + await new Promise(resolve => setTimeout(resolve, 500)) + assert.strictEqual(coveragePayloads.length, 0, `code coverage payloads should not be reported:\n${output}`) + } + + return { + ...eventsResult, + coverages: coverageResult, + output, + stdoutCodeCoverageLinesPct: getLinePctFromOutput(output), + } + } finally { + receiver.off('message', coverageRequestListener) + await receiver.stop() + } + } + + // Mocha customers are already running nyc when TIA coverage is available. If a suite is skipped without backend + // coverage, nyc's local total drops and Datadog withholds lines_pct; with meta.coverage backfill, both totals + // match. + it('keeps total code coverage stable with skipped coverage', async () => { + const baseline = await runMocha() + + assert.strictEqual(baseline.isTiaSkipped, 'false') + assert.strictEqual(baseline.codeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.ok(baseline.codeCoverageLinesPct > 0, `baseline coverage was ${baseline.codeCoverageLinesPct}`) + assert.ok(baseline.codeCoverageLinesPct < 100, `baseline coverage was ${baseline.codeCoverageLinesPct}`) + assert.ok(baseline.coverages.length > 0, 'baseline should report coverage payloads') + + const skippedWithoutCoverage = await runMocha({ + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + }) + + assert.strictEqual(skippedWithoutCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedWithoutCoverage.skippedSuites.length, 1) + assert.strictEqual(skippedWithoutCoverage.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(skippedWithoutCoverage.codeCoverageLinesPct, undefined) + assert.ok( + skippedWithoutCoverage.stdoutCodeCoverageLinesPct < baseline.stdoutCodeCoverageLinesPct, + `expected ${skippedWithoutCoverage.stdoutCodeCoverageLinesPct} to be lower ` + + `than ${baseline.stdoutCodeCoverageLinesPct}` + ) + + const skippedWithCoverage = await runMocha({ + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + skippableCoverage: { + [SKIPPED_SOURCE]: getLinesBitmapBase64(1, 20), + }, + }) + + assert.strictEqual(skippedWithCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedWithCoverage.skippedSuites.length, 1) + assert.strictEqual(skippedWithCoverage.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(skippedWithCoverage.stdoutCodeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.strictEqual(skippedWithCoverage.codeCoverageLinesPct, baseline.codeCoverageLinesPct) + }) + + it('backfills repository-relative skipped coverage when mocha runs from a subdirectory', async () => { + const runFromSubdirectory = { + command: getSubdirectoryMochaCommand(cwd), + } + const baseline = await runMocha(runFromSubdirectory) + + const skippedWithCoverage = await runMocha({ + ...runFromSubdirectory, + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SUBDIRECTORY_SKIPPED_SUITE, + }, + }], + skippableCoverage: { + [SKIPPED_SOURCE]: getLinesBitmapBase64(1, 20), + }, + }) + const sessionCoverage = skippedWithCoverage.coverages.find(coverage => !coverage.test_suite_id) + + assert.strictEqual(skippedWithCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedWithCoverage.skippedSuites.length, 1) + assert.strictEqual(skippedWithCoverage.stdoutCodeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.strictEqual(skippedWithCoverage.codeCoverageLinesPct, baseline.codeCoverageLinesPct) + assert.ok(sessionCoverage.files.some(file => file.filename === SKIPPED_SOURCE)) + }) + + // TIA suite-level CITESTCOV collection is independent from Datadog Code Coverage. With report upload disabled we + // still upload suite coverage for future TIA decisions, but we do not backfill, upload session executable coverage, + // or tag Datadog lines_pct. + it('only uploads suite coverage when TIA is enabled but coverage report upload is disabled', async () => { + const result = await runMocha({ + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + settings: { + itr_enabled: true, + code_coverage: true, + coverage_report_upload_enabled: false, + tests_skipping: true, + }, + expectSessionCoverage: false, + }) + + assert.strictEqual(result.isTiaSkipped, 'true') + assert.strictEqual(result.skippedSuites.length, 1) + assert.strictEqual(result.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(result.codeCoverageLinesPct, undefined) + assert.ok(result.stdoutCodeCoverageLinesPct > 0) + }) + + // The backend code_coverage flag keeps its original meaning: it controls suite/test CITESTCOV collection for TIA. + // With both code_coverage and coverage report upload disabled, TIA can still skip, but no coverage payload is sent. + it('does not upload citestcov payloads when TIA code coverage is disabled', async () => { + const result = await runMocha({ + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + settings: { + itr_enabled: true, + code_coverage: false, + coverage_report_upload_enabled: false, + tests_skipping: true, + }, + expectCoveragePayloads: false, + }) + + assert.strictEqual(result.isTiaSkipped, 'true') + assert.strictEqual(result.skippedSuites.length, 1) + assert.strictEqual(result.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(result.codeCoverageLinesPct, undefined) + assert.ok(result.stdoutCodeCoverageLinesPct > 0) + }) + + // coverage_report_upload_enabled is the backfill gate. Even when TIA suite coverage upload is disabled through + // code_coverage=false, Datadog Code Coverage still gets the session executable-lines payload and backfilled total. + it('backfills and reports session coverage when coverage report upload is enabled', async () => { + const settings = { + itr_enabled: true, + code_coverage: false, + coverage_report_upload_enabled: true, + tests_skipping: true, + } + const baseline = await runMocha({ + settings, + expectSuiteCoverage: false, + }) + + const skippedWithCoverage = await runMocha({ + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + skippableCoverage: { + [SKIPPED_SOURCE]: getLinesBitmapBase64(1, 20), + }, + settings, + expectSuiteCoverage: false, + }) + + assert.strictEqual(skippedWithCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedWithCoverage.skippedSuites.length, 1) + assert.strictEqual(skippedWithCoverage.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(skippedWithCoverage.stdoutCodeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.strictEqual(skippedWithCoverage.codeCoverageLinesPct, baseline.codeCoverageLinesPct) + }) + }) +} + +for (const { version, dependencies } of MOCHA_VERSION_CONFIGS) { + describeMochaVersion(version, dependencies) +} diff --git a/integration-tests/ci-visibility/tia-code-coverage.spec.js b/integration-tests/ci-visibility/tia-code-coverage.spec.js new file mode 100644 index 0000000000..016a96132f --- /dev/null +++ b/integration-tests/ci-visibility/tia-code-coverage.spec.js @@ -0,0 +1,894 @@ +'use strict' + +const assert = require('node:assert/strict') +const { exec } = require('node:child_process') +const { once } = require('node:events') +const path = require('node:path') + +const { + sandboxCwd, + useSandbox, + getCiVisAgentlessConfig, +} = require('../helpers') +const { FakeCiVisIntake } = require('../ci-visibility-intake') +const { + TEST_CODE_COVERAGE_LINES_PCT, + TEST_ITR_TESTS_SKIPPED, + TEST_SKIPPED_BY_ITR, + TEST_STATUS, + getLineCoverageBitmap, +} = require('../../packages/dd-trace/src/plugins/util/test') + +const FIXTURE_ROOT = 'ci-visibility/tia-code-coverage' +const RUN_SUITE = `${FIXTURE_ROOT}/test-run.js` +const SKIPPED_SUITE = `${FIXTURE_ROOT}/test-skipped.js` +const RUN_SOURCE = `${FIXTURE_ROOT}/src/run-dependency.js` +const SKIPPED_SOURCE = `${FIXTURE_ROOT}/src/skipped-dependency.js` +const EXTRA_SOURCE = `${FIXTURE_ROOT}/src/uncovered-dependency.js` +const DEFAULT_COLLECT_COVERAGE_FROM = `${FIXTURE_ROOT}/src/**` +const LINE_PCT_RE = /Lines\s*:\s*(\d+(?:\.\d+)?)%/ +const MINIMUM_SUPPORTED_JEST_VERSION = '28.0.0' + +function getLinesBitmapBase64 (startLine, endLine) { + const lineCoverage = {} + for (let line = startLine; line <= endLine; line++) { + lineCoverage[line] = 1 + } + return getLineCoverageBitmap(lineCoverage, true).toString('base64') +} + +function getCoverageEvents (payloads) { + return payloads + .flatMap(({ payload }) => payload) + .flatMap(({ content }) => content.coverages) +} + +function getLinePctFromOutput (output) { + const match = output.match(LINE_PCT_RE) + assert.ok(match, `coverage output did not include a lines percentage:\n${output}`) + return Number(match[1]) +} + +function getJestEnv ({ + testsToRun = `${FIXTURE_ROOT}/test-`, + collectCoverageFrom = DEFAULT_COLLECT_COVERAGE_FROM, + enableCoverage = true, + useJestRun = false, + useConfigFile = false, + configTestMatch, + configCollectCoverage = false, + configTransform, +} = {}) { + const env = { + TESTS_TO_RUN: testsToRun, + } + + if (enableCoverage) { + env.ENABLE_CODE_COVERAGE = '1' + env.COVERAGE_REPORTERS = 'text-summary' + } + if (collectCoverageFrom !== null) { + env.COLLECT_COVERAGE_FROM = collectCoverageFrom + } + if (useJestRun) { + env.USE_JEST_RUN = '1' + } + if (useConfigFile) { + env.USE_CONFIG_FILE = '1' + } + if (configTestMatch) { + env.CONFIG_TEST_MATCH = configTestMatch + } + if (configCollectCoverage) { + env.CONFIG_COLLECT_COVERAGE = '1' + } + if (configTransform) { + env.CONFIG_TRANSFORM = JSON.stringify(configTransform) + } + + return env +} + +const FRAMEWORKS = [ + { + name: 'jest', + skippedSuite: SKIPPED_SUITE, + command: `node ./ci-visibility/run-jest.js ${FIXTURE_ROOT}`, + getEnv: () => getJestEnv(), + }, +] + +const JEST_VERSION_CONFIGS = [ + { + version: 'latest', + dependencies: ['jest'], + }, + { + version: MINIMUM_SUPPORTED_JEST_VERSION, + dependencies: [ + `jest@${MINIMUM_SUPPORTED_JEST_VERSION}`, + `jest-circus@${MINIMUM_SUPPORTED_JEST_VERSION}`, + ], + }, +] + +function describeJestVersion (jestVersion, dependencies) { + describe(`TIA code coverage jest@${jestVersion}`, function () { + let cwd + let childProcess + + this.timeout(180_000) + + useSandbox(dependencies, true) + + before(() => { + cwd = sandboxCwd() + }) + + afterEach(() => { + if (childProcess?.exitCode === null) { + childProcess.kill() + } + }) + + async function runFramework ({ + framework, + suitesToSkip = [], + skippableCoverage = {}, + settings = { + itr_enabled: true, + code_coverage: true, + coverage_report_upload_enabled: true, + tests_skipping: true, + }, + expectSuiteCoverage = true, + expectSessionCoverage = true, + expectCoveragePayloads = true, + expectCoverageOutput = true, + }) { + const receiver = await new FakeCiVisIntake().start() + receiver.setSettings(settings) + receiver.setSuitesToSkip(suitesToSkip) + receiver.setSkippableCoverage(skippableCoverage) + + let eventsResult + let coverageResult + let output = '' + let receivedSkippableRequest = false + const skippableRequestListener = ({ url }) => { + if (url.endsWith('/api/v2/ci/tests/skippable')) { + receivedSkippableRequest = true + } + } + const coveragePayloads = [] + const coverageRequestListener = (message) => { + if (message.url.endsWith('/api/v2/citestcov')) { + coveragePayloads.push(message) + } + } + receiver.on('message', skippableRequestListener) + receiver.on('message', coverageRequestListener) + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + const testSessionEvent = events.find(event => event.type === 'test_session_end') + assert.ok(testSessionEvent, `test session event should be reported:\n${output}`) + const testSession = testSessionEvent.content + const skippedSuites = events + .filter(event => event.type === 'test_suite_end') + .map(event => event.content) + .filter(suite => suite.meta[TEST_SKIPPED_BY_ITR] === 'true') + + eventsResult = { + codeCoverageLinesPct: testSession.metrics[TEST_CODE_COVERAGE_LINES_PCT], + isTiaSkipped: testSession.meta[TEST_ITR_TESTS_SKIPPED], + skippedSuites, + } + }) + + const coveragePromise = expectCoveragePayloads + ? receiver.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcov'), (payloads) => { + const coverages = getCoverageEvents(payloads) + const suiteCoverage = coverages.find(coverage => coverage.test_suite_id) + const sessionCoverage = coverages.find(coverage => !coverage.test_suite_id) + const coveredFile = coverages + .flatMap(coverage => coverage.files) + .find(file => file.bitmap) + + if (expectSuiteCoverage) { + assert.ok(suiteCoverage, `suite code coverage should be reported:\n${output}`) + } else { + assert.strictEqual(suiteCoverage, undefined, `suite code coverage should not be reported:\n${output}`) + } + if (expectSessionCoverage) { + assert.ok(sessionCoverage, `session executable-line coverage should be reported:\n${output}`) + } else { + assert.strictEqual( + sessionCoverage, + undefined, + `session executable-line coverage should not be reported:\n${output}` + ) + } + assert.ok(coveredFile?.bitmap, `covered files should report line coverage bitmaps:\n${output}`) + + coverageResult = coverages + }) + : Promise.resolve() + const env = { + ...getCiVisAgentlessConfig(receiver.port), + ...framework.getEnv(), + } + childProcess = exec( + framework.command, + { + cwd: framework.cwd ? framework.cwd(cwd) : cwd, + env, + } + ) + childProcess.stdout?.on('data', chunk => { + output += chunk.toString() + }) + childProcess.stderr?.on('data', chunk => { + output += chunk.toString() + }) + + try { + const stdoutEndPromise = childProcess.stdout ? once(childProcess.stdout, 'end') : Promise.resolve() + const stderrEndPromise = childProcess.stderr ? once(childProcess.stderr, 'end') : Promise.resolve() + const [, , [exitCode]] = await Promise.all([ + eventsPromise, + coveragePromise, + once(childProcess, 'exit'), + stdoutEndPromise, + stderrEndPromise, + ]) + assert.strictEqual(exitCode, 0) + if (!expectCoveragePayloads) { + await new Promise(resolve => setTimeout(resolve, 500)) + assert.strictEqual(coveragePayloads.length, 0, `code coverage payloads should not be reported:\n${output}`) + } + + return { + ...eventsResult, + coverages: coverageResult, + output, + receivedSkippableRequest, + stdoutCodeCoverageLinesPct: expectCoverageOutput ? getLinePctFromOutput(output) : undefined, + } + } finally { + receiver.off('message', skippableRequestListener) + receiver.off('message', coverageRequestListener) + await receiver.stop() + } + } + + for (const framework of FRAMEWORKS) { + // Mixed local run: one suite still executes and one suite is skipped. Without backend coverage the total + // drops; with meta.coverage backfill, both Jest stdout and the Datadog session metric return to baseline. + it(`keeps ${framework.name} total code coverage stable with skipped coverage`, async () => { + const baseline = await runFramework({ framework }) + + assert.strictEqual(baseline.isTiaSkipped, 'false') + assert.strictEqual(baseline.codeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.ok(baseline.codeCoverageLinesPct > 0, `baseline coverage was ${baseline.codeCoverageLinesPct}`) + assert.ok(baseline.codeCoverageLinesPct < 100, `baseline coverage was ${baseline.codeCoverageLinesPct}`) + assert.ok(baseline.coverages.length > 0, 'baseline should report coverage payloads') + + const skippedWithoutCoverage = await runFramework({ + framework, + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: framework.skippedSuite, + }, + }], + }) + + assert.strictEqual(skippedWithoutCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedWithoutCoverage.skippedSuites.length, 1) + assert.strictEqual(skippedWithoutCoverage.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual( + skippedWithoutCoverage.codeCoverageLinesPct, + skippedWithoutCoverage.stdoutCodeCoverageLinesPct + ) + assert.ok( + skippedWithoutCoverage.codeCoverageLinesPct < baseline.codeCoverageLinesPct, + `expected ${skippedWithoutCoverage.codeCoverageLinesPct} to be lower than ${baseline.codeCoverageLinesPct}` + ) + + const skippedWithCoverage = await runFramework({ + framework, + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: framework.skippedSuite, + }, + }], + skippableCoverage: { + [SKIPPED_SOURCE]: getLinesBitmapBase64(1, 20), + }, + }) + + assert.strictEqual(skippedWithCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedWithCoverage.skippedSuites.length, 1) + assert.strictEqual(skippedWithCoverage.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual( + skippedWithCoverage.stdoutCodeCoverageLinesPct, + baseline.stdoutCodeCoverageLinesPct + ) + assert.strictEqual(skippedWithCoverage.codeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + }) + } + + // If suite skipping is disabled, a skippable response with meta.coverage must not alter the run. We compare + // against a no-skipping baseline, not just stdout vs. Datadog, to catch accidental backfill side effects. + it('does not alter jest coverage when suite skipping is disabled', async () => { + const framework = FRAMEWORKS[0] + const baseline = await runFramework({ framework }) + const coveredSkippedLines = getLinesBitmapBase64(1, 20) + const result = await runFramework({ + framework, + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + skippableCoverage: { + [SKIPPED_SOURCE]: coveredSkippedLines, + }, + settings: { + itr_enabled: true, + code_coverage: true, + coverage_report_upload_enabled: true, + tests_skipping: false, + }, + }) + + assert.notStrictEqual(result.isTiaSkipped, 'true') + assert.strictEqual(result.skippedSuites.length, 0) + assert.strictEqual(result.codeCoverageLinesPct, result.stdoutCodeCoverageLinesPct) + assert.strictEqual(result.stdoutCodeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.strictEqual(result.codeCoverageLinesPct, baseline.codeCoverageLinesPct) + }) + + // TIA is the gate for all Jest CITESTCOV payloads. When TIA is off, ordinary Jest coverage can still produce the + // local/stdout coverage result and Datadog lines_pct, but no suite or session coverage payload should be uploaded. + it('does not upload citestcov payloads when TIA is disabled', async () => { + const result = await runFramework({ + framework: FRAMEWORKS[0], + settings: { + itr_enabled: false, + code_coverage: true, + coverage_report_upload_enabled: true, + tests_skipping: false, + }, + expectCoveragePayloads: false, + }) + + assert.strictEqual(result.isTiaSkipped, 'false') + assert.strictEqual(result.skippedSuites.length, 0) + assert.strictEqual(result.codeCoverageLinesPct, result.stdoutCodeCoverageLinesPct) + }) + + // TIA is the top-level gate for suite skipping. Even if a malformed settings response has tests_skipping=true, + // disabling TIA must avoid the skippable request and leave ordinary Jest coverage untouched. + it('does not request skippable suites or backfill coverage when TIA is disabled', async () => { + const framework = FRAMEWORKS[0] + const baseline = await runFramework({ framework }) + const result = await runFramework({ + framework, + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + skippableCoverage: { + [SKIPPED_SOURCE]: getLinesBitmapBase64(1, 20), + }, + settings: { + itr_enabled: false, + code_coverage: true, + coverage_report_upload_enabled: true, + tests_skipping: true, + }, + expectCoveragePayloads: false, + }) + + assert.strictEqual(result.receivedSkippableRequest, false) + assert.strictEqual(result.isTiaSkipped, 'false') + assert.strictEqual(result.skippedSuites.length, 0) + assert.strictEqual(result.stdoutCodeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.strictEqual(result.codeCoverageLinesPct, baseline.codeCoverageLinesPct) + }) + + // coverage_report_upload_enabled=true is the backfill gate. When Datadog Code Coverage is enabled, TIA can force + // Jest coverage collection, backfill skipped-suite coverage, and report Datadog lines_pct even if the user did not + // configure coverage in Jest. + it('backfills and reports Datadog coverage without user jest coverage when report upload is enabled', async () => { + const framework = { + ...FRAMEWORKS[0], + getEnv: () => getJestEnv({ + collectCoverageFrom: null, + enableCoverage: false, + }), + } + const result = await runFramework({ + framework, + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + skippableCoverage: { + [SKIPPED_SOURCE]: getLinesBitmapBase64(1, 20), + }, + expectCoverageOutput: false, + }) + + assert.strictEqual(result.isTiaSkipped, 'true') + assert.strictEqual(result.skippedSuites.length, 1) + assert.strictEqual(result.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.ok(result.codeCoverageLinesPct > 0) + assert.strictEqual(result.stdoutCodeCoverageLinesPct, undefined) + }) + + // TIA suite-level CITESTCOV collection is independent from Datadog Code Coverage. With report upload disabled we + // still upload suite coverage for future TIA decisions, but we do not backfill, upload session executable coverage, + // or tag Datadog lines_pct. + it('only uploads suite coverage when TIA is enabled but coverage report upload is disabled', async () => { + const framework = FRAMEWORKS[0] + const result = await runFramework({ + framework, + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + settings: { + itr_enabled: true, + code_coverage: true, + coverage_report_upload_enabled: false, + tests_skipping: true, + }, + expectSessionCoverage: false, + }) + + assert.strictEqual(result.isTiaSkipped, 'true') + assert.strictEqual(result.skippedSuites.length, 1) + assert.strictEqual(result.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(result.codeCoverageLinesPct, undefined) + assert.ok(result.stdoutCodeCoverageLinesPct > 0) + }) + + // The same suite-only upload behavior applies when TIA has to force Jest coverage because the user did not + // configure it. coverage_report_upload_enabled=false still means no backfill, no session executable coverage, + // and no Datadog lines_pct. + it('only uploads suite coverage without report upload or user jest coverage', async () => { + const framework = { + ...FRAMEWORKS[0], + getEnv: () => getJestEnv({ + collectCoverageFrom: null, + enableCoverage: false, + }), + } + const result = await runFramework({ + framework, + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + settings: { + itr_enabled: true, + code_coverage: true, + coverage_report_upload_enabled: false, + tests_skipping: true, + }, + expectSessionCoverage: false, + expectCoverageOutput: false, + }) + + assert.strictEqual(result.isTiaSkipped, 'true') + assert.strictEqual(result.skippedSuites.length, 1) + assert.strictEqual(result.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(result.codeCoverageLinesPct, undefined) + assert.strictEqual(result.stdoutCodeCoverageLinesPct, undefined) + }) + + // The backend code_coverage flag keeps its original meaning: it controls suite/test CITESTCOV collection for TIA. + // With both code_coverage and coverage report upload disabled, TIA can still skip, but no coverage payload is sent. + it('does not upload citestcov payloads when TIA code coverage is disabled', async () => { + const result = await runFramework({ + framework: FRAMEWORKS[0], + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + settings: { + itr_enabled: true, + code_coverage: false, + coverage_report_upload_enabled: false, + tests_skipping: true, + }, + expectCoveragePayloads: false, + }) + + assert.strictEqual(result.isTiaSkipped, 'true') + assert.strictEqual(result.skippedSuites.length, 1) + assert.strictEqual(result.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(result.codeCoverageLinesPct, undefined) + assert.ok(result.stdoutCodeCoverageLinesPct > 0) + }) + + // coverage_report_upload_enabled is the backfill gate. Even when TIA suite coverage upload is disabled through + // code_coverage=false, Datadog Code Coverage still gets the session executable-lines payload and backfilled total. + it('backfills and reports session coverage when coverage report upload is enabled', async () => { + const framework = FRAMEWORKS[0] + const settings = { + itr_enabled: true, + code_coverage: false, + coverage_report_upload_enabled: true, + tests_skipping: true, + } + const baseline = await runFramework({ + framework, + settings, + expectSuiteCoverage: false, + }) + + const skippedWithCoverage = await runFramework({ + framework, + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + skippableCoverage: { + [SKIPPED_SOURCE]: getLinesBitmapBase64(1, 20), + }, + settings, + expectSuiteCoverage: false, + }) + + assert.strictEqual(skippedWithCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedWithCoverage.skippedSuites.length, 1) + assert.strictEqual(skippedWithCoverage.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(skippedWithCoverage.stdoutCodeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.strictEqual(skippedWithCoverage.codeCoverageLinesPct, baseline.codeCoverageLinesPct) + }) + + // Zero-local-suite path: every suite that Jest would run is returned as skippable. No suite should run here; + // instead, we synthesize the Jest coverage report from backend meta.coverage and the local Jest config. + it('keeps jest total code coverage stable when all local suites are skippable', async () => { + const framework = { + ...FRAMEWORKS[0], + command: `node ./ci-visibility/run-jest.js ${FIXTURE_ROOT}`, + getEnv: () => getJestEnv({ useJestRun: true }), + } + const baseline = await runFramework({ framework }) + + assert.strictEqual(baseline.isTiaSkipped, 'false') + assert.strictEqual(baseline.codeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.ok(baseline.codeCoverageLinesPct > 0, `baseline coverage was ${baseline.codeCoverageLinesPct}`) + + const coveredSkippedLines = getLinesBitmapBase64(1, 20) + const skippedWithCoverage = await runFramework({ + framework, + suitesToSkip: [ + { + type: 'suite', + attributes: { + suite: RUN_SUITE, + }, + }, + { + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }, + ], + skippableCoverage: { + [RUN_SOURCE]: coveredSkippedLines, + [SKIPPED_SOURCE]: coveredSkippedLines, + }, + expectSuiteCoverage: false, + }) + + assert.strictEqual(skippedWithCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedWithCoverage.skippedSuites.length, 2) + assert.strictEqual(skippedWithCoverage.stdoutCodeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.strictEqual(skippedWithCoverage.codeCoverageLinesPct, baseline.codeCoverageLinesPct) + }) + + // The backend returns aggregate meta.coverage for the skippable response, which can include suites outside this + // local Jest invocation. We apply that coverage as the session base because commit-level aggregation is the + // product target, even if a single shard/session reports broader coverage than it locally executed. + it('uses backend coverage outside the local run as the jest coverage base', async () => { + const framework = { + ...FRAMEWORKS[0], + command: `node ./ci-visibility/run-jest.js ${FIXTURE_ROOT}`, + getEnv: () => getJestEnv({ useJestRun: true }), + } + const baseline = await runFramework({ framework }) + + assert.strictEqual(baseline.isTiaSkipped, 'false') + assert.strictEqual(baseline.codeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.ok(baseline.codeCoverageLinesPct > 0, `baseline coverage was ${baseline.codeCoverageLinesPct}`) + + const coveredSkippedLines = getLinesBitmapBase64(1, 20) + const broaderCoverage = await runFramework({ + framework, + suitesToSkip: [ + { + type: 'suite', + attributes: { + suite: RUN_SUITE, + }, + }, + { + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }, + { + type: 'suite', + attributes: { + suite: 'ci-visibility/other-suite/test-outside-local-run.js', + }, + }, + ], + skippableCoverage: { + [RUN_SOURCE]: coveredSkippedLines, + [SKIPPED_SOURCE]: coveredSkippedLines, + [EXTRA_SOURCE]: coveredSkippedLines, + }, + expectSuiteCoverage: false, + }) + + assert.strictEqual(broaderCoverage.isTiaSkipped, 'true') + assert.strictEqual(broaderCoverage.skippedSuites.length, 2) + assert.strictEqual(broaderCoverage.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.ok( + broaderCoverage.stdoutCodeCoverageLinesPct > baseline.stdoutCodeCoverageLinesPct, + `expected ${broaderCoverage.stdoutCodeCoverageLinesPct} to be higher than ` + + `${baseline.stdoutCodeCoverageLinesPct}` + ) + assert.ok( + broaderCoverage.codeCoverageLinesPct > baseline.codeCoverageLinesPct, + `expected ${broaderCoverage.codeCoverageLinesPct} to be higher than ${baseline.codeCoverageLinesPct}` + ) + assert.strictEqual(broaderCoverage.stdoutCodeCoverageLinesPct, 100) + assert.strictEqual(broaderCoverage.codeCoverageLinesPct, 100) + }) + + // Some custom coverage transformers, including SWC-based setups, emit Istanbul metadata as a plain + // `var coverageData = ...` literal. That shape is not parsed by Istanbul's readInitialCoverage(), but we still + // need it when no local suite runs and backend meta.coverage is the only covered-line source. + it('backfills jest coverage from transformer coverageData literals', async () => { + const framework = { + ...FRAMEWORKS[0], + command: `node ./ci-visibility/run-jest.js ${FIXTURE_ROOT}`, + getEnv: () => getJestEnv({ + collectCoverageFrom: null, + configTestMatch: `**/${FIXTURE_ROOT}/test-*.js`, + configCollectCoverage: true, + configTransform: { + '^.+\\.js$': `/${FIXTURE_ROOT}/coverage-data-transformer.js`, + }, + useConfigFile: true, + useJestRun: true, + }), + } + const coveredSkippedLines = getLinesBitmapBase64(1, 20) + const result = await runFramework({ + framework, + suitesToSkip: [ + { + type: 'suite', + attributes: { + suite: RUN_SUITE, + }, + }, + { + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }, + ], + skippableCoverage: { + [RUN_SOURCE]: coveredSkippedLines, + [SKIPPED_SOURCE]: coveredSkippedLines, + }, + expectSuiteCoverage: false, + }) + + assert.strictEqual(result.isTiaSkipped, 'true') + assert.strictEqual(result.skippedSuites.length, 2) + assert.strictEqual(result.stdoutCodeCoverageLinesPct, 100) + assert.strictEqual(result.codeCoverageLinesPct, 100) + }) + + // Customers can enable Jest coverage without collectCoverageFrom. These cases keep that absence explicit so we + // do not accidentally make TIA coverage backfill depend on users configuring collection globs. + context('without collectCoverageFrom', () => { + // Config-file coverage still has enough Jest coverage machinery to publish totals. Backend coverage fills the + // skipped files and keeps the result aligned with the baseline without running a suite. + it('keeps jest config-file coverage stable', async () => { + const framework = { + ...FRAMEWORKS[0], + command: `node ./ci-visibility/run-jest.js ${FIXTURE_ROOT}`, + getEnv: () => getJestEnv({ + collectCoverageFrom: null, + configTestMatch: `**/${FIXTURE_ROOT}/test-*.js`, + configCollectCoverage: true, + useConfigFile: true, + useJestRun: true, + }), + } + const baseline = await runFramework({ framework }) + + assert.strictEqual(baseline.isTiaSkipped, 'false') + assert.strictEqual(baseline.codeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.ok(baseline.codeCoverageLinesPct > 0, `baseline coverage was ${baseline.codeCoverageLinesPct}`) + + const coveredSkippedLines = getLinesBitmapBase64(1, 20) + const skippedCoverage = await runFramework({ + framework, + suitesToSkip: [ + { + type: 'suite', + attributes: { + suite: RUN_SUITE, + }, + }, + { + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }, + ], + skippableCoverage: { + [RUN_SOURCE]: coveredSkippedLines, + [SKIPPED_SOURCE]: coveredSkippedLines, + }, + expectSuiteCoverage: false, + }) + + assert.strictEqual(skippedCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedCoverage.skippedSuites.length, 2) + assert.strictEqual(skippedCoverage.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(skippedCoverage.stdoutCodeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.strictEqual(skippedCoverage.codeCoverageLinesPct, baseline.codeCoverageLinesPct) + }) + + // Missing collectCoverageFrom should not block the skip decision when backend line coverage is present. This + // mainly guards against treating the absence of a user glob as "unsafe to skip." + it('skips when backend coverage is available', async () => { + const framework = { + ...FRAMEWORKS[0], + getEnv: () => getJestEnv({ collectCoverageFrom: null }), + } + const baseline = await runFramework({ framework }) + + assert.strictEqual(baseline.isTiaSkipped, 'false') + assert.strictEqual(baseline.codeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.ok(baseline.codeCoverageLinesPct > 0, `baseline coverage was ${baseline.codeCoverageLinesPct}`) + + const coveredSkippedLines = getLinesBitmapBase64(1, 20) + const unseedableCoverage = await runFramework({ + framework, + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + skippableCoverage: { + [SKIPPED_SOURCE]: coveredSkippedLines, + }, + }) + + assert.strictEqual(unseedableCoverage.isTiaSkipped, 'true') + assert.strictEqual(unseedableCoverage.skippedSuites.length, 1) + assert.strictEqual(unseedableCoverage.skippedSuites[0].meta[TEST_STATUS], 'skip') + }) + + // A CLI test pattern can be a prefix or regex-like value rather than a directory. Backend file paths still give + // us the files to seed, so coverage should stay stable after skipping. + it('keeps jest coverage stable when a cli pattern is not a directory path', async () => { + const framework = { + ...FRAMEWORKS[0], + command: `node ./ci-visibility/run-jest.js ${FIXTURE_ROOT}/test-`, + getEnv: () => getJestEnv({ + collectCoverageFrom: null, + useJestRun: true, + }), + } + const baseline = await runFramework({ framework }) + + assert.strictEqual(baseline.isTiaSkipped, 'false') + assert.strictEqual(baseline.codeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.ok(baseline.codeCoverageLinesPct > 0, `baseline coverage was ${baseline.codeCoverageLinesPct}`) + + const coveredSkippedLines = getLinesBitmapBase64(1, 20) + const unscopedPatternRun = await runFramework({ + framework, + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + skippableCoverage: { + [SKIPPED_SOURCE]: coveredSkippedLines, + }, + }) + + assert.strictEqual(unscopedPatternRun.isTiaSkipped, 'true') + assert.strictEqual(unscopedPatternRun.skippedSuites.length, 1) + assert.strictEqual(unscopedPatternRun.skippedSuites[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(unscopedPatternRun.stdoutCodeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.strictEqual(unscopedPatternRun.codeCoverageLinesPct, baseline.codeCoverageLinesPct) + }) + }) + + // Jest can be launched below the repository root while backend suites and coverage use repository-relative paths. + // This catches regressions where coverage filenames become cwd-relative and stop matching backend meta.coverage. + it('uses the repository root for jest coverage when launched from a subdirectory', async () => { + const framework = { + ...FRAMEWORKS[0], + command: 'node ./run-jest.js tia-code-coverage', + cwd: sandboxRoot => path.join(sandboxRoot, 'ci-visibility'), + getEnv: () => getJestEnv({ + testsToRun: 'tia-code-coverage/test-', + collectCoverageFrom: 'tia-code-coverage/src/**', + useJestRun: true, + }), + } + const baseline = await runFramework({ framework }) + + assert.strictEqual(baseline.isTiaSkipped, 'false') + assert.strictEqual(baseline.codeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + + const coveredSkippedLines = getLinesBitmapBase64(1, 20) + const skippedWithCoverage = await runFramework({ + framework, + suitesToSkip: [{ + type: 'suite', + attributes: { + suite: SKIPPED_SUITE, + }, + }], + skippableCoverage: { + [SKIPPED_SOURCE]: coveredSkippedLines, + }, + }) + const sessionCoverage = skippedWithCoverage.coverages.find(coverage => !coverage.test_suite_id) + const sessionCoverageFilenames = sessionCoverage.files.map(file => file.filename) + + assert.strictEqual(skippedWithCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedWithCoverage.skippedSuites.length, 1) + assert.strictEqual(skippedWithCoverage.stdoutCodeCoverageLinesPct, baseline.stdoutCodeCoverageLinesPct) + assert.strictEqual(skippedWithCoverage.codeCoverageLinesPct, baseline.codeCoverageLinesPct) + assert.ok(sessionCoverageFilenames.includes(RUN_SOURCE)) + assert.ok(sessionCoverageFilenames.includes(SKIPPED_SOURCE)) + }) + }) +} + +for (const { version, dependencies } of JEST_VERSION_CONFIGS) { + describeJestVersion(version, dependencies) +} diff --git a/integration-tests/ci-visibility/tia-code-coverage/coverage-data-transformer.js b/integration-tests/ci-visibility/tia-code-coverage/coverage-data-transformer.js new file mode 100644 index 0000000000..60c7bb23ff --- /dev/null +++ b/integration-tests/ci-visibility/tia-code-coverage/coverage-data-transformer.js @@ -0,0 +1,60 @@ +'use strict' + +function getStatementLineNumbers (sourceText) { + const lineNumbers = [] + const lines = sourceText.split(/\r?\n/) + + for (let index = 0; index < lines.length; index++) { + if (lines[index].trim()) { + lineNumbers.push(index + 1) + } + } + + return lineNumbers +} + +function getCoverageData (filename, sourceText) { + const statementMap = {} + const s = {} + const lines = sourceText.split(/\r?\n/) + + for (const [id, line] of getStatementLineNumbers(sourceText).entries()) { + statementMap[id] = { + start: { + line, + column: 0, + }, + end: { + line, + column: lines[line - 1].length, + }, + } + s[id] = 0 + } + + return { + path: filename, + hash: 'escaped\\coverage', + statementMap, + fnMap: {}, + branchMap: {}, + s, + f: {}, + b: {}, + } +} + +module.exports = { + canInstrument: true, + process (sourceText, filename, options) { + if (!options?.instrument || !filename.includes('/src/')) { + return { + code: sourceText, + } + } + + return { + code: `var coverageData = ${JSON.stringify(getCoverageData(filename, sourceText))};\n${sourceText}`, + } + }, +} diff --git a/integration-tests/ci-visibility/tia-code-coverage/src/run-dependency.js b/integration-tests/ci-visibility/tia-code-coverage/src/run-dependency.js new file mode 100644 index 0000000000..99d88fa19c --- /dev/null +++ b/integration-tests/ci-visibility/tia-code-coverage/src/run-dependency.js @@ -0,0 +1,5 @@ +'use strict' + +module.exports = function runDependency (a, b) { + return a + b +} diff --git a/integration-tests/ci-visibility/tia-code-coverage/src/skipped-dependency.js b/integration-tests/ci-visibility/tia-code-coverage/src/skipped-dependency.js new file mode 100644 index 0000000000..5342c74578 --- /dev/null +++ b/integration-tests/ci-visibility/tia-code-coverage/src/skipped-dependency.js @@ -0,0 +1,5 @@ +'use strict' + +module.exports = function skippedDependency (a, b) { + return a + b +} diff --git a/integration-tests/ci-visibility/tia-code-coverage/src/uncovered-dependency.js b/integration-tests/ci-visibility/tia-code-coverage/src/uncovered-dependency.js new file mode 100644 index 0000000000..b793475439 --- /dev/null +++ b/integration-tests/ci-visibility/tia-code-coverage/src/uncovered-dependency.js @@ -0,0 +1,5 @@ +'use strict' + +module.exports = function uncoveredDependency (a, b) { + return a + b +} diff --git a/integration-tests/ci-visibility/tia-code-coverage/test-run.js b/integration-tests/ci-visibility/tia-code-coverage/test-run.js new file mode 100644 index 0000000000..561f10396c --- /dev/null +++ b/integration-tests/ci-visibility/tia-code-coverage/test-run.js @@ -0,0 +1,11 @@ +'use strict' + +const assert = require('node:assert/strict') + +const sum = require('./src/run-dependency') + +describe('test-run', () => { + it('covers the run dependency', () => { + assert.strictEqual(sum(1, 2), 3) + }) +}) diff --git a/integration-tests/ci-visibility/tia-code-coverage/test-skipped.js b/integration-tests/ci-visibility/tia-code-coverage/test-skipped.js new file mode 100644 index 0000000000..086d5c0b35 --- /dev/null +++ b/integration-tests/ci-visibility/tia-code-coverage/test-skipped.js @@ -0,0 +1,11 @@ +'use strict' + +const assert = require('node:assert/strict') + +const sum = require('./src/skipped-dependency') + +describe('test-skipped', () => { + it('covers the skipped dependency', () => { + assert.strictEqual(sum(1, 2), 3) + }) +}) diff --git a/integration-tests/config-jest.js b/integration-tests/config-jest.js index d772405212..8a77e5563f 100644 --- a/integration-tests/config-jest.js +++ b/integration-tests/config-jest.js @@ -1,12 +1,30 @@ 'use strict' -module.exports = { +const config = { projects: process.env.PROJECTS ? JSON.parse(process.env.PROJECTS) : [__dirname], testPathIgnorePatterns: ['/node_modules/'], cache: false, testMatch: [ - process.env.TESTS_TO_RUN || '**/ci-visibility/test/ci-visibility-test*', + process.env.CONFIG_TEST_MATCH || process.env.TESTS_TO_RUN || '**/ci-visibility/test/ci-visibility-test*', ], testRunner: 'jest-circus/runner', testEnvironment: 'node', } + +if (process.env.COLLECT_COVERAGE_FROM) { + config.collectCoverageFrom = process.env.COLLECT_COVERAGE_FROM.split(',') +} + +if (process.env.ENABLE_CODE_COVERAGE || process.env.CONFIG_COLLECT_COVERAGE) { + config.collectCoverage = true +} + +if (process.env.COVERAGE_REPORTERS) { + config.coverageReporters = process.env.COVERAGE_REPORTERS.split(',') +} + +if (process.env.CONFIG_TRANSFORM) { + config.transform = JSON.parse(process.env.CONFIG_TRANSFORM) +} + +module.exports = config diff --git a/integration-tests/coverage-child-process.spec.js b/integration-tests/coverage-child-process.spec.js index 2e840524da..43e151dcf3 100644 --- a/integration-tests/coverage-child-process.spec.js +++ b/integration-tests/coverage-child-process.spec.js @@ -6,6 +6,7 @@ const fs = require('node:fs') const fsp = require('node:fs/promises') const os = require('node:os') const path = require('node:path') +const { inspect } = require('node:util') const { installPatch } = require('./coverage/patch-child-process') const { installLastExitHandler } = require('./coverage/pre-instrumented-writer') @@ -122,8 +123,8 @@ process.send('ready') const workerDebug = JSON.parse(fs.readFileSync(path.join(fixturesDir, 'worker-debug.json'), 'utf8')) assert.strictEqual(parentDebug.hasNycConfig, true) assert.strictEqual(workerDebug.hasNycConfig, true) - assert.ok(parentDebug.nodeOptions.includes('child-bootstrap.js')) - assert.ok(workerDebug.nodeOptions.includes('child-bootstrap.js')) + assert.ok(parentDebug.nodeOptions.includes('child-bootstrap.js'), `Got: ${inspect(parentDebug.nodeOptions)}`) + assert.ok(workerDebug.nodeOptions.includes('child-bootstrap.js'), `Got: ${inspect(workerDebug.nodeOptions)}`) assert.ok(parentDebug.coverageKeys.length > 0, 'expected parent process coverage to be populated') assert.ok(workerDebug.coverageKeys.length > 0, 'expected worker process coverage to be populated') diff --git a/integration-tests/crashtracking/crashtracking.spec.js b/integration-tests/crashtracking/crashtracking.spec.js index 17afe92d18..31b84a4b35 100644 --- a/integration-tests/crashtracking/crashtracking.spec.js +++ b/integration-tests/crashtracking/crashtracking.spec.js @@ -4,6 +4,7 @@ const assert = require('node:assert/strict') const { fork } = require('node:child_process') const os = require('node:os') const path = require('node:path') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') @@ -113,11 +114,11 @@ describeNotWindows('crashtracking integration', () => { // Ping assert.strictEqual(ping.kind, 'UnixSignal') - assert.ok(ping.message.includes('SIGABRT')) + assert.ok(ping.message.includes('SIGABRT'), `Got: ${inspect(ping.message)}`) // Full report assert.strictEqual(report.error.kind, 'UnixSignal') - assert.ok(report.error.message.includes('SIGABRT')) + assert.ok(report.error.message.includes('SIGABRT'), `Got: ${inspect(report.error.message)}`) assert.strictEqual(report.error.source_type, 'Crashtracking') // Stack frames @@ -145,8 +146,11 @@ describeNotWindows('crashtracking integration', () => { // Full report assert.strictEqual(report.error.kind, 'UnhandledException') - assert.ok(report.error.message.includes('TypeError')) - assert.ok(report.error.message.includes('integration test uncaught exception')) + assert.ok(report.error.message.includes('TypeError'), `Got: ${inspect(report.error.message)}`) + assert.ok( + report.error.message.includes('integration test uncaught exception'), + `Got: ${inspect(report.error.message)}` + ) assert.strictEqual(report.error.source_type, 'Crashtracking') // Stack frames JS frames carry file/line/column/function diff --git a/integration-tests/cucumber/cucumber.spec.js b/integration-tests/cucumber/cucumber.spec.js index ce610dec2b..31a9167bc7 100644 --- a/integration-tests/cucumber/cucumber.spec.js +++ b/integration-tests/cucumber/cucumber.spec.js @@ -6,6 +6,7 @@ const { once } = require('node:events') const { exec, execSync } = require('child_process') const fs = require('fs') const path = require('path') +const { inspect } = require('node:util') const { assertObjectContains } = require('../helpers') const { @@ -42,7 +43,6 @@ const { TEST_SUITE, TEST_CODE_OWNERS, TEST_SESSION_NAME, - TEST_LEVEL_EVENT_TYPES, DI_ERROR_DEBUG_INFO_CAPTURED, DI_DEBUG_ERROR_PREFIX, DI_DEBUG_ERROR_FILE_SUFFIX, @@ -457,9 +457,8 @@ describe(`cucumber@${version} commonJS`, () => { .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { const metadataDicts = payloads.flatMap(({ payload }) => payload.metadata) metadataDicts.forEach(metadata => { - for (const testLevel of TEST_LEVEL_EVENT_TYPES) { - assert.strictEqual(metadata[testLevel][TEST_SESSION_NAME], 'my-test-session') - } + assert.strictEqual(metadata['*'][TEST_SESSION_NAME], 'my-test-session') + assert.ok(metadata['*'][TEST_COMMAND]) }) const events = payloads.flatMap(({ payload }) => payload.events) @@ -479,14 +478,12 @@ describe(`cucumber@${version} commonJS`, () => { } assert.ok(testSessionEventContent.test_session_id) - assert.ok(testSessionEventContent.meta[TEST_COMMAND]) assert.ok(testSessionEventContent.meta[TEST_TOOLCHAIN]) assert.strictEqual(testSessionEventContent.resource.startsWith('test_session.'), true) assert.strictEqual(testSessionEventContent.meta[TEST_STATUS], 'fail') assert.ok(testModuleEventContent.test_session_id) assert.ok(testModuleEventContent.test_module_id) - assert.ok(testModuleEventContent.meta[TEST_COMMAND]) assert.ok(testModuleEventContent.meta[TEST_MODULE]) assert.strictEqual(testModuleEventContent.resource.startsWith('test_module.'), true) assert.strictEqual(testModuleEventContent.meta[TEST_STATUS], 'fail') @@ -513,7 +510,6 @@ describe(`cucumber@${version} commonJS`, () => { test_session_id: testSessionId, }, }) => { - assert.ok(meta[TEST_COMMAND]) assert.ok(meta[TEST_MODULE]) assert.ok(testSuiteId) assert.strictEqual(testModuleId.toString(10), testModuleEventContent.test_module_id.toString(10)) @@ -549,7 +545,6 @@ describe(`cucumber@${version} commonJS`, () => { test_session_id: testSessionId, }, }) => { - assert.ok(meta[TEST_COMMAND]) assert.ok(meta[TEST_MODULE]) assert.ok(testSuiteId) assert.strictEqual(testModuleId.toString(10), testModuleEventContent.test_module_id.toString(10)) @@ -571,7 +566,10 @@ describe(`cucumber@${version} commonJS`, () => { stepEvents.forEach(stepEvent => { assert.strictEqual(stepEvent.content.name, 'cucumber.step') - assert.ok(Object.hasOwn(stepEvent.content.meta, 'cucumber.step')) + assert.ok( + Object.hasOwn(stepEvent.content.meta, 'cucumber.step'), + `Available keys: ${inspect(Object.keys(stepEvent.content.meta))}` + ) if (stepEvent.content.meta['cucumber.step'] === 'the greeter says greetings') { assert.strictEqual(stepEvent.content.meta['custom_tag.when'], 'hello when') } @@ -1229,9 +1227,9 @@ describe(`cucumber@${version} commonJS`, () => { // Only tests from the non-skipped suite ran const tests = events.filter(event => event.type === 'test').map(event => event.content) - assert.ok(tests.length > 0) + assert.ok(tests.length > 0, `Expected ${tests.length} > 0`) tests.forEach(test => { - assert.ok(!test.meta[TEST_SUITE].includes('farewell')) + assert.ok(!test.meta[TEST_SUITE].includes('farewell'), `Got: ${inspect(test.meta[TEST_SUITE])}`) }) assertItrSkippingEnabledTags(events, 'true') }) @@ -2563,7 +2561,10 @@ describe(`cucumber@${version} commonJS`, () => { 'nyc output does not match the reported coverage (no --all flag)') eventsPromise.then(() => { - assert.ok(codeCoverageWithoutUntestedFiles > codeCoverageWithUntestedFiles) + assert.ok( + codeCoverageWithoutUntestedFiles > codeCoverageWithUntestedFiles, + `Expected ${codeCoverageWithoutUntestedFiles} > ${codeCoverageWithUntestedFiles}` + ) done() }).catch(done) }) @@ -2904,7 +2905,7 @@ describe(`cucumber@${version} commonJS`, () => { const atfTests = tests.filter( t => t.meta[TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX] === 'true' ) - assert.ok(atfTests.length > 0) + assert.ok(atfTests.length > 0, `Expected ${atfTests.length} > 0`) for (const test of atfTests) { assert.ok( !(TEST_IS_NEW in test.meta), @@ -3434,7 +3435,7 @@ describe(`cucumber@${version} commonJS`, () => { .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { const metadataDicts = payloads.flatMap(({ payload }) => payload.metadata) - assert.ok(metadataDicts.length > 0) + assert.ok(metadataDicts.length > 0, `Expected ${metadataDicts.length} > 0`) metadataDicts.forEach(metadata => { assert.strictEqual(metadata.test[DD_CAPABILITIES_TEST_IMPACT_ANALYSIS], '1') assert.strictEqual(metadata.test[DD_CAPABILITIES_EARLY_FLAKE_DETECTION], '1') @@ -3445,7 +3446,7 @@ describe(`cucumber@${version} commonJS`, () => { assert.strictEqual(metadata.test[DD_CAPABILITIES_TEST_MANAGEMENT_ATTEMPT_TO_FIX], '5') assert.strictEqual(metadata.test[DD_CAPABILITIES_FAILED_TEST_REPLAY], '1') // capabilities logic does not overwrite test session name - assert.strictEqual(metadata.test[TEST_SESSION_NAME], 'my-test-session-name') + assert.strictEqual(metadata['*'][TEST_SESSION_NAME], 'my-test-session-name') }) }) @@ -3676,10 +3677,16 @@ describe(`cucumber@${version} commonJS`, () => { const coverageReport = payloads[0] - assert.ok(coverageReport.headers['content-type'].includes('multipart/form-data')) + assert.ok( + coverageReport.headers['content-type'].includes('multipart/form-data'), + `Got: ${inspect(coverageReport.headers['content-type'])}` + ) assert.strictEqual(coverageReport.coverageFile.name, 'coverage') - assert.ok(coverageReport.coverageFile.content.includes('SF:')) // LCOV format + assert.ok( + coverageReport.coverageFile.content.includes('SF:'), + `Got: ${inspect(coverageReport.coverageFile.content)}` + ) // LCOV format assert.strictEqual(coverageReport.eventFile.name, 'event') assert.strictEqual(coverageReport.eventFile.content.type, 'coverage_report') diff --git a/integration-tests/cypress/cypress-atr.spec.js b/integration-tests/cypress/cypress-atr.spec.js index 3c99b3fd85..9619deec15 100644 --- a/integration-tests/cypress/cypress-atr.spec.js +++ b/integration-tests/cypress/cypress-atr.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const { exec } = require('node:child_process') const { once } = require('node:events') +const { inspect } = require('node:util') const semver = require('semver') const { @@ -102,6 +103,7 @@ moduleTypes.forEach(({ useSandbox([`cypress@${version}`, 'cypress-fail-fast@7.1.0', 'typescript'], true) before(async function () { + this.timeout(180_000) cwd = sandboxCwd() await warmCypressBinary(cwd) @@ -281,7 +283,10 @@ moduleTypes.forEach(({ 'cypress/e2e/flaky-test-retries.js.flaky test retry never passes', 'cypress/e2e/flaky-test-retries.js.flaky test retry always passes', ]) - assert.ok(!tests.some(test => test.meta[TEST_RETRY_REASON] === TEST_RETRY_REASON_TYPES.atr)) + assert.ok( + !tests.some(test => test.meta[TEST_RETRY_REASON] === TEST_RETRY_REASON_TYPES.atr), + `Got: ${inspect(tests)}` + ) }, { hardTimeout: 25000 }) await Promise.all([ diff --git a/integration-tests/cypress/cypress-efd.spec.js b/integration-tests/cypress/cypress-efd.spec.js index 9c8c62be9c..978e8eb190 100644 --- a/integration-tests/cypress/cypress-efd.spec.js +++ b/integration-tests/cypress/cypress-efd.spec.js @@ -101,6 +101,7 @@ moduleTypes.forEach(({ useSandbox([`cypress@${version}`, 'cypress-fail-fast@7.1.0', 'typescript'], true) before(async function () { + this.timeout(180_000) cwd = sandboxCwd() await warmCypressBinary(cwd) diff --git a/integration-tests/cypress/cypress-final-status.spec.js b/integration-tests/cypress/cypress-final-status.spec.js index 031eb1c910..a6ba5fb0a0 100644 --- a/integration-tests/cypress/cypress-final-status.spec.js +++ b/integration-tests/cypress/cypress-final-status.spec.js @@ -96,6 +96,7 @@ moduleTypes.forEach(({ useSandbox([`cypress@${version}`, 'cypress-fail-fast@7.1.0', 'typescript'], true) before(async function () { + this.timeout(180_000) cwd = sandboxCwd() await warmCypressBinary(cwd) diff --git a/integration-tests/cypress/cypress-impacted-tests.spec.js b/integration-tests/cypress/cypress-impacted-tests.spec.js index 7757114666..767a1b01a6 100644 --- a/integration-tests/cypress/cypress-impacted-tests.spec.js +++ b/integration-tests/cypress/cypress-impacted-tests.spec.js @@ -110,6 +110,7 @@ moduleTypes.forEach(({ useSandbox([`cypress@${version}`, 'cypress-fail-fast@7.1.0', 'typescript'], true) before(async function () { + this.timeout(180_000) cwd = sandboxCwd() await warmCypressBinary(cwd) @@ -159,7 +160,7 @@ moduleTypes.forEach(({ .filter(({ payload }) => payload.metadata?.test) .flatMap(({ payload }) => payload.metadata) - assert.ok(metadataDicts.length > 0) + assert.ok(metadataDicts.length > 0, `Expected ${metadataDicts.length} > 0`) metadataDicts.forEach(metadata => { assert.strictEqual(metadata.test[DD_CAPABILITIES_TEST_IMPACT_ANALYSIS], '1') assert.strictEqual(metadata.test[DD_CAPABILITIES_EARLY_FLAKE_DETECTION], '1') @@ -170,7 +171,7 @@ moduleTypes.forEach(({ assert.strictEqual(metadata.test[DD_CAPABILITIES_TEST_MANAGEMENT_ATTEMPT_TO_FIX], '5') assert.strictEqual(metadata.test[DD_CAPABILITIES_FAILED_TEST_REPLAY], '1') // capabilities logic does not overwrite test session name - assert.strictEqual(metadata.test[TEST_SESSION_NAME], 'my-test-session-name') + assert.strictEqual(metadata['*'][TEST_SESSION_NAME], 'my-test-session-name') }) }, { hardTimeout: 25000 }) diff --git a/integration-tests/cypress/cypress-itr.spec.js b/integration-tests/cypress/cypress-itr.spec.js index ca64036e72..085a4948f0 100644 --- a/integration-tests/cypress/cypress-itr.spec.js +++ b/integration-tests/cypress/cypress-itr.spec.js @@ -115,6 +115,7 @@ moduleTypes.forEach(({ useSandbox([`cypress@${version}`, 'cypress-fail-fast@7.1.0', 'typescript'], true) before(async function () { + this.timeout(180_000) cwd = sandboxCwd() await warmCypressBinary(cwd) @@ -538,7 +539,7 @@ moduleTypes.forEach(({ const eventsPromise = gatherCypressPayloads(receiver, childProcess, '/api/v2/citestcycle', payloads => { const events = payloads.flatMap(({ payload }) => payload.events) const tests = events.filter(event => event.type === 'test').map(event => event.content) - assert.ok(tests.length > 0) + assert.ok(tests.length > 0, `Expected ${tests.length} > 0`) tests.forEach(test => { assert.strictEqual(test.itr_correlation_id, itrCorrelationId) }) @@ -621,7 +622,7 @@ moduleTypes.forEach(({ const testEvents = events.filter(event => event.type === 'test') const testModuleEvent = events.find(event => event.type === 'test_module_end') - assert.ok(testEvents.length > 0) + assert.ok(testEvents.length > 0, `Expected ${testEvents.length} > 0`) assert.ok(testModuleEvent) testEvents.forEach(testEvent => { diff --git a/integration-tests/cypress/cypress-reporting.spec.js b/integration-tests/cypress/cypress-reporting.spec.js index f8dd20951f..0a9c13986f 100644 --- a/integration-tests/cypress/cypress-reporting.spec.js +++ b/integration-tests/cypress/cypress-reporting.spec.js @@ -6,6 +6,7 @@ const { once } = require('node:events') const fs = require('node:fs') const os = require('node:os') const path = require('node:path') +const { inspect } = require('node:util') const semver = require('semver') const { @@ -33,7 +34,6 @@ const { TEST_SUITE, TEST_CODE_OWNERS, TEST_SESSION_NAME, - TEST_LEVEL_EVENT_TYPES, DD_TEST_IS_USER_PROVIDED_SERVICE, TEST_NAME, DD_CI_LIBRARY_CONFIGURATION_ERROR_SETTINGS, @@ -157,6 +157,7 @@ moduleTypes.forEach(({ useSandbox([`cypress@${version}`, 'cypress-fail-fast@7.1.0', 'typescript'], true) before(async function () { + this.timeout(180_000) cwd = sandboxCwd() await warmCypressBinary(cwd) @@ -304,6 +305,86 @@ moduleTypes.forEach(({ }) } + it('creates cypress.step spans for each command', async () => { + const envVars = getCiVisEvpProxyConfig(receiver.port) + const specToRun = 'cypress/e2e/commands.cy.js' + + const command = version === '6.7.0' + ? `./node_modules/.bin/cypress run --config-file cypress-config.json --spec "${specToRun}"` + : testCommand + + childProcess = exec( + command, + { + cwd, + env: { + ...envVars, + CYPRESS_BASE_URL: webAppBaseUrl, + SPEC_PATTERN: specToRun, + }, + } + ) + + const receiverPromise = receiver.gatherPayloadsUntilChildExit( + childProcess, + ({ url }) => url.endsWith('/api/v2/citestcycle'), + (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + + const passTestEvent = events.find( + event => event.type === 'test' && event.content.resource.includes('runs well-known commands') + ) + const failTestEvent = events.find( + event => event.type === 'test' && event.content.resource.includes('fails on a step') + ) + assert.ok(passTestEvent, 'passing cypress.test event exists') + assert.ok(failTestEvent, 'failing cypress.test event exists') + + const stepEvents = events.filter(event => event.type === 'span' && event.content.name === 'cypress.step') + assert.ok(stepEvents.length > 0, 'cypress.step spans exist') + + const visitStep = stepEvents.find(event => event.content.meta['cypress.command'] === 'visit') + assert.ok(visitStep, 'visit step span exists') + assertObjectContains(visitStep.content, { + name: 'cypress.step', + resource: 'visit', + meta: { 'cypress.command': 'visit' }, + }) + + const getStep = stepEvents.find(event => event.content.meta['cypress.command'] === 'get') + assert.ok(getStep, 'get step span exists') + assertObjectContains(getStep.content, { + name: 'cypress.step', + resource: 'get', + meta: { 'cypress.command': 'get' }, + }) + + const containsStep = stepEvents.find(event => event.content.meta['cypress.command'] === 'contains') + assert.ok(containsStep, 'contains step span exists') + + for (const stepEvent of stepEvents) { + const matchesPass = stepEvent.content.trace_id.toString() === passTestEvent.content.trace_id.toString() + const matchesFail = stepEvent.content.trace_id.toString() === failTestEvent.content.trace_id.toString() + assert.ok(matchesPass || matchesFail, 'step span trace_id matches one of the test trace_ids') + } + + const failedStep = stepEvents.find(event => + event.content.trace_id.toString() === failTestEvent.content.trace_id.toString() && + event.content.meta[ERROR_MESSAGE] + ) + assert.ok(failedStep, 'failed step span with error exists') + assert.ok(failedStep.content.meta[ERROR_MESSAGE], 'failed step has error message') + assert.ok(failedStep.content.meta[ERROR_TYPE], 'failed step has error type') + }, + { hardTimeout: 60000 } + ) + + await Promise.all([ + once(childProcess, 'exit'), + receiverPromise, + ]) + }) + // These tests require Cypress >=10 features (defineConfig, setupNodeEvents) const over10It = (version !== '6.7.0') ? it : it.skip // Cypress <14 shipped an older ts-node ESM loader that doesn't implement the @@ -1111,7 +1192,7 @@ moduleTypes.forEach(({ const testSessionEvent = events.find(event => event.type === 'test_session_end') assert.ok(testSessionEvent) const testEvents = events.filter(event => event.type === 'test') - assert.ok(testEvents.length > 0) + assert.ok(testEvents.length > 0, `Expected ${testEvents.length} > 0`) }, { hardTimeout: 30000 }) await Promise.all([ @@ -1887,9 +1968,8 @@ moduleTypes.forEach(({ const ciVisMetadataDicts = ciVisPayloads.flatMap(({ payload }) => payload.metadata) ciVisMetadataDicts.forEach(metadata => { - for (const testLevel of TEST_LEVEL_EVENT_TYPES) { - assert.strictEqual(metadata[testLevel][TEST_SESSION_NAME], 'my-test-session') - } + assert.strictEqual(metadata['*'][TEST_SESSION_NAME], 'my-test-session') + assert.ok(metadata['*'][TEST_COMMAND]) }) const events = ciVisPayloads.flatMap(({ payload }) => payload.events) @@ -1902,14 +1982,12 @@ moduleTypes.forEach(({ const { content: testModuleEventContent } = testModuleEvent assert.ok(testSessionEventContent.test_session_id) - assert.ok(testSessionEventContent.meta[TEST_COMMAND]) assert.ok(testSessionEventContent.meta[TEST_TOOLCHAIN]) assert.strictEqual(testSessionEventContent.resource.startsWith('test_session.'), true) assert.strictEqual(testSessionEventContent.meta[TEST_STATUS], 'fail') assert.ok(testModuleEventContent.test_session_id) assert.ok(testModuleEventContent.test_module_id) - assert.ok(testModuleEventContent.meta[TEST_COMMAND]) assert.ok(testModuleEventContent.meta[TEST_MODULE]) assert.strictEqual(testModuleEventContent.resource.startsWith('test_module.'), true) assert.strictEqual(testModuleEventContent.meta[TEST_STATUS], 'fail') @@ -1943,7 +2021,6 @@ moduleTypes.forEach(({ test_session_id: testSessionId, }, }) => { - assert.ok(meta[TEST_COMMAND]) assert.ok(meta[TEST_MODULE]) assert.ok(testSuiteId) assert.strictEqual(testModuleId.toString(10), testModuleEventContent.test_module_id.toString(10)) @@ -1968,7 +2045,6 @@ moduleTypes.forEach(({ test_session_id: testSessionId, }, }) => { - assert.ok(meta[TEST_COMMAND]) assert.ok(meta[TEST_MODULE]) assert.ok(testSuiteId) assert.strictEqual(testModuleId.toString(10), testModuleEventContent.test_module_id.toString(10)) @@ -2032,6 +2108,13 @@ moduleTypes.forEach(({ }) it('can report code coverage if it is available', async () => { + receiver.setSettings({ + itr_enabled: true, + code_coverage: true, + coverage_report_upload_enabled: true, + tests_skipping: false, + }) + const envVars = getCiVisAgentlessConfig(receiver.port) childProcess = exec( @@ -2050,23 +2133,31 @@ moduleTypes.forEach(({ childProcess, ({ url }) => url === '/api/v2/citestcov', payloads => { - const [{ payload: coveragePayloads }] = payloads - - const coverages = coveragePayloads.map(coverage => coverage.content) - .flatMap(content => content.coverages) - - coverages.forEach(coverage => { - assert.ok(Object.hasOwn(coverage, 'test_session_id')) - assert.ok(Object.hasOwn(coverage, 'test_suite_id')) - assert.ok(Object.hasOwn(coverage, 'span_id')) - assert.ok(Object.hasOwn(coverage, 'files')) + const coverages = payloads + .flatMap(({ payload }) => payload) + .flatMap(coverage => coverage.content.coverages) + const testCoverages = coverages.filter(coverage => coverage.test_suite_id) + const sessionCoverage = coverages.find(coverage => !coverage.test_suite_id) + + testCoverages.forEach(coverage => { + assert.ok(Object.hasOwn(coverage, 'test_session_id'), `Available keys: ${inspect(Object.keys(coverage))}`) + assert.ok(Object.hasOwn(coverage, 'test_suite_id'), `Available keys: ${inspect(Object.keys(coverage))}`) + assert.ok(Object.hasOwn(coverage, 'span_id'), `Available keys: ${inspect(Object.keys(coverage))}`) + assert.ok(Object.hasOwn(coverage, 'files'), `Available keys: ${inspect(Object.keys(coverage))}`) }) + assert.ok(sessionCoverage, 'session executable-line coverage should be reported') + assert.ok( + sessionCoverage.files.every(file => file.bitmap), + 'session executable-line coverage should include line coverage bitmaps' + ) - const fileNames = coverages + const fileNames = testCoverages .flatMap(coverageAttachment => coverageAttachment.files) .map(file => file.filename) + const sessionFileNames = sessionCoverage.files.map(file => file.filename) assertObjectContains(fileNames, Object.keys(coverageFixture)) + assertObjectContains(sessionFileNames, Object.keys(coverageFixture)) }, { hardTimeout: 25000 }) await Promise.all([ diff --git a/integration-tests/cypress/cypress-test-management.spec.js b/integration-tests/cypress/cypress-test-management.spec.js index 31451e0896..f3cff5ecf1 100644 --- a/integration-tests/cypress/cypress-test-management.spec.js +++ b/integration-tests/cypress/cypress-test-management.spec.js @@ -105,6 +105,7 @@ moduleTypes.forEach(({ useSandbox([`cypress@${version}`, 'cypress-fail-fast@7.1.0', 'typescript'], true) before(async function () { + this.timeout(180_000) cwd = sandboxCwd() await warmCypressBinary(cwd) @@ -589,7 +590,7 @@ moduleTypes.forEach(({ const atfTests = tests.filter( t => t.meta[TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX] === 'true' ) - assert.ok(atfTests.length > 0) + assert.ok(atfTests.length > 0, `Expected ${atfTests.length} > 0`) for (const test of atfTests) { assert.ok( !(TEST_IS_NEW in test.meta), diff --git a/integration-tests/cypress/cypress-tia-code-coverage.spec.js b/integration-tests/cypress/cypress-tia-code-coverage.spec.js new file mode 100644 index 0000000000..63e293e5e2 --- /dev/null +++ b/integration-tests/cypress/cypress-tia-code-coverage.spec.js @@ -0,0 +1,374 @@ +'use strict' + +const assert = require('node:assert/strict') +const { exec } = require('node:child_process') + +const { + sandboxCwd, + useSandbox, + getCiVisAgentlessConfig, + stopCiVisTestEnv, + warmCypressBinary, +} = require('../helpers') +const { FakeCiVisIntake } = require('../ci-visibility-intake') +const { startWebAppServer, stopWebAppServer } = require('../ci-visibility/web-app-server') +const { + TEST_CODE_COVERAGE_LINES_PCT, + TEST_ITR_SKIPPING_COUNT, + TEST_ITR_TESTS_SKIPPED, + TEST_SKIPPED_BY_ITR, + TEST_STATUS, + getLineCoverageBitmap, +} = require('../../packages/dd-trace/src/plugins/util/test') +const { DD_MAJOR, NODE_MAJOR } = require('../../version') + +const requestedVersion = process.env.CYPRESS_VERSION +const oldestVersion = DD_MAJOR >= 6 ? '12.0.0' : '6.7.0' +const version = requestedVersion === 'oldest' ? oldestVersion : requestedVersion +const hookFile = 'dd-trace/loader-hook.mjs' +const CYPRESS_RUN_HARD_TIMEOUT = 70_000 +const SPEC_PATTERN = 'cypress/e2e/{other,spec}.cy.js' +const SKIPPED_TEST = { + type: 'test', + attributes: { + name: 'context passes', + suite: 'cypress/e2e/other.cy.js', + }, +} +const SKIPPED_SOURCE = 'src/utils.tsx' +const SKIPPED_SOURCE_COVERED_LINES = [1, 3, 4, 7] + +function gatherCypressPayloads (receiver, childProcess, endpoint, onPayload) { + return receiver.gatherPayloadsUntilChildExit( + childProcess, + ({ url }) => url.endsWith(endpoint), + onPayload, + { hardTimeout: CYPRESS_RUN_HARD_TIMEOUT } + ) +} + +function getLinesBitmapBase64 (lines) { + const lineCoverage = {} + for (const line of lines) { + lineCoverage[line] = 1 + } + return getLineCoverageBitmap(lineCoverage, true).toString('base64') +} + +function getCoverageEvents (payloads) { + return payloads + .flatMap(({ payload }) => payload) + .flatMap(({ content }) => content.coverages) +} + +function shouldTestsRun (type) { + if (DD_MAJOR === 5) { + if (NODE_MAJOR <= 16) { + return version === '6.7.0' && type === 'commonJS' + } + if (NODE_MAJOR > 16) { + if (NODE_MAJOR <= 18) { + return version === '12.0.0' || version === '14.5.4' + } + return version === '12.0.0' || version === '14.5.4' || version === 'latest' + } + } + if (DD_MAJOR === 6) { + if (NODE_MAJOR <= 16) { + return false + } + if (NODE_MAJOR > 16) { + if (NODE_MAJOR <= 18) { + return version === '12.0.0' || version === '14.5.4' + } + return version === '12.0.0' || version === '14.5.4' || version === 'latest' + } + } + return false +} + +const moduleTypes = [ + { + type: 'commonJS', + testCommand: function commandWithSuffic (version) { + const commandSuffix = version === '6.7.0' ? '--config-file cypress-config.json --spec "cypress/e2e/*.cy.js"' : '' + return `./node_modules/.bin/cypress run ${commandSuffix}` + }, + }, + { + type: 'esm', + testCommand: `node --loader=${hookFile} ./cypress-esm-config.mjs`, + }, +].filter(moduleType => !process.env.CYPRESS_MODULE_TYPE || process.env.CYPRESS_MODULE_TYPE === moduleType.type) + +moduleTypes.forEach(({ + type, + testCommand, +}) => { + if (typeof testCommand === 'function') { + testCommand = testCommand(version) + } + + describe(`TIA code coverage cypress@${version} ${type}`, function () { + if (!shouldTestsRun(type)) { + // eslint-disable-next-line no-console + console.log(`Skipping tests for cypress@${version} ${type} for dd-trace@${DD_MAJOR} node@${NODE_MAJOR}`) + return + } + + this.timeout(180_000) + + let cwd, childProcess, webAppBaseUrl, webAppServer + + useSandbox([`cypress@${version}`, 'cypress-fail-fast@7.1.0', 'typescript'], true) + + before(async function () { + cwd = sandboxCwd() + await warmCypressBinary(cwd) + + const webApp = await startWebAppServer() + webAppBaseUrl = webApp.baseUrl + webAppServer = webApp.server + }) + + afterEach(() => { + if (childProcess?.exitCode === null) { + childProcess.kill() + } + childProcess = undefined + }) + + after(async () => { + await stopWebAppServer(webAppServer) + }) + + async function runCypress ({ + testsToSkip = [], + skippableCoverage = {}, + settings = { + itr_enabled: true, + code_coverage: true, + coverage_report_upload_enabled: true, + tests_skipping: true, + }, + expectTestCoverage = true, + expectSessionCoverage = true, + expectCoveragePayloads = true, + specPattern = SPEC_PATTERN, + assertEvents, + } = {}) { + const receiver = await new FakeCiVisIntake().start() + receiver.setSettings(settings) + receiver.setSuitesToSkip(testsToSkip) + receiver.setSkippableCoverage(skippableCoverage) + + let eventsResult + let coverageResult + const coveragePayloads = [] + const coverageRequestListener = (message) => { + if (message.url.endsWith('/api/v2/citestcov')) { + coveragePayloads.push(message) + } + } + receiver.on('message', coverageRequestListener) + + childProcess = exec( + testCommand, + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + CYPRESS_BASE_URL: webAppBaseUrl, + SPEC_PATTERN: specPattern, + }, + } + ) + + childProcess.stdout?.pipe(process.stdout) + childProcess.stderr?.pipe(process.stderr) + + const eventsPromise = gatherCypressPayloads(receiver, childProcess, '/api/v2/citestcycle', payloads => { + const events = payloads.flatMap(({ payload }) => payload.events) + const testSession = events.find(event => event.type === 'test_session_end').content + const skippedTests = events + .filter(event => event.type === 'test') + .map(event => event.content) + .filter(test => test.meta[TEST_SKIPPED_BY_ITR] === 'true') + + eventsResult = { + codeCoverageLinesPct: testSession.metrics[TEST_CODE_COVERAGE_LINES_PCT], + isTiaSkipped: testSession.meta[TEST_ITR_TESTS_SKIPPED], + skippedTests, + tests: events.filter(event => event.type === 'test').map(event => event.content), + } + assertEvents?.(events) + }) + + const coveragePromise = expectCoveragePayloads + ? gatherCypressPayloads(receiver, childProcess, '/api/v2/citestcov', payloads => { + const coverages = getCoverageEvents(payloads) + const testCoverage = coverages.find(coverage => coverage.test_suite_id) + const sessionCoverage = coverages.find(coverage => !coverage.test_suite_id) + const coveredFile = coverages + .flatMap(coverage => coverage.files) + .find(file => file.bitmap) + + if (expectTestCoverage) { + assert.ok(testCoverage, 'test code coverage should be reported') + } else { + assert.strictEqual(testCoverage, undefined, 'test code coverage should not be reported') + } + if (expectSessionCoverage) { + assert.ok(sessionCoverage, 'session executable-line coverage should be reported') + } else { + assert.strictEqual(sessionCoverage, undefined, 'session executable-line coverage should not be reported') + } + assert.ok(coveredFile?.bitmap, 'covered files should report line coverage bitmaps') + + coverageResult = coverages + }) + : Promise.resolve() + + try { + await Promise.all([ + eventsPromise, + coveragePromise, + ]) + if (!expectCoveragePayloads) { + await new Promise(resolve => setTimeout(resolve, 500)) + assert.strictEqual(coveragePayloads.length, 0, 'code coverage payloads should not be reported') + } + + return { + ...eventsResult, + coverages: coverageResult, + } + } finally { + receiver.off('message', coverageRequestListener) + await stopCiVisTestEnv({ childProcess, receiver }) + childProcess = undefined + } + } + + it('keeps total code coverage stable with skipped coverage', async () => { + const baseline = await runCypress() + + assert.strictEqual(baseline.isTiaSkipped, 'false') + assert.ok(baseline.codeCoverageLinesPct > 0) + assert.ok(baseline.codeCoverageLinesPct < 100) + assert.ok(baseline.coverages.length > 0, 'baseline should report coverage payloads') + + const skippedWithoutCoverage = await runCypress({ + testsToSkip: [SKIPPED_TEST], + }) + + assert.strictEqual(skippedWithoutCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedWithoutCoverage.skippedTests.length, 1) + assert.strictEqual(skippedWithoutCoverage.skippedTests[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(skippedWithoutCoverage.codeCoverageLinesPct, undefined) + + const skippedWithCoverage = await runCypress({ + testsToSkip: [SKIPPED_TEST], + skippableCoverage: { + [SKIPPED_SOURCE]: getLinesBitmapBase64(SKIPPED_SOURCE_COVERED_LINES), + }, + }) + + assert.strictEqual(skippedWithCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedWithCoverage.skippedTests.length, 1) + assert.strictEqual(skippedWithCoverage.skippedTests[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(skippedWithCoverage.codeCoverageLinesPct, baseline.codeCoverageLinesPct) + }) + + it('does not skip tests with missing line coverage when coverage report upload is enabled', async () => { + const result = await runCypress({ + testsToSkip: [{ + type: 'test', + attributes: { + ...SKIPPED_TEST.attributes, + _is_missing_line_code_coverage: true, + }, + }], + specPattern: 'cypress/e2e/other.cy.js', + assertEvents: (events) => { + const test = events.find(event => + event.content.resource === 'cypress/e2e/other.cy.js.context passes' + ).content + assert.strictEqual(test.meta[TEST_STATUS], 'pass') + assert.notStrictEqual(test.meta[TEST_SKIPPED_BY_ITR], 'true') + + const testSession = events.find(event => event.type === 'test_session_end').content + assert.strictEqual(testSession.metrics[TEST_ITR_SKIPPING_COUNT], 0) + }, + }) + + assert.strictEqual(result.isTiaSkipped, 'false') + }) + + it('only uploads test coverage when TIA is enabled but coverage report upload is disabled', async () => { + const result = await runCypress({ + testsToSkip: [SKIPPED_TEST], + settings: { + itr_enabled: true, + code_coverage: true, + coverage_report_upload_enabled: false, + tests_skipping: true, + }, + expectSessionCoverage: false, + }) + + assert.strictEqual(result.isTiaSkipped, 'true') + assert.strictEqual(result.skippedTests.length, 1) + assert.strictEqual(result.skippedTests[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(result.codeCoverageLinesPct, undefined) + }) + + it('does not upload citestcov payloads when TIA code coverage is disabled', async () => { + const result = await runCypress({ + testsToSkip: [SKIPPED_TEST], + settings: { + itr_enabled: true, + code_coverage: false, + coverage_report_upload_enabled: false, + tests_skipping: true, + }, + expectCoveragePayloads: false, + }) + + assert.strictEqual(result.isTiaSkipped, 'true') + assert.strictEqual(result.skippedTests.length, 1) + assert.strictEqual(result.skippedTests[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(result.codeCoverageLinesPct, undefined) + }) + + it('backfills and reports session coverage when coverage report upload is enabled', async () => { + const settings = { + itr_enabled: true, + code_coverage: false, + coverage_report_upload_enabled: true, + tests_skipping: true, + } + const baseline = await runCypress({ + settings, + expectTestCoverage: false, + }) + + const skippedWithCoverage = await runCypress({ + testsToSkip: [SKIPPED_TEST], + skippableCoverage: { + [SKIPPED_SOURCE]: getLinesBitmapBase64(SKIPPED_SOURCE_COVERED_LINES), + }, + settings, + expectTestCoverage: false, + }) + const sessionCoverage = skippedWithCoverage.coverages.find(coverage => !coverage.test_suite_id) + const skippedCoverageFile = sessionCoverage.files.find(file => file.filename === SKIPPED_SOURCE) + + assert.strictEqual(skippedWithCoverage.isTiaSkipped, 'true') + assert.strictEqual(skippedWithCoverage.skippedTests.length, 1) + assert.strictEqual(skippedWithCoverage.skippedTests[0].meta[TEST_STATUS], 'skip') + assert.strictEqual(skippedWithCoverage.codeCoverageLinesPct, baseline.codeCoverageLinesPct) + assert.ok(skippedCoverageFile?.bitmap, 'session coverage should include line coverage bitmaps') + }) + }) +}) diff --git a/integration-tests/cypress/e2e/commands.cy.js b/integration-tests/cypress/e2e/commands.cy.js new file mode 100644 index 0000000000..2e981ea548 --- /dev/null +++ b/integration-tests/cypress/e2e/commands.cy.js @@ -0,0 +1,20 @@ +/* eslint-disable */ +describe('commands suite', () => { + it('runs well-known commands', () => { + cy.visit('/') + cy.get('.hello-world') + .should('exist') + .and('have.text', 'Hello World') + .and('be.visible') + cy.url().should('include', '/') + cy.contains('Hello World').should('be.visible') + cy.title().should('be.a', 'string') + cy.document().should('have.property', 'charset') + cy.window().should('have.property', 'document') + }) + + it('fails on a step', () => { + cy.visit('/') + cy.get('.nonexistent-element').should('exist') + }) +}) diff --git a/integration-tests/debugger/custom-logger.spec.js b/integration-tests/debugger/custom-logger.spec.js index 5047ff5fc9..424dc9e1a4 100644 --- a/integration-tests/debugger/custom-logger.spec.js +++ b/integration-tests/debugger/custom-logger.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert') +const { inspect } = require('node:util') const { setup } = require('./utils') describe('Dynamic Instrumentation', function () { @@ -21,8 +22,11 @@ describe('Dynamic Instrumentation', function () { it('should log to the custom logger from the worker thread', function (done) { t.agent.on('debugger-input', () => { - assert(stdio.some((line) => line.startsWith('[CUSTOM LOGGER][DEBUG]: [debugger]'))) - assert(stdio.some((line) => line.startsWith('[CUSTOM LOGGER][DEBUG]: [debugger:devtools_client]'))) + assert(stdio.some((line) => line.startsWith('[CUSTOM LOGGER][DEBUG]: [debugger]')), `Got: ${inspect(stdio)}`) + assert( + stdio.some((line) => line.startsWith('[CUSTOM LOGGER][DEBUG]: [debugger:devtools_client]')), + `Got: ${inspect(stdio)}` + ) assert.strictEqual(stderr.length, 0) done() }) diff --git a/integration-tests/debugger/ddtags.spec.js b/integration-tests/debugger/ddtags.spec.js index 127465b4a6..19e2508318 100644 --- a/integration-tests/debugger/ddtags.spec.js +++ b/integration-tests/debugger/ddtags.spec.js @@ -3,6 +3,7 @@ const os = require('os') const assert = require('assert') +const { inspect } = require('node:util') const { version } = require('../../package.json') const { assertObjectContains } = require('../helpers') const { setup } = require('./utils') @@ -25,7 +26,7 @@ describe('Dynamic Instrumentation', function () { t.triggerBreakpoint() t.agent.on('debugger-input', ({ query }) => { - assert.ok(Object.hasOwn(query, 'ddtags')) + assert.ok(Object.hasOwn(query, 'ddtags'), `Available keys: ${inspect(Object.keys(query))}`) const ddtags = extractDDTagsFromQuery(query) @@ -61,7 +62,7 @@ describe('Dynamic Instrumentation', function () { t.triggerBreakpoint() t.agent.on('debugger-input', ({ query }) => { - assert.ok(Object.hasOwn(query, 'ddtags')) + assert.ok(Object.hasOwn(query, 'ddtags'), `Available keys: ${inspect(Object.keys(query))}`) const ddtags = extractDDTagsFromQuery(query) diff --git a/integration-tests/debugger/diagnostics.spec.js b/integration-tests/debugger/diagnostics.spec.js index b21a94276e..f76d599795 100644 --- a/integration-tests/debugger/diagnostics.spec.js +++ b/integration-tests/debugger/diagnostics.spec.js @@ -1,6 +1,7 @@ 'use strict' 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') @@ -239,7 +240,7 @@ describe('Dynamic Instrumentation', function () { assertUUID(diagnostics.runtimeId) if (diagnostics.status === 'ERROR') { - assert.ok(Object.hasOwn(diagnostics, 'exception')) + assert.ok(Object.hasOwn(diagnostics, 'exception'), `Available keys: ${inspect(Object.keys(diagnostics))}`) assert.deepStrictEqual(['message', 'stacktrace'], Object.keys(diagnostics.exception).sort()) assert.strictEqual(typeof diagnostics.exception.message, 'string') assert.strictEqual(typeof diagnostics.exception.stacktrace, 'string') diff --git a/integration-tests/debugger/re-evaluation.spec.js b/integration-tests/debugger/re-evaluation.spec.js index da03388953..c882166de3 100644 --- a/integration-tests/debugger/re-evaluation.spec.js +++ b/integration-tests/debugger/re-evaluation.spec.js @@ -52,7 +52,7 @@ describe('Dynamic Instrumentation Probe Re-Evaluation', function () { }) afterEach(async function () { - proc?.kill(0) + proc?.kill() await agent?.stop() axios = undefined }) diff --git a/integration-tests/debugger/snapshot-global-sample-rate.spec.js b/integration-tests/debugger/snapshot-global-sample-rate.spec.js index 9e7621a313..966d0d5c9d 100644 --- a/integration-tests/debugger/snapshot-global-sample-rate.spec.js +++ b/integration-tests/debugger/snapshot-global-sample-rate.spec.js @@ -61,14 +61,14 @@ describe('Dynamic Instrumentation', function () { const timeSincePrevTimestamp = timestamp - prevTimestamp // Allow for a time variance (time will tell if this is enough). Timeouts can vary. - assert.ok(duration >= 925) - assert.ok(duration < 1050) + assert.ok(duration >= 925, `Expected ${duration} >= 925`) + assert.ok(duration < 1050, `Expected ${duration} < 1050`) // A sanity check to make sure we're not saturating the event loop. We expect a lot of snapshots to be // sampled in the beginning of the sample window and then once the threshold is hit, we expect a "quiet" // period until the end of the window. If there's no "quiet" period, then we're saturating the event loop // and this test isn't really testing anything. - assert.ok(timeSincePrevTimestamp >= 250) + assert.ok(timeSincePrevTimestamp >= 250, `Expected ${timeSincePrevTimestamp} >= 250`) clearTimeout(state[rcConfig1.config.id].timer) clearTimeout(state[rcConfig2.config.id].timer) diff --git a/integration-tests/debugger/snapshot-pruning.spec.js b/integration-tests/debugger/snapshot-pruning.spec.js index ef7e62cd0a..103304ecee 100644 --- a/integration-tests/debugger/snapshot-pruning.spec.js +++ b/integration-tests/debugger/snapshot-pruning.spec.js @@ -14,7 +14,7 @@ describe('Dynamic Instrumentation', function () { it('should prune snapshot if payload is too large', function (done) { t.agent.on('debugger-input', ({ payload: [payload] }) => { const payloadSize = Buffer.byteLength(JSON.stringify(payload)) - assert.ok(payloadSize < 1024 * 1024) // 1MB + assert.ok(payloadSize < 1024 * 1024, `Expected ${payloadSize} < ${1024 * 1024}`) // 1MB const capturesJson = JSON.stringify(payload.debugger.snapshot.captures) assert.match(capturesJson, /"pruned":true/) diff --git a/integration-tests/debugger/snapshot-time-budget.spec.js b/integration-tests/debugger/snapshot-time-budget.spec.js index e155e9d6d4..34e35598d9 100644 --- a/integration-tests/debugger/snapshot-time-budget.spec.js +++ b/integration-tests/debugger/snapshot-time-budget.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert') +const { inspect } = require('node:util') const { DEFAULT_MAX_COLLECTION_SIZE, LARGE_OBJECT_SKIP_THRESHOLD, @@ -82,7 +83,7 @@ describe('Dynamic Instrumentation', function () { // Prepare to assert that no snapshot is produced on a subsequent trigger const secondPayloadReceived = new Promise(/** @type {() => void} */ (resolve) => { t.agent.once('debugger-input', ({ payload: [{ debugger: { snapshot } }] }) => { - assert.ok(!Object.hasOwn(snapshot, 'captures')) + assert.ok(!Object.hasOwn(snapshot, 'captures'), `Available keys: ${inspect(Object.keys(snapshot))}`) assert.deepStrictEqual(snapshot.evaluationErrors, expectedEvaluationErrors) resolve() }) diff --git a/integration-tests/debugger/snapshot.spec.js b/integration-tests/debugger/snapshot.spec.js index 2489954be5..5826a5d199 100644 --- a/integration-tests/debugger/snapshot.spec.js +++ b/integration-tests/debugger/snapshot.spec.js @@ -204,9 +204,12 @@ describe('Dynamic Instrumentation', function () { if ('fields' in prop) { if (prop.notCapturedReason === 'fieldCount') { assert.strictEqual(Object.keys(prop.fields).length, maxFieldCount) - assert.ok(prop.size > maxFieldCount) + assert.ok(prop.size > maxFieldCount, `Expected ${prop.size} > ${maxFieldCount}`) } else { - assert.ok(Object.keys(prop.fields).length < maxFieldCount) + assert.ok( + Object.keys(prop.fields).length < maxFieldCount, + `Expected ${Object.keys(prop.fields).length} < ${maxFieldCount}` + ) } } @@ -228,12 +231,12 @@ describe('Dynamic Instrumentation', function () { assert.strictEqual(locals.request.type, 'Request') assert.strictEqual(Object.keys(locals.request.fields).length, maxFieldCount) assert.strictEqual(locals.request.notCapturedReason, 'fieldCount') - assert.ok(locals.request.size > maxFieldCount) + assert.ok(locals.request.size > maxFieldCount, `Expected ${locals.request.size} > ${maxFieldCount}`) assert.strictEqual(locals.fastify.type, 'Object') assert.strictEqual(Object.keys(locals.fastify.fields).length, maxFieldCount) assert.strictEqual(locals.fastify.notCapturedReason, 'fieldCount') - assert.ok(locals.fastify.size > maxFieldCount) + assert.ok(locals.fastify.size > maxFieldCount, `Expected ${locals.fastify.size} > ${maxFieldCount}`) for (const value of Object.values(locals)) { assertMaxFieldCount(value) @@ -300,7 +303,7 @@ describe('Dynamic Instrumentation', function () { const { raw } = captures.lines[t.breakpoint.line].locals.request.fields assert.strictEqual(raw.notCapturedReason, 'fieldCount') assert.strictEqual(Object.keys(raw.fields).length, 20) - assert.ok(raw.size > 20) + assert.ok(raw.size > 20, `Expected ${raw.size} > 20`) done() }) diff --git a/integration-tests/debugger/template.spec.js b/integration-tests/debugger/template.spec.js index b4064ffe04..92481f42b4 100644 --- a/integration-tests/debugger/template.spec.js +++ b/integration-tests/debugger/template.spec.js @@ -49,18 +49,20 @@ describe('Dynamic Instrumentation', function () { assert.strictEqual(messages.shift(), '[ [Object], 2, 3, ... 2 more items ]') assert.strictEqual(messages.shift(), '{}') const obj = messages.shift() - assert.strictEqual( - obj, - '{ ' + - 'foo: [Object], ' + - 'bar: true, ' + - 'baz: [Getter], ' + - (NODE_MAJOR >= 24 - ? 'Symbol(nodejs.util.inspect.custom): [Function: [nodejs.util.inspect.custom]] ' - : '[Symbol(nodejs.util.inspect.custom)]: [Function: [nodejs.util.inspect.custom]] ') + - '}' - ) - assert.strictEqual(messages.shift(), obj) // a proxy should just be stringified to the wrapped object + let expectedObjectShape = '{ ' + + 'foo: [Object], ' + + 'bar: true, ' + + 'baz: [Getter], ' + + (NODE_MAJOR >= 24 + ? 'Symbol(nodejs.util.inspect.custom): [Function: [nodejs.util.inspect.custom]] ' + : '[Symbol(nodejs.util.inspect.custom)]: [Function: [nodejs.util.inspect.custom]] ') + + '}' + assert.strictEqual(obj, expectedObjectShape) + if (NODE_MAJOR >= 26) { + // A proxy should be stringified to the wrapped object plus the proxy type in newer Node.js versions + expectedObjectShape = `Proxy(${expectedObjectShape})` + } + assert.strictEqual(messages.shift(), expectedObjectShape) assert.strictEqual(messages.shift(), ' { circular: [Circular *1] }') assert.strictEqual(messages.shift(), '[class CustomClass]') // Notice execution of `Symbol.toStringTag` getter (`foo`). There's nothing we can do about it when using @@ -203,7 +205,7 @@ describe('Dynamic Instrumentation', function () { const { evaluationErrors } = payload.debugger.snapshot - assert.ok(Array.isArray(evaluationErrors)) + assert.ok(Array.isArray(evaluationErrors), `Expected array, got ${inspect(evaluationErrors)}`) assert.strictEqual(evaluationErrors.length, 2) assert.strictEqual(evaluationErrors[0].expr, 'request.invalid.name') assert.strictEqual(evaluationErrors[0].message, 'TypeError: Cannot convert undefined or null to object') diff --git a/integration-tests/debugger/tracing-integration.spec.js b/integration-tests/debugger/tracing-integration.spec.js index d015ed4e00..e5bbde457f 100644 --- a/integration-tests/debugger/tracing-integration.spec.js +++ b/integration-tests/debugger/tracing-integration.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('assert') +const { inspect } = require('node:util') const { setup, testBasicInput } = require('./utils') describe('Dynamic Instrumentation', function () { @@ -77,8 +78,8 @@ describe('Dynamic Instrumentation', function () { const { process_tags: processTags } = payload[0] assert.strictEqual(typeof processTags, 'string') - assert.ok(processTags.includes('entrypoint.name:')) - assert.ok(processTags.includes('entrypoint.type:script')) + assert.ok(processTags.includes('entrypoint.name:'), `Got: ${inspect(processTags)}`) + assert.ok(processTags.includes('entrypoint.type:script'), `Got: ${inspect(processTags)}`) done() }) diff --git a/integration-tests/debugger/utils.js b/integration-tests/debugger/utils.js index d9b08f5ffa..42177fcfec 100644 --- a/integration-tests/debugger/utils.js +++ b/integration-tests/debugger/utils.js @@ -5,6 +5,7 @@ const os = require('os') const { basename, join } = require('path') const { readFileSync } = require('fs') const { randomUUID } = require('crypto') +const { inspect } = require('node:util') const Axios = require('axios') @@ -321,12 +322,15 @@ function setupAssertionListeners (t, done, probe) { assertBasicInputPayload(t, payload, probe) payload = payload[0] - assert.ok(typeof payload.dd === 'object' && payload.dd !== null) + assert.ok( + typeof payload.dd === 'object' && payload.dd !== null, + `Expected non-null object, got ${inspect(payload.dd)}` + ) assert.deepStrictEqual(['span_id', 'trace_id'], Object.keys(payload.dd).sort()) assert.strictEqual(typeof payload.dd.trace_id, 'string') assert.strictEqual(typeof payload.dd.span_id, 'string') - assert.ok(payload.dd.trace_id.length > 0) - assert.ok(payload.dd.span_id.length > 0) + assert.ok(payload.dd.trace_id.length > 0, `Expected ${payload.dd.trace_id.length} > 0`) + assert.ok(payload.dd.span_id.length > 0, `Expected ${payload.dd.span_id.length} > 0`) dd = payload.dd assertDD() @@ -350,7 +354,7 @@ function setupAssertionListeners (t, done, probe) { * config to use instead of t.rcConfig.config. */ function assertBasicInputPayload (t, payload, probe = t.rcConfig.config) { - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 1) const data = payload[0] @@ -383,18 +387,24 @@ function assertBasicInputPayload (t, payload, probe = t.rcConfig.config) { assertUUID(data.debugger.snapshot.id) assert.strictEqual(typeof data.debugger.snapshot.timestamp, 'number') - assert.ok(data.debugger.snapshot.timestamp > Date.now() - 1000 * 60) - assert.ok(data.debugger.snapshot.timestamp <= Date.now()) - - assert.ok(Array.isArray(data.debugger.snapshot.stack)) - assert.ok(data.debugger.snapshot.stack.length > 0) + assert.ok( + data.debugger.snapshot.timestamp > Date.now() - 1000 * 60, + `Expected ${data.debugger.snapshot.timestamp} > ${Date.now() - 1000 * 60}` + ) + assert.ok( + data.debugger.snapshot.timestamp <= Date.now(), + `Expected ${data.debugger.snapshot.timestamp} <= ${Date.now()}` + ) + + assert.ok(Array.isArray(data.debugger.snapshot.stack), `Expected array, got ${inspect(data.debugger.snapshot.stack)}`) + assert.ok(data.debugger.snapshot.stack.length > 0, `Expected ${data.debugger.snapshot.stack.length} > 0`) for (const frame of data.debugger.snapshot.stack) { - assert.ok(typeof frame === 'object' && frame !== null) + assert.ok(typeof frame === 'object' && frame !== null, `Expected non-null object, got ${inspect(frame)}`) assert.deepStrictEqual(['columnNumber', 'fileName', 'function', 'lineNumber'], Object.keys(frame).sort()) assert.strictEqual(typeof frame.fileName, 'string') assert.strictEqual(typeof frame.function, 'string') - assert.ok(frame.lineNumber > 0) - assert.ok(frame.columnNumber > 0) + assert.ok(frame.lineNumber > 0, `Expected ${frame.lineNumber} > 0`) + assert.ok(frame.columnNumber > 0, `Expected ${frame.columnNumber} > 0`) } const topFrame = data.debugger.snapshot.stack[0] // path seems to be prefixed with `/private` on Mac diff --git a/integration-tests/esbuild/basic-test.js b/integration-tests/esbuild/basic-test.js index 453ac9ca81..492d9d9a19 100755 --- a/integration-tests/esbuild/basic-test.js +++ b/integration-tests/esbuild/basic-test.js @@ -23,7 +23,7 @@ const server = app.listen(PORT, () => { app.get('/', async (_req, res) => { assert.equal( - tracer.scope().active().context()._tags.component, + tracer.scope().active().context().getTag('component'), 'express', `the sample app bundled by esbuild is not properly instrumented. using node@${process.version}` ) // bad exit diff --git a/integration-tests/esbuild/package.json b/integration-tests/esbuild/package.json index 594e2ae859..3feb99058c 100644 --- a/integration-tests/esbuild/package.json +++ b/integration-tests/esbuild/package.json @@ -26,7 +26,7 @@ "axios": "1.16.1", "express": "4.22.2", "knex": "3.2.10", - "koa": "3.2.0", - "openai": "6.38.0" + "koa": "3.2.1", + "openai": "6.39.0" } } diff --git a/integration-tests/helpers/index.js b/integration-tests/helpers/index.js index dfbf28403d..cad7476634 100644 --- a/integration-tests/helpers/index.js +++ b/integration-tests/helpers/index.js @@ -3,7 +3,7 @@ const assert = require('assert') const childProcess = require('child_process') const { exec, execSync, fork, spawn } = childProcess -const { existsSync, readFileSync, unlinkSync, writeFileSync } = require('fs') +const { existsSync, readdirSync, readFileSync, unlinkSync, writeFileSync } = require('fs') const fs = require('fs/promises') const http = require('http') const { builtinModules } = require('module') @@ -899,7 +899,7 @@ function warmCypressBinary (cwd) { return new Promise(resolve => { childProcess.exec('./node_modules/.bin/cypress run --spec __ddwarmup_no_match__.cy.js', { cwd, - timeout: 80_000, + timeout: 180_000, env: { ...process.env, NODE_OPTIONS: '' }, }, () => resolve()) }) @@ -1014,6 +1014,24 @@ function useEnv (env) { }) } +/** + * @param {string} cwd + */ +function installPlaywrightChromium (cwd) { + const { NODE_OPTIONS, ...env } = process.env + const { PLAYWRIGHT_BROWSERS_PATH } = env + + if ( + PLAYWRIGHT_BROWSERS_PATH && + existsSync(PLAYWRIGHT_BROWSERS_PATH) && + readdirSync(PLAYWRIGHT_BROWSERS_PATH).length > 0 + ) { + return + } + + execSync('npx playwright install chromium', { cwd, env, stdio: 'inherit' }) +} + /** * @param {Parameters} args */ @@ -1180,6 +1198,7 @@ module.exports = { spawnPluginIntegrationTestProcAndExpectExit, useEnv, setShouldKill, + installPlaywrightChromium, sandboxCwd, useSandbox, varySandbox, diff --git a/integration-tests/jest/jest.core.spec.js b/integration-tests/jest/jest.core.spec.js index c6be5afbcf..91800afa2b 100644 --- a/integration-tests/jest/jest.core.spec.js +++ b/integration-tests/jest/jest.core.spec.js @@ -5,6 +5,7 @@ const assert = require('node:assert/strict') const { once } = require('node:events') const { fork, exec } = require('child_process') const path = require('path') +const { inspect } = require('node:util') const { assertObjectContains } = require('../helpers') const { @@ -30,7 +31,6 @@ const { TEST_SOURCE_START, TEST_CODE_OWNERS, TEST_SESSION_NAME, - TEST_LEVEL_EVENT_TYPES, DI_ERROR_DEBUG_INFO_CAPTURED, DI_DEBUG_ERROR_PREFIX, DI_DEBUG_ERROR_FILE_SUFFIX, @@ -88,7 +88,6 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { 'office-addin-mock', 'winston', 'jest-image-snapshot', - '@fast-check/jest', ].filter(Boolean), true) before(function () { @@ -430,9 +429,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { const metadataDicts = payloads.flatMap(({ payload }) => payload.metadata) metadataDicts.forEach(metadata => { - for (const testLevel of TEST_LEVEL_EVENT_TYPES) { - assert.strictEqual(metadata[testLevel][TEST_SESSION_NAME], 'my-test-session') - } + assert.strictEqual(metadata['*'][TEST_SESSION_NAME], 'my-test-session') }) const events = payloads.flatMap(({ payload }) => payload.events) @@ -545,6 +542,11 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { const eventsPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const metadataDicts = payloads.flatMap(({ payload }) => payload.metadata) + metadataDicts.forEach(metadata => { + assert.ok(metadata['*'][TEST_COMMAND]) + }) + const events = payloads.flatMap(({ payload }) => payload.events) const testSessionEvent = events.find(event => event.type === 'test_session_end').content const testModuleEvent = events.find(event => event.type === 'test_module_end').content @@ -554,21 +556,18 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { assert.ok(testSessionEvent) assert.strictEqual(testSessionEvent.meta[TEST_STATUS], 'pass') assert.ok(testSessionEvent[TEST_SESSION_ID]) - assert.ok(testSessionEvent.meta[TEST_COMMAND]) - assert.ok(testSessionEvent[TEST_SUITE_ID] == null) - assert.ok(testSessionEvent[TEST_MODULE_ID] == null) + assert.ok(testSessionEvent[TEST_SUITE_ID] == null, `Expected ${testSessionEvent[TEST_SUITE_ID]} == null`) + assert.ok(testSessionEvent[TEST_MODULE_ID] == null, `Expected ${testSessionEvent[TEST_MODULE_ID]} == null`) assert.ok(testModuleEvent) assert.strictEqual(testModuleEvent.meta[TEST_STATUS], 'pass') assert.ok(testModuleEvent[TEST_SESSION_ID]) assert.ok(testModuleEvent[TEST_MODULE_ID]) - assert.ok(testModuleEvent.meta[TEST_COMMAND]) - assert.ok(testModuleEvent[TEST_SUITE_ID] == null) + assert.ok(testModuleEvent[TEST_SUITE_ID] == null, `Expected ${testModuleEvent[TEST_SUITE_ID]} == null`) assert.ok(testSuiteEvent) assert.strictEqual(testSuiteEvent.meta[TEST_STATUS], 'pass') assert.strictEqual(testSuiteEvent.meta[TEST_SUITE], 'ci-visibility/jest-plugin-tests/jest-test-suite.js') - assert.ok(testSuiteEvent.meta[TEST_COMMAND]) assert.ok(testSuiteEvent.meta[TEST_MODULE]) assert.ok(testSuiteEvent[TEST_SUITE_ID]) assert.ok(testSuiteEvent[TEST_SESSION_ID]) @@ -578,7 +577,6 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { assert.strictEqual(testEvent.meta[TEST_STATUS], 'pass') assert.strictEqual(testEvent.meta[TEST_NAME], 'jest-test-suite-visibility works') assert.strictEqual(testEvent.meta[TEST_SUITE], 'ci-visibility/jest-plugin-tests/jest-test-suite.js') - assert.ok(testEvent.meta[TEST_COMMAND]) assert.ok(testEvent.meta[TEST_MODULE]) assert.ok(testEvent[TEST_SUITE_ID]) assert.ok(testEvent[TEST_SESSION_ID]) @@ -697,6 +695,11 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { // --shard was added in jest@28 onlyLatestIt('works when sharding', (done) => { + receiver.setSettings({ + itr_enabled: true, + code_coverage: false, + tests_skipping: true, + }) receiver.payloadReceived(({ url }) => url === '/api/v2/citestcycle').then(events => { const testSuiteEvents = events.payload.events.filter(event => event.type === 'test_suite_end') assert.strictEqual(testSuiteEvents.length, 3) @@ -761,8 +764,8 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { assert.strictEqual(testSession.metrics[TEST_ITR_SKIPPING_COUNT], 2) done() - }) - }) + }).catch(done) + }).catch(done) childProcess = exec( runTestsCommand, { @@ -838,7 +841,10 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { assert.strictEqual(testSpans.length, 2) const spanTypes = testSpans.map(span => span.type) assertObjectContains(spanTypes, ['test']) - assert.ok(!spanTypes.some(type => ['test_session_end', 'test_suite_end', 'test_module_end'].includes(type))) + assert.ok( + !spanTypes.some(type => ['test_session_end', 'test_suite_end', 'test_module_end'].includes(type)), + `Got: ${inspect(spanTypes)}` + ) receiver.setInfoResponse({ endpoints: ['/evp_proxy/v2'] }) done() }).catch(done) @@ -860,9 +866,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { // it propagates test session name to the test and test suite events in parallel mode metadataDicts.forEach(metadata => { - for (const testLevel of TEST_LEVEL_EVENT_TYPES) { - assert.strictEqual(metadata[testLevel][TEST_SESSION_NAME], 'my-test-session') - } + assert.strictEqual(metadata['*'][TEST_SESSION_NAME], 'my-test-session') }) const events = eventsRequests.map(({ payload }) => payload) @@ -1052,7 +1056,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { assertObjectContains(eventTypes, ['test', 'test_suite_end', 'test_session_end', 'test_module_end']) const tests = events.filter(event => event.type === 'test').map(event => event.content) - assert.ok(tests.length >= 2) + assert.ok(tests.length >= 2, `Expected ${tests.length} >= 2`) tests.forEach(testEvent => { assert.strictEqual(testEvent.meta[TEST_STATUS], 'pass') }) @@ -1193,7 +1197,8 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { assert.strictEqual(failedTestSuite.content.meta[TEST_STATUS], 'fail') assert.ok( - failedTestSuite.content.meta[ERROR_MESSAGE].includes('a file outside of the scope of the test code') + failedTestSuite.content.meta[ERROR_MESSAGE].includes('a file outside of the scope of the test code'), + `Got: ${inspect(failedTestSuite.content.meta[ERROR_MESSAGE])}` ) assert.strictEqual(failedTestSuite.content.meta[ERROR_TYPE], 'Error') @@ -1235,13 +1240,14 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { // jest still reports the test suite as passing assert.strictEqual(badImportTestSuite.content.meta[TEST_STATUS], 'pass') + const errorMessage = badImportTestSuite.content.meta[ERROR_MESSAGE] assert.ok( - badImportTestSuite.content.meta[ERROR_MESSAGE] - .includes('a file after the Jest environment has been torn down') + errorMessage.includes('a file after the Jest environment has been torn down'), + `Got: ${inspect(errorMessage)}` ) assert.ok( - badImportTestSuite.content.meta[ERROR_MESSAGE] - .includes('From ci-visibility/jest-bad-import-torn-down/jest-bad-import-test.js') + errorMessage.includes('From ci-visibility/jest-bad-import-torn-down/jest-bad-import-test.js'), + `Got: ${inspect(errorMessage)}` ) // This is the error message that jest should show. We check that we don't mess it up. assert.match(badImportTestSuite.content.meta[ERROR_MESSAGE], /off-timing-import/) @@ -1270,16 +1276,17 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { }) }) - it('does not report total code coverage % if user has not configured coverage manually', (done) => { + it('reports total code coverage % when TIA forces coverage collection', (done) => { receiver.setSettings({ itr_enabled: true, code_coverage: true, + coverage_report_upload_enabled: true, tests_skipping: false, }) receiver.assertPayloadReceived(({ payload }) => { const testSession = payload.events.find(event => event.type === 'test_session_end').content - assert.ok(!(TEST_CODE_COVERAGE_LINES_PCT in testSession.metrics)) + assert.ok(testSession.metrics[TEST_CODE_COVERAGE_LINES_PCT]) }, ({ url }) => url === '/api/v2/citestcycle').then(() => done()).catch(done) childProcess = exec( @@ -1515,7 +1522,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { const events = payloads.flatMap(({ payload }) => payload.events) const testEvents = events.filter(event => event.type === 'test') - assert.ok(testEvents.length > 0) + assert.ok(testEvents.length > 0, `Expected ${testEvents.length} > 0`) }) childProcess = exec( diff --git a/integration-tests/jest/jest.test-management.spec.js b/integration-tests/jest/jest.test-management.spec.js index 18e58d006c..9a4d94dcef 100644 --- a/integration-tests/jest/jest.test-management.spec.js +++ b/integration-tests/jest/jest.test-management.spec.js @@ -6,6 +6,7 @@ const { once } = require('node:events') const { exec, execSync } = require('child_process') const path = require('path') const fs = require('fs') +const { inspect } = require('node:util') const { assertObjectContains } = require('../helpers') const { @@ -25,7 +26,6 @@ const { TEST_NAME, TEST_RETRY_REASON, TEST_SESSION_NAME, - TEST_LEVEL_EVENT_TYPES, DD_TEST_IS_USER_PROVIDED_SERVICE, TEST_MANAGEMENT_ENABLED, TEST_MANAGEMENT_IS_DISABLED, @@ -78,7 +78,6 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { 'office-addin-mock', 'winston', 'jest-image-snapshot', - '@fast-check/jest', ].filter(Boolean), true) before(function () { @@ -113,9 +112,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { const metadataDicts = payloads.flatMap(({ payload }) => payload.metadata) metadataDicts.forEach(metadata => { - for (const testLevel of TEST_LEVEL_EVENT_TYPES) { - assert.strictEqual(metadata[testLevel][TEST_SESSION_NAME], 'my-lage-package') - } + assert.strictEqual(metadata['*'][TEST_SESSION_NAME], 'my-lage-package') }) }) @@ -159,8 +156,14 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { const metadataDicts = payloads.flatMap(({ payload }) => payload.metadata) - assert.ok(metadataDicts.some(metadata => metadata.test?.[TEST_SESSION_NAME] === 'my-lage-package-a')) - assert.ok(metadataDicts.some(metadata => metadata.test?.[TEST_SESSION_NAME] === 'my-lage-package-b')) + assert.ok( + metadataDicts.some(metadata => metadata['*']?.[TEST_SESSION_NAME] === 'my-lage-package-a'), + `Got: ${inspect(metadataDicts)}` + ) + assert.ok( + metadataDicts.some(metadata => metadata['*']?.[TEST_SESSION_NAME] === 'my-lage-package-b'), + `Got: ${inspect(metadataDicts)}` + ) }) childProcess = exec( @@ -670,7 +673,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { const atfTests = tests.filter( t => t.meta[TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX] === 'true' ) - assert.ok(atfTests.length > 0) + assert.ok(atfTests.length > 0, `Expected ${atfTests.length} > 0`) for (const test of atfTests) { assert.ok( !(TEST_IS_NEW in test.meta), @@ -1707,7 +1710,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { const quarantinedTests = tests.filter( test => test.meta[TEST_NAME] === 'efd and quarantine is a quarantined failing test' ) - assert.ok(quarantinedTests.length >= 1) + assert.ok(quarantinedTests.length >= 1, `Expected ${quarantinedTests.length} >= 1`) for (const test of quarantinedTests) { assert.strictEqual(test.meta[TEST_MANAGEMENT_IS_QUARANTINED], 'true') } @@ -2001,7 +2004,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { const metadataDicts = payloads.flatMap(({ payload }) => payload.metadata) - assert.ok(metadataDicts.length > 0) + assert.ok(metadataDicts.length > 0, `Expected ${metadataDicts.length} > 0`) metadataDicts.forEach(metadata => { assert.strictEqual(metadata.test[DD_CAPABILITIES_TEST_IMPACT_ANALYSIS], '1') assert.strictEqual(metadata.test[DD_CAPABILITIES_EARLY_FLAKE_DETECTION], '1') @@ -2012,7 +2015,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { assert.strictEqual(metadata.test[DD_CAPABILITIES_TEST_MANAGEMENT_ATTEMPT_TO_FIX], '5') assert.strictEqual(metadata.test[DD_CAPABILITIES_FAILED_TEST_REPLAY], '1') // capabilities logic does not overwrite test session name - assert.strictEqual(metadata.test[TEST_SESSION_NAME], 'my-test-session-name') + assert.strictEqual(metadata['*'][TEST_SESSION_NAME], 'my-test-session-name') }) }) @@ -2545,14 +2548,14 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { }) }) - context('fast-check', () => { - onlyLatestIt('should remove seed from the test name if @fast-check/jest is used in the test', async () => { + context('seed suffix normalization', () => { + onlyLatestIt('should remove seed suffix from reported test names', async () => { const eventsPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { const events = payloads.flatMap(({ payload }) => payload.events) const tests = events.filter(event => event.type === 'test').map(event => event.content) assert.strictEqual(tests.length, 1) - assert.strictEqual(tests[0].meta[TEST_NAME], 'fast check will not include seed') + assert.strictEqual(tests[0].meta[TEST_NAME], 'seed suffix should strip seed') }) childProcess = exec( @@ -2561,7 +2564,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { cwd, env: { ...getCiVisAgentlessConfig(receiver.port), - TESTS_TO_RUN: 'jest-fast-check/jest-fast-check', + TESTS_TO_RUN: 'jest-seed-suffix/jest-seed-suffix', }, } ) @@ -2572,13 +2575,92 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { ]) }) - onlyLatestIt('should not remove seed if @fast-check/jest is not used', async () => { + onlyLatestIt('does not mark seed-suffixed tests as new when known tests use the stripped name', async () => { + receiver.setKnownTests({ + jest: { + 'ci-visibility/jest-seed-suffix/jest-seed-suffix.js': [ + 'seed suffix should strip seed', + ], + }, + }) + receiver.setSettings({ + early_flake_detection: { + enabled: true, + slow_test_retries: { + '5s': 2, + }, + faulty_session_threshold: 100, + }, + known_tests_enabled: true, + }) + const eventsPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { const events = payloads.flatMap(({ payload }) => payload.events) const tests = events.filter(event => event.type === 'test').map(event => event.content) assert.strictEqual(tests.length, 1) - assert.strictEqual(tests[0].meta[TEST_NAME], 'fast check with seed should include seed (with seed=12)') + assert.strictEqual(tests[0].meta[TEST_NAME], 'seed suffix should strip seed') + assert.ok(!(TEST_IS_NEW in tests[0].meta)) + }) + + childProcess = exec( + runTestsCommand, + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + TESTS_TO_RUN: 'jest-seed-suffix/jest-seed-suffix', + }, + } + ) + + await Promise.all([ + once(childProcess, 'exit'), + eventsPromise, + ]) + }) + + onlyLatestIt('keeps seed-like describe suffixes when matching test management tests', async () => { + const testName = 'seed suffix (with seed=12) should preserve describe seed suffix' + receiver.setSettings({ test_management: { enabled: true, attempt_to_fix_retries: 2 } }) + receiver.setTestManagementTests({ + jest: { + suites: { + 'ci-visibility/jest-seed-suffix/jest-describe-seed-suffix.js': { + tests: { + [testName]: { + properties: { + attempt_to_fix: true, + }, + }, + }, + }, + }, + }, + }) + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { + const events = payloads.flatMap(({ payload }) => payload.events) + const tests = events.filter(event => event.type === 'test').map(event => event.content) + const retriedTests = tests.filter(test => test.meta[TEST_NAME] === testName) + + assert.strictEqual(retriedTests.length, 3) + assert.ok(!(TEST_IS_RETRY in retriedTests[0].meta)) + assert.deepStrictEqual( + retriedTests.map(test => test.meta[TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX]), + ['true', 'true', 'true'] + ) + assert.deepStrictEqual( + retriedTests.slice(1).map(test => ({ + reason: test.meta[TEST_RETRY_REASON], + retry: test.meta[TEST_IS_RETRY], + })), + [ + { reason: TEST_RETRY_REASON_TYPES.atf, retry: 'true' }, + { reason: TEST_RETRY_REASON_TYPES.atf, retry: 'true' }, + ] + ) }) childProcess = exec( @@ -2587,7 +2669,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { cwd, env: { ...getCiVisAgentlessConfig(receiver.port), - TESTS_TO_RUN: 'jest-fast-check/jest-no-fast-check', + TESTS_TO_RUN: 'jest-seed-suffix/jest-describe-seed-suffix', }, } ) @@ -2656,10 +2738,16 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { const coverageReport = payloads[0] - assert.ok(coverageReport.headers['content-type'].includes('multipart/form-data')) + assert.ok( + coverageReport.headers['content-type'].includes('multipart/form-data'), + `Got: ${inspect(coverageReport.headers['content-type'])}` + ) assert.strictEqual(coverageReport.coverageFile.name, 'coverage') - assert.ok(coverageReport.coverageFile.content.includes('SF:')) // LCOV format + assert.ok( + coverageReport.coverageFile.content.includes('SF:'), + `Got: ${inspect(coverageReport.coverageFile.content)}` + ) // LCOV format assert.strictEqual(coverageReport.eventFile.name, 'event') assert.strictEqual(coverageReport.eventFile.content.type, 'coverage_report') diff --git a/integration-tests/jest/jest.itr-efd.spec.js b/integration-tests/jest/jest.tia-efd.spec.js similarity index 95% rename from integration-tests/jest/jest.itr-efd.spec.js rename to integration-tests/jest/jest.tia-efd.spec.js index a9edf78675..6f6c9b2ae4 100644 --- a/integration-tests/jest/jest.itr-efd.spec.js +++ b/integration-tests/jest/jest.tia-efd.spec.js @@ -6,6 +6,7 @@ const { once } = require('node:events') const { fork, exec } = require('child_process') const path = require('path') const fs = require('fs') +const { inspect } = require('node:util') const { assertObjectContains } = require('../helpers') const { @@ -46,6 +47,7 @@ const { DD_CI_LIBRARY_CONFIGURATION_ERROR_SKIPPABLE_TESTS, DD_CI_LIBRARY_CONFIGURATION_ERROR_KNOWN_TESTS, DD_CI_LIBRARY_CONFIGURATION_ERROR_TEST_MANAGEMENT_TESTS, + getLineCoverageBitmap, } = require('../../packages/dd-trace/src/plugins/util/test') const { ERROR_MESSAGE } = require('../../packages/dd-trace/src/constants') const { DD_MAJOR, NODE_MAJOR } = require('../../version') @@ -63,6 +65,7 @@ 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 shouldInstallJestEnvironmentJsdom = JEST_VERSION === 'latest' || Number(JEST_VERSION.split('.')[0]) >= 28 +const isJestCoverageBackfillSupported = JEST_VERSION === 'latest' || Number(JEST_VERSION.split('.')[0]) >= 28 function assertItrSkippingEnabledTags (events, expected) { const testSuite = events.find(event => event.type === 'test_suite_end').content @@ -71,6 +74,14 @@ function assertItrSkippingEnabledTags (events, expected) { assert.strictEqual(test.meta[TEST_ITR_SKIPPING_ENABLED], expected) } +function getLinesBitmapBase64 (startLine, endLine) { + const lineCoverage = {} + for (let line = startLine; line <= endLine; line++) { + lineCoverage[line] = 1 + } + return getLineCoverageBitmap(lineCoverage, true).toString('base64') +} + // TODO: add ESM tests describe(`jest@${JEST_VERSION} commonJS`, () => { let receiver @@ -93,7 +104,6 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { 'office-addin-mock', 'winston', 'jest-image-snapshot', - '@fast-check/jest', ].filter(Boolean), true) before(function () { @@ -154,6 +164,13 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { }) it('can report code coverage', async () => { + receiver.setSettings({ + itr_enabled: true, + code_coverage: true, + coverage_report_upload_enabled: true, + tests_skipping: true, + }) + const libraryConfigRequestPromise = receiver.payloadReceived( ({ url }) => url === '/api/v2/libraries/tests/services/setting' ) @@ -180,12 +197,26 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { }, }], }) - const allCoverageFiles = codeCovRequest.payload - .flatMap(coverage => coverage.content.coverages) + const coverages = codeCovRequest.payload.flatMap(coverage => coverage.content.coverages) + const allCoverageFiles = coverages .flatMap(file => file.files) .map(file => file.filename) + const coveredSourceFile = coverages + .flatMap(coverage => coverage.files) + .find(file => file.filename === 'ci-visibility/test/sum.js') + const sessionCoverage = coverages.find(coverage => !coverage.test_suite_id) assertObjectContains(allCoverageFiles.sort(), expectedCoverageFiles.sort()) + assert.ok(coveredSourceFile.bitmap, 'covered source files should report line coverage bitmaps') + if (isJestCoverageBackfillSupported) { + assert.ok(sessionCoverage, 'session executable line coverage should be reported') + assert.ok( + sessionCoverage.files.every(file => file.bitmap), + 'session executable line coverage files should report bitmaps' + ) + } else { + assert.strictEqual(sessionCoverage, undefined) + } const [coveragePayload] = codeCovRequest.payload assert.ok(coveragePayload.content.coverages[0].test_session_id) @@ -265,6 +296,9 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { suite: 'ci-visibility/test/ci-visibility-test.js', }, }]) + receiver.setSkippableCoverage({ + 'ci-visibility/test/ci-visibility-test.js': getLinesBitmapBase64(1, 20), + }) const skippableRequestPromise = receiver.payloadReceived(({ url }) => url === '/api/v2/ci/tests/skippable') const coverageRequestPromise = receiver.payloadReceived(({ url }) => url === '/api/v2/citestcov') @@ -317,12 +351,20 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { runTestsCommand, { cwd, - env: getCiVisAgentlessConfig(receiver.port), + env: { + ...getCiVisAgentlessConfig(receiver.port), + ENABLE_CODE_COVERAGE: '1', + }, } ) }) it('marks the test session as skipped if every suite is skipped', (done) => { + receiver.setSettings({ + itr_enabled: true, + code_coverage: false, + tests_skipping: true, + }) receiver.setSuitesToSkip( [ { @@ -444,6 +486,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { }) it('does not skip suites if suite is marked as unskippable', (done) => { + const coveredSkippedLines = getLinesBitmapBase64(1, 20) receiver.setSuitesToSkip([ { type: 'suite', @@ -458,6 +501,10 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { }, }, ]) + receiver.setSkippableCoverage({ + 'ci-visibility/unskippable-test/test-to-skip.js': coveredSkippedLines, + 'ci-visibility/unskippable-test/test-unskippable.js': coveredSkippedLines, + }) const eventsPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { @@ -515,6 +562,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { }) it('only sets forced to run if suite was going to be skipped by ITR', (done) => { + const coveredSkippedLines = getLinesBitmapBase64(1, 20) receiver.setSuitesToSkip([ { type: 'suite', @@ -523,6 +571,9 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { }, }, ]) + receiver.setSkippableCoverage({ + 'ci-visibility/unskippable-test/test-to-skip.js': coveredSkippedLines, + }) const eventsPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { @@ -643,6 +694,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { receiver.setSettings({ itr_enabled: true, code_coverage: true, + coverage_report_upload_enabled: true, tests_skipping: true, }) @@ -652,6 +704,9 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { suite: 'ci-visibility/test/ci-visibility-test.js', }, }]) + receiver.setSkippableCoverage({ + 'ci-visibility/test/ci-visibility-test.js': getLinesBitmapBase64(1, 20), + }) const eventsPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { @@ -686,46 +741,11 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { }) }) - it('keeps user coverage reporters when DD_TEST_TIA_KEEP_COV_CONFIG is true', async () => { - receiver.setSettings({ - itr_enabled: true, - code_coverage: true, - tests_skipping: true, - }) - - receiver.setSuitesToSkip([{ - type: 'suite', - attributes: { - suite: 'ci-visibility/test/ci-visibility-test.js', - }, - }]) - - const lcovPath = path.join(cwd, 'coverage', 'lcov.info') - fs.rmSync(path.join(cwd, 'coverage'), { recursive: true, force: true }) - - childProcess = exec( - runTestsCommand, - { - cwd, - env: { - ...getCiVisAgentlessConfig(receiver.port), - COVERAGE_REPORTERS: 'lcov', - DD_TEST_TIA_KEEP_COV_CONFIG: 'true', - }, - } - ) - try { - await once(childProcess, 'exit') - assert.strictEqual(fs.existsSync(lcovPath), true) - } finally { - fs.rmSync(path.join(cwd, 'coverage'), { recursive: true, force: true }) - } - }) - - it('overrides user coverage reporters when code coverage is enabled because of us', async () => { + it('does not run coverage reporters when TIA forces coverage collection', async () => { receiver.setSettings({ itr_enabled: true, code_coverage: true, + coverage_report_upload_enabled: false, tests_skipping: true, }) @@ -750,7 +770,8 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { } ) try { - await once(childProcess, 'exit') + const [exitCode] = await once(childProcess, 'exit') + assert.strictEqual(exitCode, 0) assert.strictEqual(fs.existsSync(lcovPath), false) } finally { fs.rmSync(path.join(cwd, 'coverage'), { recursive: true, force: true }) @@ -761,6 +782,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { receiver.setSettings({ itr_enabled: true, code_coverage: true, + coverage_report_upload_enabled: true, tests_skipping: true, }) @@ -793,10 +815,12 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { } }) - it('calculates executable lines even if there have been skipped suites', (done) => { + it('calculates total code coverage using skippable suite coverage', async () => { + const coveredSkippedLines = getLinesBitmapBase64(1, 20) receiver.setSettings({ itr_enabled: true, code_coverage: true, + coverage_report_upload_enabled: true, tests_skipping: true, }) @@ -806,17 +830,25 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { suite: 'ci-visibility/test-total-code-coverage/test-skipped.js', }, }]) + receiver.setSkippableCoverage({ + 'ci-visibility/test-total-code-coverage/test-skipped.js': coveredSkippedLines, + 'ci-visibility/test-total-code-coverage/unused-dependency.js': coveredSkippedLines, + }) const eventsPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { const events = payloads.flatMap(({ payload }) => payload.events) const testSession = events.find(event => event.type === 'test_session_end').content - // Before https://github.com/DataDog/dd-trace-js/pull/4336, this would've been 100% - // The reason is that skipping jest's `addUntestedFiles`, we would not see unexecuted lines. - // In this cause, these would be from the `unused-dependency.js` file. - // It is 50% now because we only cover 1 out of 2 files (`used-dependency.js`). - assert.strictEqual(testSession.metrics[TEST_CODE_COVERAGE_LINES_PCT], 50) + assert.strictEqual(testSession.meta[TEST_ITR_TESTS_SKIPPED], 'true') + // Jest still adds untested files to total coverage, including unused-dependency.js from the skipped + // suite. The result stays at 100% because backend meta.coverage backfills those skipped lines before the + // test session total is published. + if (isJestCoverageBackfillSupported) { + assert.strictEqual(testSession.metrics[TEST_CODE_COVERAGE_LINES_PCT], 100) + } else { + assert.strictEqual(testSession.metrics[TEST_CODE_COVERAGE_LINES_PCT], undefined) + } }) childProcess = exec( @@ -832,9 +864,9 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { } ) - childProcess.on('exit', () => { - eventsPromise.then(done).catch(done) - }) + const [exitCode] = await once(childProcess, 'exit') + assert.strictEqual(exitCode, 0) + await eventsPromise }) it('reports code coverage relative to the repository root, not working directory', (done) => { @@ -864,6 +896,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { cwd, env: { ...getCiVisAgentlessConfig(receiver.port), + ENABLE_CODE_COVERAGE: '1', PROJECTS: JSON.stringify([{ testMatch: ['**/subproject-test*'], testEnvironment: 'node', @@ -880,6 +913,72 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { }) }) + it('skips repository-relative suites when jest rootDir is a subproject', async () => { + const suite = 'ci-visibility/subproject/subproject-test.js' + receiver.setSettings({ + itr_enabled: true, + code_coverage: true, + coverage_report_upload_enabled: true, + tests_skipping: true, + }) + receiver.setSuitesToSkip([ + { + type: 'suite', + attributes: { + suite, + }, + }, + ]) + receiver.setSkippableCoverage({ + [suite]: getLinesBitmapBase64(1, 11), + 'ci-visibility/subproject/dependency.js': getLinesBitmapBase64(1, 5), + }) + + const eventsPromise = receiver + .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { + const events = payloads.flatMap(({ payload }) => payload.events) + const skippedSuites = events + .filter(event => event.type === 'test_suite_end') + .filter(event => event.content.meta[TEST_SKIPPED_BY_ITR] === 'true') + const skippedSuite = events.find(event => { + return event.type === 'test_suite_end' && event.content.resource === `test_suite.${suite}` + }).content + const testSession = events.find(event => event.type === 'test_session_end').content + + assert.strictEqual(skippedSuites.length, 1) + assert.strictEqual(skippedSuite.meta[TEST_STATUS], 'skip') + assert.strictEqual(skippedSuite.meta[TEST_SKIPPED_BY_ITR], 'true') + assert.strictEqual(testSession.meta[TEST_ITR_TESTS_SKIPPED], 'true') + if (isJestCoverageBackfillSupported) { + assert.strictEqual(testSession.metrics[TEST_CODE_COVERAGE_LINES_PCT], 100) + } else { + assert.strictEqual(testSession.metrics[TEST_CODE_COVERAGE_LINES_PCT], undefined) + } + }) + + childProcess = exec( + 'node ./node_modules/jest/bin/jest --config config-jest.js --rootDir ci-visibility/subproject --coverage', + { + cwd, + env: { + ...getCiVisAgentlessConfig(receiver.port), + COLLECT_COVERAGE_FROM: 'subproject-test.js,subproject-test-2.js,dependency.js', + PROJECTS: JSON.stringify([{ + testMatch: ['**/subproject-test*'], + testEnvironment: 'node', + testRunner: 'jest-circus/runner', + }]), + }, + } + ) + + const [, [exitCode]] = await Promise.all([ + eventsPromise, + once(childProcess, 'exit'), + ]) + assert.strictEqual(exitCode, 0) + }) + it('report code coverage with all mocked files', async () => { const codeCovRequestPromise = receiver.payloadReceived(({ url }) => url === '/api/v2/citestcov') @@ -902,6 +1001,7 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { cwd, env: { ...getCiVisAgentlessConfig(receiver.port), + ENABLE_CODE_COVERAGE: '1', TESTS_TO_RUN: 'jest/mocked-test.js', }, } @@ -3576,8 +3676,8 @@ describe(`jest@${JEST_VERSION} commonJS`, () => { ddsource: 'dd_debugger', level: 'error', }) - assert.ok(diLog.ddtags.includes('git.repository_url:')) - assert.ok(diLog.ddtags.includes('git.commit.sha:')) + assert.ok(diLog.ddtags.includes('git.repository_url:'), `Got: ${inspect(diLog.ddtags)}`) + assert.ok(diLog.ddtags.includes('git.commit.sha:'), `Got: ${inspect(diLog.ddtags)}`) assert.strictEqual(diLog.debugger.snapshot.language, 'javascript') assertObjectContains(diLog.debugger.snapshot.captures.lines['6'].locals, { a: { diff --git a/integration-tests/mocha/mocha.spec.js b/integration-tests/mocha/mocha.spec.js index 832be4d89c..f68429e61c 100644 --- a/integration-tests/mocha/mocha.spec.js +++ b/integration-tests/mocha/mocha.spec.js @@ -5,6 +5,7 @@ const fs = require('fs') const assert = require('node:assert/strict') const { once } = require('node:events') const path = require('path') +const { inspect } = require('node:util') const { assertObjectContains } = require('../helpers') const { @@ -39,7 +40,6 @@ const { TEST_SOURCE_START, TEST_CODE_OWNERS, TEST_SESSION_NAME, - TEST_LEVEL_EVENT_TYPES, TEST_EARLY_FLAKE_ABORT_REASON, DI_ERROR_DEBUG_INFO_CAPTURED, DI_DEBUG_ERROR_PREFIX, @@ -75,6 +75,7 @@ const { DD_CI_LIBRARY_CONFIGURATION_ERROR_KNOWN_TESTS, DD_CI_LIBRARY_CONFIGURATION_ERROR_TEST_MANAGEMENT_TESTS, TEST_FINAL_STATUS, + getLineCoverageBitmap, } = require('../../packages/dd-trace/src/plugins/util/test') const { DD_HOST_CPU_COUNT } = require('../../packages/dd-trace/src/plugins/util/env') const { @@ -93,6 +94,14 @@ function assertItrSkippingEnabledTags (events, expected) { assert.strictEqual(test.meta[TEST_ITR_SKIPPING_ENABLED], expected) } +function getLinesBitmapBase64 (startLine, endLine) { + const lineCoverage = {} + for (let line = startLine; line <= endLine; line++) { + lineCoverage[line] = 1 + } + return getLineCoverageBitmap(lineCoverage, true).toString('base64') +} + const runTestsCommand = 'node ./ci-visibility/run-mocha.js' const runTestsWithCoverageCommand = `./node_modules/nyc/bin/nyc.js -r=text-summary ${runTestsCommand}` const testFile = 'ci-visibility/run-mocha.js' @@ -244,9 +253,7 @@ describe(`mocha@${MOCHA_VERSION}`, function () { const metadataDicts = payloads.flatMap(({ payload }) => payload.metadata) metadataDicts.forEach(metadata => { - for (const testLevel of TEST_LEVEL_EVENT_TYPES) { - assert.strictEqual(metadata[testLevel][TEST_SESSION_NAME], 'my-test-session') - } + assert.strictEqual(metadata['*'][TEST_SESSION_NAME], 'my-test-session') }) const events = payloads.flatMap(({ payload }) => payload.events) @@ -788,11 +795,11 @@ describe(`mocha@${MOCHA_VERSION}`, function () { assert.strictEqual(test.meta[COMPONENT], 'mocha') assert.strictEqual(test.meta[TEST_STATUS], 'fail') assert.strictEqual(test.meta[ERROR_TYPE], 'TypeError') - assert.ok( - test.meta[ERROR_MESSAGE] - .includes('mocha-fail-hook-sync "before each" hook for "will not run but be reported as failed":') - ) - assert.match(test.meta[ERROR_MESSAGE], /Cannot set /) + const errorMessage = test.meta[ERROR_MESSAGE] + const expectedHookPrefix = + 'mocha-fail-hook-sync "before each" hook for "will not run but be reported as failed":' + assert.ok(errorMessage.includes(expectedHookPrefix), `Got: ${inspect(errorMessage)}`) + assert.match(errorMessage, /Cannot set /) assert.ok(test.meta[ERROR_STACK]) }) @@ -1181,7 +1188,10 @@ describe(`mocha@${MOCHA_VERSION}`, function () { assert.ok(suite, `Expected suite event for ${suiteFile}`) assert.strictEqual(suite.meta[TEST_STATUS], 'pass') }) - tests.forEach(test => assert.ok(suiteFiles.includes(test.meta[TEST_SUITE]))) + tests.forEach(test => assert.ok( + suiteFiles.includes(test.meta[TEST_SUITE]), + `Got: ${inspect(suiteFiles)}` + )) }) childProcess = exec( @@ -1233,7 +1243,10 @@ describe(`mocha@${MOCHA_VERSION}`, function () { assert.ok(suite, `Expected suite event for ${suiteFile}`) assert.strictEqual(suite.meta[TEST_STATUS], 'pass') }) - tests.forEach(test => assert.ok(suiteFiles.includes(test.meta[TEST_SUITE]))) + tests.forEach(test => assert.ok( + suiteFiles.includes(test.meta[TEST_SUITE]), + `Got: ${inspect(suiteFiles)}` + )) }) childProcess = exec( @@ -1594,9 +1607,8 @@ describe(`mocha@${MOCHA_VERSION}`, function () { const metadataDicts = payloads.flatMap(({ payload }) => payload.metadata) metadataDicts.forEach(metadata => { - for (const testLevel of TEST_LEVEL_EVENT_TYPES) { - assert.strictEqual(metadata[testLevel][TEST_SESSION_NAME], 'my-test-session') - } + assert.ok(metadata['*'][TEST_COMMAND]) + assert.strictEqual(metadata['*'][TEST_SESSION_NAME], 'my-test-session') }) const events = payloads.flatMap(({ payload }) => payload.events) @@ -1616,7 +1628,6 @@ describe(`mocha@${MOCHA_VERSION}`, function () { test_module_id: testModuleId, test_session_id: testSessionId, }) => { - assert.ok(meta[TEST_COMMAND]) assert.ok(meta[TEST_MODULE]) assert.ok(testSuiteId) assert.strictEqual(testModuleId.toString(10), moduleEventContent.test_module_id.toString(10)) @@ -1630,7 +1641,6 @@ describe(`mocha@${MOCHA_VERSION}`, function () { test_module_id: testModuleId, test_session_id: testSessionId, }) => { - assert.ok(meta[TEST_COMMAND]) assert.ok(meta[TEST_MODULE]) assert.ok(testSuiteId) assert.strictEqual(testModuleId.toString(10), moduleEventContent.test_module_id.toString(10)) @@ -1911,7 +1921,14 @@ describe(`mocha@${MOCHA_VERSION}`, function () { }) }) - it('can report code coverage', (done) => { + it('can report code coverage', async () => { + receiver.setSettings({ + itr_enabled: true, + code_coverage: true, + coverage_report_upload_enabled: true, + tests_skipping: true, + }) + let testOutput = '' const libraryConfigRequestPromise = receiver.payloadReceived( ({ url }) => url === '/api/v2/libraries/tests/services/setting' @@ -1919,7 +1936,7 @@ describe(`mocha@${MOCHA_VERSION}`, function () { const codeCovRequestPromise = receiver.payloadReceived(({ url }) => url === '/api/v2/citestcov') const eventsRequestPromise = receiver.payloadReceived(({ url }) => url === '/api/v2/citestcycle') - Promise.all([ + const requestsPromise = Promise.all([ libraryConfigRequestPromise, codeCovRequestPromise, eventsRequestPromise, @@ -1966,7 +1983,7 @@ describe(`mocha@${MOCHA_VERSION}`, function () { (acc, type) => type === 'test_suite_end' ? acc + 1 : acc, 0 ) assert.strictEqual(numSuites, 2) - }).catch(done) + }) childProcess = exec( runTestsWithCoverageCommand, @@ -1978,11 +1995,15 @@ describe(`mocha@${MOCHA_VERSION}`, function () { childProcess.stdout?.on('data', (chunk) => { testOutput += chunk.toString() }) - childProcess.on('exit', () => { - // coverage report - assert.match(testOutput, /Lines {7}/) - done() - }) + const stdoutEndPromise = childProcess.stdout ? once(childProcess.stdout, 'end') : Promise.resolve() + const [, [exitCode]] = await Promise.all([ + requestsPromise, + once(childProcess, 'exit'), + stdoutEndPromise, + ]) + assert.strictEqual(exitCode, 0) + // coverage report + assert.match(testOutput, /Lines {7}/) }) it('does not report code coverage if disabled by the API', (done) => { @@ -2022,19 +2043,22 @@ describe(`mocha@${MOCHA_VERSION}`, function () { ) }) - it('can skip suites received by the intelligent test runner API and still reports code coverage', (done) => { + it('can skip suites received by the intelligent test runner API and still reports code coverage', async () => { receiver.setSuitesToSkip([{ type: 'suite', attributes: { suite: 'ci-visibility/test/ci-visibility-test.js', }, }]) + receiver.setSkippableCoverage({ + 'ci-visibility/test/ci-visibility-test.js': getLinesBitmapBase64(1, 20), + }) const skippableRequestPromise = receiver.payloadReceived(({ url }) => url === '/api/v2/ci/tests/skippable') const coverageRequestPromise = receiver.payloadReceived(({ url }) => url === '/api/v2/citestcov') const eventsRequestPromise = receiver.payloadReceived(({ url }) => url === '/api/v2/citestcycle') - Promise.all([ + const requestsPromise = Promise.all([ skippableRequestPromise, coverageRequestPromise, eventsRequestPromise, @@ -2074,8 +2098,7 @@ describe(`mocha@${MOCHA_VERSION}`, function () { assert.strictEqual(testModule.meta[TEST_ITR_SKIPPING_TYPE], 'suite') assert.strictEqual(testModule.metrics[TEST_ITR_SKIPPING_COUNT], 1) assertItrSkippingEnabledTags(eventsRequest.payload.events, 'true') - done() - }).catch(done) + }) childProcess = exec( runTestsWithCoverageCommand, @@ -2084,9 +2107,19 @@ describe(`mocha@${MOCHA_VERSION}`, function () { env: getCiVisAgentlessConfig(receiver.port), } ) + const [, [exitCode]] = await Promise.all([ + requestsPromise, + once(childProcess, 'exit'), + ]) + assert.strictEqual(exitCode, 0) }) - it('marks the test session as skipped if every suite is skipped', (done) => { + it('marks the test session as skipped if every suite is skipped', async () => { + receiver.setSettings({ + itr_enabled: true, + code_coverage: false, + tests_skipping: true, + }) receiver.setSuitesToSkip( [ { @@ -2117,11 +2150,11 @@ describe(`mocha@${MOCHA_VERSION}`, function () { env: getCiVisAgentlessConfig(receiver.port), } ) - childProcess.on('exit', () => { - eventsPromise.then(() => { - done() - }).catch(done) - }) + const [, [exitCode]] = await Promise.all([ + eventsPromise, + once(childProcess, 'exit'), + ]) + assert.strictEqual(exitCode, 0) }) it('does not skip tests if git metadata upload fails', (done) => { @@ -2207,7 +2240,8 @@ describe(`mocha@${MOCHA_VERSION}`, function () { ) }) - it('does not skip suites if suite is marked as unskippable', (done) => { + it('does not skip suites if suite is marked as unskippable', async () => { + const coveredSkippedLines = getLinesBitmapBase64(1, 20) receiver.setSuitesToSkip([ { type: 'suite', @@ -2222,6 +2256,10 @@ describe(`mocha@${MOCHA_VERSION}`, function () { }, }, ]) + receiver.setSkippableCoverage({ + 'ci-visibility/unskippable-test/test-to-skip.js': coveredSkippedLines, + 'ci-visibility/unskippable-test/test-unskippable.js': coveredSkippedLines, + }) const eventsPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { @@ -2275,14 +2313,14 @@ describe(`mocha@${MOCHA_VERSION}`, function () { } ) - childProcess.on('exit', () => { - eventsPromise.then(() => { - done() - }).catch(done) - }) + const [, [exitCode]] = await Promise.all([ + eventsPromise, + once(childProcess, 'exit'), + ]) + assert.strictEqual(exitCode, 0) }) - it('only sets forced to run if suite was going to be skipped by ITR', (done) => { + it('only sets forced to run if suite was going to be skipped by ITR', async () => { receiver.setSuitesToSkip([ { type: 'suite', @@ -2291,6 +2329,9 @@ describe(`mocha@${MOCHA_VERSION}`, function () { }, }, ]) + receiver.setSkippableCoverage({ + 'ci-visibility/unskippable-test/test-to-skip.js': getLinesBitmapBase64(1, 20), + }) const eventsPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { @@ -2344,11 +2385,11 @@ describe(`mocha@${MOCHA_VERSION}`, function () { } ) - childProcess.on('exit', () => { - eventsPromise.then(() => { - done() - }).catch(done) - }) + const [, [exitCode]] = await Promise.all([ + eventsPromise, + once(childProcess, 'exit'), + ]) + assert.strictEqual(exitCode, 0) }) it('sets _dd.ci.itr.tests_skipped to false if the received suite is not skipped', (done) => { @@ -2456,6 +2497,9 @@ describe(`mocha@${MOCHA_VERSION}`, function () { suite: 'ci-visibility/test/ci-visibility-test.js', }, }]) + receiver.setSkippableCoverage({ + 'ci-visibility/test/ci-visibility-test.js': getLinesBitmapBase64(1, 20), + }) const eventsPromise = receiver .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), (payloads) => { @@ -4085,7 +4129,10 @@ describe(`mocha@${MOCHA_VERSION}`, function () { 'nyc output does not match the reported coverage (no --all flag)') eventsPromise.then(() => { - assert.ok(codeCoverageWithoutUntestedFiles > codeCoverageWithUntestedFiles) + assert.ok( + codeCoverageWithoutUntestedFiles > codeCoverageWithUntestedFiles, + `Expected ${codeCoverageWithoutUntestedFiles} > ${codeCoverageWithUntestedFiles}` + ) done() }).catch(done) }) @@ -4829,7 +4876,7 @@ describe(`mocha@${MOCHA_VERSION}`, function () { const atfTests = tests.filter( t => t.meta[TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX] === 'true' ) - assert.ok(atfTests.length > 0) + assert.ok(atfTests.length > 0, `Expected ${atfTests.length} > 0`) for (const test of atfTests) { assert.ok( !(TEST_IS_NEW in test.meta), @@ -5491,7 +5538,7 @@ describe(`mocha@${MOCHA_VERSION}`, function () { assert.strictEqual(testSession.meta[TEST_MANAGEMENT_ENABLED], 'true') assert.strictEqual(testSession.meta[MOCHA_IS_PARALLEL], 'true') const tests = events.filter(event => event.type === 'test').map(event => event.content) - assert.ok(tests.length > 0) + assert.ok(tests.length > 0, `Expected ${tests.length} > 0`) const suiteEvents = events.filter(event => event.type === 'test_suite_end') assert.strictEqual(suiteEvents.length, 2, 'Expected exactly 2 suites to be reported') // Verify that tests have different runtime IDs, confirming parallel execution in different processes @@ -5548,7 +5595,7 @@ describe(`mocha@${MOCHA_VERSION}`, function () { receiver.gatherPayloadsMaxTimeout(({ url }) => url.endsWith('citestcycle'), (payloads) => { const metadataDicts = payloads.flatMap(({ payload }) => payload.metadata) - assert.ok(metadataDicts.length > 0) + assert.ok(metadataDicts.length > 0, `Expected ${metadataDicts.length} > 0`) metadataDicts.forEach(metadata => { assert.strictEqual(metadata.test[DD_CAPABILITIES_TEST_IMPACT_ANALYSIS], '1') assert.strictEqual(metadata.test[DD_CAPABILITIES_TEST_MANAGEMENT_ATTEMPT_TO_FIX], '5') @@ -5558,7 +5605,7 @@ describe(`mocha@${MOCHA_VERSION}`, function () { assert.strictEqual(metadata.test[DD_CAPABILITIES_TEST_MANAGEMENT_DISABLE], '1') assert.strictEqual(metadata.test[DD_CAPABILITIES_FAILED_TEST_REPLAY], '1') // capabilities logic does not overwrite test session name - assert.strictEqual(metadata.test[TEST_SESSION_NAME], 'my-test-session-name') + assert.strictEqual(metadata['*'][TEST_SESSION_NAME], 'my-test-session-name') }) }) @@ -6030,10 +6077,16 @@ describe(`mocha@${MOCHA_VERSION}`, function () { const coverageReport = payloads[0] - assert.ok(coverageReport.headers['content-type'].includes('multipart/form-data')) + assert.ok( + coverageReport.headers['content-type'].includes('multipart/form-data'), + `Got: ${inspect(coverageReport.headers['content-type'])}` + ) assert.strictEqual(coverageReport.coverageFile.name, 'coverage') - assert.ok(coverageReport.coverageFile.content.includes('SF:')) // LCOV format + assert.ok( + coverageReport.coverageFile.content.includes('SF:'), + `Got: ${inspect(coverageReport.coverageFile.content)}` + ) // LCOV format assert.strictEqual(coverageReport.eventFile.name, 'event') assert.strictEqual(coverageReport.eventFile.content.type, 'coverage_report') diff --git a/integration-tests/openfeature/openfeature-exposure-events.spec.js b/integration-tests/openfeature/openfeature-exposure-events.spec.js index 1b255dc0ce..34b9934d8e 100644 --- a/integration-tests/openfeature/openfeature-exposure-events.spec.js +++ b/integration-tests/openfeature/openfeature-exposure-events.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const { assertObjectContains, sandboxCwd, useSandbox, FakeAgent, spawnProc, stopProc } = require('../helpers') const { UNACKNOWLEDGED, ACKNOWLEDGED } = require('../../packages/dd-trace/src/remote_config/apply_states') const ufcPayloads = require('./fixtures/ufc-payloads') @@ -11,10 +12,10 @@ const RC_PRODUCT = 'FFE_FLAGS' // Helper function to check exposure event structure function validateExposureEvent (event, expectedFlag, expectedUser, expectedAttributes = {}) { - assert.ok(Object.hasOwn(event, 'timestamp')) - assert.ok(Object.hasOwn(event, 'flag')) - assert.ok(Object.hasOwn(event, 'variant')) - assert.ok(Object.hasOwn(event, 'subject')) + assert.ok(Object.hasOwn(event, 'timestamp'), `Available keys: ${inspect(Object.keys(event))}`) + assert.ok(Object.hasOwn(event, 'flag'), `Available keys: ${inspect(Object.keys(event))}`) + assert.ok(Object.hasOwn(event, 'variant'), `Available keys: ${inspect(Object.keys(event))}`) + assert.ok(Object.hasOwn(event, 'subject'), `Available keys: ${inspect(Object.keys(event))}`) assert.strictEqual(event.flag.key, expectedFlag) assert.strictEqual(event.subject.id, expectedUser) @@ -76,7 +77,7 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => { // Listen for exposure events agent.on('exposures', ({ payload, headers }) => { - assert.ok(Object.hasOwn(payload, 'exposures')) + assert.ok(Object.hasOwn(payload, 'exposures'), `Available keys: ${inspect(Object.keys(payload))}`) assertObjectContains(payload, { context: { service: 'ffe-test-service', @@ -173,7 +174,7 @@ describe('OpenFeature Remote Config and Exposure Events Integration', () => { const exposureEvents = [] agent.on('exposures', ({ payload }) => { - assert.ok(Object.hasOwn(payload, 'exposures')) + assert.ok(Object.hasOwn(payload, 'exposures'), `Available keys: ${inspect(Object.keys(payload))}`) assertObjectContains(payload, { context: { service: 'ffe-test-service', diff --git a/integration-tests/playwright/playwright-active-test-span.spec.js b/integration-tests/playwright/playwright-active-test-span.spec.js index cd708413a0..f6a36cb29d 100644 --- a/integration-tests/playwright/playwright-active-test-span.spec.js +++ b/integration-tests/playwright/playwright-active-test-span.spec.js @@ -2,12 +2,14 @@ const assert = require('node:assert') const { once } = require('node:events') -const { exec, execSync } = require('child_process') +const { exec } = require('child_process') +const { inspect } = require('node:util') const satisfies = require('semifies') const { sandboxCwd, useSandbox, + installPlaywrightChromium, getCiVisAgentlessConfig, getCiVisEvpProxyConfig, assertObjectContains, @@ -23,14 +25,13 @@ const { TEST_IS_RUM_ACTIVE, TEST_BROWSER_VERSION, } = require('../../packages/dd-trace/src/plugins/util/test') -const { DD_MAJOR } = require('../../version') const { PLAYWRIGHT_VERSION } = process.env const NUM_RETRIES_EFD = 3 const latest = 'latest' -const oldest = DD_MAJOR >= 6 ? '1.38.0' : '1.18.0' +const { oldest } = require('./versions') const versions = [oldest, latest] versions.forEach((version) => { @@ -56,11 +57,7 @@ versions.forEach((version) => { this.timeout(120000) cwd = sandboxCwd() - const { NODE_OPTIONS, ...restOfEnv } = process.env - // Install chromium (configured in integration-tests/playwright.config.js) - // *Be advised*: this means that we'll only be using chromium for this test suite - // This will use cached browsers if available, otherwise download - execSync('npx playwright install chromium', { cwd, env: restOfEnv, stdio: 'inherit' }) + installPlaywrightChromium(cwd) // Create fresh server instances to avoid issues with retries webAppServer = createWebAppServer() @@ -179,7 +176,10 @@ versions.forEach((version) => { [TEST_STATUS]: 'pass', [TEST_IS_RUM_ACTIVE]: 'true', }) - assert.ok(Object.hasOwn(test.meta, TEST_BROWSER_VERSION)) + assert.ok( + Object.hasOwn(test.meta, TEST_BROWSER_VERSION), + `Available keys: ${inspect(Object.keys(test.meta))}` + ) } }) }) @@ -224,8 +224,8 @@ versions.forEach((version) => { .filter(({ metric, tags }) => metric === 'event_finished' && tags.includes('event_type:test')) eventFinishedTestEvents.forEach(({ tags }) => { - assert.ok(tags.includes('is_rum')) - assert.ok(tags.includes('test_framework:playwright')) + assert.ok(tags.includes('is_rum'), `Got: ${inspect(tags)}`) + assert.ok(tags.includes('test_framework:playwright'), `Got: ${inspect(tags)}`) }) }) diff --git a/integration-tests/playwright/playwright-atr.spec.js b/integration-tests/playwright/playwright-atr.spec.js index 22dedd7f23..c410cc108b 100644 --- a/integration-tests/playwright/playwright-atr.spec.js +++ b/integration-tests/playwright/playwright-atr.spec.js @@ -2,12 +2,13 @@ const assert = require('node:assert') const { once } = require('node:events') -const { exec, execSync } = require('child_process') +const { exec } = require('child_process') const satisfies = require('semifies') const { sandboxCwd, useSandbox, + installPlaywrightChromium, getCiVisAgentlessConfig, getCiVisEvpProxyConfig, } = require('../helpers') @@ -20,12 +21,11 @@ const { TEST_HAS_FAILED_ALL_RETRIES, TEST_RETRY_REASON_TYPES, } = require('../../packages/dd-trace/src/plugins/util/test') -const { DD_MAJOR } = require('../../version') const { PLAYWRIGHT_VERSION } = process.env const latest = 'latest' -const oldest = DD_MAJOR >= 6 ? '1.38.0' : '1.18.0' +const { oldest } = require('./versions') const versions = [oldest, latest] versions.forEach((version) => { @@ -51,11 +51,7 @@ versions.forEach((version) => { this.timeout(120000) cwd = sandboxCwd() - const { NODE_OPTIONS, ...restOfEnv } = process.env - // Install chromium (configured in integration-tests/playwright.config.js) - // *Be advised*: this means that we'll only be using chromium for this test suite - // This will use cached browsers if available, otherwise download - execSync('npx playwright install chromium', { cwd, env: restOfEnv, stdio: 'inherit' }) + installPlaywrightChromium(cwd) // Create fresh server instance to avoid issues with retries webAppServer = createWebAppServer() diff --git a/integration-tests/playwright/playwright-efd.spec.js b/integration-tests/playwright/playwright-efd.spec.js index d86f725478..48e0d20043 100644 --- a/integration-tests/playwright/playwright-efd.spec.js +++ b/integration-tests/playwright/playwright-efd.spec.js @@ -2,12 +2,13 @@ const assert = require('node:assert') const { once } = require('node:events') -const { exec, execSync } = require('child_process') +const { exec } = require('child_process') const satisfies = require('semifies') const { sandboxCwd, useSandbox, + installPlaywrightChromium, getCiVisAgentlessConfig, assertObjectContains, } = require('../helpers') @@ -25,7 +26,6 @@ const { TEST_BROWSER_NAME, TEST_RETRY_REASON_TYPES, } = require('../../packages/dd-trace/src/plugins/util/test') -const { DD_MAJOR } = require('../../version') const { PLAYWRIGHT_VERSION } = process.env @@ -33,7 +33,7 @@ const NUM_RETRIES_EFD = 3 const PLAYWRIGHT_EFD_GATHER_TIMEOUT = 60000 const latest = 'latest' -const oldest = DD_MAJOR >= 6 ? '1.38.0' : '1.18.0' +const { oldest } = require('./versions') const versions = [oldest, latest] versions.forEach((version) => { @@ -59,11 +59,7 @@ versions.forEach((version) => { this.timeout(120000) cwd = sandboxCwd() - const { NODE_OPTIONS, ...restOfEnv } = process.env - // Install chromium (configured in integration-tests/playwright.config.js) - // *Be advised*: this means that we'll only be using chromium for this test suite - // This will use cached browsers if available, otherwise download - execSync('npx playwright install chromium', { cwd, env: restOfEnv, stdio: 'inherit' }) + installPlaywrightChromium(cwd) // Create fresh server instance to avoid issues with retries webAppServer = createWebAppServer() diff --git a/integration-tests/playwright/playwright-final-status.spec.js b/integration-tests/playwright/playwright-final-status.spec.js index 87b0ed6e12..43ed4a0faf 100644 --- a/integration-tests/playwright/playwright-final-status.spec.js +++ b/integration-tests/playwright/playwright-final-status.spec.js @@ -2,12 +2,13 @@ const assert = require('node:assert') const { once } = require('node:events') -const { exec, execSync } = require('child_process') +const { exec } = require('child_process') const satisfies = require('semifies') const { sandboxCwd, useSandbox, + installPlaywrightChromium, getCiVisAgentlessConfig, } = require('../helpers') const { FakeCiVisIntake } = require('../ci-visibility-intake') @@ -20,14 +21,14 @@ const { TEST_MANAGEMENT_IS_DISABLED, TEST_MANAGEMENT_IS_QUARANTINED, } = require('../../packages/dd-trace/src/plugins/util/test') -const { DD_MAJOR } = require('../../version') const { PLAYWRIGHT_VERSION } = process.env const NUM_RETRIES_EFD = 3 +const RETRY_FINAL_STATUS_TIMEOUT = 60000 const latest = 'latest' -const oldest = DD_MAJOR >= 6 ? '1.38.0' : '1.18.0' +const { oldest } = require('./versions') const versions = [oldest, latest] versions.forEach((version) => { @@ -56,11 +57,7 @@ versions.forEach((version) => { this.timeout(120000) cwd = sandboxCwd() - const { NODE_OPTIONS, ...restOfEnv } = process.env - // Install chromium (configured in integration-tests/playwright.config.js) - // *Be advised*: this means that we'll only be using chromium for this test suite - // This will use cached browsers if available, otherwise download - execSync('npx playwright install chromium', { cwd, env: restOfEnv, stdio: 'inherit' }) + installPlaywrightChromium(cwd) // Create fresh server instance to avoid issues with retries webAppServer = createWebAppServer() @@ -159,7 +156,7 @@ versions.forEach((version) => { const nonFinalRuns = eventuallyPassingTests.filter(t => !(TEST_FINAL_STATUS in t.meta)) assert.strictEqual(nonFinalRuns.length, eventuallyPassingTests.length - 1, 'All other ATR runs should not have TEST_FINAL_STATUS') - }, 30000) + }, RETRY_FINAL_STATUS_TIMEOUT) // --retries=2 is passed via CLI so test.info().retry increments correctly across all playwright versions. // dd-trace won't override it since its guard is `if (project.retries === 0)`. @@ -243,7 +240,7 @@ versions.forEach((version) => { 'highest-level-describe leading and trailing spaces should work with annotated tests', 'pass' ) - }, 30000) + }, RETRY_FINAL_STATUS_TIMEOUT) childProcess = exec( './node_modules/.bin/playwright test -c playwright.config.js', @@ -289,7 +286,7 @@ versions.forEach((version) => { const eventuallyPassingTests = tests.filter( test => test.meta[TEST_NAME] === 'playwright should eventually pass after retrying' ) - assert.ok(eventuallyPassingTests.length > 1) + assert.ok(eventuallyPassingTests.length > 1, `Expected ${eventuallyPassingTests.length} > 1`) const finalRuns = eventuallyPassingTests.filter(t => TEST_FINAL_STATUS in t.meta) assert.strictEqual(finalRuns.length, 1, @@ -300,7 +297,7 @@ versions.forEach((version) => { const nonFinalRuns = eventuallyPassingTests.filter(t => !(TEST_FINAL_STATUS in t.meta)) assert.strictEqual(nonFinalRuns.length, eventuallyPassingTests.length - 1, 'All other ATR runs should not have TEST_FINAL_STATUS') - }, 30000) + }, RETRY_FINAL_STATUS_TIMEOUT) // --retries=2 is passed via CLI so test.retries is correctly set at startup. // dd-trace won't override it since its guard is `if (project.retries === 0)`. diff --git a/integration-tests/playwright/playwright-impacted-tests.spec.js b/integration-tests/playwright/playwright-impacted-tests.spec.js index d700e42f32..e163b5c445 100644 --- a/integration-tests/playwright/playwright-impacted-tests.spec.js +++ b/integration-tests/playwright/playwright-impacted-tests.spec.js @@ -10,6 +10,7 @@ const satisfies = require('semifies') const { sandboxCwd, useSandbox, + installPlaywrightChromium, getCiVisAgentlessConfig, assertObjectContains, } = require('../helpers') @@ -24,14 +25,13 @@ const { TEST_RETRY_REASON_TYPES, TEST_IS_MODIFIED, } = require('../../packages/dd-trace/src/plugins/util/test') -const { DD_MAJOR } = require('../../version') const { PLAYWRIGHT_VERSION } = process.env const NUM_RETRIES_EFD = 3 const latest = 'latest' -const oldest = DD_MAJOR >= 6 ? '1.38.0' : '1.18.0' +const { oldest } = require('./versions') const versions = [oldest, latest] versions.forEach((version) => { @@ -57,11 +57,7 @@ versions.forEach((version) => { this.timeout(120000) cwd = sandboxCwd() - const { NODE_OPTIONS, ...restOfEnv } = process.env - // Install chromium (configured in integration-tests/playwright.config.js) - // *Be advised*: this means that we'll only be using chromium for this test suite - // This will use cached browsers if available, otherwise download - execSync('npx playwright install chromium', { cwd, env: restOfEnv, stdio: 'inherit' }) + installPlaywrightChromium(cwd) // Create fresh server instance to avoid issues with retries webAppServer = createWebAppServer() diff --git a/integration-tests/playwright/playwright-reporting.spec.js b/integration-tests/playwright/playwright-reporting.spec.js index 022354a372..198a0a36ee 100644 --- a/integration-tests/playwright/playwright-reporting.spec.js +++ b/integration-tests/playwright/playwright-reporting.spec.js @@ -2,12 +2,14 @@ const assert = require('node:assert') const { once } = require('node:events') -const { exec, execSync } = require('child_process') +const { exec } = require('child_process') +const { inspect } = require('node:util') const satisfies = require('semifies') const { sandboxCwd, useSandbox, + installPlaywrightChromium, getCiVisAgentlessConfig, getCiVisEvpProxyConfig, assertObjectContains, @@ -21,10 +23,11 @@ const { TEST_SOURCE_FILE, TEST_PARAMETERS, TEST_BROWSER_NAME, + TEST_FRAMEWORK_VERSION, TEST_SUITE, TEST_CODE_OWNERS, TEST_SESSION_NAME, - TEST_LEVEL_EVENT_TYPES, + TEST_COMMAND, DD_TEST_IS_USER_PROVIDED_SERVICE, DD_CAPABILITIES_TEST_IMPACT_ANALYSIS, DD_CAPABILITIES_EARLY_FLAKE_DETECTION, @@ -41,12 +44,11 @@ const { } = require('../../packages/dd-trace/src/plugins/util/test') const { DD_HOST_CPU_COUNT } = require('../../packages/dd-trace/src/plugins/util/env') const { ERROR_MESSAGE } = require('../../packages/dd-trace/src/constants') -const { DD_MAJOR } = require('../../version') const { PLAYWRIGHT_VERSION } = process.env const latest = 'latest' -const oldest = DD_MAJOR >= 6 ? '1.38.0' : '1.18.0' +const { oldest } = require('./versions') const versions = [oldest, latest] versions.forEach((version) => { @@ -72,11 +74,7 @@ versions.forEach((version) => { this.timeout(120000) cwd = sandboxCwd() - const { NODE_OPTIONS, ...restOfEnv } = process.env - // Install chromium (configured in integration-tests/playwright.config.js) - // *Be advised*: this means that we'll only be using chromium for this test suite - // This will use cached browsers if available, otherwise download - execSync('npx playwright install chromium', { cwd, env: restOfEnv, stdio: 'inherit' }) + installPlaywrightChromium(cwd) // Create fresh server instance to avoid issues with retries webAppServer = createWebAppServer() @@ -240,9 +238,7 @@ versions.forEach((version) => { const metadataDicts = payloads.flatMap(({ payload }) => payload.metadata) metadataDicts.forEach(metadata => { - for (const testLevel of TEST_LEVEL_EVENT_TYPES) { - assert.strictEqual(metadata[testLevel][TEST_SESSION_NAME], 'my-test-session') - } + assert.strictEqual(metadata['*'][TEST_SESSION_NAME], 'my-test-session') }) const events = payloads.flatMap(({ payload }) => payload.events) @@ -254,9 +250,15 @@ versions.forEach((version) => { const stepEvents = events.filter(event => event.type === 'span') - assert.ok(testSessionEvent.content.resource.includes('test_session.playwright test')) + assert.ok( + testSessionEvent.content.resource.includes('test_session.playwright test'), + `Got: ${inspect(testSessionEvent.content.resource)}` + ) assert.strictEqual(testSessionEvent.content.meta[TEST_STATUS], 'fail') - assert.ok(testModuleEvent.content.resource.includes('test_module.playwright test')) + assert.ok( + testModuleEvent.content.resource.includes('test_module.playwright test'), + `Got: ${inspect(testModuleEvent.content.resource)}` + ) assert.strictEqual(testModuleEvent.content.meta[TEST_STATUS], 'fail') assert.strictEqual(testSessionEvent.content.meta[TEST_TYPE], 'browser') assert.strictEqual(testModuleEvent.content.meta[TEST_TYPE], 'browser') @@ -312,6 +314,7 @@ versions.forEach((version) => { true ) assert.strictEqual(testEvent.content.meta[DD_TEST_IS_USER_PROVIDED_SERVICE], 'false') + assert.ok(testEvent.content.meta[TEST_FRAMEWORK_VERSION]) // Can read DD_TAGS assertObjectContains(testEvent.content.meta, { 'test.customtag': 'customvalue', @@ -339,7 +342,10 @@ versions.forEach((version) => { stepEvents.forEach(stepEvent => { assert.strictEqual(stepEvent.content.name, 'playwright.step') - assert.ok(Object.hasOwn(stepEvent.content.meta, 'playwright.step')) + assert.ok( + Object.hasOwn(stepEvent.content.meta, 'playwright.step'), + `Available keys: ${inspect(Object.keys(stepEvent.content.meta))}` + ) }) const annotatedTest = testEvents.find(test => test.content.resource.endsWith('should work with annotated tests') @@ -569,7 +575,7 @@ versions.forEach((version) => { .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { const metadataDicts = payloads.flatMap(({ payload }) => payload.metadata) - assert.ok(metadataDicts.length > 0) + assert.ok(metadataDicts.length > 0, `Expected ${metadataDicts.length} > 0`) metadataDicts.forEach(metadata => { assert.strictEqual(metadata.test[DD_CAPABILITIES_TEST_IMPACT_ANALYSIS], undefined) assert.strictEqual(metadata.test[DD_CAPABILITIES_AUTO_TEST_RETRIES], '1') @@ -589,7 +595,8 @@ versions.forEach((version) => { assert.strictEqual(metadata.test[DD_CAPABILITIES_FAILED_TEST_REPLAY], undefined) } // capabilities logic does not overwrite test session name - assert.strictEqual(metadata.test[TEST_SESSION_NAME], 'my-test-session-name') + assert.strictEqual(metadata['*'][TEST_SESSION_NAME], 'my-test-session-name') + assert.strictEqual(metadata['*'][TEST_COMMAND], 'playwright test -c playwright.config.js') }) }) diff --git a/integration-tests/playwright/playwright-test-management.spec.js b/integration-tests/playwright/playwright-test-management.spec.js index 720c99c8a1..822470fa41 100644 --- a/integration-tests/playwright/playwright-test-management.spec.js +++ b/integration-tests/playwright/playwright-test-management.spec.js @@ -2,12 +2,14 @@ const assert = require('node:assert') const { once } = require('node:events') -const { exec, execSync } = require('child_process') +const { inspect } = require('node:util') +const { exec } = require('child_process') const satisfies = require('semifies') const { sandboxCwd, useSandbox, + installPlaywrightChromium, getCiVisAgentlessConfig, assertObjectContains, } = require('../helpers') @@ -29,14 +31,13 @@ const { TEST_MANAGEMENT_ATTEMPT_TO_FIX_PASSED, TEST_RETRY_REASON_TYPES, } = require('../../packages/dd-trace/src/plugins/util/test') -const { DD_MAJOR } = require('../../version') const { PLAYWRIGHT_VERSION } = process.env const PLAYWRIGHT_TEST_MANAGEMENT_GATHER_TIMEOUT = 60000 const latest = 'latest' -const oldest = DD_MAJOR >= 6 ? '1.38.0' : '1.18.0' +const { oldest } = require('./versions') const versions = [oldest, latest] versions.forEach((version) => { @@ -62,11 +63,7 @@ versions.forEach((version) => { this.timeout(120000) cwd = sandboxCwd() - const { NODE_OPTIONS, ...restOfEnv } = process.env - // Install chromium (configured in integration-tests/playwright.config.js) - // *Be advised*: this means that we'll only be using chromium for this test suite - // This will use cached browsers if available, otherwise download - execSync('npx playwright install chromium', { cwd, env: restOfEnv, stdio: 'inherit' }) + installPlaywrightChromium(cwd) // Create fresh server instance to avoid issues with retries webAppServer = createWebAppServer() @@ -213,9 +210,10 @@ versions.forEach((version) => { if (isDisabled && !isAttemptingToFix) { assert.strictEqual(attemptedToFixTests.length, 2) - assert.ok(attemptedToFixTests.every(test => - test.meta[TEST_MANAGEMENT_IS_DISABLED] === 'true' - )) + assert.ok( + attemptedToFixTests.every(test => test.meta[TEST_MANAGEMENT_IS_DISABLED] === 'true'), + `Got: ${inspect(attemptedToFixTests.map(t => t.meta[TEST_MANAGEMENT_IS_DISABLED]))}` + ) // if the test is disabled and not attempting to fix, there will be no retries return } @@ -497,7 +495,7 @@ versions.forEach((version) => { const atfTests = tests.filter( t => t.meta[TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX] === 'true' ) - assert.ok(atfTests.length > 0) + assert.ok(atfTests.length > 0, `Expected ${atfTests.length} > 0`) for (const test of atfTests) { assert.ok( !(TEST_IS_NEW in test.meta), diff --git a/integration-tests/playwright/versions.js b/integration-tests/playwright/versions.js new file mode 100644 index 0000000000..90ee673f79 --- /dev/null +++ b/integration-tests/playwright/versions.js @@ -0,0 +1,9 @@ +'use strict' + +const { DD_MAJOR } = require('../../version') + +const oldest = DD_MAJOR >= 6 ? '1.38.0' : '1.18.0' +const latest = require('../../packages/dd-trace/test/plugins/versions/package.json') + .dependencies['@playwright/test'] + +module.exports = { oldest, latest } diff --git a/integration-tests/profiler/profiler.spec.js b/integration-tests/profiler/profiler.spec.js index debc511c84..b8d232ed06 100644 --- a/integration-tests/profiler/profiler.spec.js +++ b/integration-tests/profiler/profiler.spec.js @@ -9,6 +9,7 @@ const fs = require('fs/promises') const fsync = require('fs') const net = require('net') const zlib = require('zlib') +const { inspect } = require('node:util') const satisfies = require('semifies') const { Profile } = require('../../vendor/dist/pprof-format') const { @@ -58,7 +59,7 @@ function expectProfileMessagePromise (agent, timeout, assert.strictEqual(typeof event.info.profiler.activation, 'string') assert.strictEqual(typeof event.info.profiler.ssi.mechanism, 'string') const attachments = event.attachments - assert.ok(Array.isArray(attachments)) + assert.ok(Array.isArray(attachments), `Expected array, got ${inspect(attachments)}`) // Profiler encodes the files with Promise.all, so their ordering is not guaranteed assert.deepStrictEqual(attachments.slice().sort(), fileNames.sort()) for (const [index, fileName] of attachments.entries()) { @@ -703,7 +704,7 @@ describe('profiler', () => { execArgv: oomExecArgv, env: { ...oomEnv, - DD_PROFILING_EXPERIMENTAL_OOM_HEAP_LIMIT_EXTENSION_SIZE: '15000000', + DD_PROFILING_EXPERIMENTAL_OOM_HEAP_LIMIT_EXTENSION_SIZE: '20000000', DD_PROFILING_EXPERIMENTAL_OOM_MAX_HEAP_EXTENSION_COUNT: '3', }, }) @@ -797,8 +798,8 @@ describe('profiler', () => { // There's a race between the periodic uploader and the on-shutdown // upload, so the count can include up to one extra request. requestCount = requests.points[0][1] - assert.ok(requestCount >= 1) - assert.ok(requestCount <= 4) + assert.ok(requestCount >= 1, `Expected ${requestCount} >= 1`) + assert.ok(requestCount <= 4, `Expected ${requestCount} <= 4`) const responses = series.find(s => s.metric === 'profile_api.responses') assert.strictEqual(responses.type, 'count') @@ -862,7 +863,7 @@ describe('profiler', () => { 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) + assert.ok(sampleContexts.points[0][1] >= 1, `Expected ${sampleContexts.points[0][1]} >= 1`) }) }, requestType: 'generate-metrics', diff --git a/integration-tests/remote_config.spec.js b/integration-tests/remote_config.spec.js index 7929dc74bc..ecf3ec2223 100644 --- a/integration-tests/remote_config.spec.js +++ b/integration-tests/remote_config.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const Axios = require('axios') const { sandboxCwd, useSandbox, FakeAgent, spawnProc, stopProc } = require('./helpers') describe('Remote config client id', () => { @@ -60,13 +61,13 @@ describe('Remote config client id', () => { 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:'))) - assert.ok(processTags.some(tag => tag.startsWith('entrypoint.name:'))) - assert.ok(processTags.some(tag => tag.startsWith('entrypoint.type:'))) - assert.ok(processTags.some(tag => tag.startsWith('entrypoint.workdir:'))) + 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')) + assert.ok(processTags.some(tag => tag === 'entrypoint.type:script'), `Got: ${inspect(processTags)}`) agent.removeListener('remote-config-request', handleRemoteConfigRequest) done() } catch (err) { @@ -106,7 +107,10 @@ describe('Remote config client id', () => { await axios.get('/') return agent.assertMessageReceived(({ payload }) => { - assert.ok(payload[0][0].meta['_dd.rc.client_id'] == null) + assert.ok( + payload[0][0].meta['_dd.rc.client_id'] == null, + `Expected ${payload[0][0].meta['_dd.rc.client_id']} == null` + ) }) }) }) diff --git a/integration-tests/selenium/selenium.spec.js b/integration-tests/selenium/selenium.spec.js index 2751853dcc..e4725b0ba6 100644 --- a/integration-tests/selenium/selenium.spec.js +++ b/integration-tests/selenium/selenium.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const { once } = require('node:events') const { exec } = require('child_process') +const { inspect } = require('node:util') const { sandboxCwd, useSandbox, @@ -98,8 +99,14 @@ versionRange.forEach(version => { }, }) - assert.ok(Object.hasOwn(seleniumTest.meta, TEST_BROWSER_VERSION)) - assert.ok(Object.hasOwn(seleniumTest.meta, TEST_BROWSER_DRIVER_VERSION)) + assert.ok( + Object.hasOwn(seleniumTest.meta, TEST_BROWSER_VERSION), + `Available keys: ${inspect(Object.keys(seleniumTest.meta))}` + ) + assert.ok( + Object.hasOwn(seleniumTest.meta, TEST_BROWSER_DRIVER_VERSION), + `Available keys: ${inspect(Object.keys(seleniumTest.meta))}` + ) }) const telemetryPromise = receiver @@ -115,8 +122,8 @@ versionRange.forEach(version => { .filter(({ metric, tags }) => metric === 'event_finished' && tags.includes('event_type:test')) eventFinishedTestEvents.forEach(({ tags }) => { - assert.ok(tags.includes('is_rum')) - assert.ok(tags.includes('browser_driver:selenium')) + assert.ok(tags.includes('is_rum'), `Got: ${inspect(tags)}`) + assert.ok(tags.includes('browser_driver:selenium'), `Got: ${inspect(tags)}`) }) }) diff --git a/integration-tests/startup.spec.js b/integration-tests/startup.spec.js index 25712639ca..8c74823460 100644 --- a/integration-tests/startup.spec.js +++ b/integration-tests/startup.spec.js @@ -86,9 +86,9 @@ execArgvs.forEach(({ execArgv, skip, optional = true }) => { }) return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 1) - assert.ok(Array.isArray(payload[0])) + assert.ok(Array.isArray(payload[0]), `Expected array, got ${inspect(payload[0])}`) assert.strictEqual(payload[0].length, 1) assert.strictEqual(payload[0][0].name, 'web.request') }) @@ -140,9 +140,9 @@ execArgvs.forEach(({ execArgv, skip, optional = true }) => { }) return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, `localhost:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 1) - assert.ok(Array.isArray(payload[0])) + assert.ok(Array.isArray(payload[0]), `Expected array, got ${inspect(payload[0])}`) assert.strictEqual(payload[0].length, 1) assert.strictEqual(payload[0][0].name, 'web.request') }) @@ -169,9 +169,9 @@ execArgvs.forEach(({ execArgv, skip, optional = true }) => { }) return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 1) - assert.ok(Array.isArray(payload[0])) + assert.ok(Array.isArray(payload[0]), `Expected array, got ${inspect(payload[0])}`) assert.strictEqual(payload[0].length, 1) assert.strictEqual(payload[0][0].name, 'web.request') }) @@ -187,9 +187,9 @@ execArgvs.forEach(({ execArgv, skip, optional = true }) => { }) return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, `localhost:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 1) - assert.ok(Array.isArray(payload[0])) + assert.ok(Array.isArray(payload[0]), `Expected array, got ${inspect(payload[0])}`) assert.strictEqual(payload[0].length, 1) assert.strictEqual(payload[0][0].name, 'web.request') }) @@ -215,9 +215,9 @@ execArgvs.forEach(({ execArgv, skip, optional = true }) => { }) return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, '127.0.0.1:8126') - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 1) - assert.ok(Array.isArray(payload[0])) + assert.ok(Array.isArray(payload[0]), `Expected array, got ${inspect(payload[0])}`) assert.strictEqual(payload[0].length, 1) assert.strictEqual(payload[0][0].name, 'web.request') }) @@ -233,9 +233,9 @@ execArgvs.forEach(({ execArgv, skip, optional = true }) => { }) return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, '127.0.0.1:8126') - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 1) - assert.ok(Array.isArray(payload[0])) + assert.ok(Array.isArray(payload[0]), `Expected array, got ${inspect(payload[0])}`) assert.strictEqual(payload[0].length, 1) assert.strictEqual(payload[0][0].name, 'web.request') }) diff --git a/integration-tests/vitest/vitest.advanced.spec.js b/integration-tests/vitest/vitest.advanced.spec.js index 499df77718..f6fb76019a 100644 --- a/integration-tests/vitest/vitest.advanced.spec.js +++ b/integration-tests/vitest/vitest.advanced.spec.js @@ -5,6 +5,7 @@ const { once } = require('node:events') const { exec, execSync } = require('child_process') const path = require('path') const fs = require('fs') +const { inspect } = require('node:util') const { assertObjectContains } = require('../helpers') const { @@ -19,6 +20,7 @@ const { TEST_TYPE, TEST_IS_RETRY, TEST_SESSION_NAME, + TEST_COMMAND, TEST_SOURCE_FILE, TEST_IS_NEW, TEST_NAME, @@ -79,9 +81,12 @@ versions.forEach((version) => { .gatherPayloadsMaxTimeout(({ url }) => url.endsWith('/api/v2/citestcycle'), payloads => { const metadataDicts = payloads.flatMap(({ payload }) => payload.metadata) - assert.ok(metadataDicts.length > 0) + assert.ok(metadataDicts.length > 0, `Expected ${metadataDicts.length} > 0`) metadataDicts.forEach(metadata => { - assert.ok(!Object.hasOwn(metadata.test, DD_CAPABILITIES_TEST_IMPACT_ANALYSIS)) + assert.ok( + !Object.hasOwn(metadata.test, DD_CAPABILITIES_TEST_IMPACT_ANALYSIS), + `Available keys: ${inspect(Object.keys(metadata.test))}` + ) assertObjectContains(metadata.test, { [DD_CAPABILITIES_EARLY_FLAKE_DETECTION]: '1', @@ -91,9 +96,10 @@ versions.forEach((version) => { [DD_CAPABILITIES_TEST_MANAGEMENT_DISABLE]: '1', [DD_CAPABILITIES_TEST_MANAGEMENT_ATTEMPT_TO_FIX]: '5', [DD_CAPABILITIES_FAILED_TEST_REPLAY]: '1', - // capabilities logic does not overwrite test session name - [TEST_SESSION_NAME]: 'my-test-session-name', }) + // capabilities logic does not overwrite test session name + assert.strictEqual(metadata['*'][TEST_SESSION_NAME], 'my-test-session-name') + assert.strictEqual(metadata['*'][TEST_COMMAND], 'vitest run') }) }) @@ -370,10 +376,16 @@ versions.forEach((version) => { const coverageReport = payloads[0] - assert.ok(coverageReport.headers['content-type'].includes('multipart/form-data')) + assert.ok( + coverageReport.headers['content-type'].includes('multipart/form-data'), + `Got: ${inspect(coverageReport.headers['content-type'])}` + ) assert.strictEqual(coverageReport.coverageFile.name, 'coverage') - assert.ok(coverageReport.coverageFile.content.includes('SF:')) // LCOV format + assert.ok( + coverageReport.coverageFile.content.includes('SF:'), + `Got: ${inspect(coverageReport.coverageFile.content)}` + ) // LCOV format assert.strictEqual(coverageReport.eventFile.name, 'event') assert.strictEqual(coverageReport.eventFile.content.type, 'coverage_report') diff --git a/integration-tests/vitest/vitest.core.spec.js b/integration-tests/vitest/vitest.core.spec.js index 4f1bfa4681..1721da3c80 100644 --- a/integration-tests/vitest/vitest.core.spec.js +++ b/integration-tests/vitest/vitest.core.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const { once } = require('node:events') const { exec } = require('child_process') +const { inspect } = require('node:util') const { assertObjectContains } = require('../helpers') const { @@ -20,7 +21,6 @@ const { TEST_CODE_COVERAGE_LINES_PCT, TEST_SESSION_NAME, TEST_COMMAND, - TEST_LEVEL_EVENT_TYPES, TEST_SOURCE_FILE, TEST_SOURCE_START, TEST_IS_NEW, @@ -109,9 +109,8 @@ versions.forEach((version) => { const metadataDicts = payloads.flatMap(({ payload }) => payload.metadata) metadataDicts.forEach(metadata => { - for (const testLevel of TEST_LEVEL_EVENT_TYPES) { - assert.strictEqual(metadata[testLevel][TEST_SESSION_NAME], 'my-test-session') - } + assert.strictEqual(metadata['*'][TEST_SESSION_NAME], 'my-test-session') + assert.ok(metadata['*'][TEST_COMMAND]) }) const events = payloads.flatMap(({ payload }) => payload.events) @@ -128,9 +127,15 @@ versions.forEach((version) => { const testSuiteEvents = events.filter(event => event.type === 'test_suite_end') const testEvents = events.filter(event => event.type === 'test') - assert.ok(testSessionEvent.content.resource.includes('test_session.vitest run')) + assert.ok( + testSessionEvent.content.resource.includes('test_session.vitest run'), + `Got: ${inspect(testSessionEvent.content.resource)}` + ) assert.strictEqual(testSessionEvent.content.meta[TEST_STATUS], 'fail') - assert.ok(testModuleEvent.content.resource.includes('test_module.vitest run')) + assert.ok( + testModuleEvent.content.resource.includes('test_module.vitest run'), + `Got: ${inspect(testModuleEvent.content.resource)}` + ) assert.strictEqual(testModuleEvent.content.meta[TEST_STATUS], 'fail') assert.strictEqual(testSessionEvent.content.meta[TEST_TYPE], 'test') assert.strictEqual(testModuleEvent.content.meta[TEST_TYPE], 'test') @@ -224,7 +229,6 @@ versions.forEach((version) => { if (poolConfig === 'forks') { assert.strictEqual(test.content.meta[TEST_IS_TEST_FRAMEWORK_WORKER], 'true') } - assert.strictEqual(test.content.meta[TEST_COMMAND], 'vitest run') assert.ok(test.content.metrics[DD_HOST_CPU_COUNT]) assert.strictEqual(test.content.meta[DD_TEST_IS_USER_PROVIDED_SERVICE], 'false') }) @@ -234,7 +238,6 @@ versions.forEach((version) => { if (poolConfig === 'forks') { assert.strictEqual(testSuite.content.meta[TEST_IS_TEST_FRAMEWORK_WORKER], 'true') } - assert.strictEqual(testSuite.content.meta[TEST_COMMAND], 'vitest run') assert.strictEqual( testSuite.content.meta[TEST_SOURCE_FILE].startsWith('ci-visibility/vitest-tests/test-visibility'), true @@ -541,7 +544,7 @@ versions.forEach((version) => { Promise.all([eventsPromise, once(childProcess, 'exit')]).then(() => { if (version !== '1.6.0') { - assert.ok(childStdout.includes(CUSTOM_SEQUENCER_MARKER)) + assert.ok(childStdout.includes(CUSTOM_SEQUENCER_MARKER), `Got: ${inspect(childStdout)}`) } done() }).catch(done) diff --git a/integration-tests/vitest/vitest.test-management.spec.js b/integration-tests/vitest/vitest.test-management.spec.js index 8c6e4ec8b6..70da31d626 100644 --- a/integration-tests/vitest/vitest.test-management.spec.js +++ b/integration-tests/vitest/vitest.test-management.spec.js @@ -511,7 +511,7 @@ versions.forEach((version) => { const atfTests = tests.filter( t => t.meta[TEST_MANAGEMENT_IS_ATTEMPT_TO_FIX] === 'true' ) - assert.ok(atfTests.length > 0) + assert.ok(atfTests.length > 0, `Expected ${atfTests.length} > 0`) for (const test of atfTests) { assert.ok( !(TEST_IS_NEW in test.meta), diff --git a/integration-tests/webpack/basic-test.js b/integration-tests/webpack/basic-test.js index 1713727c03..d9e9805eeb 100644 --- a/integration-tests/webpack/basic-test.js +++ b/integration-tests/webpack/basic-test.js @@ -20,7 +20,7 @@ const server = app.listen(PORT, () => { app.get('/', async (_req, res) => { assert.equal( - tracer.scope().active().context()._tags.component, + tracer.scope().active().context().getTag('component'), 'express', `the sample app bundled by webpack is not properly instrumented. using node@${process.version}` ) // bad exit diff --git a/integration-tests/webpack/package.json b/integration-tests/webpack/package.json index cb4f567db7..8124028ae8 100644 --- a/integration-tests/webpack/package.json +++ b/integration-tests/webpack/package.json @@ -15,7 +15,7 @@ "author": "Thomas Hunter II ", "license": "ISC", "dependencies": { - "axios": "1.15.2", + "axios": "1.16.0", "express": "4.22.1", "knex": "3.1.0" } diff --git a/package.json b/package.json index 7294e8bf32..67e5319ecd 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "dd-trace", - "version": "5.104.0", + "version": "5.105.0", "description": "Datadog APM tracing client for JavaScript", "main": "index.js", "typings": "index.d.ts", "scripts": { "env": "bash ./plugin-env", - "prepare": "cd vendor && npm ci --include=dev", + "prepare": "node scripts/patch-istanbul-lib-coverage.js && cd vendor && npm ci --include=dev", "preinstall": "node scripts/preinstall.js", "prepack": "node scripts/release/swap-v5-types.js", "bench": "node benchmark/index.js", @@ -19,7 +19,7 @@ "type:check": "tsc --noEmit -p tsconfig.dev.json", "type:doc:build": "cd docs && yarn && yarn build", "type:doc:test": "cd docs && yarn && yarn test", - "lint": "node scripts/check_licenses.js && node scripts/check-no-coverage-artifacts.js && node scripts/check-no-mcr-images.js && node scripts/check-docker-image-shas.js && eslint . --concurrency=auto --max-warnings 0", + "lint": "node scripts/check_licenses.js && node scripts/check-no-coverage-artifacts.js && node scripts/check-no-mcr-images.js && node scripts/check-docker-image-shas.js && eslint . --concurrency=auto --max-warnings 0 && npm run lint:codeowners:ci && npm run verify-exercised-tests", "lint:fix": "node scripts/check_licenses.js && node scripts/check-no-coverage-artifacts.js && node scripts/check-no-mcr-images.js && node scripts/check-docker-image-shas.js && eslint . --concurrency=auto --max-warnings 0 --fix", "lint:inspect": "npx @eslint/config-inspector@latest", "lint:codeowners": "codeowners-audit", @@ -129,7 +129,7 @@ }, "homepage": "https://github.com/DataDog/dd-trace-js#readme", "engines": { - "node": ">=18 <26" + "node": ">=18 <27" }, "files": [ "/package.json", @@ -168,14 +168,14 @@ "optionalDependencies": { "@datadog/libdatadog": "0.9.3", "@datadog/native-appsec": "11.0.1", - "@datadog/native-iast-taint-tracking": "4.1.0", + "@datadog/native-iast-taint-tracking": "4.2.0", "@datadog/native-metrics": "3.1.2", - "@datadog/openfeature-node-server": "1.1.2", - "@datadog/pprof": "5.14.1", + "@datadog/openfeature-node-server": "1.2.1", + "@datadog/pprof": "5.14.4", "@datadog/wasm-js-rewriter": "5.0.1", "@opentelemetry/api": ">=1.0.0 <1.10.0", "@opentelemetry/api-logs": "<1.0.0", - "oxc-parser": "^0.129.0" + "oxc-parser": "^0.132.0" }, "devDependencies": { "@actions/core": "^3.0.1", @@ -190,16 +190,16 @@ "@types/mocha": "^10.0.10", "@types/node": "^18.19.106", "@types/sinon": "^21.0.1", - "axios": "^1.16.0", + "axios": "^1.16.1", "benchmark": "^2.1.4", "body-parser": "^2.2.2", - "bun": "1.3.13", + "bun": "1.3.14", "codeowners-audit": "^2.9.0", "eslint": "^9.39.2", "eslint-plugin-cypress": "^6.4.1", "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jsdoc": "^62.9.0", - "eslint-plugin-mocha": "^11.2.0", + "eslint-plugin-jsdoc": "^63.0.0", + "eslint-plugin-mocha": "^11.3.0", "eslint-plugin-n": "^18.0.1", "eslint-plugin-promise": "^7.3.0", "eslint-plugin-sonarjs": "^4.0.3", @@ -212,7 +212,7 @@ "istanbul-lib-report": "^3.0.0", "istanbul-reports": "^3.0.2", "jszip": "^3.10.1", - "mocha": "^11.6.0", + "mocha": "^11.7.6", "mocha-junit-reporter": "^2.2.1", "mocha-multi-reporters": "^1.5.1", "multer": "^2.1.1", @@ -224,12 +224,12 @@ "proxyquire": "^2.1.3", "retry": "^0.13.1", "semifies": "^1.0.0", - "semver": "^7.7.2", + "semver": "^7.8.1", "sinon": "^22.0.0", "tiktoken": "^1.0.21", "typescript": "^6.0.3", "workerpool": "^10.0.2", - "yaml": "^2.8.4", + "yaml": "^2.9.0", "yarn-deduplicate": "^6.0.2" } } diff --git a/packages/datadog-code-origin/test/index.spec.js b/packages/datadog-code-origin/test/index.spec.js index 33033953ed..f76ea90391 100644 --- a/packages/datadog-code-origin/test/index.spec.js +++ b/packages/datadog-code-origin/test/index.spec.js @@ -23,9 +23,15 @@ describe('code origin', () => { assert.strictEqual(tags['_dd.code_origin.type'], 'entry') assert.strictEqual(tags['_dd.code_origin.frames.0.file'], testedFile) assert.strictEqual(typeof tags['_dd.code_origin.frames.0.line'], 'string') - assert(Number(tags['_dd.code_origin.frames.0.line']) > 0) + assert( + Number(tags['_dd.code_origin.frames.0.line']) > 0, + `Expected ${Number(tags['_dd.code_origin.frames.0.line'])} > 0` + ) assert.strictEqual(typeof tags['_dd.code_origin.frames.0.column'], 'string') - assert(Number(tags['_dd.code_origin.frames.0.column']) > 0) + assert( + Number(tags['_dd.code_origin.frames.0.column']) > 0, + `Expected ${Number(tags['_dd.code_origin.frames.0.column'])} > 0` + ) assert.strictEqual(tags['_dd.code_origin.frames.0.method'], 'tag') assert.strictEqual('_dd.code_origin.frames.0.type' in tags, false) }) @@ -68,14 +74,16 @@ describe('code origin', () => { const { file, line, column, method, type } = frames[i] assert.strictEqual(tags[`_dd.code_origin.frames.${i}.file`], file) if (line === undefined) { - assert.strictEqual(typeof tags[`_dd.code_origin.frames.${i}.line`], 'string') - assert(Number(tags[`_dd.code_origin.frames.${i}.line`]) > 0) + const lineTag = tags[`_dd.code_origin.frames.${i}.line`] + assert.strictEqual(typeof lineTag, 'string') + assert(Number(lineTag) > 0, `Expected ${lineTag} to parse to a positive number`) } else { assert.strictEqual(tags[`_dd.code_origin.frames.${i}.line`], String(line)) } if (column === undefined) { - assert.strictEqual(typeof tags[`_dd.code_origin.frames.${i}.column`], 'string') - assert(Number(tags[`_dd.code_origin.frames.${i}.column`]) > 0) + const columnTag = tags[`_dd.code_origin.frames.${i}.column`] + assert.strictEqual(typeof columnTag, 'string') + assert(Number(columnTag) > 0, `Expected ${columnTag} to parse to a positive number`) } else { assert.strictEqual(tags[`_dd.code_origin.frames.${i}.column`], String(column)) } diff --git a/packages/datadog-core/src/storage.js b/packages/datadog-core/src/storage.js index 2258b7c849..4c6d39dd67 100644 --- a/packages/datadog-core/src/storage.js +++ b/packages/datadog-core/src/storage.js @@ -19,7 +19,7 @@ class DatadogStorage extends AsyncLocalStorage { * @override */ enterWith (store) { - const handle = {} + const handle = { noop: store?.noop } stores.set(handle, store) super.enterWith(handle) } diff --git a/packages/datadog-instrumentations/src/aerospike.js b/packages/datadog-instrumentations/src/aerospike.js index 4b2be37861..cb8f346b08 100644 --- a/packages/datadog-instrumentations/src/aerospike.js +++ b/packages/datadog-instrumentations/src/aerospike.js @@ -41,7 +41,7 @@ function wrapProcess (process) { addHook({ name: 'aerospike', file: 'lib/commands/command.js', - versions: ['4', '5', '6'], + versions: ['>=4'], }, commandFactory => { return shimmer.wrapFunction(commandFactory, f => wrapCreateCommand(f)) diff --git a/packages/datadog-instrumentations/src/ai.js b/packages/datadog-instrumentations/src/ai.js index 3cdbd8c4ef..fcbbf6b0be 100644 --- a/packages/datadog-instrumentations/src/ai.js +++ b/packages/datadog-instrumentations/src/ai.js @@ -13,12 +13,12 @@ const tracers = new WeakSet() const wrappedModels = new WeakSet() /** - * Publishes already-converted AI guard style messages to the AIGuard channel. + * Publishes already-converted AI-style messages to the AI Guard evaluation channel. * - * @param {Array} messages - AI guard style messages to evaluate + * @param {Array} messages - AI-style messages to evaluate. * @returns {Promise} */ -function publishToAIGuard (messages) { +function publishEvaluation (messages) { return new Promise((resolve, reject) => { aiguardChannel.publish({ messages, integration: 'ai', resolve, reject }) }) @@ -47,10 +47,11 @@ function wrapModelWithAIGuard (model) { // Run AI Guard input evaluation and LLM call in parallel. // The LLM has no side effects so it is safe to discard its result if AI Guard blocks. - return Promise.all([publishToAIGuard(inputMessages), originalResult]) + return Promise.all([publishEvaluation(inputMessages), originalResult]) .then(([, result]) => { if (!result.content?.length) return result - return publishToAIGuard(buildOutputMessages(inputMessages, result.content)) + const outputMessages = buildOutputMessages(inputMessages, result.content) + return publishEvaluation(outputMessages) .then(() => result) }) } @@ -70,7 +71,7 @@ function wrapModelWithAIGuard (model) { // Run AI Guard input evaluation and LLM call in parallel. // The LLM has no side effects so it is safe to discard its result if AI Guard blocks. - return Promise.all([publishToAIGuard(inputMessages), originalResult]) + return Promise.all([publishEvaluation(inputMessages), originalResult]) .then(([, result]) => { const chunks = [] const reader = result.stream.getReader() @@ -89,7 +90,7 @@ function wrapModelWithAIGuard (model) { const content = toolCalls.length ? toolCalls : text ? [{ type: 'text', text }] : [] const evaluate = content.length - ? publishToAIGuard(buildOutputMessages(inputMessages, content)) + ? publishEvaluation(buildOutputMessages(inputMessages, content)) : Promise.resolve() return evaluate.then(() => { diff --git a/packages/datadog-instrumentations/src/aws-sdk.js b/packages/datadog-instrumentations/src/aws-sdk.js index 0669fdba0f..1686f450fc 100644 --- a/packages/datadog-instrumentations/src/aws-sdk.js +++ b/packages/datadog-instrumentations/src/aws-sdk.js @@ -324,6 +324,19 @@ addHook({ name: '@aws-sdk/smithy-client', versions: ['>=3'] }, smithy => { return smithy }) +// `@aws-sdk/client-*` >= 3.1046.0 dropped `@smithy/smithy-client` and now +// extends from `@smithy/core/client` directly. The `Client.send` contract is +// unchanged, but the host module moved -- patch the new home so the v3 hooks +// keep firing. +addHook({ + name: '@smithy/core', + file: 'dist-cjs/submodules/client/index.js', + versions: ['>=3.24.0'], +}, smithyCoreClient => { + shimmer.wrap(smithyCoreClient.Client.prototype, 'send', wrapSmithySend) + return smithyCoreClient +}) + addHook({ name: 'aws-sdk', versions: ['>=2.3.0'] }, AWS => { shimmer.wrap(AWS.config, 'setPromisesDependency', setPromisesDependency => { return function wrappedSetPromisesDependency (dep) { diff --git a/packages/datadog-instrumentations/src/azure-cosmos.js b/packages/datadog-instrumentations/src/azure-cosmos.js new file mode 100644 index 0000000000..291b97e117 --- /dev/null +++ b/packages/datadog-instrumentations/src/azure-cosmos.js @@ -0,0 +1,7 @@ +'use strict' + +const { addHook, getHooks } = require('./helpers/instrument') + +for (const hook of getHooks('@azure/cosmos')) { + addHook(hook, exports => exports) +} diff --git a/packages/datadog-instrumentations/src/azure-functions.js b/packages/datadog-instrumentations/src/azure-functions.js index dc0c7c70ef..1911ced09b 100644 --- a/packages/datadog-instrumentations/src/azure-functions.js +++ b/packages/datadog-instrumentations/src/azure-functions.js @@ -26,6 +26,9 @@ addHook({ name: '@azure/functions', versions: ['>=4'], patchDefault: false }, (a // Event Hub triggers shimmer.wrap(app, 'eventHub', wrapHandler) + // CosmosDB triggers + shimmer.wrap(app, 'cosmosDB', wrapHandler) + return azureFunction }) diff --git a/packages/datadog-instrumentations/src/cucumber.js b/packages/datadog-instrumentations/src/cucumber.js index 8890dcc6b6..3c183c1e8d 100644 --- a/packages/datadog-instrumentations/src/cucumber.js +++ b/packages/datadog-instrumentations/src/cucumber.js @@ -7,21 +7,26 @@ const shimmer = require('../../datadog-shimmer') const log = require('../../dd-trace/src/log') const { getEnvironmentVariable } = require('../../dd-trace/src/config/helper') const { - getCoveredFilenamesFromCoverage, + getCoveredFilesFromCoverage, + getExecutableFilesFromCoverage, resetCoverage, mergeCoverage, fromCoverageMapToCoverage, getTestSuitePath, + getRelativeCoverageFiles, CUCUMBER_WORKER_TRACE_PAYLOAD_CODE, getIsFaultyEarlyFlakeDetection, getEfdRetryCount, getMaxEfdRetryCount, + applySkippedCoverageToCoverage, + getTestCoverageLinesPercentage, recordAttemptToFixExecution, collectAttemptToFixExecutionsFromTraces, logAttemptToFixTestExecution, logTestOptimizationSummary, getTestOptimizationRequestResults, } = require('../../dd-trace/src/plugins/util/test') +const { writeCoverageBackfillToCache } = require('../../dd-trace/src/ci-visibility/test-optimization-cache') const satisfies = require('../../../vendor/dist/semifies') const { addHook, channel } = require('./helpers/instrument') @@ -86,10 +91,14 @@ let pickleByFile = {} const pickleResultByFile = {} let skippableSuites = [] +let skippableSuitesCoverage = {} +let skippedSuitesCoverage = {} let itrCorrelationId = '' let isForcedToRun = false let isUnskippable = false +let isItrEnabled = false let isSuitesSkippingEnabled = false +let isCoverageReportUploadEnabled = false let isEarlyFlakeDetectionEnabled = false let earlyFlakeDetectionNumRetries = 0 let earlyFlakeDetectionSlowTestRetries = {} @@ -106,11 +115,55 @@ let numTestRetries = 0 let knownTests = {} let skippedSuites = [] let isSuitesSkipped = false +let repositoryRoot function isValidKnownTests (receivedKnownTests) { return !!receivedKnownTests.cucumber } +function hasSkippableSuitesCoverage () { + return skippableSuitesCoverage && + typeof skippableSuitesCoverage === 'object' && + Object.keys(skippableSuitesCoverage).length > 0 +} + +function isTiaCoverageBackfillEnabled () { + return isItrEnabled && isCoverageReportUploadEnabled +} + +function getCoverageRootDir () { + return repositoryRoot || process.cwd() +} + +function shouldReportCodeCoverageLinesPct (hasBackfilledCoverage) { + return !isSuitesSkipped || hasBackfilledCoverage +} + +function getSkippedSuitesCoverageForRun () { + return isSuitesSkipped && isTiaCoverageBackfillEnabled() && hasSkippableSuitesCoverage() + ? skippableSuitesCoverage + : {} +} + +function applySkippedCoverageToCucumberCoverageMap () { + if (!isTiaCoverageBackfillEnabled()) return false + return applySkippedCoverageToCoverage(originalCoverageMap, skippedSuitesCoverage, getCoverageRootDir()) +} + +function getCucumberTestSessionCoverageFiles () { + return getRelativeCoverageFiles(getExecutableFilesFromCoverage(originalCoverageMap), getCoverageRootDir()) +} + +function resetSuiteSkippingRunState () { + skippableSuites = [] + skippableSuitesCoverage = {} + skippedSuitesCoverage = {} + skippedSuites = [] + isSuitesSkipped = false + repositoryRoot = undefined + writeCoverageBackfillToCache({}) +} + function getSuiteStatusFromTestStatuses (testStatuses) { if (testStatuses.includes('fail')) { return 'fail' @@ -683,6 +736,7 @@ function getWrappedStart (start, frameworkVersion, isParallel = false, isCoordin if (!libraryConfigurationCh.hasSubscribers) { return start.apply(this, arguments) } + resetSuiteSkippingRunState() const options = getCucumberOptions(this) if (!isParallel && this.adapter?.options) { @@ -692,11 +746,14 @@ function getWrappedStart (start, frameworkVersion, isParallel = false, isCoordin const configurationResponse = await getChannelPromise(libraryConfigurationCh, frameworkVersion) + repositoryRoot = configurationResponse.repositoryRoot + isItrEnabled = configurationResponse.libraryConfig?.isItrEnabled isEarlyFlakeDetectionEnabled = configurationResponse.libraryConfig?.isEarlyFlakeDetectionEnabled earlyFlakeDetectionNumRetries = configurationResponse.libraryConfig?.earlyFlakeDetectionNumRetries earlyFlakeDetectionSlowTestRetries = configurationResponse.libraryConfig?.earlyFlakeDetectionSlowTestRetries ?? {} earlyFlakeDetectionFaultyThreshold = configurationResponse.libraryConfig?.earlyFlakeDetectionFaultyThreshold - isSuitesSkippingEnabled = configurationResponse.libraryConfig?.isSuitesSkippingEnabled + isSuitesSkippingEnabled = isItrEnabled && configurationResponse.libraryConfig?.isSuitesSkippingEnabled + isCoverageReportUploadEnabled = configurationResponse.libraryConfig?.isCoverageReportUploadEnabled isFlakyTestRetriesEnabled = configurationResponse.libraryConfig?.isFlakyTestRetriesEnabled const configRetryCount = configurationResponse.libraryConfig?.flakyTestRetriesCount numTestRetries = (typeof configRetryCount === 'number' && configRetryCount > 0) ? configRetryCount : 0 @@ -733,6 +790,7 @@ function getWrappedStart (start, frameworkVersion, isParallel = false, isCoordin errorSkippableRequest = skippableResponse.err skippableSuites = skippableResponse.skippableSuites ?? [] + skippableSuitesCoverage = skippableResponse.skippableSuitesCoverage ?? {} if (!errorSkippableRequest) { const filteredPickles = isCoordinator @@ -753,6 +811,8 @@ function getWrappedStart (start, frameworkVersion, isParallel = false, isCoordin } skippedSuites = [...filteredPickles.skippedSuites] + skippedSuitesCoverage = getSkippedSuitesCoverageForRun() + writeCoverageBackfillToCache(skippedSuitesCoverage, getCoverageRootDir()) itrCorrelationId = skippableResponse.itrCorrelationId } } @@ -816,13 +876,25 @@ function getWrappedStart (start, frameworkVersion, isParallel = false, isCoordin } let testCodeCoverageLinesTotal + let testSessionCoverageFiles - if (global.__coverage__) { + if (global.__coverage__ || untestedCoverage) { try { + let hasBackfilledCoverage = false if (untestedCoverage) { originalCoverageMap.merge(fromCoverageMapToCoverage(untestedCoverage)) } - testCodeCoverageLinesTotal = originalCoverageMap.getCoverageSummary().lines.pct + hasBackfilledCoverage = applySkippedCoverageToCucumberCoverageMap() + if (shouldReportCodeCoverageLinesPct(hasBackfilledCoverage)) { + testCodeCoverageLinesTotal = getTestCoverageLinesPercentage( + originalCoverageMap, + undefined, + getCoverageRootDir() + ) + } + if (isTiaCoverageBackfillEnabled()) { + testSessionCoverageFiles = getCucumberTestSessionCoverageFiles() + } } catch { // ignore errors } @@ -834,6 +906,7 @@ function getWrappedStart (start, frameworkVersion, isParallel = false, isCoordin status: success ? 'pass' : 'fail', isSuitesSkipped, testCodeCoverageLinesTotal, + testSessionCoverageFiles, numSkippedSuites: skippedSuites.length, hasUnskippableSuites: isUnskippable, hasForcedToRunSuites: isForcedToRun, @@ -1008,7 +1081,7 @@ function getWrappedRunTestCase (runTestCaseFunction, isNewerCucumberVersion = fa // last test in suite const testSuiteStatus = getSuiteStatusFromTestStatuses(pickleResultByFile[testFileAbsolutePath]) if (global.__coverage__) { - const coverageFiles = getCoveredFilenamesFromCoverage(global.__coverage__) + const coverageFiles = getCoveredFilesFromCoverage(global.__coverage__) testSuiteCodeCoverageCh.publish({ coverageFiles, diff --git a/packages/datadog-instrumentations/src/dns.js b/packages/datadog-instrumentations/src/dns.js index 05ca9b60cb..d27f173992 100644 --- a/packages/datadog-instrumentations/src/dns.js +++ b/packages/datadog-instrumentations/src/dns.js @@ -3,6 +3,7 @@ const shimmer = require('../../datadog-shimmer') const { addHook } = require('./helpers/instrument') const { createCallbackInstrumentor } = require('./helpers/callback-instrumentor') +const { createPromiseInstrumentor } = require('./helpers/promise-instrumentor') const rrtypes = { resolveAny: 'ANY', @@ -18,30 +19,55 @@ const rrtypes = { resolveSoa: 'SOA', } -addHook({ name: 'dns' }, dns => { - const lookup = createCallbackInstrumentor('apm:dns:lookup', { captureResult: true }) - const lookupService = createCallbackInstrumentor('apm:dns:lookup_service', { captureResult: true }) - const resolve = createCallbackInstrumentor('apm:dns:resolve', { captureResult: true }) - const reverse = createCallbackInstrumentor('apm:dns:reverse', { captureResult: true }) - - shimmer.wrap(dns, 'lookup', lookup(buildArgsContext())) - shimmer.wrap(dns, 'lookupService', lookupService(buildArgsContext())) - shimmer.wrap(dns, 'resolve', resolve(buildArgsContext())) - shimmer.wrap(dns, 'reverse', reverse(buildArgsContext())) - - patchResolveShorthands(dns, resolve) +// `dns.promises` and `require('dns/promises')` resolve to the same exports object. Both +// access paths register a hook, so without a guard the second hook to fire would stack a +// second wrap layer on top and publish every `apm:dns:*` event twice per call. The WeakSet +// collapses the two hooks to one wrap regardless of which one runs first. +const wrappedPromiseApis = new WeakSet() - if (dns.Resolver) { - shimmer.wrap(dns.Resolver.prototype, 'resolve', resolve(buildArgsContext())) - shimmer.wrap(dns.Resolver.prototype, 'reverse', reverse(buildArgsContext())) +addHook({ name: 'dns' }, dns => { + patchApi(dns, createCallbackInstrumentor, buildCallbackArgsContext) - patchResolveShorthands(dns.Resolver.prototype, resolve) + if (dns.promises) { + patchPromiseApi(dns.promises) } return dns }) -function patchResolveShorthands (prototype, resolve) { +addHook({ name: 'dns/promises' }, dnsPromises => { + patchPromiseApi(dnsPromises) + return dnsPromises +}) + +function patchPromiseApi (api) { + if (wrappedPromiseApis.has(api)) return + wrappedPromiseApis.add(api) + patchApi(api, createPromiseInstrumentor, buildPromiseArgsContext) +} + +function patchApi (api, instrumentorFactory, buildArgsContext) { + const lookup = instrumentorFactory('apm:dns:lookup', { captureResult: true }) + const lookupService = instrumentorFactory('apm:dns:lookup_service', { captureResult: true }) + const resolve = instrumentorFactory('apm:dns:resolve', { captureResult: true }) + const reverse = instrumentorFactory('apm:dns:reverse', { captureResult: true }) + + shimmer.wrap(api, 'lookup', lookup(buildArgsContext())) + shimmer.wrap(api, 'lookupService', lookupService(buildArgsContext())) + shimmer.wrap(api, 'resolve', resolve(buildArgsContext())) + shimmer.wrap(api, 'reverse', reverse(buildArgsContext())) + + patchResolveShorthands(api, resolve, buildArgsContext) + + if (api.Resolver) { + shimmer.wrap(api.Resolver.prototype, 'resolve', resolve(buildArgsContext())) + shimmer.wrap(api.Resolver.prototype, 'reverse', reverse(buildArgsContext())) + + patchResolveShorthands(api.Resolver.prototype, resolve, buildArgsContext) + } +} + +function patchResolveShorthands (prototype, resolve, buildArgsContext) { for (const method of Object.keys(rrtypes)) { if (prototype[method]) { shimmer.wrap(prototype, method, resolve(buildArgsContext(rrtypes[method]))) @@ -49,7 +75,7 @@ function patchResolveShorthands (prototype, resolve) { } } -function buildArgsContext (rrtype) { +function buildCallbackArgsContext (rrtype) { return function (_, args) { if (args.length < 2) return const captured = [...args] @@ -60,3 +86,13 @@ function buildArgsContext (rrtype) { return { args: captured } } } + +function buildPromiseArgsContext (rrtype) { + return function (_, args) { + const captured = [...args] + if (rrtype) { + captured.push(rrtype) + } + return { args: captured } + } +} diff --git a/packages/datadog-instrumentations/src/fastify.js b/packages/datadog-instrumentations/src/fastify.js index a50b0493d0..8f3dc268e6 100644 --- a/packages/datadog-instrumentations/src/fastify.js +++ b/packages/datadog-instrumentations/src/fastify.js @@ -54,65 +54,119 @@ function wrapAddHook (addHook) { if (typeof fn !== 'function') return addHook.apply(this, arguments) - arguments[arguments.length - 1] = shimmer.wrapFunction(fn, fn => function (request, reply, done) { - const req = getReq(request) - const ctx = { req } - - try { - // done callback is always the last argument - const doneCallback = arguments[arguments.length - 1] - - if (typeof doneCallback === 'function') { - arguments[arguments.length - 1] = function (err) { - ctx.error = err - publishError(ctx) - - const hasCookies = request.cookies && Object.keys(request.cookies).length > 0 - - if (cookieParserReadCh.hasSubscribers && hasCookies && !cookiesPublished.has(req)) { - ctx.res = getRes(reply) - ctx.abortController = new AbortController() - ctx.cookies = request.cookies - - cookieParserReadCh.publish(ctx) - cookiesPublished.add(req) - - if (ctx.abortController.signal.aborted) return - } - - if (name === 'onRequest' || name === 'preParsing') { - parsingContexts.set(req, ctx) - - return callbackFinishCh.runStores(ctx, () => { - return doneCallback.apply(this, arguments) - }) - } - return doneCallback.apply(this, arguments) - } - - return fn.apply(this, arguments) - } - - const promise = fn.apply(this, arguments) - - if (promise && typeof promise.catch === 'function') { - return promise.catch(err => { - ctx.error = err - return publishError(ctx) - }) - } - - return promise - } catch (e) { - ctx.error = e - throw publishError(ctx) + arguments[arguments.length - 1] = shimmer.wrapFunction(fn, fn => function wrappedHook () { + // Fast path: every fastify request invokes each addHook'd handler, so the wrap + // runs in the user's hot path. The only side effects this wrapper carries are + // the three channels below; when none of them have a subscriber (the default + // plugin config, and the steady state once appsec / cookie subscribers detach), + // the wrap has nothing to do, and a `fn.apply(this, arguments)` forward keeps + // V8's CallApplyArguments fast path intact. + // + // The previous shape mutated `arguments[arguments.length - 1]` to swap `done`. + // That mutation materialises the magical arguments object and disables V8 + // inlining of the enclosing function. The slow path below builds a fresh args + // array instead so the hot fast path keeps a clean forward. + if (errorChannel.hasSubscribers || cookieParserReadCh.hasSubscribers || callbackFinishCh.hasSubscribers) { + return invokeHookWithContext(name, fn, this, arguments) } + return fn.apply(this, arguments) }) return addHook.apply(this, arguments) }) } +/** + * Slow path of {@link wrapAddHook}; entered only when at least one wrap-fed + * channel has a subscriber. Allocates the per-request context, rewraps `done`, + * and forwards to the user-supplied hook. + * + * @param {string} name Lifecycle phase the hook was registered against. + * @param {Function} fn User-supplied hook. + * @param {unknown} thisArg `this` Fastify passes to the hook. + * @param {ArrayLike} args Fastify's positional args; the dispatcher always + * places `done` as the trailing positional (see fastify/lib/hooks.js hookIterator, + * onSendHookRunner, preParsingHookRunner, onRequestAbortHookRunner). + */ +function invokeHookWithContext (name, fn, thisArg, args) { + const request = args[0] + const reply = args[1] + const req = getReq(request) + const ctx = { req } + + try { + const lastArg = args[args.length - 1] + + if (typeof lastArg === 'function') { + // Copy the args so we can swap the trailing `done` without touching the + // caller's magical arguments object. Fastify hook arities are 2 to 4 + // across lifecycle phases, but `done` is always last. + const callArgs = [...args] + callArgs[callArgs.length - 1] = wrapHookDone(ctx, request, reply, req, name, lastArg) + return fn.apply(thisArg, callArgs) + } + + const promise = fn.apply(thisArg, args) + + if (promise && typeof promise.catch === 'function') { + return promise.catch(error => { + ctx.error = error + return publishError(ctx) + }) + } + + return promise + } catch (error) { + ctx.error = error + throw publishError(ctx) + } +} + +/** + * Per-request closure invoked when fastify resolves the user hook's `done`. + * Captures `ctx` plus the dispatcher-level fields needed to publish on the + * cookie / callback channels. The closure cannot be hoisted: fastify invokes + * `done` with a single `(err)` arg, so request / reply / req / name / doneCallback + * must close over rather than ride the call signature. + * + * @param {{ req: unknown, [key: string]: unknown }} ctx + * @param {{ cookies?: Record, [key: string]: unknown }} request + * @param {object} reply + * @param {unknown} req + * @param {string} name + * @param {Function} doneCallback + */ +function wrapHookDone (ctx, request, reply, req, name, doneCallback) { + return function wrappedDone (error) { + ctx.error = error + publishError(ctx) + + const hasCookies = request.cookies && Object.keys(request.cookies).length > 0 + + if (cookieParserReadCh.hasSubscribers && hasCookies && !cookiesPublished.has(req)) { + ctx.res = getRes(reply) + ctx.abortController = new AbortController() + ctx.cookies = request.cookies + + cookieParserReadCh.publish(ctx) + cookiesPublished.add(req) + + if (ctx.abortController.signal.aborted) return + } + + if (name === 'onRequest' || name === 'preParsing') { + parsingContexts.set(req, ctx) + + if (callbackFinishCh.hasSubscribers) { + const self = this + const allArgs = arguments + return callbackFinishCh.runStores(ctx, () => doneCallback.apply(self, allArgs)) + } + } + return doneCallback.apply(this, arguments) + } +} + function onRequest (request, reply, done) { if (typeof done !== 'function') return @@ -157,45 +211,51 @@ function preValidation (request, reply, done) { const ctx = parsingContexts.get(req) ctx.res = res - const processInContext = () => { - let abortController + if (!ctx) return processInContext(request, ctx, done, req) - if (queryParamsReadCh.hasSubscribers && request.query) { - abortController ??= new AbortController() - ctx.abortController = abortController - ctx.query = request.query - queryParamsReadCh.publish(ctx) - - if (abortController.signal.aborted) return - } + preValidationCh.runStores(ctx, processInContext, undefined, request, ctx, done, req) +} - // Analyze body before schema validation - if (bodyParserReadCh.hasSubscribers && request.body && !bodyPublished.has(req)) { - abortController ??= new AbortController() - ctx.abortController = abortController - ctx.body = request.body - bodyParserReadCh.publish(ctx) +/** + * @param {{ query?: object, body?: object, params?: object, [key: string]: unknown }} request + * @param {{ res?: object, abortController?: AbortController, [key: string]: unknown }} ctx + * @param {Function} done + * @param {unknown} req + */ +function processInContext (request, ctx, done, req) { + let abortController + + if (queryParamsReadCh.hasSubscribers && request.query) { + abortController ??= new AbortController() + ctx.abortController = abortController + ctx.query = request.query + queryParamsReadCh.publish(ctx) + + if (abortController.signal.aborted) return + } - bodyPublished.add(req) + // Analyze body before schema validation + if (bodyParserReadCh.hasSubscribers && request.body && !bodyPublished.has(req)) { + abortController ??= new AbortController() + ctx.abortController = abortController + ctx.body = request.body + bodyParserReadCh.publish(ctx) - if (abortController.signal.aborted) return - } + bodyPublished.add(req) - if (pathParamsReadCh.hasSubscribers && request.params) { - abortController ??= new AbortController() - ctx.abortController = abortController - ctx.params = request.params - pathParamsReadCh.publish(ctx) + if (abortController.signal.aborted) return + } - if (abortController.signal.aborted) return - } + if (pathParamsReadCh.hasSubscribers && request.params) { + abortController ??= new AbortController() + ctx.abortController = abortController + ctx.params = request.params + pathParamsReadCh.publish(ctx) - done() + if (abortController.signal.aborted) return } - if (!ctx) return processInContext() - - preValidationCh.runStores(ctx, processInContext) + done() } function preParsing (request, reply, payload, done) { diff --git a/packages/datadog-instrumentations/src/graphql.js b/packages/datadog-instrumentations/src/graphql.js index d3b593a39e..ae5aab3285 100644 --- a/packages/datadog-instrumentations/src/graphql.js +++ b/packages/datadog-instrumentations/src/graphql.js @@ -1,5 +1,7 @@ 'use strict' +const { AsyncLocalStorage } = require('node:async_hooks') + const shimmer = require('../../datadog-shimmer') const { addHook, @@ -10,7 +12,13 @@ const ddGlobal = globalThis[Symbol.for('dd-trace')] /** cached objects */ +// `contexts` is the fast resolver-side lookup; `executeCtx` is the fallback +// when `contextValue` is a primitive and cannot key a WeakMap. const contexts = new WeakMap() +const executeCtx = new AsyncLocalStorage() +// Tracks normalized args already instrumented in an outer wrap so graphql-yoga +// (which stacks `execute` + `normalizedExecutor`) only emits one span per call. +const instrumentedArgs = new WeakSet() const documentSources = new WeakMap() const patchedResolvers = new WeakSet() const patchedTypes = new WeakSet() @@ -62,14 +70,17 @@ function getOperation (document, operationName) { function normalizeArgs (args, defaultFieldResolver) { if (args.length !== 1) return normalizePositional(args, defaultFieldResolver) - args[0].contextValue ||= {} - args[0].fieldResolver = wrapResolve(args[0].fieldResolver || defaultFieldResolver) + const original = args[0] + const normalized = { + ...original, + fieldResolver: wrapResolve(original.fieldResolver || defaultFieldResolver), + } - return args[0] + args[0] = normalized + return normalized } function normalizePositional (args, defaultFieldResolver) { - args[3] = args[3] || {} // contextValue args[6] = wrapResolve(args[6] || defaultFieldResolver) // fieldResolver args.length = Math.max(args.length, 7) @@ -84,6 +95,12 @@ function normalizePositional (args, defaultFieldResolver) { } } +// `WeakMap.set` throws `TypeError` on a non-object key; `get`/`has`/`delete` +// silently miss. Skip the WeakMap entirely for non-keyable `contextValue`. +function isWeakMapKey (value) { + return value !== null && typeof value === 'object' +} + function wrapParse (parse) { return function (source) { if (!parseStartCh.hasSubscribers) { @@ -155,14 +172,21 @@ function wrapExecute (execute) { return exe.apply(this, arguments) } + // The outer wrap leaves its normalized args object in `arguments[0]`; on + // graphql-yoga's inner wrap that reference is already known here. + if (instrumentedArgs.has(arguments[0])) { + return exe.apply(this, arguments) + } + const args = normalizeArgs(arguments, defaultFieldResolver) const schema = args.schema const document = args.document const source = documentSources.get(document) const contextValue = args.contextValue + const keyable = isWeakMapKey(contextValue) const operation = getOperation(document, args.operationName) - if (contexts.has(contextValue)) { + if (keyable && contexts.has(contextValue)) { return exe.apply(this, arguments) } @@ -171,19 +195,23 @@ function wrapExecute (execute) { args, docSource: source, source, - fields: Object.create(null), + fields: new Map(), abortController: new AbortController(), } + // Only the object form leaves a stable single-object handle in + // `arguments[0]` for the inner wrap to see. + if (args === arguments[0]) instrumentedArgs.add(args) + return startExecuteCh.runStores(ctx, () => { if (schema) { wrapFields(schema._queryType) wrapFields(schema._mutationType) } - contexts.set(contextValue, ctx) + if (keyable) contexts.set(contextValue, ctx) - return callInAsyncScope(exe, this, arguments, ctx.abortController, (err, res) => { + const finish = (err, res) => { if (finishResolveCh.hasSubscribers) finishResolvers(ctx) const error = err || (res && res.errors && res.errors[0]) @@ -194,8 +222,16 @@ function wrapExecute (execute) { } ctx.res = res + if (keyable) contexts.delete(contextValue) + instrumentedArgs.delete(args) finishExecuteCh.publish(ctx) - }) + } + + // Skip the ALS entry on the common object-`contextValue` path; the + // resolver reaches `ctx` via the WeakMap there. + return keyable + ? callInAsyncScope(exe, this, arguments, ctx.abortController, finish) + : executeCtx.run(ctx, () => callInAsyncScope(exe, this, arguments, ctx.abortController, finish)) }) } } @@ -207,18 +243,40 @@ function wrapResolve (resolve) { function resolveAsync (source, args, contextValue, info) { if (!startResolveCh.hasSubscribers) return resolve.apply(this, arguments) - const ctx = contexts.get(contextValue) + // `WeakMap.get(primitive)` returns `undefined`, so the fallback covers + // executes that ran with a primitive `contextValue`. + const ctx = contexts.get(contextValue) ?? executeCtx.getStore() + /* istanbul ignore if: resolver invoked outside execute(), so no per-execute ctx was registered */ if (!ctx) return resolve.apply(this, arguments) const field = assertField(ctx, info, args) - return callInAsyncScope(resolve, this, arguments, ctx.abortController, (err) => { - field.ctx.error = err - field.ctx.info = info - field.ctx.field = field - updateFieldCh.publish(field.ctx) - }) + if (ctx.abortController.signal.aborted) { + publishResolverFinish(field, null) + throw new AbortError('Aborted') + } + + try { + const result = resolve.call(this, source, args, contextValue, info) + if (result !== null && typeof result?.then === 'function') { + return result.then( + res => { + publishResolverFinish(field, null) + return res + }, + error => { + publishResolverFinish(field, error) + throw error + } + ) + } + publishResolverFinish(field, null) + return result + } catch (error) { + publishResolverFinish(field, error) + throw error + } } patchedResolvers.add(resolveAsync) @@ -226,72 +284,130 @@ function wrapResolve (resolve) { return resolveAsync } -function callInAsyncScope (fn, thisArg, args, abortController, cb) { - cb = cb || (() => {}) +/** + * @param {{ ctx: object, error: unknown }} field + * @param {unknown} error + */ +function publishResolverFinish (field, error) { + const fieldCtx = field.ctx + fieldCtx.error = error + fieldCtx.field = field + updateFieldCh.publish(fieldCtx) +} - if (abortController?.signal.aborted) { +function callInAsyncScope (fn, thisArg, args, abortController, cb) { + if (abortController.signal.aborted) { cb(null, null) throw new AbortError('Aborted') } try { const result = fn.apply(thisArg, args) - if (result && typeof result.then === 'function') { + if (result !== null && typeof result?.then === 'function') { return result.then( res => { cb(null, res) return res }, - err => { - cb(err) - throw err + /* istanbul ignore next: graphql.execute() rejects only via custom executors (graphql-yoga / graphql-tools) */ + error => { + cb(error) + throw error } ) } cb(null, result) return result - } catch (err) { - cb(err) - throw err - } -} - -function pathToArray (path) { - let length = 0 - for (let curr = path; curr; curr = curr.prev) { - length += 1 - } - - const flattened = new Array(length) - let index = length - for (let curr = path; curr; curr = curr.prev) { - flattened[--index] = curr.key + } catch (error) { + cb(error) + throw error } - return flattened } +/** + * @typedef {{ prev: PathNode | undefined, key: string | number }} PathNode + * + * @typedef {{ error: unknown, ctx: object }} TrackedField + */ + +/** + * @param {{ + * fields: Map, + * collapse: boolean, + * collapsedFields?: Map, + * pathCache?: Map, + * }} rootCtx + * @param {import('graphql').GraphQLResolveInfo} info + * @param {Record} args + */ function assertField (rootCtx, info, args) { - const pathInfo = info && info.path - - const path = pathToArray(pathInfo) - - const pathString = path.join('.') - const fields = rootCtx.fields - - let field = fields[pathString] - - if (!field) { - const fieldCtx = { info, rootCtx, args, path, pathString } - startResolveCh.publish(fieldCtx) - field = fields[pathString] = { - error: null, - ctx: fieldCtx, - } + const path = info.path + const collapse = rootCtx.collapse + + const cache = rootCtx.pathCache ??= new Map() + const prev = path.prev + const key = path.key + const segment = collapse && typeof key !== 'string' ? '*' : key + + const pathString = prev === undefined + ? String(segment) + : (cache.get(prev) ?? buildCachedPathString(prev, cache, collapse)) + '.' + segment + cache.set(path, pathString) + + const fieldCtx = { + rootCtx, + args, + path, + pathString, + fieldName: info.fieldName, + returnType: info.returnType, + fieldNode: info.fieldNodes[0], + variableValues: info.variableValues, + } + // Publish per resolver call, before the collapse / depth dedupe below. + // IAST mutates each call's own args object; if siblings 2..N skip the + // publish, those args objects never get tainted. + startResolveCh.publish(fieldCtx) + + let collapsedFields + if (collapse) { + collapsedFields = rootCtx.collapsedFields ??= new Map() + const existing = collapsedFields.get(pathString) + // Subsequent siblings of a collapsed list share the first sibling's field + // so updateFieldCh fires for every call and the span's finishTime tracks + // the last sibling's completion, not the first. + if (existing !== undefined) return existing } + const field = { error: null, ctx: fieldCtx } + rootCtx.fields.set(path, field) + if (collapsedFields !== undefined) collapsedFields.set(pathString, field) return field } +/** + * Cold path for assertField. graphql-js inserts a synthetic array-index + * node between a list field and its items, and that node never reaches a + * resolver — so assertField has no chance to cache it. The first child of + * the list item that hits the path cache lands here to walk and populate + * back to a cached ancestor. + * + * @param {PathNode} path + * @param {Map} cache + * @param {boolean} collapse + */ +function buildCachedPathString (path, cache, collapse) { + const key = path.key + const segment = collapse && typeof key !== 'string' ? '*' : key + const prev = path.prev + + const pathString = prev === undefined + ? String(segment) + : (cache.get(prev) ?? buildCachedPathString(prev, cache, collapse)) + '.' + segment + cache.set(path, pathString) + return pathString +} + function wrapFields (type) { if (!type || !type._fields || patchedTypes.has(type)) { return @@ -323,14 +439,19 @@ function wrapFieldType (field) { } function finishResolvers ({ fields }) { - for (const field of Object.values(fields)) { - field.ctx.finishTime = field.finishTime - field.ctx.field = field + for (const field of fields.values()) { + const fieldCtx = field.ctx + // A depth-gated field publishes startResolveCh for IAST/AppSec but the + // resolve plugin's start short-circuits before creating a span, so there + // is no span here to finish. + if (fieldCtx.currentStore === undefined) continue + fieldCtx.finishTime = field.finishTime + fieldCtx.field = field if (field.error) { - field.ctx.error = field.error - resolveErrorCh.publish(field.ctx) + fieldCtx.error = field.error + resolveErrorCh.publish(fieldCtx) } - finishResolveCh.publish(field.ctx) + finishResolveCh.publish(fieldCtx) } } @@ -343,6 +464,11 @@ addHook({ name: '@graphql-tools/executor', versions: ['>=0.0.14'] }, executor => return executor }) +// TODO(BridgeAR): graphql >=17.0.0-alpha.9 routes execute() through +// experimentalExecuteIncrementally(), bypassing this hook. The same +// function returns { initialResult, subsequentResults } for @defer / +// @stream which callInAsyncScope does not handle — execute finishes +// before the streamed payloads land. addHook({ name: 'graphql', file: 'execution/execute.js', versions: ['>=0.10'] }, execute => { shimmer.wrap(execute, 'execute', wrapExecute(execute)) return execute diff --git a/packages/datadog-instrumentations/src/helpers/ai-messages.js b/packages/datadog-instrumentations/src/helpers/ai-messages.js index 37fac8e340..9a15749b8e 100644 --- a/packages/datadog-instrumentations/src/helpers/ai-messages.js +++ b/packages/datadog-instrumentations/src/helpers/ai-messages.js @@ -1,5 +1,53 @@ 'use strict' +/** + * Returns the value as a string, JSON-stringifying it when it is not already a string. + * Returns the value unchanged when it is `null` or `undefined`. + * + * @param {unknown} value + * @returns {string|undefined|null} + */ +function stringifyIfNeeded (value) { + if (value == null) return value + return typeof value === 'string' ? value : JSON.stringify(value) +} + +const FILE_FALLBACK = '[file]' +const IMAGE_FALLBACK = '[image]' + +const OPENAI_RESPONSE_TOOL_CALL_TYPES = new Set([ + 'apply_patch_call', + 'code_interpreter_call', + 'computer_call', + 'custom_tool_call', + 'file_search_call', + 'function_call', + 'image_generation_call', + 'local_shell_call', + 'mcp_call', + 'shell_call', + 'web_search_call', +]) + +const OPENAI_RESPONSE_TOOL_OUTPUT_TYPES = new Set([ + 'apply_patch_call_output', + 'computer_call_output', + 'custom_tool_call_output', + 'function_call_output', + 'local_shell_call_output', + 'shell_call_output', +]) + +/** + * Returns a stringified value, falling back to an empty string for absent values. + * + * @param {unknown} value + * @returns {string} + */ +function stringifyOrEmpty (value) { + return stringifyIfNeeded(value) ?? '' +} + /** * Converts a LanguageModelV2FilePart with an image mediaType to an AI guard style image_url content part. * @@ -79,12 +127,11 @@ function convertVercelPromptToMessages (prompt) { if (part.type === 'text') { textParts.push(part.text) } else if (part.type === 'tool-call') { - const args = part.args ?? part.input toolCalls.push({ id: part.toolCallId, function: { name: part.toolName, - arguments: typeof args === 'string' ? args : JSON.stringify(args), + arguments: stringifyIfNeeded(part.args ?? part.input), }, }) } @@ -103,11 +150,10 @@ function convertVercelPromptToMessages (prompt) { for (const part of msg.content) { if (part.type === 'tool-result') { - const result = part.result ?? part.output messages.push({ role: 'tool', tool_call_id: part.toolCallId, - content: typeof result === 'string' ? result : JSON.stringify(result), + content: stringifyIfNeeded(part.result ?? part.output), }) } } @@ -118,6 +164,58 @@ function convertVercelPromptToMessages (prompt) { return messages } +/** + * Converts OpenAI chat-completions messages to the message format expected by AI Guard. + * + * Modern `tool_calls` messages already match the expected shape. Deprecated chat + * completions `function_call` and `function` role messages are normalized to the + * equivalent tool-call shape so AI Guard can classify them as tool interactions. + * + * @param {Array} messages + * @returns {Array|undefined} + */ +function normalizeOpenAIChatMessages (messages) { + if (!Array.isArray(messages) || messages.length === 0) return + + const normalizedMessages = [] + for (const message of messages) { + const normalized = normalizeOpenAIChatMessage(message) + if (normalized) normalizedMessages.push(normalized) + } + return normalizedMessages.length ? normalizedMessages : undefined +} + +/** + * Converts one OpenAI chat-completions message to AI Guard's expected shape. + * + * @param {object} message + * @returns {object|undefined} + */ +function normalizeOpenAIChatMessage (message) { + if (!message || typeof message !== 'object') return + + if (message.role === 'function') { + return { + role: 'tool', + tool_call_id: message.tool_call_id ?? message.name, + content: stringifyOrEmpty(message.content), + } + } + + if (!message.function_call) return message + + const { function_call: functionCall, ...normalized } = message + const name = functionCall.name + normalized.tool_calls ??= [{ + id: message.tool_call_id ?? name, + function: { + name, + arguments: stringifyOrEmpty(functionCall.arguments), + }, + }] + return normalized +} + /** * Converts LLM output tool calls to AI guard style message format. * @@ -130,16 +228,13 @@ function buildToolCallOutputMessages (inputMessages, toolCalls) { ...inputMessages, { role: 'assistant', - tool_calls: toolCalls.map(tc => { - const args = tc.args ?? tc.input - return { - id: tc.toolCallId, - function: { - name: tc.toolName, - arguments: typeof args === 'string' ? args : JSON.stringify(args), - }, - } - }), + tool_calls: toolCalls.map(tc => ({ + id: tc.toolCallId, + function: { + name: tc.toolName, + arguments: stringifyIfNeeded(tc.args ?? tc.input), + }, + })), }, ] } @@ -173,10 +268,223 @@ function buildOutputMessages (inputMessages, content) { return inputMessages } +/** + * Converts OpenAI Responses API input/output items to OpenAI chat-style messages. + * + * @param {string|Array|undefined} items + * @param {string} defaultRole + * @returns {Array} + */ +function convertOpenAIResponseItemsToMessages (items, defaultRole) { + if (typeof items === 'string') return [{ role: defaultRole, content: items }] + if (!Array.isArray(items)) return [] + + const messages = [] + for (const item of items) { + const converted = openAIResponseItemToMessage(item, defaultRole) + if (Array.isArray(converted)) { + for (const message of converted) messages.push(message) + } else if (converted) { + messages.push(converted) + } + } + return messages +} + +/** + * Converts OpenAI reusable prompt variables to user messages for AI Guard. + * + * The reusable prompt template body is not available on the request, but its + * variables are user/application-provided content that OpenAI substitutes into + * the prompt. Screening them closes prompt-only `responses.create({ prompt })` + * calls and prompt variables used alongside `input`. + * + * @param {{variables?: Record|null}|undefined|null} prompt + * @returns {Array} + */ +function convertOpenAIResponsePromptToMessages (prompt) { + const variables = prompt?.variables + if (!variables || typeof variables !== 'object') return [] + + const messages = [] + for (const value of Object.values(variables)) { + const content = openAIResponsePromptVariableToMessageContent(value) + if (content != null) messages.push({ role: 'user', content }) + } + return messages +} + +/** + * Converts one OpenAI reusable prompt variable value to message content. + * + * Routes every variable through `openAIResponseContentToMessageContent` so the + * result follows the same string-when-text-only / array-when-multimodal shape + * convention used elsewhere in this file. Media variables that produce no + * usable content (e.g. an `input_image` with no URL or `file_id`) fall back to + * a stable text marker so AI Guard still observes that a media variable was + * attached. + * + * @param {string|object} value + * @returns {string|Array<{type: string, text?: string, image_url?: {url: string}}>|undefined} + */ +function openAIResponsePromptVariableToMessageContent (value) { + let part + if (typeof value === 'string') { + part = { type: 'input_text', text: value } + } else if (value && typeof value === 'object') { + part = value + } else { + return + } + + const content = openAIResponseContentToMessageContent([part]) + if (content != null) return content + if (part.type === 'input_image') return IMAGE_FALLBACK +} + +/** + * Converts one OpenAI Responses API item to an OpenAI chat-style message. + * + * @param {object} item + * @param {string} defaultRole + * @returns {object|Array|undefined} + */ +function openAIResponseItemToMessage (item, defaultRole) { + if (!item || typeof item !== 'object') return + const type = item.type ?? 'message' + + if (type === 'message') { + const content = openAIResponseContentToMessageContent(item.content) + if (content != null) return { role: item.role || defaultRole, content } + } else if (OPENAI_RESPONSE_TOOL_CALL_TYPES.has(type)) { + return openAIResponseToolCallToMessages(item) + } else if (OPENAI_RESPONSE_TOOL_OUTPUT_TYPES.has(type)) { + return openAIResponseToolOutputToMessage(item) + } +} + +/** + * Converts a Responses API tool-call item to one or more chat-style messages. + * + * Most tool-call items represent only the assistant's tool request. MCP and + * image-generation items can also carry tool output on the same item, so include + * a linked tool message when output-like fields are present. + * + * @param {object} item + * @returns {object|Array} + */ +function openAIResponseToolCallToMessages (item) { + const toolCallId = item.call_id ?? item.id ?? item.name ?? item.type + const message = { + role: 'assistant', + tool_calls: [{ + id: toolCallId, + function: { + name: item.name ?? item.server_label ?? item.type, + arguments: stringifyOrEmpty(item.arguments ?? item.input ?? item.action), + }, + }], + } + + if (item.output == null && item.result == null && item.error == null) return message + return [message, openAIResponseToolOutputToMessage(item)] +} + +/** + * Converts a Responses API tool-output item to a chat-style tool message. + * + * @param {object} item + * @returns {object} + */ +function openAIResponseToolOutputToMessage (item) { + return { + role: 'tool', + tool_call_id: item.call_id ?? item.id, + content: openAIResponseOutputValueToMessageContent(item.output ?? item.result ?? item.error), + } +} + +/** + * Converts Responses API tool output to message content. + * + * @param {unknown} output + * @returns {string|Array<{type: string, text?: string, image_url?: {url: string}}>} + */ +function openAIResponseOutputValueToMessageContent (output) { + const content = openAIResponseContentToMessageContent(output) + return content ?? stringifyOrEmpty(output) +} + +/** + * Converts OpenAI Responses API content to OpenAI chat-style message content. + * + * @param {string|Array|undefined} content + * @returns {string|Array<{type: string, text?: string, image_url?: {url: string}}>|undefined} + */ +function openAIResponseContentToMessageContent (content) { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return + + const parts = [] + let hasImages = false + + for (const part of content) { + if (!part) continue + if (typeof part === 'string') { + parts.push({ type: 'text', text: part }) + } else if ((part.type === 'input_text' || part.type === 'output_text' || part.type === 'text') && + typeof part.text === 'string') { + parts.push({ type: 'text', text: part.text }) + } else if (part.type === 'refusal' && typeof part.refusal === 'string') { + parts.push({ type: 'text', text: part.refusal }) + } else if (part.type === 'input_image' || part.type === 'image_url') { + const image = openAIResponseImageContentPart(part) + if (image) { + hasImages = true + parts.push(image) + } + } else if (part.type === 'input_file') { + parts.push({ type: 'text', text: openAIResponseFileContentPart(part) }) + } + } + + if (!parts.length) return + if (hasImages) return parts + return parts.map(part => part.text).join('\n') +} + +/** + * Converts an OpenAI image content part to AI Guard image_url content. + * + * @param {{image_url?: string|{url?: string}, file_id?: string, url?: string}} part + * @returns {{type: 'image_url', image_url: {url: string}}|undefined} + */ +function openAIResponseImageContentPart (part) { + const url = typeof part.image_url === 'string' ? part.image_url : part.image_url?.url ?? part.url + if (url) return { type: 'image_url', image_url: { url } } + if (part.file_id) return { type: 'image_url', image_url: { url: part.file_id } } +} + +/** + * Extracts a stable text marker from an OpenAI file content part. + * + * @param {{file_id?: string|null, file_url?: string, filename?: string, file_data?: string}} part + * @returns {string} + */ +function openAIResponseFileContentPart (part) { + return part.file_id ?? part.file_url ?? part.filename ?? FILE_FALLBACK +} + module.exports = { convertVercelPromptToMessages, convertFilePartToImageUrl, + normalizeOpenAIChatMessages, buildToolCallOutputMessages, buildTextOutputMessages, buildOutputMessages, + convertOpenAIResponseItemsToMessages, + convertOpenAIResponsePromptToMessages, + openAIResponseContentToMessageContent, } diff --git a/packages/datadog-instrumentations/src/helpers/hooks.js b/packages/datadog-instrumentations/src/helpers/hooks.js index e67bdafc92..e317990e89 100644 --- a/packages/datadog-instrumentations/src/helpers/hooks.js +++ b/packages/datadog-instrumentations/src/helpers/hooks.js @@ -5,6 +5,7 @@ module.exports = { child_process: () => require('../child_process'), crypto: () => require('../crypto'), dns: () => require('../dns'), + 'dns/promises': () => require('../dns'), fs: { serverless: false, fn: () => require('../fs') }, http: () => require('../http'), http2: () => require('../http2'), @@ -21,6 +22,7 @@ module.exports = { '@modelcontextprotocol/sdk': () => require('../modelcontextprotocol-sdk'), 'apollo-server-core': () => require('../apollo-server-core'), '@aws-sdk/smithy-client': () => require('../aws-sdk'), + '@azure/cosmos': { esmFirst: true, fn: () => require('../azure-cosmos') }, '@azure/event-hubs': () => require('../azure-event-hubs'), '@azure/functions': () => require('../azure-functions'), 'durable-functions': () => require('../azure-durable-functions'), @@ -48,6 +50,7 @@ module.exports = { '@prisma/client': { esmFirst: true, fn: () => require('../prisma') }, './runtime/library.js': () => require('../prisma'), '@redis/client': () => require('../redis'), + '@smithy/core': () => require('../aws-sdk'), '@smithy/smithy-client': () => require('../aws-sdk'), '@vitest/runner': { esmFirst: true, fn: () => require('../vitest') }, aerospike: () => require('../aerospike'), @@ -111,6 +114,7 @@ module.exports = { multer: () => require('../multer'), mysql: () => require('../mysql'), mysql2: () => require('../mysql2'), + '@nats-io/nats-core': () => require('../nats'), next: () => require('../next'), 'node-serialize': () => require('../node-serialize'), nyc: () => require('../nyc'), diff --git a/packages/datadog-instrumentations/src/helpers/instrument.js b/packages/datadog-instrumentations/src/helpers/instrument.js index 332586f021..f9b389961b 100644 --- a/packages/datadog-instrumentations/src/helpers/instrument.js +++ b/packages/datadog-instrumentations/src/helpers/instrument.js @@ -58,7 +58,8 @@ exports.getHooks = function getHooks (names) { * @param {string} [args.file] path to file within package to instrument. Defaults to 'index.js'. * @param {string} [args.filePattern] pattern to match files within package to instrument * @param {boolean} [args.patchDefault] whether to patch the default export. Defaults to true. - * @param {(moduleExports: unknown, version: string, isIitm?: boolean) => unknown} [hook] Patches module exports + * @param {(moduleExports: unknown, version: string, isIitm?: boolean, hookMeta?: object) => unknown} [hook] + * Patches module exports */ exports.addHook = function addHook ({ name, versions, file, filePattern, patchDefault }, hook) { if (!instrumentations[name]) { diff --git a/packages/datadog-instrumentations/src/helpers/openai-ai-guard.js b/packages/datadog-instrumentations/src/helpers/openai-ai-guard.js new file mode 100644 index 0000000000..510be70d8e --- /dev/null +++ b/packages/datadog-instrumentations/src/helpers/openai-ai-guard.js @@ -0,0 +1,269 @@ +'use strict' + +const dc = require('dc-polyfill') +const shimmer = require('../../../datadog-shimmer') +const { + convertOpenAIResponseItemsToMessages, + convertOpenAIResponsePromptToMessages, + normalizeOpenAIChatMessages, +} = require('./ai-messages') + +// TODO: this channel name is incorrect, instrumentations publish with THEIR name, not with their subscribers names. +const aiguardChannel = dc.channel('dd-trace:ai:aiguard') + +/** + * @typedef {object} ResourceHandler + * @property {(callArgs: object) => (Array|undefined)} getInputMessages + * @property {(body: object) => Array} getOutputMessages + * @property {(inputMessages: Array, outputMessages: Array) => Promise} + * publishOutputEvaluation + */ + +/** + * @typedef {object} Guard + * @property {ResourceHandler} handler + * @property {Array} inputMessages + * @property {() => Promise} getInputEval + */ + +/** + * Publishes already-converted AI-style messages to the AI Guard evaluation channel. + * + * @param {Array} messages - AI-style messages to evaluate. + * @returns {Promise} + */ +function publishEvaluation (messages) { + return new Promise((resolve, reject) => { + aiguardChannel.publish({ messages, integration: 'openai', resolve, reject }) + }) +} + +/** + * Extracts OpenAI input messages from a `chat.completions.create` call. + * + * @param {object} callArgs - First argument passed to the wrapped method + * @returns {Array|undefined} + */ +function getChatCompletionsInputMessages (callArgs) { + return normalizeOpenAIChatMessages(callArgs?.messages) +} + +/** + * Extracts OpenAI output messages from a `chat.completions.create` parsed body. + * Includes any choice whose message carries content (including empty string), + * `tool_calls`, a `refusal` field, or the deprecated `function_call` field. GPT-4o + * emits `{content: null, refusal: "..."}` on policy refusals, and pre-tool-call + * SDK paths still produce `function_call`-only output — AI Guard must still see them. + * + * @param {object} body - Parsed response body + * @returns {Array} + */ +function getChatCompletionsOutputMessages (body) { + const eligible = [] + const choices = Array.isArray(body?.choices) ? body.choices : [] + for (const choice of choices) { + const message = choice?.message + if ( + message?.content != null || + message?.tool_calls?.length || + message?.refusal != null || + message?.function_call != null + ) { + eligible.push(message) + } + } + return normalizeOpenAIChatMessages(eligible) ?? [] +} + +/** + * Publishes AI Guard After Model evaluation for `chat.completions` output. + * + * Chat completions may return multiple choices when `n > 1`. Screen every choice + * concurrently so any unsafe assistant output rejects `.parse()`, regardless of + * which choice the caller ends up using. + * + * @param {Array} inputMessages + * @param {Array} outputMessages - One entry per choice + * @returns {Promise>} + */ +function publishChatCompletionsOutputEvaluation (inputMessages, outputMessages) { + const evals = [] + for (const message of outputMessages) { + evals.push(publishEvaluation([...inputMessages, message])) + } + return Promise.all(evals) +} + +/** + * Extracts OpenAI input messages from a `responses.create` call. The `instructions` + * field is treated as a developer prompt — it directly steers model behavior and the + * LLMObs OpenAI plugin already surfaces it as one — so AI Guard must screen it too. + * + * AI Guard `/evaluate` accepts a single leading system/developer message; if the + * caller's `input` already begins with one, prepend the `instructions` text to its + * content rather than emit a second developer turn. + * + * @param {object} callArgs - First argument passed to the wrapped method + * @returns {Array|undefined} + */ +function getResponsesInputMessages (callArgs) { + const messages = [ + ...convertOpenAIResponseItemsToMessages(callArgs?.input, 'user'), + ...convertOpenAIResponsePromptToMessages(callArgs?.prompt), + ] + + const instructions = typeof callArgs?.instructions === 'string' && callArgs.instructions.length + ? callArgs.instructions + : null + if (!instructions) return messages.length ? messages : undefined + + const first = messages[0] + if (first && (first.role === 'developer' || first.role === 'system')) { + const merged = { role: 'developer', content: mergeInstructionsWithContent(instructions, first.content) } + return [merged, ...messages.slice(1)] + } + return [{ role: 'developer', content: instructions }, ...messages] +} + +/** + * Merges Responses API instructions with an existing leading developer/system content value. + * + * @param {string} instructions + * @param {string|Array|undefined} content + * @returns {string|Array} + */ +function mergeInstructionsWithContent (instructions, content) { + if (Array.isArray(content)) return [{ type: 'text', text: instructions }, ...content] + if (typeof content === 'string' && content.length) return `${instructions}\n\n${content}` + return instructions +} + +/** + * Extracts OpenAI output messages from a `responses.create` parsed body. + * + * @param {object} body - Parsed response body + * @returns {Array} + */ +function getResponsesOutputMessages (body) { + return convertOpenAIResponseItemsToMessages(body?.output, 'assistant') +} + +/** + * Publishes AI Guard After Model evaluation for `responses` output. + * + * The Responses API returns a single conversation turn whose `output` items form one + * coherent message (reasoning steps + final assistant message + tool calls + ...); + * they are screened together as a single evaluation. + * + * @param {Array} inputMessages + * @param {Array} outputMessages + * @returns {Promise} + */ +function publishResponsesOutputEvaluation (inputMessages, outputMessages) { + return publishEvaluation([...inputMessages, ...outputMessages]) +} + +/** + * Per-resource handlers describing how AI Guard reads inputs and screens outputs for + * each LLM-prompt-accepting OpenAI endpoint. The keys also serve as the set of + * resources eligible for AI Guard evaluation. + * + * @type {Record} + */ +const RESOURCE_HANDLERS = { + 'chat.completions': { + getInputMessages: getChatCompletionsInputMessages, + getOutputMessages: getChatCompletionsOutputMessages, + publishOutputEvaluation: publishChatCompletionsOutputEvaluation, + }, + responses: { + getInputMessages: getResponsesInputMessages, + getOutputMessages: getResponsesOutputMessages, + publishOutputEvaluation: publishResponsesOutputEvaluation, + }, +} + +/** + * Reports whether the AI Guard channel has subscribers. The OpenAI instrumentation + * uses this to decide whether to take the AI Guard path at all. + * + * @returns {boolean} + */ +function hasSubscribers () { + return aiguardChannel.hasSubscribers +} + +/** + * Builds a guard handle when AI Guard is enabled and applicable to this call. The + * handle binds the per-resource handler so downstream functions never re-dispatch + * on `baseResource`. Returns null when AI Guard does not apply (no subscribers, + * non-eligible resource, streaming, or no input messages). + * + * @param {string} baseResource - e.g. `'chat.completions'` or `'responses'` + * @param {object} callArgs - First argument passed to the wrapped OpenAI method + * @param {boolean} stream - Whether the caller asked for a streamed response + * @returns {Guard|null} + */ +function createGuard (baseResource, callArgs, stream) { + // Streaming AI Guard support lands in a follow-up PR. For now, provider-level AI + // Guard only evaluates non-streaming responses. + if (stream || !aiguardChannel.hasSubscribers) return null + const handler = RESOURCE_HANDLERS[baseResource] + if (!handler) return null + + const inputMessages = handler.getInputMessages(callArgs) + if (!inputMessages) return null + + let inputEvalPromise + const getInputEval = () => (inputEvalPromise ??= publishEvaluation(inputMessages)) + return { handler, inputMessages, getInputEval } +} + +/** + * Wraps `apiProm.asResponse` so callers that consume the raw `Response` object still + * receive the Before Model verdict. After Model evaluation is not performed on this + * path because the response body has not been parsed. + * + * @param {object} apiProm - APIPromise returned from the OpenAI SDK method + * @param {Guard} guard + */ +function wrapAsResponse (apiProm, guard) { + if (typeof apiProm.asResponse !== 'function') return + shimmer.wrap(apiProm, 'asResponse', origAsResponse => function (...args) { + const responsePromise = origAsResponse.apply(this, args) + return Promise.all([guard.getInputEval(), responsePromise]).then(([, response]) => response) + }) +} + +/** + * Gates the parsed-body promise on Before Model evaluation. Resolves to the SDK's + * result only once the Before Model verdict is in. + * + * @param {Promise} parsedPromise + * @param {Guard} guard + * @returns {Promise} + */ +function gateParse (parsedPromise, guard) { + return Promise.all([guard.getInputEval(), parsedPromise]).then(([, result]) => result) +} + +/** + * Runs After Model evaluation against the response body. + * + * @param {Guard} guard + * @param {object} body - Parsed OpenAI response body + * @returns {Promise} + */ +function evaluateOutput (guard, body) { + const outputMessages = guard.handler.getOutputMessages(body) + if (!outputMessages.length) return Promise.resolve() + return guard.handler.publishOutputEvaluation(guard.inputMessages, outputMessages) +} + +module.exports = { + hasSubscribers, + createGuard, + wrapAsResponse, + gateParse, + evaluateOutput, +} diff --git a/packages/datadog-instrumentations/src/helpers/promise-instrumentor.js b/packages/datadog-instrumentations/src/helpers/promise-instrumentor.js new file mode 100644 index 0000000000..7bb5502423 --- /dev/null +++ b/packages/datadog-instrumentations/src/helpers/promise-instrumentor.js @@ -0,0 +1,42 @@ +'use strict' + +const dc = require('dc-polyfill') + +const { channel } = require('./instrument') + +/** + * Shimmer-compatible instrumentor for promise-returning APIs (e.g. `dns.promises.lookup`). + * Mirrors `createCallbackInstrumentor`'s channel triplet (`:start`, `:finish`, `:error`) + * so a plugin subscribing to those channels for the callback variant works for the promise + * variant unchanged. `:finish` is the `tracingChannel` `asyncEnd` slot, so it fires after the + * promise settles with `ctx.result` set to the resolved value. + * + * @param {string} prefix + * @returns {(buildContext: (thisArg: unknown, args: unknown[]) => object | undefined) => + * (fn: Function) => Function} + */ +function createPromiseInstrumentor (prefix) { + const start = channel(prefix + ':start') + const finish = channel(prefix + ':finish') + const error = channel(prefix + ':error') + const tracing = dc.tracingChannel({ + start, + end: channel(prefix + ':end'), + asyncStart: channel(prefix + ':asyncStart'), + asyncEnd: finish, + error, + }) + + return function instrument (buildContext) { + return function wrap (fn) { + return function (...args) { + if (!start.hasSubscribers) return fn.apply(this, args) + const ctx = buildContext(this, args) + if (ctx === undefined) return fn.apply(this, args) + return tracing.tracePromise(fn, ctx, this, ...args) + } + } + } +} + +module.exports = { createPromiseInstrumentor } diff --git a/packages/datadog-instrumentations/src/helpers/register.js b/packages/datadog-instrumentations/src/helpers/register.js index 66c2de19f4..bad9cffe3b 100644 --- a/packages/datadog-instrumentations/src/helpers/register.js +++ b/packages/datadog-instrumentations/src/helpers/register.js @@ -133,7 +133,7 @@ for (const name of names) { try { loadChannel.publish({ name }) - moduleExports = hook(moduleExports, moduleVersion, isIitm) ?? moduleExports + moduleExports = hook(moduleExports, moduleVersion, isIitm, { moduleBaseDir, moduleName }) ?? moduleExports } catch (error) { log.info('Error during ddtrace instrumentation of application, aborting.', error) telemetry('error', [ diff --git a/packages/datadog-instrumentations/src/helpers/rewriter/index.js b/packages/datadog-instrumentations/src/helpers/rewriter/index.js index 28c4a2ed68..e9cfa5e237 100644 --- a/packages/datadog-instrumentations/src/helpers/rewriter/index.js +++ b/packages/datadog-instrumentations/src/helpers/rewriter/index.js @@ -5,7 +5,7 @@ const { join } = require('path') const { pathToFileURL } = require('url') const log = require('../../../../dd-trace/src/log') const { create } = require('../../../../../vendor/dist/@apm-js-collab/code-transformer') -const { traceAsyncIterator, traceIterator } = require('./transforms') +const { waitForAsyncEnd } = require('./transforms') const instrumentations = require('./instrumentations') // `dc-polyfill` is referenced from injected `require()` (CJS) and `import` @@ -34,8 +34,7 @@ const matcherCjs = create(instrumentations, dcPolyfillCjs) const matcherEsm = create(instrumentations, dcPolyfillEsm) for (const matcher of [matcherCjs, matcherEsm]) { - matcher.addTransform('traceIterator', traceIterator) - matcher.addTransform('traceAsyncIterator', traceAsyncIterator) + matcher.addTransform('waitForAsyncEnd', waitForAsyncEnd) } function rewrite (content, filename, format) { diff --git a/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/azure-cosmos.js b/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/azure-cosmos.js new file mode 100644 index 0000000000..2e4f9a0dc4 --- /dev/null +++ b/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/azure-cosmos.js @@ -0,0 +1,50 @@ +'use strict' + +module.exports = [{ + module: { + name: '@azure/cosmos', + versionRange: '>=4.4.1', + filePath: 'dist/browser/plugins/Plugin.js', + }, + functionQuery: { + functionName: 'executePlugins', + kind: 'Async', + }, + channelName: 'executePlugins', +}, +{ + module: { + name: '@azure/cosmos', + versionRange: '>=4.4.1', + filePath: 'dist/commonjs/plugins/Plugin.js', + }, + functionQuery: { + functionName: 'executePlugins', + kind: 'Async', + }, + channelName: 'executePlugins', +}, +{ + module: { + name: '@azure/cosmos', + versionRange: '>=4.4.1', + filePath: 'dist/esm/plugins/Plugin.js', + }, + functionQuery: { + functionName: 'executePlugins', + kind: 'Async', + }, + channelName: 'executePlugins', +}, +{ + module: { + name: '@azure/cosmos', + versionRange: '>=4.4.1', + filePath: 'dist/react-native/plugins/Plugin.js', + }, + functionQuery: { + functionName: 'executePlugins', + kind: 'Async', + }, + channelName: 'executePlugins', +}] diff --git a/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/index.js b/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/index.js index 9a67278604..9962e5e3c4 100644 --- a/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/index.js +++ b/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/index.js @@ -2,8 +2,10 @@ module.exports = [ ...require('./ai'), + ...require('./azure-cosmos'), ...require('./bullmq'), ...require('./langchain'), ...require('./langgraph'), ...require('./modelcontextprotocol-sdk'), + ...require('./playwright'), ] diff --git a/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/langgraph.js b/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/langgraph.js index 9e07864063..b755a61c4b 100644 --- a/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/langgraph.js +++ b/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/langgraph.js @@ -10,9 +10,10 @@ module.exports = [ functionQuery: { methodName: 'stream', className: 'Pregel', + kind: 'Async', + returnKind: 'AsyncIterator', }, channelName: 'Pregel_stream', - transform: 'traceAsyncIterator', }, { module: { @@ -23,8 +24,9 @@ module.exports = [ functionQuery: { methodName: 'stream', className: 'Pregel', + kind: 'Async', + returnKind: 'AsyncIterator', }, channelName: 'Pregel_stream', - transform: 'traceAsyncIterator', }, ] diff --git a/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/playwright.js b/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/playwright.js new file mode 100644 index 0000000000..5d58f43b1d --- /dev/null +++ b/packages/datadog-instrumentations/src/helpers/rewriter/instrumentations/playwright.js @@ -0,0 +1,85 @@ +'use strict' + +// Playwright 1.60 bundles several former hook targets into local classes/functions. +// Keep these rewrites limited to private bundled internals that addHook cannot wrap. +module.exports = [ + { + module: { + name: 'playwright', + versionRange: '>=1.60.0', + filePath: 'lib/runner/index.js', + }, + functionQuery: { + className: 'Dispatcher', + methodName: 'run', + kind: 'Async', + }, + channelName: 'Dispatcher_run', + }, + { + module: { + name: 'playwright', + versionRange: '>=1.60.0', + filePath: 'lib/runner/index.js', + }, + functionQuery: { + className: 'Dispatcher', + methodName: '_createWorker', + kind: 'Sync', + }, + channelName: 'Dispatcher_createWorker', + }, + { + module: { + name: 'playwright', + versionRange: '>=1.60.0', + filePath: 'lib/runner/index.js', + }, + functionQuery: { + className: 'ProcessHost', + methodName: 'startRunner', + kind: 'Async', + }, + channelName: 'ProcessHost_startRunner', + }, + { + module: { + name: 'playwright', + versionRange: '>=1.60.0', + filePath: 'lib/runner/index.js', + }, + functionQuery: { + functionName: 'createRootSuite', + kind: 'Async', + }, + channelName: 'createRootSuite', + }, + { + module: { + name: 'playwright-core', + versionRange: '>=1.60.0', + filePath: 'lib/coreBundle.js', + }, + astQuery: 'AssignmentExpression[left.name="Page2"] > ClassExpression > ClassBody > ' + + 'MethodDefinition[kind="method"][key.name="goto"] > FunctionExpression[async], ' + + 'VariableDeclarator[id.name="Page2"] > ClassExpression > ClassBody > ' + + 'MethodDefinition[kind="method"][key.name="goto"] > FunctionExpression[async], ' + + 'ClassDeclaration[id.name="Page2"] > ClassBody > ' + + 'MethodDefinition[kind="method"][key.name="goto"] > FunctionExpression[async]', + functionQuery: { + methodName: 'goto', + kind: 'Async', + }, + channelName: 'Page_goto', + }, + { + module: { + name: 'playwright-core', + versionRange: '>=1.60.0', + filePath: 'lib/coreBundle.js', + }, + astQuery: 'ReturnStatement > CallExpression[callee.object.name="promise"][callee.property.name="then"]', + channelName: 'Page_goto', + transform: 'waitForAsyncEnd', + }, +] diff --git a/packages/datadog-instrumentations/src/helpers/rewriter/transforms.js b/packages/datadog-instrumentations/src/helpers/rewriter/transforms.js index 549d8b9900..a24d2d9db1 100644 --- a/packages/datadog-instrumentations/src/helpers/rewriter/transforms.js +++ b/packages/datadog-instrumentations/src/helpers/rewriter/transforms.js @@ -1,246 +1,47 @@ 'use strict' -// TODO: Move traceIterator to Orchestrion. - -const { parse, query, traverse } = require('./compiler') - -const tracingChannelPredicate = (node) => ( - node.specifiers?.[0]?.local?.name === 'tr_ch_apm_tracingChannel' || - node.declarations?.[0]?.id?.properties?.[0]?.value?.name === 'tr_ch_apm_tracingChannel' -) - -const transforms = module.exports = { - /** - * @param {{ dcModule: string, moduleType: 'esm' | 'cjs' }} state - * @param {import('estree').Program} node - */ - tracingChannelImport ({ dcModule, moduleType }, node) { - if (node.body.some(tracingChannelPredicate)) return - - // The vendored matcher state exposes `moduleType` (`esm` / `cjs`), so we - // read that field directly. Naming it `sourceType` here used to silently - // pick the CJS branch for every ESM file, leaving `require()` baked into - // pure ESM modules like `@langchain/langgraph/dist/pregel/index.js`. - const isModule = moduleType === 'esm' - - const index = node.body.findIndex(child => child.directive === 'use strict') - const code = isModule - ? `import tr_ch_apm_dc from "${dcModule}"; const {tracingChannel: tr_ch_apm_tracingChannel} = tr_ch_apm_dc` - : `const {tracingChannel: tr_ch_apm_tracingChannel} = require("${dcModule}")` - - node.body.splice(index + 1, 0, ...parse(code, { isModule }).body) - }, - - tracingChannelDeclaration (state, node) { - const { channelName, module: { name } } = state - const channelVariable = 'tr_ch_apm$' + channelName.replaceAll(':', '_') - - if (node.body.some(child => child.declarations?.[0]?.id?.name === channelVariable)) return - - transforms.tracingChannelImport(state, node) - - const index = node.body.findIndex(tracingChannelPredicate) - const code = ` - const ${channelVariable} = tr_ch_apm_tracingChannel("orchestrion:${name}:${channelName}") - ` - - node.body.splice(index + 1, 0, parse(code).body[0]) - }, - - traceAsyncIterator: traceAny, - traceIterator: traceAny, -} - -function traceAny (state, node, _parent, ancestry) { - const program = ancestry[ancestry.length - 1] - - if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') { - traceInstanceMethod(state, node, program) - } else { - traceFunction(state, node, program) - } -} - -function traceFunction (state, node, program) { - transforms.tracingChannelDeclaration(state, program) - - node.body = wrap(state, { - type: 'FunctionExpression', - params: node.params, - body: node.body, - async: node.async, - expression: false, - generator: node.generator, - }, program) - - // The original function no longer contains any calls to `await` or `yield` as - // the function body is copied to the internal wrapped function, so we set - // these to false to avoid altering the return value of the wrapper. The old - // values are instead copied to the new AST node above. - node.generator = false - node.async = false - - wrapSuper(state, node) -} - -function traceInstanceMethod (state, node, program) { - const { functionQuery, operator } = state - const { methodName } = functionQuery - - const classBody = node.body - - // If the method exists on the class, we return as it will be patched later - // while traversing child nodes later on. - if (classBody.body.some(({ key }) => key.name === methodName)) return - - // Method doesn't exist on the class so we assume an instance method and - // wrap it in the constructor instead. - let ctor = classBody.body.find(({ kind }) => kind === 'constructor') - - transforms.tracingChannelDeclaration(state, program) - - if (!ctor) { - ctor = parse( - node.superClass - ? 'class A { constructor (...args) { super(...args) } }' - : 'class A { constructor () {} }' - ).body[0].body.body[0] // Extract constructor from dummy class body. - - classBody.body.unshift(ctor) - } - - const ctorBody = parse(` - const __apm$${methodName} = this["${methodName}"] - this["${methodName}"] = function () {} - `).body - - // Extract only right-hand side function of line 2. - const fn = ctorBody[1].expression.right - - fn.async = operator === 'tracePromise' - fn.body = wrap(state, { type: 'Identifier', name: `__apm$${methodName}` }, program) - - wrapSuper(state, fn) - - ctor.value.body.body.push(...ctorBody) -} - -function wrap (state, node, program) { - const { operator } = state - - if (operator === 'traceAsyncIterator') return wrapIterator(state, node, program) - if (operator === 'traceIterator') return wrapIterator(state, node, program) -} - -function wrapSuper (_state, node) { - const members = new Set() - - traverse( - node.body, - '[object.type=Super]', - (node, parent) => { - const { name } = node.property - - let child - - if (parent.callee) { - // This is needed because for generator functions we have to move the - // original function to a nested wrapped function, but we can't use an - // arrow function because arrow function cannot be generator functions, - // and `super` cannot be called from a nested function, so we have to - // rewrite any `super` call to not use the keyword. - const { expression } = parse(`__apm$super['${name}'].call(this)`).body[0] - - parent.callee = child = expression.callee - parent.arguments.unshift(...expression.arguments) - } else { - parent.expression = child = parse(`__apm$super['${name}']`).body[0] - } - - child.computed = parent.callee.computed - child.optional = parent.callee.optional - - members.add(name) - } - ) - - for (const name of members) { - const member = parse(` - class Wrapper { - wrapper () { - __apm$super['${name}'] = super['${name}'] - } - } - `).body[0].body.body[0].value.body.body[0] - - node.body.body.unshift(member) - } - - if (members.size > 0) { - node.body.body.unshift(parse('const __apm$super = {}').body[0]) +// Custom transforms registered via InstrumentationMatcher.addTransform(). +// +// Use this file for transforms that are not yet supported upstream in +// @apm-js-collab/code-transformer (Orchestrion) or that cannot land there +// for dd-trace-specific reasons. Once a transform is available natively in +// the library, replace the custom registration with the built-in option and +// remove the entry here. + +const { parse, query } = require('./compiler') + +module.exports = { waitForAsyncEnd } + +/** + * Injects a wait for `ctx.asyncEndPromise` into a generated `tracePromise` + * wrapper's native-Promise fulfillment handler. + * + * @param {object} _state + * @param {import('estree').CallExpression} node + * @returns {void} + */ +function waitForAsyncEnd (_state, node) { + const onFulfilled = node.arguments[0] + const statements = onFulfilled?.body?.body + + if (!statements || query(onFulfilled.body, '[id.name=__apm$asyncEndPromise]').length > 0) { + return } -} -function wrapIterator (state, node, program) { - const { channelName, operator } = state - const baseChannel = channelName.replaceAll(':', '_') - const channelVariable = 'tr_ch_apm$' + baseChannel - const nextChannel = baseChannel + '_next' - const traceMethod = operator === 'traceAsyncIterator' ? 'tracePromise' : 'traceSync' - const traceNext = `tr_ch_apm$${nextChannel}.${traceMethod}` + const returnIndex = statements.findIndex(statement => ( + statement.type === 'ReturnStatement' && statement.argument?.name === 'result' + )) - transforms.tracingChannelDeclaration({ ...state, channelName: nextChannel }, program) + if (returnIndex === -1) return - const wrapper = parse(` + const waitStatements = parse(` function wrapper () { - const __apm$traced = () => { - const __apm$wrapped = () => {}; - return __apm$wrapped.apply(this, arguments); - }; - - if (!${channelVariable}.start.hasSubscribers) return __apm$traced(); - - { - const wrap = iter => { - const { next: iterNext, return: iterReturn, throw: iterThrow } = iter; - - iter.next = (...args) => ${traceNext}(iterNext, ctx, iter, ...args); - iter.return = (...args) => ${traceNext}(iterReturn, ctx, iter, ...args); - iter.throw = (...args) => ${traceNext}(iterThrow, ctx, iter, ...args); - - return iter; - }; - const ctx = { - arguments, - self: this, - moduleVersion: "1.0.0" - }; - const iter = ${channelVariable}.traceSync(__apm$traced, ctx); - - if (typeof iter.then !== 'function') return wrap(iter); - - return iter.then(result => { - ctx.result = result; - - ${channelVariable}.asyncStart.publish(ctx); - ${channelVariable}.asyncEnd.publish(ctx); - - return wrap(result); - }, err => { - ctx.error = err; - - ${channelVariable}.error.publish(ctx); - ${channelVariable}.asyncStart.publish(ctx); - ${channelVariable}.asyncEnd.publish(ctx); - - return Promise.reject(err); - }); - }; + const __apm$asyncEndPromise = __apm$ctx.asyncEndPromise; + if (__apm$asyncEndPromise && typeof __apm$asyncEndPromise.then === 'function') { + return __apm$asyncEndPromise.then(() => result, () => result); + } } - `).body[0].body // Extract only block statement of function body. - - // Replace the right-hand side assignment of `const __apm$wrapped = () => {}`. - query(wrapper, '[id.name=__apm$wrapped]')[0].init = node + `).body[0].body.body - return wrapper + statements.splice(returnIndex, 0, ...waitStatements) } diff --git a/packages/datadog-instrumentations/src/hono.js b/packages/datadog-instrumentations/src/hono.js index ed11498e7b..0eea722a4d 100644 --- a/packages/datadog-instrumentations/src/hono.js +++ b/packages/datadog-instrumentations/src/hono.js @@ -14,6 +14,11 @@ const enterChannel = channel('apm:hono:middleware:enter') const exitChannel = channel('apm:hono:middleware:exit') const finishChannel = channel('apm:hono:middleware:finish') +// Tracks handlers registered via `app.use()` so route-publishing wrappers +// installed by `wrapRouterAdd` can skip middleware-only matches (a request +// matching only middleware should keep the bare HTTP-method resource name). +const middlewareHandlers = new WeakSet() + // `app.request()` and non-node adapters call `app.fetch` without an `incoming` // IncomingMessage; the APM `web` helpers depend on one, so the wrappers below // skip publishing whenever it is missing. @@ -27,6 +32,53 @@ function wrapFetch (fetch) { } } +function wrapUse (originalUse) { + return function (arg1, ...handlers) { + if (typeof arg1 === 'function') middlewareHandlers.add(arg1) + for (const h of handlers) middlewareHandlers.add(h) + return originalUse.call(this, arg1, ...handlers) + } +} + +// `app.basePath()` returns a clone Hono instance built via the library's +// internal class binding, so it never hits our instrumented constructor. The +// clone shares the parent router (so `router.add` stays wrapped), but its +// `use` is a fresh per-instance method that must be wrapped too, otherwise +// middleware registered on the sub-app never lands in `middlewareHandlers`. +function wrapBasePath (originalBasePath) { + return function (path) { + const clone = originalBasePath.apply(this, arguments) + shimmer.wrap(clone, 'use', wrapUse) + shimmer.wrap(clone, 'basePath', wrapBasePath) + return clone + } +} + +function wrapRouterAdd (originalAdd) { + return function (method, path, handlerData) { + const handler = handlerData?.[0] + if (typeof handler === 'function' && !middlewareHandlers.has(handler)) { + const meta = handlerData[1] + const wrappedHandler = function (context, next) { + const req = context.env?.incoming + if (req && routeChannel.hasSubscribers) { + routeChannel.publish({ req, route: meta?.path }) + } + return handler.apply(this, arguments) + } + handlerData = [wrappedHandler, meta] + } + return originalAdd.call(this, method, path, handlerData) + } +} + +function instrumentHonoInstance (instance) { + shimmer.wrap(instance, 'fetch', wrapFetch) + shimmer.wrap(instance, 'use', wrapUse) + shimmer.wrap(instance, 'basePath', wrapBasePath) + shimmer.wrap(instance.router, 'add', wrapRouterAdd) +} + function onErrorFn (error, _context_) { throw error } @@ -74,7 +126,6 @@ function wrapMiddleware (middleware, route) { if (!req) { return middleware.apply(this, arguments) } - routeChannel.publish({ req, route }) enterChannel.publish({ req, name, route }) if (typeof next === 'function') { arguments[1] = wrapNext(req, route, next) @@ -113,7 +164,7 @@ addHook({ class Hono extends hono.Hono { constructor (...args) { super(...args) - shimmer.wrap(this, 'fetch', wrapFetch) + instrumentHonoInstance(this) } } @@ -130,7 +181,7 @@ addHook({ class Hono extends hono.Hono { constructor (...args) { super(...args) - shimmer.wrap(this, 'fetch', wrapFetch) + instrumentHonoInstance(this) } } diff --git a/packages/datadog-instrumentations/src/http/server.js b/packages/datadog-instrumentations/src/http/server.js index ebbe9662f7..c56c826f94 100644 --- a/packages/datadog-instrumentations/src/http/server.js +++ b/packages/datadog-instrumentations/src/http/server.js @@ -43,7 +43,7 @@ function wrapResponseEmit (emit) { return emit.apply(this, arguments) } - if (['finish', 'close'].includes(eventName) && !requestFinishedSet.has(this)) { + if ((eventName === 'finish' || eventName === 'close') && !requestFinishedSet.has(this)) { finishServerCh.publish({ req: this.req }) requestFinishedSet.add(this) } @@ -51,6 +51,7 @@ function wrapResponseEmit (emit) { return emit.apply(this, arguments) } } + function wrapEmit (emit) { return function (eventName, req, res) { if (!startServerCh.hasSubscribers) { @@ -61,8 +62,12 @@ function wrapEmit (emit) { res.req = req const abortController = new AbortController() + // Single ctx shared with `exitServerCh` below and forwarded by the + // server plugin to `incomingHttpRequestStart`; existing subscribers + // only read the message, so the reuse is safe. + const ctx = { req, res, abortController } - startServerCh.publish({ req, res, abortController }) + startServerCh.publish(ctx) try { if (abortController.signal.aborted) { @@ -76,7 +81,7 @@ function wrapEmit (emit) { throw err } finally { - exitServerCh.publish({ req }) + exitServerCh.publish(ctx) } } return emit.apply(this, arguments) @@ -107,7 +112,7 @@ function wrapWriteHead (writeHead) { } // this doesn't support explicit duplicate headers, but it's an edge case - const responseHeaders = Object.assign(this.getHeaders(), obj) + const responseHeaders = obj === undefined ? this.getHeaders() : Object.assign(this.getHeaders(), obj) startWriteHeadCh.publish({ req: this.req, diff --git a/packages/datadog-instrumentations/src/jest.js b/packages/datadog-instrumentations/src/jest.js index dac28bf48e..9ed1de6782 100644 --- a/packages/datadog-instrumentations/src/jest.js +++ b/packages/datadog-instrumentations/src/jest.js @@ -4,7 +4,7 @@ const realSetTimeout = setTimeout const { readFileSync } = require('node:fs') -const { builtinModules } = require('node:module') +const { builtinModules, createRequire } = require('node:module') const path = require('path') const satisfies = require('../../../vendor/dist/semifies') const { DD_MAJOR } = require('../../../version') @@ -12,7 +12,8 @@ const shimmer = require('../../datadog-shimmer') const { getEnvironmentVariable } = require('../../dd-trace/src/config/helper') const log = require('../../dd-trace/src/log') const { - getCoveredFilenamesFromCoverage, + getCoveredFilesFromCoverage, + getExecutableFilesFromCoverage, JEST_WORKER_TRACE_PAYLOAD_CODE, JEST_WORKER_COVERAGE_PAYLOAD_CODE, JEST_WORKER_TELEMETRY_PAYLOAD_CODE, @@ -30,14 +31,21 @@ const { logAttemptToFixTestExecution, logTestOptimizationSummary, getEfdRetryCount, + getTestCoverageLinesPercentage, + applySkippedCoverageToCoverage, getTestOptimizationRequestResults, } = require('../../dd-trace/src/plugins/util/test') const { - SEED_SUFFIX_RE, getFormattedJestTestParameters, getJestTestName, + getRawJestTestName, getJestSuitesToRun, + removeSeedSuffixFromTestName, } = require('../../datadog-plugin-jest/src/util') +const { + addCoverageBackfillUntestedFiles, + getCoverageBackfillFiles, +} = require('./jest/coverage-backfill') const { addHook, channel } = require('./helpers/instrument') const testSessionStartCh = channel('ci:jest:session:start') @@ -84,11 +92,13 @@ const isJestWorker = !!getEnvironmentVariable('JEST_WORKER_ID') const RETRY_TIMES = Symbol.for('RETRY_TIMES') let skippableSuites = [] +let skippableSuitesCoverage = {} +let skippedSuitesCoverage = {} let knownTests = {} let isCodeCoverageEnabled = false -let isCodeCoverageEnabledBecauseOfUs = false +let isCoverageReportUploadEnabled = false +let isItrEnabled = false let isSuitesSkippingEnabled = false -let DD_TEST_TIA_KEEP_COV_CONFIG = false let isUserCodeCoverageEnabled = false let isSuitesSkipped = false let numSkippedSuites = 0 @@ -106,6 +116,13 @@ let testManagementTests = {} let testManagementAttemptToFixRetries = 0 let isImpactedTestsEnabled = false let modifiedFiles = {} +let repositoryRoot +let lastCoverageMap +let lastCoverageMapRootDir +let coverageBackfillContexts +let coverageBackfillFiles +let coverageReporterClass +let coverageReporterRequire let activeTestSuiteAbsolutePath let isConsoleErrorWrapped = false @@ -130,13 +147,13 @@ const efdSlowAbortedTests = new Set() const efdNewTestCandidates = new Set() // Tests that are genuinely new (not in known tests list). const newTests = new Set() -const testSuiteAbsolutePathsWithFastCheck = new Set() -const testSuiteFastCheckUsage = new Map() const testSuiteJestObjects = new Map() const wrappedJestGlobals = new WeakSet() const wrappedJestObjects = new WeakSet() const wrappedWorkerInitializers = new WeakSet() const publishedRuntimeReferenceErrors = new WeakMap() +const wrappedCoverageReporters = new WeakSet() +const coverageReporterRequires = new WeakMap() const BREAKPOINT_HIT_GRACE_PERIOD_MS = 200 const ATR_RETRY_SUPPRESSION_FLAG = '_ddDisableAtrRetry' @@ -145,12 +162,22 @@ const MINIMUM_JEST_VERSION_BEFORE_30 = DD_MAJOR >= 6 ? '>=28.0.0 <30.0.0' : '>=2 const MINIMUM_JEST_WORKER_VERSION_BEFORE_30 = DD_MAJOR >= 6 ? '>=28.0.0 <30.0.0' : '>=24.9.0 <30.0.0' const MINIMUM_JEST_CONFIG_ASYNC_VERSION = DD_MAJOR >= 6 ? '>=28.0.0' : '>=25.1.0' const MINIMUM_JEST_TEST_SCHEDULER_VERSION = DD_MAJOR >= 6 ? '>=28.0.0' : '>=27.0.0' +const MINIMUM_JEST_COVERAGE_BACKFILL_VERSION = '>=28.0.0' const atrSuppressedErrors = new Map() let hasWarnedDeprecatedJestVersion = false +let isJestCoverageBackfillSupported = false // Track quarantined tests whose errors were suppressed, keyed by "suite › testName" const quarantinedFailingTests = new Set() +function getJestRepositoryRoot (readConfigsResult) { + const configuredRepositoryRoot = readConfigsResult.configs + ?.find(config => config.testEnvironmentOptions?._ddRepositoryRoot) + ?.testEnvironmentOptions._ddRepositoryRoot + + return configuredRepositoryRoot || process.cwd() +} + /** * Sends suppressed quarantine test names from a worker process to the main process. * Supports both child_process (process.send) and worker_threads (parentPort.postMessage). @@ -293,9 +320,7 @@ function getAttemptToFixExecutionsFromJestResults (result) { if (!testManagementTestsForSuite) continue for (const { fullName, status } of testResults) { - const testName = testSuiteAbsolutePathsWithFastCheck.has(testFilePath) - ? fullName.replace(SEED_SUFFIX_RE, '') - : fullName + const testName = removeSeedSuffixFromTestName(fullName) const testStatus = getTestStatusFromJestResult(status) if (!testStatus) continue @@ -341,7 +366,6 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { super(config, context) const rootDir = config.globalConfig ? config.globalConfig.rootDir : config.rootDir this.rootDir = rootDir - this.testSuite = getTestSuitePath(context.testPath, rootDir) this.nameToParams = {} this.global._ddtrace = global._ddtrace this.hasSnapshotTests = undefined @@ -354,6 +378,7 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { this.testEnvironmentOptions = getTestEnvironmentOptions(config) const repositoryRoot = this.testEnvironmentOptions._ddRepositoryRoot + this.testSuite = getTestSuitePath(context.testPath, rootDir) // TODO: could we grab testPath from `this.getVmContext().expect.getState()` instead? // so we don't rely on context being passed (some custom test environment do not pass it) @@ -542,14 +567,11 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { } } - getShouldStripSeedFromTestName () { - return doesTestSuiteUseFastCheck(this.testSuiteAbsolutePath) - } - // At the `add_test` event we don't have the test object yet, so we can't use it getTestNameFromAddTestEvent (event, state) { - const describeSuffix = getJestTestName(state.currentDescribeBlock, this.getShouldStripSeedFromTestName()) - return describeSuffix ? `${describeSuffix} ${event.testName}` : event.testName + const describeSuffix = getRawJestTestName(state.currentDescribeBlock) + const testName = describeSuffix ? `${describeSuffix} ${event.testName}` : event.testName + return removeSeedSuffixFromTestName(testName) } async handleTestEvent (event, state) { @@ -571,7 +593,7 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { }) } if (event.name === 'test_start') { - const testName = getJestTestName(event.test, this.getShouldStripSeedFromTestName()) + const testName = getJestTestName(event.test) if (testsToBeRetried.has(testName)) { // This is needed because we're retrying tests with the same name this.resetSnapshotState() @@ -775,7 +797,7 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { let attemptToFixFailed = false let failedAllTests = false let isAttemptToFix = false - const testName = getJestTestName(event.test, this.getShouldStripSeedFromTestName()) + const testName = getJestTestName(event.test) if (this.isTestManagementTestsEnabled) { isAttemptToFix = this.testManagementTestsForThisSuite?.attemptToFix?.includes(testName) if (isAttemptToFix) { @@ -955,7 +977,7 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { // so Jest doesn't see the failure (prevents --bail from stopping the run). const ctx = testContexts.get(test) if (ctx?.isQuarantined && !ctx.isAttemptToFix) { - const testName = getJestTestName(test, this.getShouldStripSeedFromTestName()) + const testName = getJestTestName(test) quarantinedFailingTests.add(`${ctx.suite} › ${testName}`) } else { test.errors = errors @@ -979,7 +1001,7 @@ function getWrappedEnvironment (BaseEnvironment, jestVersion) { testsToBeRetried.clear() } if (event.name === 'test_skip' || event.name === 'test_todo') { - const testName = getJestTestName(event.test, this.getShouldStripSeedFromTestName()) + const testName = getJestTestName(event.test) testSkippedCh.publish({ test: { name: testName, @@ -1129,8 +1151,107 @@ function getTestEnvironment (pkg, jestVersion) { return getWrappedEnvironment(pkg, jestVersion) } +function getRepositoryRootFromConfig (config, fallbackRootDir) { + return config?.testEnvironmentOptions?._ddRepositoryRoot || repositoryRoot || fallbackRootDir || process.cwd() +} + +function getRepositoryRootFromContexts (contexts, fallbackRootDir) { + const [firstContext] = contexts || [] + return getRepositoryRootFromConfig(firstContext?.config, fallbackRootDir) +} + +function getRepositoryRootFromTest (test, fallbackRootDir) { + return getRepositoryRootFromConfig(test?.context?.config, fallbackRootDir) +} + +function hasSkippableSuitesCoverage () { + return skippableSuitesCoverage && + typeof skippableSuitesCoverage === 'object' && + Object.keys(skippableSuitesCoverage).length > 0 +} + +function shouldCollectJestCoverageForTia () { + return shouldReportJestSuiteCoverageForTia() || + (isJestCoverageBackfillSupported && isItrEnabled && isCoverageReportUploadEnabled) +} + +function shouldReportJestSuiteCoverageForTia () { + return isItrEnabled && isCodeCoverageEnabled +} + +function hasJestCoverageMap () { + return isUserCodeCoverageEnabled || shouldCollectJestCoverageForTia() +} + +// TIA coverage backfill is part of Datadog Code Coverage, not the per-suite TIA coverage upload. +function isTiaCoverageBackfillEnabled () { + return isJestCoverageBackfillSupported && isItrEnabled && isCoverageReportUploadEnabled && hasJestCoverageMap() +} + +// Non-TIA Jest coverage keeps the legacy metric. TIA only reports it when Datadog Code Coverage is enabled and +// either the run is complete locally or the skipped suites can be backfilled. +function shouldReportCodeCoverageLinesPct () { + if (!hasJestCoverageMap()) return false + if (!isItrEnabled) return true + if (!isCoverageReportUploadEnabled) return false + + // If no suites were actually skipped, the local Jest coverage map is complete and does not need backfill. + return !isSuitesSkipped || isTiaCoverageBackfillEnabled() +} + +function getHookRequire (hookMeta) { + if (!hookMeta?.moduleBaseDir) return + + return createRequire(path.join(hookMeta.moduleBaseDir, 'package.json')) +} + +function getCoverageBackfillRequire (CoverageReporter) { + const hookedCoverageReporterRequire = CoverageReporter && coverageReporterRequires.get(CoverageReporter) + if (hookedCoverageReporterRequire) return hookedCoverageReporterRequire + if (coverageReporterRequire) return coverageReporterRequire + + const coverageReporterFilename = CoverageReporter?.filename || coverageReporterClass?.filename + if (coverageReporterFilename) { + return createRequire(`${path.join(path.dirname(coverageReporterFilename), 'CoverageWorker')}.js`) + } + + return require +} + +function getTestContexts (tests) { + if (!tests?.length) return + + const contexts = new Set() + for (const test of tests) { + if (test.context) { + contexts.add(test.context) + } + } + return contexts.size ? contexts : undefined +} + +function getCoverageBackfillContexts (contexts) { + return contexts?.size ? contexts : coverageBackfillContexts || contexts +} + +function resetSuiteSkippingRunState () { + isSuitesSkipped = false + numSkippedSuites = 0 + hasUnskippableSuites = false + hasForcedToRunSuites = false + hasFilteredSkippableSuites = false + skippedSuitesCoverage = {} + lastCoverageMap = undefined + lastCoverageMapRootDir = undefined + coverageBackfillContexts = undefined + coverageBackfillFiles = undefined +} + function applySuiteSkipping (originalTests, rootDir, frameworkVersion) { - const jestSuitesToRun = getJestSuitesToRun(skippableSuites, originalTests, rootDir || process.cwd()) + if (!isItrEnabled || !isSuitesSkippingEnabled) return originalTests + + const suitePathRoot = getRepositoryRootFromTest(originalTests[0], rootDir) + const jestSuitesToRun = getJestSuitesToRun(skippableSuites, originalTests, suitePathRoot) hasFilteredSkippableSuites = true log.debug('%d out of %d suites are going to run.', jestSuitesToRun.suitesToRun.length, originalTests.length) hasUnskippableSuites = jestSuitesToRun.hasUnskippableSuites @@ -1138,12 +1259,112 @@ function applySuiteSkipping (originalTests, rootDir, frameworkVersion) { isSuitesSkipped = jestSuitesToRun.suitesToRun.length !== originalTests.length numSkippedSuites = jestSuitesToRun.skippedSuites.length + skippedSuitesCoverage = isSuitesSkipped && isTiaCoverageBackfillEnabled() && hasSkippableSuitesCoverage() + ? skippableSuitesCoverage + : {} + coverageBackfillContexts = isSuitesSkipped && isTiaCoverageBackfillEnabled() + ? getTestContexts(originalTests) + : undefined + coverageBackfillFiles = isSuitesSkipped && isTiaCoverageBackfillEnabled() && hasSkippableSuitesCoverage() + ? getCoverageBackfillFiles(skippableSuitesCoverage, suitePathRoot, getTestSuitePath) + : undefined itrSkippedSuitesCh.publish({ skippedSuites: jestSuitesToRun.skippedSuites, frameworkVersion }) return jestSuitesToRun.suitesToRun } +function applySkippedCoverageToJestCoverageMap (coverageMap, rootDir) { + if (!coverageMap || !isSuitesSkipped || !isTiaCoverageBackfillEnabled()) return + applySkippedCoverageToCoverage( + coverageMap, + skippedSuitesCoverage, + rootDir || process.cwd() + ) +} + +function reporterDispatcherWrapper (reporterDispatcherPackage) { + const ReporterDispatcher = reporterDispatcherPackage.default ?? reporterDispatcherPackage + if (ReporterDispatcher?.prototype?.onRunComplete) { + shimmer.wrap(ReporterDispatcher.prototype, 'onRunComplete', onRunComplete => function (contexts, results) { + if (isSuitesSkipped && isTiaCoverageBackfillEnabled()) { + applySkippedCoverageToJestCoverageMap(results?.coverageMap, getRepositoryRootFromContexts(contexts)) + } + return onRunComplete.apply(this, arguments) + }) + } + + return reporterDispatcherPackage +} + +function wrapCoverageReporter (CoverageReporter, hookMeta) { + if (!CoverageReporter?.prototype?.onRunComplete || wrappedCoverageReporters.has(CoverageReporter)) { + return + } + + coverageReporterRequire = getHookRequire(hookMeta) || coverageReporterRequire + if (coverageReporterRequire) { + coverageReporterRequires.set(CoverageReporter, coverageReporterRequire) + } + coverageReporterClass = CoverageReporter + wrappedCoverageReporters.add(CoverageReporter) + if (CoverageReporter.prototype._addUntestedFiles) { + shimmer.wrap(CoverageReporter.prototype, '_addUntestedFiles', addUntestedFiles => function (...args) { + const rootDir = repositoryRoot || this._globalConfig?.rootDir || process.cwd() + args[0] = getCoverageBackfillContexts(args[0]) + const result = addUntestedFiles.apply(this, args) + if (!isSuitesSkipped || !isTiaCoverageBackfillEnabled()) return result + + const addBackfillAndApplyCoverage = () => { + return addCoverageBackfillUntestedFiles({ + coverageMap: this._coverageMap, + testContexts: args[0], + rootDir, + CoverageReporter, + coverageBackfillFiles, + getCoverageBackfillRequire, + }).then(() => { + applySkippedCoverageToJestCoverageMap(this._coverageMap, rootDir) + }) + } + + return Promise.resolve(result).then(value => { + return addBackfillAndApplyCoverage().then(() => value) + }) + }) + } + + shimmer.wrap(CoverageReporter.prototype, 'onRunComplete', onRunComplete => async function (contexts, results) { + const coverageContexts = getCoverageBackfillContexts(contexts) + const rootDir = getRepositoryRootFromContexts(coverageContexts, this._globalConfig?.rootDir) + const coverageMap = results?.coverageMap || this._coverageMap + if (isSuitesSkipped && isTiaCoverageBackfillEnabled()) { + await addCoverageBackfillUntestedFiles({ + coverageMap, + testContexts: coverageContexts, + rootDir, + CoverageReporter, + coverageBackfillFiles, + getCoverageBackfillRequire, + }) + applySkippedCoverageToJestCoverageMap(coverageMap, rootDir) + } + lastCoverageMap = coverageMap + lastCoverageMapRootDir = rootDir + return onRunComplete.call(this, coverageContexts, results) + }) +} + +function reportersWrapper (reportersPackage, _version, _isIitm, hookMeta) { + wrapCoverageReporter(reportersPackage.CoverageReporter, hookMeta) + return reportersPackage +} + +function coverageReporterWrapper (coverageReporterPackage, _version, _isIitm, hookMeta) { + wrapCoverageReporter(coverageReporterPackage.default ?? coverageReporterPackage, hookMeta) + return coverageReporterPackage +} + addHook({ name: 'jest-environment-node', versions: [MINIMUM_JEST_VERSION], @@ -1188,7 +1409,9 @@ function searchSourceWrapper (searchSourcePackage, frameworkVersion) { const [{ rootDir, shard }] = arguments if (isKnownTestsEnabled) { - const projectSuites = testPaths.tests.map(test => getTestSuitePath(test.path, test.context.config.rootDir)) + const projectSuites = testPaths.tests.map(test => { + return getTestSuitePath(test.path, getRepositoryRootFromTest(test, test.context.config.rootDir)) + }) // If the `jest` key does not exist in the known tests response, we consider the Early Flake detection faulty. const isFaulty = !knownTests?.jest || @@ -1208,14 +1431,11 @@ function searchSourceWrapper (searchSourcePackage, frameworkVersion) { } } + // When Jest sharding is enabled, filter after Jest picks this process's shard. Different shards usually run in + // different CI jobs, so their skippable requests can happen at different times and receive different responses. + // Filtering before Jest shards would make each job shard a different base test list, which can cause duplicate + // suite execution across shards. if (shard?.shardCount > 1 || !isSuitesSkippingEnabled || !skippableSuites.length) { - // If the user is using jest sharding, we want to apply the filtering of tests in the shard process. - // The reason for this is the following: - // The tests for different shards are likely being run in different CI jobs so - // the requests to the skippable endpoint might be done at different times and their responses might be different. - // If the skippable endpoint is returning different suites and we filter the list of tests here, - // the base list of tests that is used for sharding might be different, - // causing the shards to potentially run the same suite. return testPaths } const { tests } = testPaths @@ -1230,6 +1450,8 @@ function searchSourceWrapper (searchSourcePackage, frameworkVersion) { function getCliWrapper (isNewJestVersion) { return function cliWrapper (cli, jestVersion) { warnDeprecatedJestVersion(jestVersion) + isJestCoverageBackfillSupported = !!jestVersion && + satisfies(jestVersion, MINIMUM_JEST_COVERAGE_BACKFILL_VERSION) if (isNewJestVersion) { cli = shimmer.wrap( @@ -1245,15 +1467,17 @@ function getCliWrapper (isNewJestVersion) { return runCLI.apply(this, arguments) } + resetSuiteSkippingRunState() + try { const { err, libraryConfig } = await getChannelPromise(libraryConfigurationCh, { frameworkVersion: jestVersion, }) if (!err) { isCodeCoverageEnabled = libraryConfig.isCodeCoverageEnabled - isSuitesSkippingEnabled = libraryConfig.isSuitesSkippingEnabled - DD_TEST_TIA_KEEP_COV_CONFIG = - libraryConfig.DD_TEST_TIA_KEEP_COV_CONFIG ?? DD_TEST_TIA_KEEP_COV_CONFIG + isCoverageReportUploadEnabled = libraryConfig.isCoverageReportUploadEnabled + isItrEnabled = libraryConfig.isItrEnabled + isSuitesSkippingEnabled = isItrEnabled && libraryConfig.isSuitesSkippingEnabled isEarlyFlakeDetectionEnabled = libraryConfig.isEarlyFlakeDetectionEnabled earlyFlakeDetectionNumRetries = libraryConfig.earlyFlakeDetectionNumRetries earlyFlakeDetectionSlowTestRetries = libraryConfig.earlyFlakeDetectionSlowTestRetries ?? {} @@ -1297,10 +1521,18 @@ function getCliWrapper (isNewJestVersion) { if (isSuitesSkippingEnabled) { try { - const { err, skippableSuites: receivedSkippableSuites } = - skippableSuitesResponse || await getChannelPromise(skippableSuitesCh) - if (!err) { + const { + err, + skippableSuites: receivedSkippableSuites, + skippableSuitesCoverage: receivedSkippableSuitesCoverage, + } = skippableSuitesResponse || await getChannelPromise(skippableSuitesCh) + if (err) { + skippableSuitesCoverage = {} + skippedSuitesCoverage = {} + } else { skippableSuites = receivedSkippableSuites + skippableSuitesCoverage = receivedSkippableSuitesCoverage || {} + skippedSuitesCoverage = {} } } catch (err) { log.error('Jest test-suite skippable error', err) @@ -1335,13 +1567,16 @@ function getCliWrapper (isNewJestVersion) { } const processArgv = process.argv.slice(2).join(' ') - testSessionStartCh.publish({ command: `jest ${processArgv}`, frameworkVersion: jestVersion }) + testSessionStartCh.publish({ + command: `jest ${processArgv}`, + frameworkVersion: jestVersion, + }) const result = await runCLI.apply(this, arguments) const { results: { - coverageMap, + coverageMap: resultCoverageMap, numFailedTestSuites, numFailedTests, numRuntimeErrorTestSuites = 0, @@ -1357,11 +1592,30 @@ function getCliWrapper (isNewJestVersion) { const mustNotFlipSuccess = hasSuiteLevelFailures || hasRunLevelFailure let testCodeCoverageLinesTotal + let testSessionCoverageFiles + const shouldReportTestSessionCoverage = isTiaCoverageBackfillEnabled() - if (isUserCodeCoverageEnabled) { + if (shouldReportCodeCoverageLinesPct()) { try { - const { pct, total } = coverageMap.getCoverageSummary().lines - testCodeCoverageLinesTotal = total === 0 ? 0 : pct + const coverageMap = resultCoverageMap || lastCoverageMap + const coverageRootDir = lastCoverageMapRootDir || + repositoryRoot || + result.globalConfig?.rootDir || + process.cwd() + if (isSuitesSkipped) { + applySkippedCoverageToJestCoverageMap(coverageMap, coverageRootDir) + } + testCodeCoverageLinesTotal = getTestCoverageLinesPercentage( + coverageMap, + undefined, + coverageRootDir + ) + if (shouldReportTestSessionCoverage) { + testSessionCoverageFiles = getExecutableFilesFromCoverage(coverageMap).map(({ filename, bitmap }) => ({ + filename: getTestSuitePath(filename, coverageRootDir), + bitmap, + })) + } } catch { // ignore errors } @@ -1383,9 +1637,7 @@ function getCliWrapper (isNewJestVersion) { for (const { testResults, testFilePath } of result.results.testResults) { const suite = getTestSuitePath(testFilePath, result.globalConfig.rootDir) for (const { fullName } of testResults) { - const name = testSuiteAbsolutePathsWithFastCheck.has(testFilePath) - ? fullName.replace(SEED_SUFFIX_RE, '') - : fullName + const name = removeSeedSuffixFromTestName(fullName) fullNameToSuite.set(name, suite) } } @@ -1424,10 +1676,8 @@ function getCliWrapper (isNewJestVersion) { .testResults.flatMap(({ testResults, testFilePath: testSuiteAbsolutePath }) => ( testResults.map(({ fullName: testName, status }) => ( { - // Strip @fast-check/jest seed suffix so the name matches what was reported via TEST_NAME - testName: testSuiteAbsolutePathsWithFastCheck.has(testSuiteAbsolutePath) - ? testName.replace(SEED_SUFFIX_RE, '') - : testName, + // Strip seed suffix so the name matches what was reported via TEST_NAME. + testName: removeSeedSuffixFromTestName(testName), testSuiteAbsolutePath, status, } @@ -1546,7 +1796,9 @@ function getCliWrapper (isNewJestVersion) { isSuitesSkipped, isSuitesSkippingEnabled, isCodeCoverageEnabled, + isCoverageReportUploadEnabled, testCodeCoverageLinesTotal, + testSessionCoverageFiles, numSkippedSuites, hasUnskippableSuites, hasForcedToRunSuites, @@ -1572,7 +1824,7 @@ function getCliWrapper (isNewJestVersion) { logSessionSummary(ignoredFailuresSummary, getAttemptToFixExecutionsFromJestResults(result)) - numSkippedSuites = 0 + resetSuiteSkippingRunState() return result }, { @@ -1581,28 +1833,6 @@ function getCliWrapper (isNewJestVersion) { } } -function coverageReporterWrapper (coverageReporter) { - const CoverageReporter = coverageReporter.default ?? coverageReporter - - /** - * If ITR is active, we're running fewer tests, so of course the total code coverage is reduced. - * This calculation adds no value, so we'll skip it, as long as the user has not manually opted in to code coverage, - * in which case we'll leave it. - */ - // `_addUntestedFiles` is an async function - shimmer.wrap(CoverageReporter.prototype, '_addUntestedFiles', addUntestedFiles => function (...args) { - if (DD_TEST_TIA_KEEP_COV_CONFIG) { - return addUntestedFiles.apply(this, args) - } - if (isCodeCoverageEnabledBecauseOfUs) { - return Promise.resolve() - } - return addUntestedFiles.apply(this, args) - }) - - return coverageReporter -} - function shouldWaitForTestSuiteFinish (environment) { return isJestWorker && environment.globalConfig?.workerIdleMemoryLimit !== undefined } @@ -1626,7 +1856,6 @@ function publishTestSuiteFinish (payload, waitForFinish) { function cleanupTestSuiteState (testSuiteAbsolutePath) { testSuiteMockedFiles.delete(testSuiteAbsolutePath) - testSuiteFastCheckUsage.delete(testSuiteAbsolutePath) testSuiteJestObjects.delete(testSuiteAbsolutePath) } @@ -1681,6 +1910,23 @@ addHook({ return sequencerPackage }) +addHook({ + name: '@jest/core', + file: 'build/cli/index.js', + versions: [MINIMUM_JEST_VERSION_BEFORE_30], +}, getCliWrapper(false)) + +addHook({ + name: '@jest/core', + versions: ['>=30.0.0'], +}, getCliWrapper(true)) + +addHook({ + name: '@jest/core', + file: 'build/ReporterDispatcher.js', + versions: [MINIMUM_JEST_VERSION], +}, reporterDispatcherWrapper) + if (DD_MAJOR < 6) { addHook({ name: '@jest/reporters', @@ -1689,29 +1935,16 @@ if (DD_MAJOR < 6) { }, coverageReporterWrapper) } -addHook({ - name: '@jest/reporters', - file: 'build/CoverageReporter.js', - versions: [DD_MAJOR >= 6 ? '>=28.0.0' : '>=26.6.2'], -}, coverageReporterWrapper) - addHook({ name: '@jest/reporters', versions: ['>=30.0.0'], -}, (reporters) => { - return shimmer.wrap(reporters, 'CoverageReporter', coverageReporterWrapper, { replaceGetter: true }) -}) - -addHook({ - name: '@jest/core', - file: 'build/cli/index.js', - versions: [MINIMUM_JEST_VERSION_BEFORE_30], -}, getCliWrapper(false)) +}, reportersWrapper) addHook({ - name: '@jest/core', - versions: ['>=30.0.0'], -}, getCliWrapper(true)) + name: '@jest/reporters', + file: 'build/CoverageReporter.js', + versions: [DD_MAJOR >= 6 ? '>=28.0.0' : '>=26.6.2'], +}, coverageReporterWrapper) function jestAdapterWrapper (jestAdapter, jestVersion) { const adapter = jestAdapter.default ?? jestAdapter @@ -1745,10 +1978,13 @@ function jestAdapterWrapper (jestAdapter, jestVersion) { if (environment.testEnvironmentOptions?._ddTestCodeCoverageEnabled) { const root = environment.repositoryRoot || environment.rootDir - const getFilesWithPath = (files) => files.map(file => getTestSuitePath(file, root)) - - const coverageFiles = getFilesWithPath(getCoveredFilenamesFromCoverage(environment.global.__coverage__)) - const mockedFiles = getFilesWithPath(getMockedFiles(environment.testSuiteAbsolutePath)) + const coverageFiles = getCoveredFilesFromCoverage(environment.global.__coverage__) + .map(file => ({ + ...file, + filename: getTestSuitePath(file.filename, root), + })) + const mockedFiles = getMockedFiles(environment.testSuiteAbsolutePath) + .map(file => getTestSuitePath(file, root)) testSuiteCodeCoverageCh.publish({ coverageFiles, @@ -1828,41 +2064,48 @@ addHook({ }, jestAdapterWrapper) function configureTestEnvironment (readConfigsResult) { - const { configs } = readConfigsResult - testSessionConfigurationCh.publish(configs.map(config => config.testEnvironmentOptions)) - // We can't directly use isCodeCoverageEnabled when reporting coverage in `jestAdapterWrapper` - // because `jestAdapterWrapper` runs in a different process. We have to go through `testEnvironmentOptions` - for (const config of configs) { - config.testEnvironmentOptions._ddTestCodeCoverageEnabled = isCodeCoverageEnabled - } - + repositoryRoot = getJestRepositoryRoot(readConfigsResult) isUserCodeCoverageEnabled = !!readConfigsResult.globalConfig.collectCoverage - isCodeCoverageEnabledBecauseOfUs = isCodeCoverageEnabled && !isUserCodeCoverageEnabled - - if (readConfigsResult.globalConfig.forceExit) { - log.warn("Jest's '--forceExit' flag has been passed. This may cause loss of data.") - } + const isCodeCoverageEnabledBecauseOfUs = shouldCollectJestCoverageForTia() && !isUserCodeCoverageEnabled if (isCodeCoverageEnabledBecauseOfUs) { - const globalConfig = { + readConfigsResult.globalConfig = { ...readConfigsResult.globalConfig, collectCoverage: true, + coverageReporters: ['none'], } - readConfigsResult.globalConfig = globalConfig + readConfigsResult.configs = readConfigsResult.configs.map(config => ({ + ...config, + coverageReporters: ['none'], + })) } + + // We can't directly use the parent process flags when reporting suite coverage in `jestAdapterWrapper` + // because `jestAdapterWrapper` runs in a different process. We have to go through `testEnvironmentOptions`. + const configs = readConfigsResult.configs.map(config => { + const testEnvironmentOptions = config.testEnvironmentOptions || {} + testEnvironmentOptions._ddRepositoryRoot = repositoryRoot + testEnvironmentOptions._ddTestCodeCoverageEnabled = shouldReportJestSuiteCoverageForTia() + + return { + ...config, + testEnvironmentOptions, + } + }) + readConfigsResult.configs = configs + testSessionConfigurationCh.publish(readConfigsResult.configs.map(config => config.testEnvironmentOptions)) + repositoryRoot = getJestRepositoryRoot(readConfigsResult) + + if (readConfigsResult.globalConfig.forceExit) { + log.warn("Jest's '--forceExit' flag has been passed. This may cause loss of data.") + } + if (isSuitesSkippingEnabled) { // If suite skipping is enabled, we pass `passWithNoTests` in case every test gets skipped. const globalConfig = { ...readConfigsResult.globalConfig, passWithNoTests: true, } - if (isCodeCoverageEnabledBecauseOfUs && !DD_TEST_TIA_KEEP_COV_CONFIG) { - globalConfig.coverageReporters = ['none'] - readConfigsResult.configs = configs.map(config => ({ - ...config, - coverageReporters: ['none'], - })) - } readConfigsResult.globalConfig = globalConfig } @@ -1897,6 +2140,7 @@ const DD_TEST_ENVIRONMENT_OPTION_KEYS = [ '_ddIsEarlyFlakeDetectionEnabled', '_ddEarlyFlakeDetectionSlowTestRetries', '_ddRepositoryRoot', + '_ddTestCodeCoverageEnabled', '_ddIsFlakyTestRetriesEnabled', '_ddFlakyTestRetriesCount', '_ddItrSkippingEnabledTags', @@ -2080,38 +2324,6 @@ function wrapJestGlobalsForRuntime (runtime) { }) } -function recordFastCheckUsage (runtime, from, moduleName) { - if (moduleName !== '@fast-check/jest') return - - if (from) { - testSuiteAbsolutePathsWithFastCheck.add(from) - testSuiteFastCheckUsage.set(from, true) - } - if (runtime?._testPath) { - testSuiteAbsolutePathsWithFastCheck.add(runtime._testPath) - testSuiteFastCheckUsage.set(runtime._testPath, true) - } -} - -function doesTestSuiteUseFastCheck (testSuiteAbsolutePath) { - if (!testSuiteAbsolutePath) return false - if (testSuiteFastCheckUsage.has(testSuiteAbsolutePath)) { - return testSuiteFastCheckUsage.get(testSuiteAbsolutePath) - } - - try { - const usesFastCheck = readFileSync(testSuiteAbsolutePath, 'utf8').includes('@fast-check/jest') - testSuiteFastCheckUsage.set(testSuiteAbsolutePath, usesFastCheck) - if (usesFastCheck) { - testSuiteAbsolutePathsWithFastCheck.add(testSuiteAbsolutePath) - } - return usesFastCheck - } catch { - testSuiteFastCheckUsage.set(testSuiteAbsolutePath, false) - return false - } -} - function getLastLoggedReferenceError (runtime) { const loggedReferenceErrors = runtime?.loggedReferenceErrors if (!loggedReferenceErrors?.size) return @@ -2220,8 +2432,6 @@ addHook({ // To bypass jest's own require engine return requireOutsideJestRequireEngine(this, moduleName) } - // This means that `@fast-check/jest` is used in the test file. - recordFastCheckUsage(this, from, moduleName) let returnedValue try { returnedValue = requireModuleOrMock.apply(this, arguments) diff --git a/packages/datadog-instrumentations/src/jest/coverage-backfill.js b/packages/datadog-instrumentations/src/jest/coverage-backfill.js new file mode 100644 index 0000000000..b1ed08bc63 --- /dev/null +++ b/packages/datadog-instrumentations/src/jest/coverage-backfill.js @@ -0,0 +1,163 @@ +'use strict' + +const { readFileSync } = require('node:fs') +const path = require('node:path') + +const COVERAGE_BACKFILL_CACHE_DIRECTORY = 'dd-trace-coverage-backfill' +const TRANSFORM_OPTIONS = { + instrument: true, + supportsDynamicImport: true, + supportsExportNamespaceFrom: true, + supportsStaticESM: true, + supportsTopLevelAwait: true, +} + +function getCoverageBackfillFiles (skippableSuitesCoverage, rootDir, getTestSuitePath) { + const files = [] + for (const filename of Object.keys(skippableSuitesCoverage || {})) { + const relativeFilename = path.isAbsolute(filename) + ? getTestSuitePath(filename, rootDir) + : filename + files.push(relativeFilename) + } + return files +} + +// Use a separate Jest cache namespace for synthetic backfill transforms so they cannot reuse or overwrite normal +// Jest transform cache entries produced during the user's test run. +function getCoverageBackfillConfig (config) { + if (!config?.cacheDirectory) return config + + return { + ...config, + cacheDirectory: path.join(config.cacheDirectory, COVERAGE_BACKFILL_CACHE_DIRECTORY), + } +} + +function getCoverageBackfillDependencies (CoverageReporter, getCoverageBackfillRequire) { + const coverageWorkerRequire = getCoverageBackfillRequire(CoverageReporter) + + return { + createFileCoverage: coverageWorkerRequire('istanbul-lib-coverage').createFileCoverage, + createScriptTransformer: coverageWorkerRequire('@jest/transform').createScriptTransformer, + readInitialCoverage: coverageWorkerRequire('istanbul-lib-instrument').readInitialCoverage, + } +} + +// Some transformers expose Istanbul coverage as a literal that readInitialCoverage does not parse. +function extractCoverageDataObject (code) { + const marker = 'var coverageData = ' + const start = code.indexOf(marker) + if (start === -1) return + + let depth = 0 + let quote + let escaped = false + let index = start + marker.length + for (; index < code.length; index++) { + const char = code[index] + if (quote) { + if (escaped) { + escaped = false + } else if (char === '\\') { + escaped = true + } else if (char === quote) { + quote = undefined + } + continue + } + if (char === '"' || char === "'" || char === '`') { + quote = char + } else if (char === '{') { + depth++ + } else if (char === '}') { + depth-- + if (depth === 0) { + index++ + break + } + } + } + if (depth !== 0) return + + try { + // eslint-disable-next-line no-new-func + return new Function(`return (${code.slice(start + marker.length, index)})`)() + } catch { + // Ignore transformer output that does not contain parseable Istanbul metadata. + } +} + +// Read the Istanbul file metadata emitted by Jest's transformer. +function getCoverageDataFromCode (code, readInitialCoverage) { + return readInitialCoverage(code)?.coverageData || extractCoverageDataObject(code) +} + +function transformFileWithTransformers (absoluteFile, sourceText, transformers, readInitialCoverage) { + return Promise.all(transformers.map(transformer => { + return transformer.transformSourceAsync(absoluteFile, sourceText, TRANSFORM_OPTIONS) + .then(({ code }) => getCoverageDataFromCode(code, readInitialCoverage)) + .catch(() => {}) + })).then(coverageDataByContext => coverageDataByContext.find(Boolean)) +} + +function getBackfillCoverageDataForFile (file, rootDir, transformers, coverageMap, readInitialCoverage) { + const absoluteFile = path.isAbsolute(file) ? file : path.join(rootDir, file) + if (coverageMap.data[absoluteFile]) return Promise.resolve() + + let sourceText + try { + sourceText = readFileSync(absoluteFile, 'utf8') + } catch { + return Promise.resolve() + } + + return transformFileWithTransformers(absoluteFile, sourceText, transformers, readInitialCoverage) +} + +// Seed Jest's coverage map with files that did not run locally but are covered by backend meta.coverage. +function addCoverageBackfillUntestedFiles ({ + coverageMap, + testContexts, + rootDir, + CoverageReporter, + coverageBackfillFiles, + getCoverageBackfillRequire, +}) { + if (!coverageBackfillFiles?.length || !coverageMap || !rootDir) return Promise.resolve() + + let createFileCoverage, createScriptTransformer, readInitialCoverage + try { + ({ + createFileCoverage, + createScriptTransformer, + readInitialCoverage, + } = getCoverageBackfillDependencies(CoverageReporter, getCoverageBackfillRequire)) + } catch { + return Promise.resolve() + } + + const contexts = [...(testContexts || [])] + return Promise.all(contexts.map(context => { + return createScriptTransformer(getCoverageBackfillConfig(context.config)).catch(() => {}) + })) + .then(transformers => transformers.filter(Boolean)) + .then(transformers => { + if (!transformers.length) return [] + return Promise.all(coverageBackfillFiles.map(file => { + return getBackfillCoverageDataForFile(file, rootDir, transformers, coverageMap, readInitialCoverage) + })) + }) + .then(coverageDataByFile => { + for (const coverageData of coverageDataByFile) { + if (coverageData && !coverageMap.data[coverageData.path]) { + coverageMap.addFileCoverage(createFileCoverage(coverageData)) + } + } + }) +} + +module.exports = { + addCoverageBackfillUntestedFiles, + getCoverageBackfillFiles, +} diff --git a/packages/datadog-instrumentations/src/kafkajs.js b/packages/datadog-instrumentations/src/kafkajs.js index 561744aff7..9e963d02ca 100644 --- a/packages/datadog-instrumentations/src/kafkajs.js +++ b/packages/datadog-instrumentations/src/kafkajs.js @@ -62,6 +62,7 @@ addHook({ name: 'kafkajs', file: 'src/index.js', versions: ['>=1.4'] }, (BaseKaf shimmer.wrap(Kafka.prototype, 'producer', createProducer => function () { const producer = createProducer.apply(this, arguments) const originalSend = producer.send + const originalSendBatch = producer.sendBatch const bootstrapServers = this._brokers const cluster = clientToCluster.get(producer) @@ -75,35 +76,46 @@ addHook({ name: 'kafkajs', file: 'src/index.js', versions: ['>=1.4'] }, (BaseKaf } } - producer.send = function (...args) { - if (!producerStartCh.hasSubscribers) { - return originalSend.apply(this, args) - } - - // Fast path: kafkajs has fetched metadata, so versions and clusterId - // are already on the broker pool. + /** + * Resolve the negotiated clusterId once and hand it to `call`. Fast path reads + * `cluster.brokerPool.metadata` synchronously when kafkajs already fetched it. + * Slow path primes `refreshMetadataIfNecessary`, which `sharedPromiseTo` + * deduplicates with kafkajs's own internal fetch so total latency is unchanged. + * + * @param {(clusterId: string | undefined) => Promise} call + */ + const withClusterId = (call) => { const metadata = cluster?.brokerPool?.metadata if (metadata) { refreshHeaderSupport() - return runSend.call(this, args, metadata.clusterId) + return call(metadata.clusterId) } - - // Slow path, taken at most once per producer connect cycle. Prime the - // metadata fetch kafkajs's send would do internally a few stack frames - // later. `sharedPromiseTo` collapses our call and kafkajs's call into a - // single round trip, so total latency is unchanged. if (typeof cluster?.refreshMetadataIfNecessary !== 'function') { - return runSend.call(this, args) + return call() } return cluster.refreshMetadataIfNecessary().then( () => { refreshHeaderSupport() - return runSend.call(this, args, cluster.brokerPool?.metadata?.clusterId) + return call(cluster.brokerPool?.metadata?.clusterId) }, - () => runSend.call(this, args) + () => call() ) } + producer.send = function (...args) { + if (!producerStartCh.hasSubscribers) { + return originalSend.apply(this, args) + } + return withClusterId((clusterId) => runSend.call(this, args, clusterId)) + } + + producer.sendBatch = function (...args) { + if (!producerStartCh.hasSubscribers) { + return originalSendBatch.apply(this, args) + } + return withClusterId((clusterId) => runSendBatch.call(this, args, clusterId)) + } + function runSend (args, clusterId) { const arg0 = args[0] const topic = arg0?.topic @@ -166,6 +178,98 @@ addHook({ name: 'kafkajs', file: 'src/index.js', versions: ['>=1.4'] }, (BaseKaf }) } + function runSendBatch (args, clusterId) { + const arg0 = args[0] + const inputTopicMessages = Array.isArray(arg0?.topicMessages) ? arg0.topicMessages : [] + if (inputTopicMessages.length === 0) { + return originalSendBatch.apply(this, args) + } + + // One ctx per topicMessages entry — kafkajs implements `send` as a single-entry + // `sendBatch` (`producer/messageProducer.js`), so one span per entry is the same + // unit `send` already produces. Cloning only happens for valid arrays so kafkajs + // still sees and rejects a caller's malformed `messages` field. + const outputEntries = new Array(inputTopicMessages.length) + const ctxList = [] + let cloned = false + for (let i = 0; i < inputTopicMessages.length; i++) { + const entry = inputTopicMessages[i] + const topic = entry?.topic + const rawMessages = entry?.messages + let entryMessages = rawMessages + if (Array.isArray(rawMessages) && rawMessages.length > 0) { + entryMessages = cloneMessages(rawMessages, !disableHeaderInjection) + outputEntries[i] = { ...entry, messages: entryMessages } + cloned = true + } else { + outputEntries[i] = entry + } + ctxList.push({ + bootstrapServers, + clusterId, + disableHeaderInjection, + messages: Array.isArray(entryMessages) ? entryMessages : [], + topic, + }) + } + if (cloned) { + args[0] = { ...arg0, topicMessages: outputEntries } + } + + for (const ctx of ctxList) { + producerStartCh.runStores(ctx, noop) + } + + let result + try { + result = originalSendBatch.apply(this, args) + } catch (error) { + failProduceBatch(ctxList, error) + throw error + } + + result.then( + (res) => { + for (const ctx of ctxList) { + ctx.result = res + producerFinishCh.publish(ctx) + } + // kafkajs returns a single aggregated response covering every topic; + // commit fires once so the plugin's `setOffset` loop runs once per + // entry of the response, not once per span. + producerCommitCh.publish(ctxList[0]) + }, + (error) => failProduceBatch(ctxList, error) + ) + + return result + } + + /** + * Tag every open ctx with the shared error, then publish error + finish so the + * plugin closes each span. The mixed-version safety net (broker advertised + * Produce v3+ but the leader rejected the headers) fires at most once per + * failed batch and short-circuits subsequent sends to the disabled path. + * + * @param {Array} ctxList + * @param {Error} error + */ + function failProduceBatch (ctxList, error) { + if (error?.name === 'KafkaJSProtocolError' && error.type === 'UNKNOWN') { + disableHeaderInjection = true + refreshHeaderSupport = noop + log.error( + // eslint-disable-next-line @stylistic/max-len + 'Kafka Broker responded with UNKNOWN_SERVER_ERROR (-1). Please look at broker logs for more information. Tracer message header injection for Kafka is disabled.' + ) + } + for (const ctx of ctxList) { + ctx.error = error + producerErrorCh.publish(ctx) + producerFinishCh.publish(ctx) + } + } + return producer }) diff --git a/packages/datadog-instrumentations/src/mocha/main.js b/packages/datadog-instrumentations/src/mocha/main.js index 566aece433..3b0d46652c 100644 --- a/packages/datadog-instrumentations/src/mocha/main.js +++ b/packages/datadog-instrumentations/src/mocha/main.js @@ -6,16 +6,21 @@ const { DD_MAJOR } = require('../../../../version') const { addHook, channel } = require('../helpers/instrument') const shimmer = require('../../../datadog-shimmer') const { isMarkedAsUnskippable } = require('../../../datadog-plugin-jest/src/util') +const { writeCoverageBackfillToCache } = require('../../../dd-trace/src/ci-visibility/test-optimization-cache') const log = require('../../../dd-trace/src/log') const { getEnvironmentVariable } = require('../../../dd-trace/src/config/helper') const { getTestSuitePath, MOCHA_WORKER_TRACE_PAYLOAD_CODE, fromCoverageMapToCoverage, - getCoveredFilenamesFromCoverage, + getCoveredFilesFromCoverage, + getExecutableFilesFromCoverage, + applySkippedCoverageToCoverage, mergeCoverage, resetCoverage, getIsFaultyEarlyFlakeDetection, + getRelativeCoverageFiles, + getTestCoverageLinesPercentage, collectTestOptimizationSummariesFromTraces, logTestOptimizationSummary, getTestOptimizationRequestResults, @@ -53,6 +58,8 @@ const unskippableSuites = [] let suitesToSkip = [] let isSuitesSkipped = false let skippedSuites = [] +let skippableSuitesCoverage = {} +let skippedSuitesCoverage = {} let itrCorrelationId = '' let isForcedToRun = false const config = {} @@ -133,10 +140,33 @@ function haveRootTestsFinished (rootTests) { return true } +function getSuitePath (suite) { + return getTestSuitePath(suite.file, process.cwd()) +} + +function getSuitesToSkip (originalSuites) { + return getSuitesToSkipFromPaths(originalSuites.map(getSuitePath)) +} + +function getSuitesToSkipFromPaths (localSuites) { + const localSuitesSet = new Set(localSuites) + const suitesToSkipForRun = [] + + for (const suite of suitesToSkip) { + if (localSuitesSet.has(suite)) { + suitesToSkipForRun.push(suite) + } + } + + return suitesToSkipForRun +} + function getFilteredSuites (originalSuites) { + const suitesToSkipForRun = getSuitesToSkip(originalSuites) + return originalSuites.reduce((acc, suite) => { - const testPath = getTestSuitePath(suite.file, process.cwd()) - const shouldSkip = suitesToSkip.includes(testPath) + const testPath = getSuitePath(suite) + const shouldSkip = suitesToSkipForRun.includes(testPath) const isUnskippable = unskippableSuites.includes(suite.file) if (shouldSkip && !isUnskippable) { acc.skippedSuites.add(testPath) @@ -144,7 +174,50 @@ function getFilteredSuites (originalSuites) { acc.suitesToRun.push(suite) } return acc - }, { suitesToRun: [], skippedSuites: new Set() }) + }, { suitesToRun: [], skippedSuites: new Set(), suitesToSkipForRun }) +} + +function hasSkippableSuitesCoverage () { + return skippableSuitesCoverage && + typeof skippableSuitesCoverage === 'object' && + Object.keys(skippableSuitesCoverage).length > 0 +} + +function isTiaCoverageBackfillEnabled () { + return config.isItrEnabled && config.isCoverageReportUploadEnabled +} + +function getCoverageRootDir () { + return config.repositoryRoot || process.cwd() +} + +function shouldReportCodeCoverageLinesPct (hasBackfilledCoverage) { + return !isSuitesSkipped || hasBackfilledCoverage +} + +function getSkippedSuitesCoverageForRun () { + return isSuitesSkipped && isTiaCoverageBackfillEnabled() && hasSkippableSuitesCoverage() + ? skippableSuitesCoverage + : {} +} + +function applySkippedCoverageToMochaCoverageMap () { + if (!isTiaCoverageBackfillEnabled()) return false + return applySkippedCoverageToCoverage(originalCoverageMap, skippedSuitesCoverage, getCoverageRootDir()) +} + +function getMochaTestSessionCoverageFiles () { + return getRelativeCoverageFiles(getExecutableFilesFromCoverage(originalCoverageMap), getCoverageRootDir()) +} + +function resetSuiteSkippingRunState () { + isSuitesSkipped = false + skippedSuites = [] + skippableSuitesCoverage = {} + skippedSuitesCoverage = {} + untestedCoverage = undefined + config.repositoryRoot = undefined + writeCoverageBackfillToCache({}) } function getOnStartHandler (frameworkVersion) { @@ -218,12 +291,24 @@ function getOnEndHandler (isParallel) { testFileToSuiteCtx.clear() let testCodeCoverageLinesTotal - if (global.__coverage__) { + let testSessionCoverageFiles + if (global.__coverage__ || untestedCoverage) { try { + let hasBackfilledCoverage = false if (untestedCoverage) { originalCoverageMap.merge(fromCoverageMapToCoverage(untestedCoverage)) } - testCodeCoverageLinesTotal = originalCoverageMap.getCoverageSummary().lines.pct + hasBackfilledCoverage = applySkippedCoverageToMochaCoverageMap() + if (shouldReportCodeCoverageLinesPct(hasBackfilledCoverage)) { + testCodeCoverageLinesTotal = getTestCoverageLinesPercentage( + originalCoverageMap, + undefined, + getCoverageRootDir() + ) + } + if (isTiaCoverageBackfillEnabled()) { + testSessionCoverageFiles = getMochaTestSessionCoverageFiles() + } } catch { // ignore errors } @@ -235,6 +320,7 @@ function getOnEndHandler (isParallel) { status, isSuitesSkipped, testCodeCoverageLinesTotal, + testSessionCoverageFiles, numSkippedSuites: skippedSuites.length, hasForcedToRunSuites: isForcedToRun, hasUnskippableSuites: !!unskippableSuites.length, @@ -276,23 +362,39 @@ function applyTestManagementTestsResponse ({ err, testManagementTests: receivedT } } -function getExecutionConfiguration (runner, isParallel, frameworkVersion, onFinishRequest) { +function getExecutionConfiguration (runner, isParallel, frameworkVersion, onFinishRequest, localSuites) { const ctx = { isParallel, frameworkVersion, } let skippableSuitesResponse - - const onReceivedSkippableSuites = ({ err, skippableSuites, itrCorrelationId: responseItrCorrelationId }) => { + resetSuiteSkippingRunState() + + const onReceivedSkippableSuites = ({ + err, + skippableSuites, + itrCorrelationId: responseItrCorrelationId, + skippableSuitesCoverage: responseSkippableSuitesCoverage, + }) => { if (err) { suitesToSkip = [] + skippableSuitesCoverage = {} } else { suitesToSkip = skippableSuites itrCorrelationId = responseItrCorrelationId + skippableSuitesCoverage = responseSkippableSuitesCoverage || {} } + if (localSuites) { + suitesToSkip = getSuitesToSkipFromPaths(localSuites) + mochaGlobalRunCh.runStores(ctx, () => { + onFinishRequest() + }) + return + } + // We remove the suites that we skip through ITR const filteredSuites = getFilteredSuites(runner.suite.suites) - const { suitesToRun } = filteredSuites + const { suitesToRun, suitesToSkipForRun } = filteredSuites isSuitesSkipped = suitesToRun.length !== runner.suite.suites.length @@ -301,6 +403,9 @@ function getExecutionConfiguration (runner, isParallel, frameworkVersion, onFini runner.suite.suites = suitesToRun skippedSuites = [...filteredSuites.skippedSuites] + suitesToSkip = suitesToSkipForRun + skippedSuitesCoverage = getSkippedSuitesCoverageForRun() + writeCoverageBackfillToCache(skippedSuitesCoverage, getCoverageRootDir()) mochaGlobalRunCh.runStores(ctx, () => { onFinishRequest() @@ -346,12 +451,13 @@ function getExecutionConfiguration (runner, isParallel, frameworkVersion, onFini } } - const onReceivedConfiguration = ({ err, libraryConfig }) => { + const onReceivedConfiguration = ({ err, libraryConfig, repositoryRoot }) => { if (err || !skippableSuitesCh.hasSubscribers || !knownTestsCh.hasSubscribers) { return mochaGlobalRunCh.runStores(ctx, () => { onFinishRequest() }) } + config.repositoryRoot = repositoryRoot config.isEarlyFlakeDetectionEnabled = libraryConfig.isEarlyFlakeDetectionEnabled config.earlyFlakeDetectionNumRetries = libraryConfig.earlyFlakeDetectionNumRetries config.earlyFlakeDetectionSlowTestRetries = libraryConfig.earlyFlakeDetectionSlowTestRetries ?? {} @@ -360,7 +466,10 @@ function getExecutionConfiguration (runner, isParallel, frameworkVersion, onFini config.isTestManagementTestsEnabled = libraryConfig.isTestManagementEnabled config.testManagementAttemptToFixRetries = libraryConfig.testManagementAttemptToFixRetries config.isImpactedTestsEnabled = libraryConfig.isImpactedTestsEnabled - config.isSuitesSkippingEnabled = libraryConfig.isSuitesSkippingEnabled + config.isItrEnabled = libraryConfig.isItrEnabled + config.isCodeCoverageEnabled = libraryConfig.isCodeCoverageEnabled + config.isCoverageReportUploadEnabled = libraryConfig.isCoverageReportUploadEnabled + config.isSuitesSkippingEnabled = config.isItrEnabled && libraryConfig.isSuitesSkippingEnabled config.isFlakyTestRetriesEnabled = libraryConfig.isFlakyTestRetriesEnabled config.flakyTestRetriesCount = libraryConfig.flakyTestRetriesCount @@ -588,7 +697,7 @@ addHook({ const status = getRootSuiteStatus(rootTests) if (global.__coverage__) { - const coverageFiles = getCoveredFilenamesFromCoverage(global.__coverage__) + const coverageFiles = getCoveredFilesFromCoverage(global.__coverage__) testSuiteCodeCoverageCh.publish({ coverageFiles, suiteFile: file }) mergeCoverage(global.__coverage__, originalCoverageMap) resetCoverage(global.__coverage__) @@ -786,7 +895,7 @@ addHook({ } if (global.__coverage__) { - const coverageFiles = getCoveredFilenamesFromCoverage(global.__coverage__) + const coverageFiles = getCoveredFilesFromCoverage(global.__coverage__) testSuiteCodeCoverageCh.publish({ coverageFiles, @@ -908,11 +1017,11 @@ addHook({ } } + const localSuites = files.map(file => getTestSuitePath(file, process.cwd())) getExecutionConfiguration(this, true, frameworkVersion, () => { if (config.isKnownTestsEnabled) { - const testSuites = files.map(file => getTestSuitePath(file, process.cwd())) const isFaulty = getIsFaultyEarlyFlakeDetection( - testSuites, + localSuites, config.knownTests?.mocha || {}, config.earlyFlakeDetectionFaultyThreshold ) @@ -937,11 +1046,13 @@ addHook({ } isSuitesSkipped = skippedFiles.length > 0 skippedSuites = skippedFiles + skippedSuitesCoverage = getSkippedSuitesCoverageForRun() + writeCoverageBackfillToCache(skippedSuitesCoverage, getCoverageRootDir()) run.apply(this, [cb, { files: filteredFiles }]) } else { run.apply(this, arguments) } - }) + }, localSuites) return this }) diff --git a/packages/datadog-instrumentations/src/nats.js b/packages/datadog-instrumentations/src/nats.js new file mode 100644 index 0000000000..e2834a2ba8 --- /dev/null +++ b/packages/datadog-instrumentations/src/nats.js @@ -0,0 +1,182 @@ +'use strict' + +// Shimmer required: NATS consumer paths need argument modification — the user's +// `opts.callback` is wrapped before being handed to SubscriptionImpl, and the +// returned subscription's async iterator is wrapped so iterator-style consumers +// get receive events. Orchestrion can only wrap method calls, not arguments +// or returned iterables. + +const shimmer = require('../../datadog-shimmer') +const { addHook, channel } = require('./helpers/instrument') + +const publishStartCh = channel('apm:nats:publish:start') +const publishFinishCh = channel('apm:nats:publish:finish') +const publishErrorCh = channel('apm:nats:publish:error') + +const consumeStartCh = channel('apm:nats:consume:start') +const consumeFinishCh = channel('apm:nats:consume:finish') +const consumeErrorCh = channel('apm:nats:consume:error') + +// Tracks connections that are currently inside a `request`/`requestMany` call +// so the nested `this.publish(...)` they issue short-circuits without creating +// a second producer span (the outer request wrap already created one and +// injected headers — the inner publish would double-count it). A WeakSet avoids +// changing the shape of the user's connection object. +const requestsInFlight = new WeakSet() + +// Captured from the `lib/headers.js` hook below. The nats-core package always +// imports `./headers` from `lib/nats.js`, so by the time we wrap `publish` the +// reference is set. No defensive checks needed at call sites. +let createHeaders + +addHook({ name: '@nats-io/nats-core', versions: ['>=3.0.0'], file: 'lib/headers.js' }, exports => { + createHeaders = exports.headers + return exports +}) + +// transport-node re-exports nats-core internals — the passthrough hook ensures +// the package name is registered so `withVersions('nats', '@nats-io/transport-node', ...)` +// can resolve it in plugin tests. +addHook({ name: '@nats-io/transport-node', versions: ['>=3.0.0'] }, exports => exports) + +function wrapSyncProducer (original, type) { + return function (subject, data, options) { + if (!publishStartCh.hasSubscribers) { + return original.apply(this, arguments) + } + const opts = { ...options } + const ctx = { type, subject, data, options: opts, connection: this, createHeaders } + return publishStartCh.runStores(ctx, () => { + try { + return original.call(this, subject, data, opts) + } catch (err) { + ctx.error = err + publishErrorCh.publish(ctx) + throw err + } finally { + publishFinishCh.publish(ctx) + } + }) + } +} + +// publish is also wrapped by `wrapSyncProducer`, but request/requestMany call +// `this.publish(...)` internally. Set a marker on the connection so the inner +// publish wrap short-circuits — see `wrapPublish`. +function wrapAsyncProducer (original, type) { + return function (subject, data, options) { + if (!publishStartCh.hasSubscribers) { + return original.apply(this, arguments) + } + const opts = { ...options } + const ctx = { type, subject, data, options: opts, connection: this, createHeaders } + return publishStartCh.runStores(ctx, () => { + requestsInFlight.add(this) + let promise + try { + // `request`/`requestMany` never throw synchronously — they wrap their own + // input validation in a try/catch that returns `Promise.reject`. + promise = original.call(this, subject, data, opts) + } finally { + // The nested `this.publish(...)` runs during the synchronous body of + // request/requestMany, so clearing the marker as soon as the call + // returns is sufficient — the promise resolution happens later. + requestsInFlight.delete(this) + } + return Promise.resolve(promise).then( + result => { + ctx.result = result + publishFinishCh.publish(ctx) + return result + }, + err => { + ctx.error = err + publishErrorCh.publish(ctx) + publishFinishCh.publish(ctx) + throw err + } + ) + }) + } +} + +function wrapPublish (original) { + const wrapped = wrapSyncProducer(original, 'publish') + return function (subject, data, options) { + // Called from inside request/requestMany — the outer wrap already produced + // a span and injected headers; running the inner wrap would double-count. + if (requestsInFlight.has(this)) { + return original.apply(this, arguments) + } + return wrapped.apply(this, arguments) + } +} + +function wrapSubscribeCallback (userCallback, subject, connection) { + return function (err, message) { + if (!message || err) { + return userCallback.call(this, err, message) + } + const ctx = { subject, message, connection } + return consumeStartCh.runStores(ctx, () => { + try { + return userCallback.call(this, err, message) + } catch (e) { + ctx.error = e + consumeErrorCh.publish(ctx) + throw e + } finally { + consumeFinishCh.publish(ctx) + } + }) + } +} + +// Iterator-style consumers don't expose a delivery callback we can wrap, so +// the consume span represents the moment of receipt only — it starts and +// finishes before the value is yielded to user code, and the user's loop +// body is not parented under the span. +function wrapAsyncIteratorFactory (asyncIterator, subject, connection) { + return function () { + const iterator = asyncIterator.apply(this, arguments) + iterator.next = shimmer.wrapCallback(iterator.next, next => function () { + return next.apply(this, arguments).then(result => { + if (result && !result.done && result.value) { + const ctx = { subject, message: result.value, connection } + consumeStartCh.runStores(ctx, () => { + consumeFinishCh.publish(ctx) + }) + } + return result + }) + }) + return iterator + } +} + +addHook({ name: '@nats-io/nats-core', versions: ['>=3.0.0'], file: 'lib/nats.js' }, exports => { + const proto = exports.NatsConnectionImpl.prototype + + shimmer.wrap(proto, 'publish', wrapPublish) + shimmer.wrap(proto, 'request', request => wrapAsyncProducer(request, 'request')) + shimmer.wrap(proto, 'requestMany', requestMany => wrapAsyncProducer(requestMany, 'requestMany')) + + shimmer.wrap(proto, 'subscribe', subscribe => function (subject, opts) { + if (!consumeStartCh.hasSubscribers) { + return subscribe.apply(this, arguments) + } + + const userOpts = opts ?? {} + if (typeof userOpts.callback === 'function') { + arguments[1] = { ...userOpts, callback: wrapSubscribeCallback(userOpts.callback, subject, this) } + return subscribe.apply(this, arguments) + } + + const sub = subscribe.apply(this, arguments) + shimmer.wrap(sub, Symbol.asyncIterator, asyncIterator => + wrapAsyncIteratorFactory(asyncIterator, subject, this)) + return sub + }) + + return exports +}) diff --git a/packages/datadog-instrumentations/src/nyc.js b/packages/datadog-instrumentations/src/nyc.js index ded0d07c2b..9db0851ba4 100644 --- a/packages/datadog-instrumentations/src/nyc.js +++ b/packages/datadog-instrumentations/src/nyc.js @@ -2,7 +2,12 @@ const shimmer = require('../../datadog-shimmer') const { getEnvironmentVariable } = require('../../dd-trace/src/config/helper') -const { setupSettingsCachePath } = require('../../dd-trace/src/ci-visibility/test-optimization-cache') +const { + readCoverageBackfillFromCache, + readCoverageBackfillRootDirFromCache, + setupSettingsCachePath, +} = require('../../dd-trace/src/ci-visibility/test-optimization-cache') +const { applySkippedCoverageToCoverage } = require('../../dd-trace/src/plugins/util/test') const { addHook, channel } = require('./helpers/instrument') const codeCoverageWrapCh = channel('ci:nyc:wrap') @@ -16,6 +21,38 @@ addHook({ // when dd-trace fetches library configuration setupSettingsCachePath() + if (nycPackage.prototype.getCoverageMapFromAllCoverageFiles) { + // Some test frameworks receive skipped-suite coverage in the test process, but nyc merges reports later in the nyc + // process. Reuse the settings cache path as the process handoff so nyc can backfill skipped files before reporting. + shimmer.wrap( + nycPackage.prototype, + 'getCoverageMapFromAllCoverageFiles', + getCoverageMapFromAllCoverageFiles => function (...args) { + const coverageMap = getCoverageMapFromAllCoverageFiles.apply(this, args) + const applyCoverageBackfill = (resolvedCoverageMap) => { + try { + if (!resolvedCoverageMap) { + return resolvedCoverageMap + } + applySkippedCoverageToCoverage( + resolvedCoverageMap, + readCoverageBackfillFromCache(), + readCoverageBackfillRootDirFromCache() || this.cwd + ) + } catch { + // Do not break nyc's report generation if the cached backfill is stale or malformed. + } + return resolvedCoverageMap + } + + if (coverageMap && typeof coverageMap.then === 'function') { + return coverageMap.then(applyCoverageBackfill) + } + return applyCoverageBackfill(coverageMap) + } + ) + } + // `wrap` is an async function shimmer.wrap(nycPackage.prototype, 'wrap', wrap => function (...args) { // Only relevant if the config `all` is set to true (for untested code coverage) diff --git a/packages/datadog-instrumentations/src/openai.js b/packages/datadog-instrumentations/src/openai.js index c9fb5a345f..a8182548f2 100644 --- a/packages/datadog-instrumentations/src/openai.js +++ b/packages/datadog-instrumentations/src/openai.js @@ -3,6 +3,7 @@ const dc = require('dc-polyfill') const shimmer = require('../../datadog-shimmer') const { addHook } = require('./helpers/instrument') +const aiGuard = require('./helpers/openai-ai-guard') const ch = dc.tracingChannel('apm:openai:request') const onStreamedChunkCh = dc.channel('apm:openai:request:chunk') @@ -216,15 +217,20 @@ for (const extension of extensions) { for (const methodName of methods) { shimmer.wrap(targetPrototype, methodName, methodFn => function (...args) { - if (!ch.start.hasSubscribers) { + if (!ch.start.hasSubscribers && !aiGuard.hasSubscribers()) { return methodFn.apply(this, args) } - // The OpenAI library lets you set `stream: true` on the options arg to any method // However, we only want to handle streamed responses in specific cases // chat.completions and completions const stream = streamedResponse && getOption(args, 'stream', false) + const guard = aiGuard.createGuard(baseResource, args[0], stream) + + if (!ch.start.hasSubscribers && !guard) { + return methodFn.apply(this, args) + } + const client = this._client || this.client const ctx = { @@ -249,7 +255,7 @@ for (const extension of extensions) { const parsedPromise = origApiPromParse.apply(this, args) .then(body => Promise.all([this.responsePromise, body])) - return handleUnwrappedAPIPromise(parsedPromise, ctx, stream) + return handleUnwrappedAPIPromise(parsedPromise, ctx, stream, guard) }) return unwrappedPromise @@ -262,9 +268,11 @@ for (const extension of extensions) { const parsedPromise = origApiPromParse.apply(this, args) .then(body => Promise.all([this.responsePromise, body])) - return handleUnwrappedAPIPromise(parsedPromise, ctx, stream) + return handleUnwrappedAPIPromise(parsedPromise, ctx, stream, guard) }) + if (guard) aiGuard.wrapAsResponse(apiProm, guard) + ch.end.publish(ctx) return apiProm @@ -276,8 +284,10 @@ for (const extension of extensions) { } } -function handleUnwrappedAPIPromise (apiProm, ctx, stream) { - return apiProm +function handleUnwrappedAPIPromise (apiProm, ctx, stream, guard) { + const guardedApiProm = guard ? aiGuard.gateParse(apiProm, guard) : apiProm + + return guardedApiProm .then(([{ response, options }, body]) => { if (stream) { if (body.iterator) { @@ -287,22 +297,27 @@ function handleUnwrappedAPIPromise (apiProm, ctx, stream) { body.response.body, Symbol.asyncIterator, wrapStreamIterator(response, options, ctx) ) } - } else { - finish(ctx, { - headers: response.headers, - data: body, - request: { - path: response.url, - method: options.method, - }, - }) + return body } - return body + finish(ctx, { + headers: response.headers, + data: body, + request: { + path: response.url, + method: options.method, + }, + }) + + if (!guard) return body + + return aiGuard.evaluateOutput(guard, body).then(() => body) }) .catch(error => { - finish(ctx, undefined, error) - + // ctx.result is set inside finish(); if absent, finish never ran (sync throw in + // success branch, before-model block, or openai error) — record the error now. + // If finish already ran successfully (after-model block), don't double-publish. + if (!ctx.result) finish(ctx, undefined, error) throw error }) } diff --git a/packages/datadog-instrumentations/src/oracledb.js b/packages/datadog-instrumentations/src/oracledb.js index 0c796f4964..c88ab08b1a 100644 --- a/packages/datadog-instrumentations/src/oracledb.js +++ b/packages/datadog-instrumentations/src/oracledb.js @@ -22,7 +22,7 @@ function finish (ctx) { addHook({ name: 'oracledb', versions: ['>=5'], file: 'lib/oracledb.js' }, oracledb => { shimmer.wrap(oracledb.Connection.prototype, 'execute', execute => { - return function wrappedExecute (dbQuery, ...args) { + return function wrappedExecute (dbQuery) { if (!startChannel.hasSubscribers) { return execute.apply(this, arguments) } @@ -72,6 +72,11 @@ addHook({ name: 'oracledb', versions: ['>=5'], file: 'lib/oracledb.js' }, oracle } return startChannel.runStores(ctx, () => { + // bindStart is skipped when tracing is suppressed (legacy store is `noop`), + // leaving ctx.injected unset — do not overwrite the caller's SQL argument. + if (ctx.injected !== undefined) { + arguments[0] = ctx.injected + } try { let result = execute.apply(this, arguments) diff --git a/packages/datadog-instrumentations/src/pino.js b/packages/datadog-instrumentations/src/pino.js index d28bfe3329..0dae6795ac 100644 --- a/packages/datadog-instrumentations/src/pino.js +++ b/packages/datadog-instrumentations/src/pino.js @@ -6,7 +6,16 @@ const { addHook, } = require('./helpers/instrument') +/** + * @param {string} symbol + * @param {(original: Function) => Function} wrapper + * @param {Function} pino + */ function wrapPino (symbol, wrapper, pino) { + /** + * @param {unknown[]} args + * @returns {unknown} + */ return function pinoWithTrace (...args) { const instance = pino.apply(this, args) @@ -22,15 +31,18 @@ function wrapPino (symbol, wrapper, pino) { } function wrapAsJson (asJson) { - const ch = channel('apm:pino:log') + const jsonCh = channel('apm:pino:log:json') return function asJsonWithTrace (obj, msg, num, time) { obj = arguments[0] = obj || {} - const payload = { message: obj } - ch.publish(payload) - arguments[0] = payload.message + // Caller-provided `dd` wins -- skip the splice so a bespoke `dd` survives. + if (!jsonCh.hasSubscribers || Object.hasOwn(obj, 'dd')) { + return asJson.apply(this, arguments) + } - return asJson.apply(this, arguments) + const payload = { line: asJson.apply(this, arguments) } + jsonCh.publish(payload) + return payload.line } } diff --git a/packages/datadog-instrumentations/src/playwright.js b/packages/datadog-instrumentations/src/playwright.js index ba92e251b0..23aa8087b8 100644 --- a/packages/datadog-instrumentations/src/playwright.js +++ b/packages/datadog-instrumentations/src/playwright.js @@ -2,6 +2,7 @@ // Capture real timers at module load time, before any test can install fake timers. const realSetTimeout = setTimeout +const realClearTimeout = clearTimeout const { performance } = require('node:perf_hooks') const satisfies = require('../../../vendor/dist/semifies') @@ -25,7 +26,7 @@ const { getValueFromEnvSources, } = require('../../dd-trace/src/config/helper') const { DD_MAJOR } = require('../../../version') -const { addHook, channel } = require('./helpers/instrument') +const { addHook, channel, tracingChannel } = require('./helpers/instrument') const testStartCh = channel('ci:playwright:test:start') const testFinishCh = channel('ci:playwright:test:finish') @@ -46,6 +47,12 @@ const testSuiteFinishCh = channel('ci:playwright:test-suite:finish') const workerReportCh = channel('ci:playwright:worker:report') const testPageGotoCh = channel('ci:playwright:test:page-goto') +const dispatcherRunCh = tracingChannel('orchestrion:playwright:Dispatcher_run') +const dispatcherCreateWorkerCh = tracingChannel('orchestrion:playwright:Dispatcher_createWorker') +const processHostStartRunnerCh = tracingChannel('orchestrion:playwright:ProcessHost_startRunner') +const createRootSuiteCh = tracingChannel('orchestrion:playwright:createRootSuite') +const pageGotoCh = tracingChannel('orchestrion:playwright-core:Page_goto') + const testToCtx = new WeakMap() const testSuiteToCtx = new Map() const testSuiteToTestStatuses = new Map() @@ -53,6 +60,7 @@ const testSuiteToErrors = new Map() const testsToTestStatuses = new Map() const RUM_FLUSH_WAIT_TIME = Number(getValueFromEnvSources('DD_CIVISIBILITY_RUM_FLUSH_WAIT_MILLIS')) || 500 +const DD_PROPERTIES_TIMEOUT = 5000 let applyRepeatEachIndex = null @@ -95,12 +103,17 @@ const efdRetryTestsById = new Map() const efdScheduledOriginalTestKeys = new Set() const efdStartedOriginalTestKeys = new Set() const efdSlowAbortedTests = new Set() +const ddPropertiesByTestId = new Map() +const ddPropertiesRequestsByTestId = new Map() let rootDir = '' let sessionProjects = [] const MINIMUM_SUPPORTED_VERSION_RANGE_EFD = '>=1.38.0' // TODO: remove this once we drop support for v5 const EFD_RETRY_COUNT_REQUEST = 'ddEfdRetryCountRequest' const EFD_RETRY_COUNT_RESPONSE = 'ddEfdRetryCountResponse' +const DD_PROPERTIES_REQUEST = 'ddPropertiesRequest' +const DD_PROPERTIES_RESPONSE = 'ddProperties' +const kDdPlaywrightWorkerInstrumented = Symbol('ddPlaywrightWorkerInstrumented') function isValidKnownTests (receivedKnownTests) { return !!receivedKnownTests.playwright @@ -281,6 +294,43 @@ function sendEfdRetryCountToWorkerWhenAvailable (workerProcess, testId) { }) } +function sendDdPropertiesToWorker (workerProcess, testId, properties) { + workerProcess.send({ + type: DD_PROPERTIES_RESPONSE, + testId, + properties, + }) +} + +function setDdPropertiesForTest (workerProcess, testId, properties) { + ddPropertiesByTestId.set(testId, properties) + + const requests = ddPropertiesRequestsByTestId.get(testId) + if (requests) { + ddPropertiesRequestsByTestId.delete(testId) + for (const resolveRequest of requests) { + resolveRequest(properties) + } + } + + sendDdPropertiesToWorker(workerProcess, testId, properties) +} + +function sendDdPropertiesToWorkerWhenAvailable (workerProcess, testId) { + const properties = ddPropertiesByTestId.get(testId) + if (properties) { + sendDdPropertiesToWorker(workerProcess, testId, properties) + return + } + + if (!ddPropertiesRequestsByTestId.has(testId)) { + ddPropertiesRequestsByTestId.set(testId, []) + } + ddPropertiesRequestsByTestId.get(testId).push((properties) => { + sendDdPropertiesToWorker(workerProcess, testId, properties) + }) +} + /** * @param {object} test * @returns {boolean} @@ -354,11 +404,15 @@ function getSuiteType (test, type) { return suite } +function isSuiteEntry (entry) { + return entry.constructor.name === 'Suite' || entry.constructor.name === '_Suite' +} + // Copy of Suite#_deepClone but with a function to filter tests function deepCloneSuite (suite, filterTest, tags = [], configureCopiedTest) { const copy = suite._clone() for (const entry of suite._entries) { - if (entry.constructor.name === 'Suite') { + if (isSuiteEntry(entry)) { copy._addSuite(deepCloneSuite(entry, filterTest, tags, configureCopiedTest)) } else { if (filterTest(entry)) { @@ -445,6 +499,10 @@ function getProjectsFromRunner (runner, configArg) { } function getProjectsFromDispatcher (dispatcher) { + const bundledConfig = dispatcher._testRun?.config?.config?.projects + if (bundledConfig) { + return bundledConfig + } const newConfig = dispatcher._config?.config?.projects if (newConfig) { return newConfig @@ -888,31 +946,118 @@ function deferEfdRetryGroups (testGroups) { return [...groupsWithOriginalTests, ...efdRetryOnlyGroups] } +function prepareDispatcherRun (dispatcher, args) { + let testGroups = args[0] + + // Filter out disabled tests from testGroups before they get scheduled, + // unless they have attemptToFix (in which case they should still run and be retried) + if (isTestManagementTestsEnabled) { + for (const group of testGroups) { + group.tests = group.tests.filter(test => !test._ddIsDisabled || test._ddIsAttemptToFix) + } + // Remove empty groups + testGroups = testGroups.filter(group => group.tests.length > 0) + } + + if (isEarlyFlakeDetectionEnabled) { + testGroups = deferEfdRetryGroups(testGroups) + } + + if (!dispatcher._allTests) { + // Removed in https://github.com/microsoft/playwright/commit/1e52c37b254a441cccf332520f60225a5acc14c7 + // Not available from >=1.44.0 + dispatcher._ddAllTests = testGroups.flatMap(g => g.tests) + } + remainingTestsByFile = getTestsBySuiteFromTestGroups(testGroups) + args[0] = testGroups +} + function dispatcherRunWrapperNew (run) { - return function (testGroups) { - // Filter out disabled tests from testGroups before they get scheduled, - // unless they have attemptToFix (in which case they should still run and be retried) - if (isTestManagementTestsEnabled) { - for (const group of testGroups) { - group.tests = group.tests.filter(test => !test._ddIsDisabled || test._ddIsAttemptToFix) - } - // Remove empty groups - testGroups = testGroups.filter(group => group.tests.length > 0) + return function () { + prepareDispatcherRun(this, arguments) + return run.apply(this, arguments) + } +} + +function onDispatcherCreateWorker (dispatcher, worker) { + if (!worker) { + return worker + } + + const projects = getProjectsFromDispatcher(dispatcher) + sessionProjects = projects + + worker.on('testBegin', ({ testId }) => { + const test = getTestByTestId(dispatcher, testId) + const browser = getBrowserNameFromProjects(projects, test) + const shouldCreateTestSpan = test.expectedStatus === 'skipped' + testBeginHandler(test, browser, shouldCreateTestSpan) + }) + worker.on('testEnd', ({ testId, status, errors, annotations }) => { + const test = getTestByTestId(dispatcher, testId) + + const isTimeout = status === 'timedOut' + const testStatus = STATUS_TO_TEST_STATUS[status] + const shouldCreateTestSpan = test.expectedStatus === 'skipped' + if (shouldCreateTestSpan && !testToCtx.has(test)) { + testBeginHandler(test, getBrowserNameFromProjects(projects, test), true) } + testEndHandler( + { + test, + annotations, + testStatus, + error: errors && errors[0], + isTimeout, + shouldCreateTestSpan, + projects, + } + ) + const testResult = test.results.at(-1) + const isAtrRetry = testResult?.retry > 0 && + isFlakyTestRetriesEnabled && + !test._ddIsAttemptToFix && + !test._ddIsEfdRetry - if (isEarlyFlakeDetectionEnabled) { - testGroups = deferEfdRetryGroups(testGroups) + // EFD retries (new or modified tests) are implemented as clones with retries=0, + // so testWillRetry always returns false for them. Instead, we track how many + // executions have been reported via testsToTestStatuses (updated by testEndHandler + // above) and mark the execution final once the count reaches the expected total. + // This mirrors how ATF finality is detected and centralizes the decision in the + // main process, so workers only need to act on the _ddIsFinalExecution flag. + const isEfdManagedTest = isTestEfdManaged(test) + let isFinalExecution + if (isEfdManagedTest) { + const efdTestStatuses = testsToTestStatuses.get(getTestEfdKey(test)) || [] + isFinalExecution = efdTestStatuses.length === getEfdRetryCountForTest(test) + 1 + } else if (test._ddIsAttemptToFix) { + isFinalExecution = !!(test._ddHasPassedAttemptToFixRetries || test._ddHasFailedAttemptToFixRetries) + } else { + isFinalExecution = !testWillRetry(test, testStatus) } - if (!this._allTests) { - // Removed in https://github.com/microsoft/playwright/commit/1e52c37b254a441cccf332520f60225a5acc14c7 - // Not available from >=1.44.0 - this._ddAllTests = testGroups.flatMap(g => g.tests) + const ddProperties = { + _ddIsDisabled: test._ddIsDisabled, + _ddIsQuarantined: test._ddIsQuarantined, + _ddIsAttemptToFix: test._ddIsAttemptToFix, + _ddIsAttemptToFixRetry: test._ddIsAttemptToFixRetry, + _ddIsNew: test._ddIsNew, + _ddIsEfdRetry: test._ddIsEfdRetry, + _ddHasFailedAllRetries: test._ddHasFailedAllRetries, + _ddHasPassedAttemptToFixRetries: test._ddHasPassedAttemptToFixRetries, + _ddHasFailedAttemptToFixRetries: test._ddHasFailedAttemptToFixRetries, + _ddIsAtrRetry: isAtrRetry, + _ddIsModified: test._ddIsModified, + _ddIsFinalExecution: isFinalExecution, + _ddIsEfdManagedTest: isEfdManagedTest, + _ddEarlyFlakeAbortReason: efdSlowAbortedTests.has(getTestEfdKey(test)) ? 'slow' : undefined, + _ddHasPassedAnyEfdAttempt: (testsToTestStatuses.get(getTestEfdKey(test)) || []).includes('pass'), } - remainingTestsByFile = getTestsBySuiteFromTestGroups(testGroups) - arguments[0] = testGroups - return run.apply(this, arguments) - } + + setDdPropertiesForTest(worker.process, test.id, ddProperties) + }) + + return worker } function dispatcherHook (dispatcherExport) { @@ -960,82 +1105,7 @@ function dispatcherHookNew (dispatcherExport, runWrapper) { shimmer.wrap(dispatcherExport.Dispatcher.prototype, '_createWorker', createWorker => function (...args) { const dispatcher = this const worker = createWorker.apply(this, args) - const projects = getProjectsFromDispatcher(dispatcher) - sessionProjects = projects - - worker.on('testBegin', ({ testId }) => { - const test = getTestByTestId(dispatcher, testId) - const browser = getBrowserNameFromProjects(projects, test) - const shouldCreateTestSpan = test.expectedStatus === 'skipped' - testBeginHandler(test, browser, shouldCreateTestSpan) - }) - worker.on('testEnd', ({ testId, status, errors, annotations }) => { - const test = getTestByTestId(dispatcher, testId) - - const isTimeout = status === 'timedOut' - const testStatus = STATUS_TO_TEST_STATUS[status] - const shouldCreateTestSpan = test.expectedStatus === 'skipped' - if (shouldCreateTestSpan && !testToCtx.has(test)) { - testBeginHandler(test, getBrowserNameFromProjects(projects, test), true) - } - testEndHandler( - { - test, - annotations, - testStatus, - error: errors && errors[0], - isTimeout, - shouldCreateTestSpan, - projects, - } - ) - const testResult = test.results.at(-1) - const isAtrRetry = testResult?.retry > 0 && - isFlakyTestRetriesEnabled && - !test._ddIsAttemptToFix && - !test._ddIsEfdRetry - - // EFD retries (new or modified tests) are implemented as clones with retries=0, - // so testWillRetry always returns false for them. Instead, we track how many - // executions have been reported via testsToTestStatuses (updated by testEndHandler - // above) and mark the execution final once the count reaches the expected total. - // This mirrors how ATF finality is detected and centralizes the decision in the - // main process, so workers only need to act on the _ddIsFinalExecution flag. - const isEfdManagedTest = isTestEfdManaged(test) - let isFinalExecution - if (isEfdManagedTest) { - const efdTestStatuses = testsToTestStatuses.get(getTestEfdKey(test)) || [] - isFinalExecution = efdTestStatuses.length === getEfdRetryCountForTest(test) + 1 - } else if (test._ddIsAttemptToFix) { - isFinalExecution = !!(test._ddHasPassedAttemptToFixRetries || test._ddHasFailedAttemptToFixRetries) - } else { - isFinalExecution = !testWillRetry(test, testStatus) - } - - // We want to send the ddProperties to the worker - worker.process.send({ - type: 'ddProperties', - testId: test.id, - properties: { - _ddIsDisabled: test._ddIsDisabled, - _ddIsQuarantined: test._ddIsQuarantined, - _ddIsAttemptToFix: test._ddIsAttemptToFix, - _ddIsAttemptToFixRetry: test._ddIsAttemptToFixRetry, - _ddIsNew: test._ddIsNew, - _ddIsEfdRetry: test._ddIsEfdRetry, - _ddHasFailedAllRetries: test._ddHasFailedAllRetries, - _ddHasPassedAttemptToFixRetries: test._ddHasPassedAttemptToFixRetries, - _ddHasFailedAttemptToFixRetries: test._ddHasFailedAttemptToFixRetries, - _ddIsAtrRetry: isAtrRetry, - _ddIsModified: test._ddIsModified, - _ddIsFinalExecution: isFinalExecution, - _ddIsEfdManagedTest: isEfdManagedTest, - _ddEarlyFlakeAbortReason: efdSlowAbortedTests.has(getTestEfdKey(test)) ? 'slow' : undefined, - _ddHasPassedAnyEfdAttempt: (testsToTestStatuses.get(getTestEfdKey(test)) || []).includes('pass'), - }, - }) - }) - return worker + return onDispatcherCreateWorker(dispatcher, worker) }) return dispatcherExport } @@ -1046,7 +1116,6 @@ function runAllTestsWrapper (runAllTests, playwrightVersion) { let onDone rootDir = getRootDir(this, config) - const processArgv = process.argv.slice(2).join(' ') const command = `playwright ${processArgv}` testSessionStartCh.publish({ command, frameworkVersion: playwrightVersion, rootDir }) @@ -1233,6 +1302,8 @@ function runAllTestsWrapper (runAllTests, playwrightVersion) { efdScheduledOriginalTestKeys.clear() efdStartedOriginalTestKeys.clear() efdSlowAbortedTests.clear() + ddPropertiesByTestId.clear() + ddPropertiesRequestsByTestId.clear() // TODO: we can trick playwright into thinking the session passed by returning // 'passed' here. We might be able to use this for both EFD and Test Management tests. @@ -1259,6 +1330,85 @@ function runnerHookNew (runnerExport, playwrightVersion) { return runnerExport } +function runnerIndexHook (runnerExport, playwrightVersion) { + let wrappedTestRunner + runnerExport = shimmer.wrap(runnerExport, 'testRunner', function (originalGetter) { + return function () { + if (!wrappedTestRunner) { + wrappedTestRunner = runnerHookNew(originalGetter.call(this), playwrightVersion) + } + return wrappedTestRunner + } + }) + + const baseReporter = runnerExport.base?.TerminalReporter + if (baseReporter) { + shimmer.wrap(baseReporter.prototype, 'generateSummary', generateSummaryWrapper) + } + + return runnerExport +} + +function commonIndexHook (commonExport) { + applyRepeatEachIndex = commonExport.suiteUtils?.applyRepeatEachIndex + + let wrappedStartProcessRunner + commonExport = shimmer.wrap(commonExport, 'startProcessRunner', function (originalGetter) { + return function () { + if (!wrappedStartProcessRunner) { + const startProcessRunner = originalGetter.call(this) + wrappedStartProcessRunner = function (create) { + return startProcessRunner.call(this, function () { + const processRunner = create.apply(this, arguments) + instrumentWorkerMainMethods(processRunner) + return processRunner + }) + } + } + return wrappedStartProcessRunner + } + }) + + return commonExport +} + +dispatcherRunCh.subscribe({ + start (ctx) { + prepareDispatcherRun(ctx.self, ctx.arguments) + }, +}) + +dispatcherCreateWorkerCh.subscribe({ + end (ctx) { + onDispatcherCreateWorker(ctx.self, ctx.result) + }, +}) + +processHostStartRunnerCh.subscribe({ + start (ctx) { + prepareProcessHostStartRunner(ctx.self) + }, + asyncEnd (ctx) { + finishProcessHostStartRunner(ctx.self) + }, +}) + +createRootSuiteCh.subscribe({ + asyncEnd (ctx) { + if (ctx.error) { + return + } + processRootSuite(ctx.result || ctx.arguments?.[0]) + }, +}) + +pageGotoCh.subscribe({ + asyncEnd (ctx) { + // The Page.goto rewriter waits for this so tests closing immediately after navigation still get RUM tags. + ctx.asyncEndPromise = handlePageGoto(ctx.self) + }, +}) + if (DD_MAJOR < 6) { // <1.38.0 is only supported up to version 5 addHook({ name: '@playwright/test', @@ -1291,28 +1441,40 @@ if (DD_MAJOR < 6) { // <1.38.0 is only supported up to version 5 }, runnerHook) } +addHook({ + name: 'playwright', + file: 'lib/runner/index.js', + versions: ['>=1.60.0'], +}, runnerIndexHook) + +addHook({ + name: 'playwright', + file: 'lib/common/index.js', + versions: ['>=1.60.0'], +}, commonIndexHook) + addHook({ name: 'playwright', file: 'lib/runner/runner.js', - versions: ['>=1.38.0'], + versions: ['>=1.38.0 <1.60.0'], }, runnerHook) addHook({ name: 'playwright', file: 'lib/runner/testRunner.js', - versions: ['>=1.55.0'], + versions: ['>=1.55.0 <1.60.0'], }, runnerHookNew) addHook({ name: 'playwright', file: 'lib/runner/dispatcher.js', - versions: ['>=1.38.0'], + versions: ['>=1.38.0 <1.60.0'], }, (dispatcher) => dispatcherHookNew(dispatcher, dispatcherRunWrapperNew)) addHook({ name: 'playwright', file: 'lib/common/suiteUtils.js', - versions: ['>=1.38.0'], + versions: ['>=1.38.0 <1.60.0'], }, suiteUtilsPackage => { // We grab `applyRepeatEachIndex` to use it later // `applyRepeatEachIndex` needs to be applied to a cloned suite @@ -1361,102 +1523,142 @@ function applyRetriesToTests ( } } -addHook({ - name: 'playwright', - file: 'lib/runner/loadUtils.js', - versions: ['>=1.38.0'], -}, (loadUtilsPackage) => { - const oldCreateRootSuite = loadUtilsPackage.createRootSuite +function processRootSuite (createRootSuiteReturnValue) { + if (!isKnownTestsEnabled && !isTestManagementTestsEnabled && !isImpactedTestsEnabled) { + return createRootSuiteReturnValue + } - async function newCreateRootSuite () { - if (!isKnownTestsEnabled && !isTestManagementTestsEnabled && !isImpactedTestsEnabled) { - return oldCreateRootSuite.apply(this, arguments) - } + if (!createRootSuiteReturnValue) { + return createRootSuiteReturnValue + } - const createRootSuiteReturnValue = await oldCreateRootSuite.apply(this, arguments) - // From v1.56.0 on, createRootSuite returns `{ rootSuite, topLevelProjects }` - const rootSuite = createRootSuiteReturnValue.rootSuite || createRootSuiteReturnValue - - const allTests = rootSuite.allTests() - - if (isTestManagementTestsEnabled) { - const fileSuitesWithManagedTestsToProjects = new Map() - for (const test of allTests) { - const testProperties = getTestProperties(test) - // Disabled tests are skipped unless they have attemptToFix - if (testProperties.disabled) { - test._ddIsDisabled = true - if (!testProperties.attemptToFix) { - test.expectedStatus = 'skipped' - // setting test.expectedStatus to 'skipped' does not work for every case, - // so we need to filter out disabled tests in dispatcherRunWrapperNew, - // so they don't get to the workers - continue - } + // From v1.56.0 on, createRootSuite returns `{ rootSuite, topLevelProjects }` + const rootSuite = createRootSuiteReturnValue.rootSuite || createRootSuiteReturnValue + if (typeof rootSuite?.allTests !== 'function') { + return createRootSuiteReturnValue + } + + const allTests = rootSuite.allTests() + + if (isTestManagementTestsEnabled) { + const fileSuitesWithManagedTestsToProjects = new Map() + for (const test of allTests) { + const testProperties = getTestProperties(test) + // Disabled tests are skipped unless they have attemptToFix + if (testProperties.disabled) { + test._ddIsDisabled = true + if (!testProperties.attemptToFix) { + test.expectedStatus = 'skipped' + // setting test.expectedStatus to 'skipped' does not work for every case, + // so we need to filter out disabled tests in dispatcherRunWrapperNew, + // so they don't get to the workers + continue } - if (testProperties.quarantined) { - test._ddIsQuarantined = true - if (!testProperties.attemptToFix) { - // Do not skip quarantined tests, let them run and overwrite results post-run if they fail - const testFqn = getTestFullyQualifiedName(test) - quarantinedButNotAttemptToFixFqns.add(testFqn) - } + } + if (testProperties.quarantined) { + test._ddIsQuarantined = true + if (!testProperties.attemptToFix) { + // Do not skip quarantined tests, let them run and overwrite results post-run if they fail + const testFqn = getTestFullyQualifiedName(test) + quarantinedButNotAttemptToFixFqns.add(testFqn) } - if (testProperties.attemptToFix) { - test._ddIsAttemptToFix = true - // Prevent ATR or `--retries` from retrying attemptToFix tests - test.retries = 0 - const fileSuite = getSuiteType(test, 'file') - - if (!fileSuitesWithManagedTestsToProjects.has(fileSuite)) { - fileSuitesWithManagedTestsToProjects.set(fileSuite, getSuiteType(test, 'project')) - } + } + if (testProperties.attemptToFix) { + test._ddIsAttemptToFix = true + // Prevent ATR or `--retries` from retrying attemptToFix tests + test.retries = 0 + const fileSuite = getSuiteType(test, 'file') + + if (!fileSuitesWithManagedTestsToProjects.has(fileSuite)) { + fileSuitesWithManagedTestsToProjects.set(fileSuite, getSuiteType(test, 'project')) } } - applyRetriesToTests( - fileSuitesWithManagedTestsToProjects, - (test) => test._ddIsAttemptToFix, - [ - (test) => test._ddIsQuarantined && '_ddIsQuarantined', - (test) => test._ddIsDisabled && '_ddIsDisabled', - '_ddIsAttemptToFix', - '_ddIsAttemptToFixRetry', - ], - testManagementAttemptToFixRetries - ) } + applyRetriesToTests( + fileSuitesWithManagedTestsToProjects, + (test) => test._ddIsAttemptToFix, + [ + (test) => test._ddIsQuarantined && '_ddIsQuarantined', + (test) => test._ddIsDisabled && '_ddIsDisabled', + '_ddIsAttemptToFix', + '_ddIsAttemptToFixRetry', + ], + testManagementAttemptToFixRetries + ) + } - if (isImpactedTestsEnabled) { - const impactedTests = allTests.filter(test => { - let isImpacted = false - isModifiedCh.publish({ - filePath: test._requireFile, - modifiedFiles, - onDone: (isModified) => { isImpacted = isModified }, - }) - return isImpacted + if (isImpactedTestsEnabled) { + const impactedTests = allTests.filter(test => { + let isImpacted = false + isModifiedCh.publish({ + filePath: test._requireFile, + modifiedFiles, + onDone: (isModified) => { isImpacted = isModified }, }) + return isImpacted + }) - const fileSuitesWithImpactedTestsToProjects = new Map() - for (const impactedTest of impactedTests) { - impactedTest._ddIsModified = true - if (isEarlyFlakeDetectionEnabled && impactedTest.expectedStatus !== 'skipped') { - markEfdManagedTest(impactedTest) - const fileSuite = getSuiteType(impactedTest, 'file') - if (!fileSuitesWithImpactedTestsToProjects.has(fileSuite)) { - fileSuitesWithImpactedTestsToProjects.set(fileSuite, getSuiteType(impactedTest, 'project')) + const fileSuitesWithImpactedTestsToProjects = new Map() + for (const impactedTest of impactedTests) { + impactedTest._ddIsModified = true + if (isEarlyFlakeDetectionEnabled && impactedTest.expectedStatus !== 'skipped') { + markEfdManagedTest(impactedTest) + const fileSuite = getSuiteType(impactedTest, 'file') + if (!fileSuitesWithImpactedTestsToProjects.has(fileSuite)) { + fileSuitesWithImpactedTestsToProjects.set(fileSuite, getSuiteType(impactedTest, 'project')) + } + } + } + // If something change in the file, all tests in the file are impacted, hence the () => true filter + applyRetriesToTests( + fileSuitesWithImpactedTestsToProjects, + () => true, + [ + '_ddIsModified', + '_ddIsEfdRetry', + (test) => (isKnownTestsEnabled && isNewTest(test) ? '_ddIsNew' : null), + ], + getConfiguredEfdRetryCount(), + (copiedTest, originalTest, retryIndex) => { + markEfdRetryTest(copiedTest, retryIndex, originalTest) + markEfdManagedTest(copiedTest) + }, + getEfdRetryRepeatEachIndex + ) + } + + if (isKnownTestsEnabled) { + const newTests = allTests.filter(isNewTest) + + const isFaulty = getIsFaultyEarlyFlakeDetection( + allTests.map(test => getTestSuitePath(test._requireFile, rootDir)), + knownTests.playwright, + earlyFlakeDetectionFaultyThreshold + ) + + if (isFaulty) { + isEarlyFlakeDetectionEnabled = false + isKnownTestsEnabled = false + isEarlyFlakeDetectionFaulty = true + } else { + const fileSuitesWithNewTestsToProjects = new Map() + for (const newTest of newTests) { + newTest._ddIsNew = true + if (isEarlyFlakeDetectionEnabled && newTest.expectedStatus !== 'skipped' && !newTest._ddIsModified) { + // Prevent ATR or `--retries` from retrying new tests if EFD is enabled + newTest.retries = 0 + markEfdManagedTest(newTest) + const fileSuite = getSuiteType(newTest, 'file') + if (!fileSuitesWithNewTestsToProjects.has(fileSuite)) { + fileSuitesWithNewTestsToProjects.set(fileSuite, getSuiteType(newTest, 'project')) } } } - // If something change in the file, all tests in the file are impacted, hence the () => true filter + applyRetriesToTests( - fileSuitesWithImpactedTestsToProjects, - () => true, - [ - '_ddIsModified', - '_ddIsEfdRetry', - (test) => (isKnownTestsEnabled && isNewTest(test) ? '_ddIsNew' : null), - ], + fileSuitesWithNewTestsToProjects, + isNewTest, + ['_ddIsNew', '_ddIsEfdRetry'], getConfiguredEfdRetryCount(), (copiedTest, originalTest, retryIndex) => { markEfdRetryTest(copiedTest, retryIndex, originalTest) @@ -1465,50 +1667,20 @@ addHook({ getEfdRetryRepeatEachIndex ) } + } - if (isKnownTestsEnabled) { - const newTests = allTests.filter(isNewTest) - - const isFaulty = getIsFaultyEarlyFlakeDetection( - allTests.map(test => getTestSuitePath(test._requireFile, rootDir)), - knownTests.playwright, - earlyFlakeDetectionFaultyThreshold - ) - - if (isFaulty) { - isEarlyFlakeDetectionEnabled = false - isKnownTestsEnabled = false - isEarlyFlakeDetectionFaulty = true - } else { - const fileSuitesWithNewTestsToProjects = new Map() - for (const newTest of newTests) { - newTest._ddIsNew = true - if (isEarlyFlakeDetectionEnabled && newTest.expectedStatus !== 'skipped' && !newTest._ddIsModified) { - // Prevent ATR or `--retries` from retrying new tests if EFD is enabled - newTest.retries = 0 - markEfdManagedTest(newTest) - const fileSuite = getSuiteType(newTest, 'file') - if (!fileSuitesWithNewTestsToProjects.has(fileSuite)) { - fileSuitesWithNewTestsToProjects.set(fileSuite, getSuiteType(newTest, 'project')) - } - } - } + return createRootSuiteReturnValue +} - applyRetriesToTests( - fileSuitesWithNewTestsToProjects, - isNewTest, - ['_ddIsNew', '_ddIsEfdRetry'], - getConfiguredEfdRetryCount(), - (copiedTest, originalTest, retryIndex) => { - markEfdRetryTest(copiedTest, retryIndex, originalTest) - markEfdManagedTest(copiedTest) - }, - getEfdRetryRepeatEachIndex - ) - } - } +addHook({ + name: 'playwright', + file: 'lib/runner/loadUtils.js', + versions: ['>=1.38.0 <1.60.0'], +}, (loadUtilsPackage) => { + const oldCreateRootSuite = loadUtilsPackage.createRootSuite - return createRootSuiteReturnValue + async function newCreateRootSuite () { + return processRootSuite(await oldCreateRootSuite.apply(this, arguments)) } // We need to proxy the createRootSuite function because the function is not configurable @@ -1522,32 +1694,47 @@ addHook({ }) }) +function prepareProcessHostStartRunner (processHost) { + processHost._extraEnv = { + ...processHost._extraEnv, + // Used to detect that we're in a playwright worker + DD_PLAYWRIGHT_WORKER: '1', + } +} + +function finishProcessHostStartRunner (processHost) { + if (!processHost.process) { + return + } + + // We add a new listener to `processHost.process`, which represents the worker + processHost.process.on('message', (message) => { + if (message?.type === EFD_RETRY_COUNT_REQUEST) { + sendEfdRetryCountToWorkerWhenAvailable(processHost.process, message.testId) + return + } + if (message?.type === DD_PROPERTIES_REQUEST) { + sendDdPropertiesToWorkerWhenAvailable(processHost.process, message.testId) + return + } + // These messages are [code, payload]. The payload is test data + if (Array.isArray(message) && message[0] === PLAYWRIGHT_WORKER_TRACE_PAYLOAD_CODE) { + workerReportCh.publish(message[1]) + } + }) +} + // main process hook addHook({ name: 'playwright', file: 'lib/runner/processHost.js', - versions: ['>=1.38.0'], + versions: ['>=1.38.0 <1.60.0'], }, (processHostPackage) => { shimmer.wrap(processHostPackage.ProcessHost.prototype, 'startRunner', startRunner => async function () { - this._extraEnv = { - ...this._extraEnv, - // Used to detect that we're in a playwright worker - DD_PLAYWRIGHT_WORKER: '1', - } + prepareProcessHostStartRunner(this) const res = await startRunner.apply(this, arguments) - - // We add a new listener to `this.process`, which is represents the worker - this.process.on('message', (message) => { - if (message?.type === EFD_RETRY_COUNT_REQUEST) { - sendEfdRetryCountToWorkerWhenAvailable(this.process, message.testId) - return - } - // These messages are [code, payload]. The payload is test data - if (Array.isArray(message) && message[0] === PLAYWRIGHT_WORKER_TRACE_PAYLOAD_CODE) { - workerReportCh.publish(message[1]) - } - }) + finishProcessHostStartRunner(this) return res }) @@ -1555,34 +1742,36 @@ addHook({ return processHostPackage }) +async function handlePageGoto (page) { + try { + if (page && typeof page.evaluate === 'function') { + const { isRumInstrumented, isRumActive, rumSamplingRate } = await page.evaluate(detectRum) + if (isRumInstrumented && rumSamplingRate < 100 && !isRumActive) { + log.debug("RUM was detected on the page, but it isn't active because the sampling rate is below 100%") + } + + if (isRumActive) { + testPageGotoCh.publish({ + isRumActive, + page, + }) + } + } + } catch (e) { + // ignore errors such as redirects, context destroyed, etc + log.error('goto hook error', e) + } +} + addHook({ name: 'playwright-core', file: 'lib/client/page.js', - versions: ['>=1.38.0'], + versions: ['>=1.38.0 <1.60.0'], }, (pagePackage) => { shimmer.wrap(pagePackage.Page.prototype, 'goto', goto => async function (url, options) { const response = await goto.apply(this, arguments) - const page = this - - try { - if (page) { - const { isRumInstrumented, isRumActive, rumSamplingRate } = await page.evaluate(detectRum) - if (isRumInstrumented && rumSamplingRate < 100 && !isRumActive) { - log.debug("RUM was detected on the page, but it isn't active because the sampling rate is below 100%") - } - - if (isRumActive) { - testPageGotoCh.publish({ - isRumActive, - page, - }) - } - } - } catch (e) { - // ignore errors such as redirects, context destroyed, etc - log.error('goto hook error', e) - } + await handlePageGoto(this) return response }) @@ -1590,17 +1779,19 @@ addHook({ return pagePackage }) -// Only in worker -addHook({ - name: 'playwright', - file: 'lib/worker/workerMain.js', - versions: ['>=1.38.0'], -}, (workerPackage) => { +function instrumentWorkerMainMethods (workerMain) { + if (!workerMain || workerMain[kDdPlaywrightWorkerInstrumented] || + typeof workerMain._runTest !== 'function' || typeof workerMain.dispatchEvent !== 'function') { + return workerMain + } + + Object.defineProperty(workerMain, kDdPlaywrightWorkerInstrumented, { value: true }) + // we assume there's only a test running at a time let steps = [] const stepInfoByStepId = {} - shimmer.wrap(workerPackage.WorkerMain.prototype, '_runTest', _runTest => async function (test) { + shimmer.wrap(workerMain, '_runTest', _runTest => async function (test) { await waitForEfdRetryCount(test) if (shouldSkipEfdRetry(test)) { test._ddShouldSkipEfdRetry = true @@ -1636,6 +1827,27 @@ addHook({ browserName, } testToCtx.set(test, testCtx) + + // Wait for ddProperties to be received and processed. The main process sends + // this during Playwright's testEnd event, which can happen before _runTest + // resolves in 1.60 when retry clones run across multiple workers. + let hasDdProperties = false + const ddPropertiesDeferred = {} + const ddPropertiesPromise = new Promise(resolve => { + ddPropertiesDeferred.resolve = resolve + }) + const ddPropertiesMessageHandler = ({ type, testId, properties }) => { + if (type === DD_PROPERTIES_RESPONSE && testId === test.id) { + hasDdProperties = true + if (properties) { + Object.assign(test, properties) + } + process.removeListener('message', ddPropertiesMessageHandler) + ddPropertiesDeferred.resolve() + } + } + process.on('message', ddPropertiesMessageHandler) + // TODO - In the future we may need to implement a mechanism to send test properties // to the worker process before _runTest is called testStartCh.runStores(testCtx, () => { @@ -1708,6 +1920,16 @@ addHook({ } } + if (!hasDdProperties && process.send) { + process.send({ + type: DD_PROPERTIES_REQUEST, + testId: test.id, + }) + } else if (!hasDdProperties) { + process.removeListener('message', ddPropertiesMessageHandler) + ddPropertiesDeferred.resolve() + } + // testInfo.errors could be better than "error", // which will only include timeout error (even though the test failed because of a different error) @@ -1722,26 +1944,17 @@ addHook({ onDone = resolve }) - // Wait for ddProperties to be received and processed - // Create a promise that will be resolved when the properties are received - const ddPropertiesPromise = new Promise(resolve => { - const messageHandler = ({ type, testId, properties }) => { - if (type === 'ddProperties' && testId === test.id) { - // Apply the properties to the test object - if (properties) { - Object.assign(test, properties) - } - process.removeListener('message', messageHandler) - resolve() - } - } - - // Add the listener - process.on('message', messageHandler) + // Wait for the properties to be received, but do not block the worker forever if IPC fails. + const ddPropertiesTimeoutPromise = new Promise(resolve => { + const ddPropertiesTimeout = realSetTimeout(() => { + process.removeListener('message', ddPropertiesMessageHandler) + resolve() + }, DD_PROPERTIES_TIMEOUT) + ddPropertiesPromise.then(() => { + realClearTimeout(ddPropertiesTimeout) + }) }) - - // Wait for the properties to be received - await ddPropertiesPromise + await Promise.race([ddPropertiesPromise, ddPropertiesTimeoutPromise]) const finalStatus = getFinalStatus({ isFinalExecution: test._ddIsFinalExecution, @@ -1787,7 +2000,7 @@ addHook({ // We reproduce what happens in `Dispatcher#_onStepBegin` and `Dispatcher#_onStepEnd`, // since `startTime` and `duration` are not available directly in the worker process - shimmer.wrap(workerPackage.WorkerMain.prototype, 'dispatchEvent', dispatchEvent => function (event, payload) { + shimmer.wrap(workerMain, 'dispatchEvent', dispatchEvent => function (event, payload) { if (event === 'stepBegin') { stepInfoByStepId[payload.stepId] = { startTime: payload.wallTime, @@ -1808,6 +2021,16 @@ addHook({ return dispatchEvent.apply(this, arguments) }) + return workerMain +} + +// Only in worker +addHook({ + name: 'playwright', + file: 'lib/worker/workerMain.js', + versions: ['>=1.38.0 <1.60.0'], +}, (workerPackage) => { + instrumentWorkerMainMethods(workerPackage.WorkerMain.prototype) return workerPackage }) @@ -1856,7 +2079,7 @@ function generateSummaryWrapper (generateSummary) { addHook({ name: 'playwright', file: 'lib/reporters/base.js', - versions: ['>=1.38.0'], + versions: ['>=1.38.0 <1.60.0'], }, (reportersPackage) => { // v1.50.0 changed the name of the base reporter from BaseReporter to TerminalReporter if (reportersPackage.TerminalReporter) { diff --git a/packages/datadog-instrumentations/src/router.js b/packages/datadog-instrumentations/src/router.js index d89ffca57a..1553f65e92 100644 --- a/packages/datadog-instrumentations/src/router.js +++ b/packages/datadog-instrumentations/src/router.js @@ -8,7 +8,6 @@ const { getCompileToRegexp } = require('./path-to-regexp') const { getRouterMountPaths, joinPath, - getLayerMatchers, setLayerMatchers, isAppMounted, setRouterMountPath, @@ -42,34 +41,47 @@ function createWrapRouterMethod (name, compile) { const nextChannel = channel(`apm:${name}:middleware:next`) const routeAddedChannel = channel(`apm:${name}:route:added`) - function wrapLayerHandle (layer, original) { - original._name = original._name || layer.name + function wrapLayerHandle (layer, original, matchers) { + // Resolve `name` once at wrap time: cached on the original for any code + // that reads `_name`, captured in the closure so the per-call body avoids + // the property-lookup / `||` fallback. + const name = original._name || layer.name || original.name + original._name = name + + // Wrap-time matcher analysis. The single-pattern case yields a constant + // route; only multi-pattern stacks need a per-request layer.path match. + let captureRoute + let needMultiMatch = false + if (matchers.length !== 0 && !isFastStar(layer, matchers) && !isFastSlash(layer, matchers)) { + if (matchers.length === 1) { + captureRoute = matchers[0].path + } else { + needMultiMatch = true + } + } - return shimmer.wrapFunction(original, original => function (...args) { - if (!enterChannel.hasSubscribers) return original.apply(this, args) + // Split by arity: router only ever dispatches 3-arg request handlers + // through `Layer.handleRequest` and 4-arg error handlers through + // `Layer.handleError`. Specialising lets the per-call body use named + // parameters and `.call`, avoiding the rest-spread Array allocation that + // the unified shape forced on every middleware invocation. + return original.length === 4 + ? shimmer.wrapFunction(original, errorHandlerLayerWrap(layer, name, captureRoute, needMultiMatch, matchers)) + : shimmer.wrapFunction(original, requestHandlerLayerWrap(layer, name, captureRoute, needMultiMatch, matchers)) + } - const matchers = getLayerMatchers(layer) - const lastIndex = args.length - 1 - const name = original._name || original.name - const req = args[args.length > 3 ? 1 : 0] - const next = args[lastIndex] + function requestHandlerLayerWrap (layer, name, captureRoute, needMultiMatch, matchers) { + return original => function (req, res, next) { + if (!enterChannel.hasSubscribers) return original.call(this, req, res, next) - if (typeof next === 'function') { - args[lastIndex] = wrapNext(req, next) - } + const wrappedNext = typeof next === 'function' ? wrapNext(req, next) : next - let route - - if (matchers?.length && !isFastStar(layer, matchers) && !isFastSlash(layer, matchers)) { - if (matchers.length === 1) { - // The host already matched this layer; the lone pattern is the route. - route = matchers[0].path - } else { - for (const matcher of matchers) { - if (matcher.regex?.test(layer.path)) { - route = matcher.path - break - } + let route = captureRoute + if (needMultiMatch) { + for (const matcher of matchers) { + if (matcher.regex?.test(layer.path)) { + route = matcher.path + break } } } @@ -77,7 +89,7 @@ function createWrapRouterMethod (name, compile) { enterChannel.publish({ name, req, route, layer }) try { - return original.apply(this, args) + return original.call(this, req, res, wrappedNext) } catch (error) { errorChannel.publish({ req, error }) nextChannel.publish({ req }) @@ -87,15 +99,47 @@ function createWrapRouterMethod (name, compile) { } finally { exitChannel.publish({ req }) } - }) + } + } + + function errorHandlerLayerWrap (layer, name, captureRoute, needMultiMatch, matchers) { + return original => function (error, req, res, next) { + if (!enterChannel.hasSubscribers) return original.call(this, error, req, res, next) + + const wrappedNext = typeof next === 'function' ? wrapNext(req, next) : next + + let route = captureRoute + if (needMultiMatch) { + for (const matcher of matchers) { + if (matcher.regex?.test(layer.path)) { + route = matcher.path + break + } + } + } + + enterChannel.publish({ name, req, route, layer }) + + try { + return original.call(this, error, req, res, wrappedNext) + } catch (caught) { + errorChannel.publish({ req, error: caught }) + nextChannel.publish({ req }) + finishChannel.publish({ req }) + + throw caught + } finally { + exitChannel.publish({ req }) + } + } } function wrapStack (layers, matchers) { for (const layer of layers) { if (layer.__handle) { // express-async-errors - layer.__handle = wrapLayerHandle(layer, layer.__handle) + layer.__handle = wrapLayerHandle(layer, layer.__handle, matchers) } else { - layer.handle = wrapLayerHandle(layer, layer.handle) + layer.handle = wrapLayerHandle(layer, layer.handle, matchers) } setLayerMatchers(layer, matchers) @@ -258,15 +302,15 @@ addHook({ name: 'router', versions: ['>=2'] }, Router => { shimmer.wrap(router, 'handle', function wrapHandle (originalHandle) { return function wrappedHandle (req, res, next) { - const abortController = new AbortController() - if (queryParserReadCh.hasSubscribers && req) { + const abortController = new AbortController() + queryParserReadCh.publish({ req, res, query: req.query, abortController }) if (abortController.signal.aborted) return } - return originalHandle.apply(this, arguments) + return originalHandle.call(this, req, res, next) } }) diff --git a/packages/datadog-instrumentations/src/stripe.js b/packages/datadog-instrumentations/src/stripe.js index 3f3c6264c9..d8af181b5d 100644 --- a/packages/datadog-instrumentations/src/stripe.js +++ b/packages/datadog-instrumentations/src/stripe.js @@ -97,7 +97,7 @@ function wrapStripe (Stripe) { addHook({ name: 'stripe', - versions: ['9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '>=20.0.0 <22'], + versions: ['>=9 <22'], }, Stripe => shimmer.wrapFunction(Stripe, wrapLegacyStripe)) addHook({ diff --git a/packages/datadog-instrumentations/test/ai.spec.js b/packages/datadog-instrumentations/test/ai.spec.js index 78f945a5ee..8eb11dc4dd 100644 --- a/packages/datadog-instrumentations/test/ai.spec.js +++ b/packages/datadog-instrumentations/test/ai.spec.js @@ -7,7 +7,7 @@ const sinon = require('sinon') const { wrapModelWithAIGuard } = require('../src/ai') -const aiguardChannel = channel('dd-trace:ai:aiguard') +const evaluateChannel = channel('dd-trace:ai:aiguard') const prompt = [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }] @@ -39,15 +39,15 @@ function subscribeAutoResolve () { calls.push({ messages: ctx.messages }) ctx.resolve() } - aiguardChannel.subscribe(handler) - return { calls, unsubscribe: () => aiguardChannel.unsubscribe(handler) } + evaluateChannel.subscribe(handler) + return { calls, unsubscribe: () => evaluateChannel.unsubscribe(handler) } } function subscribeAutoReject () { const err = Object.assign(new Error(), { name: 'AIGuardAbortError', reason: 'blocked' }) const handler = ctx => ctx.reject(err) - aiguardChannel.subscribe(handler) - return { err, unsubscribe: () => aiguardChannel.unsubscribe(handler) } + evaluateChannel.subscribe(handler) + return { err, unsubscribe: () => evaluateChannel.unsubscribe(handler) } } describe('wrapModelWithAIGuard', () => { @@ -205,12 +205,12 @@ describe('wrapModelWithAIGuard', () => { callCount++ callCount === 1 ? ctx.resolve() : ctx.reject(err) } - aiguardChannel.subscribe(handler) + evaluateChannel.subscribe(handler) model.doGenerate = sinon.stub().resolves({ content: [{ type: 'text', text: 'bad' }] }) wrapModelWithAIGuard(model) return assert.rejects(() => model.doGenerate({ prompt }), e => e === err) - .finally(() => aiguardChannel.unsubscribe(handler)) + .finally(() => evaluateChannel.unsubscribe(handler)) }) it('does not wrap already wrapped model', () => { @@ -364,13 +364,13 @@ describe('wrapModelWithAIGuard', () => { callCount++ callCount === 1 ? ctx.resolve() : ctx.reject(err) } - aiguardChannel.subscribe(handler) + evaluateChannel.subscribe(handler) const chunks = [{ type: 'text-delta', textDelta: 'bad response' }, { type: 'finish' }] model.doStream = sinon.stub().resolves({ stream: makeStream(chunks) }) wrapModelWithAIGuard(model) return assert.rejects(() => model.doStream({ prompt }), e => e === err) - .finally(() => aiguardChannel.unsubscribe(handler)) + .finally(() => evaluateChannel.unsubscribe(handler)) }) }) }) diff --git a/packages/datadog-instrumentations/test/body-parser.spec.js b/packages/datadog-instrumentations/test/body-parser.spec.js index c543658c75..57fc6d489e 100644 --- a/packages/datadog-instrumentations/test/body-parser.spec.js +++ b/packages/datadog-instrumentations/test/body-parser.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const axios = require('axios') const dc = require('dc-polyfill') @@ -91,7 +92,7 @@ withVersions('body-parser', 'body-parser', version => { assert.ok(payload.req) assert.ok(payload.res) - assert.ok(Object.hasOwn(store, 'span')) + assert.ok(Object.hasOwn(store, 'span'), `Available keys: ${inspect(Object.keys(store))}`) sinon.assert.calledOnce(middlewareProcessBodyStub) assert.strictEqual(res.data, 'DONE') diff --git a/packages/datadog-instrumentations/test/child_process.spec.js b/packages/datadog-instrumentations/test/child_process.spec.js index 4f5534c035..6bd6a0dafc 100644 --- a/packages/datadog-instrumentations/test/child_process.spec.js +++ b/packages/datadog-instrumentations/test/child_process.spec.js @@ -4,7 +4,7 @@ const assert = require('node:assert/strict') const fs = require('node:fs') const os = require('node:os') const path = require('node:path') -const { promisify } = require('node:util') +const { promisify, inspect } = require('node:util') const dc = require('dc-polyfill') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') @@ -787,7 +787,7 @@ describe('child process', () => { child.once('close', () => { sinon.assert.calledOnce(start) const context = start.firstCall.firstArg - assert.ok(Array.isArray(context.callArgs)) + assert.ok(Array.isArray(context.callArgs), `Expected array, got ${inspect(context.callArgs)}`) assert.strictEqual(context.callArgs[0], 'echo') assert.deepStrictEqual(context.callArgs[1], ['hello']) done() @@ -799,7 +799,7 @@ describe('child process', () => { sinon.assert.calledOnce(start) const context = start.firstCall.firstArg - assert.ok(Array.isArray(context.callArgs)) + assert.ok(Array.isArray(context.callArgs), `Expected array, got ${inspect(context.callArgs)}`) assert.strictEqual(context.callArgs[0], 'echo') assert.deepStrictEqual(context.callArgs[1], ['hello']) }) diff --git a/packages/datadog-instrumentations/test/electron/preload.spec.js b/packages/datadog-instrumentations/test/electron/preload.spec.js index 2aad8b9d6f..1ec5e30f2a 100644 --- a/packages/datadog-instrumentations/test/electron/preload.spec.js +++ b/packages/datadog-instrumentations/test/electron/preload.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') const proxyquire = require('proxyquire') @@ -81,21 +82,21 @@ describe('electron/preload', () => { it('includes location.hostname when no config is provided', () => { const bridge = loadPreload(null) const hosts = JSON.parse(bridge.getAllowedWebViewHosts()) - assert.ok(hosts.includes('test.example.com')) + assert.ok(hosts.includes('test.example.com'), `Got: ${inspect(hosts)}`) }) it('includes both location.hostname and configured hosts', () => { const bridge = loadPreload({ allowedWebViewHosts: ['allowed.example.com'] }) const hosts = JSON.parse(bridge.getAllowedWebViewHosts()) - assert.ok(hosts.includes('test.example.com')) - assert.ok(hosts.includes('allowed.example.com')) + assert.ok(hosts.includes('test.example.com'), `Got: ${inspect(hosts)}`) + assert.ok(hosts.includes('allowed.example.com'), `Got: ${inspect(hosts)}`) }) it('deduplicates hosts when location.hostname is also in configured hosts', () => { const bridge = loadPreload({ allowedWebViewHosts: ['test.example.com', 'other.example.com'] }) const hosts = JSON.parse(bridge.getAllowedWebViewHosts()) assert.strictEqual(hosts.filter(h => h === 'test.example.com').length, 1) - assert.ok(hosts.includes('other.example.com')) + assert.ok(hosts.includes('other.example.com'), `Got: ${inspect(hosts)}`) }) }) diff --git a/packages/datadog-instrumentations/test/fastify.spec.js b/packages/datadog-instrumentations/test/fastify.spec.js new file mode 100644 index 0000000000..70d019e33c --- /dev/null +++ b/packages/datadog-instrumentations/test/fastify.spec.js @@ -0,0 +1,533 @@ +'use strict' + +const assert = require('node:assert/strict') + +const dc = require('dc-polyfill') +const { afterEach, before, describe, it } = require('mocha') +const proxyquire = require('proxyquire').noPreserveCache() +const sinon = require('sinon') + +// Channels the addHook wrapper feeds. The unit tests subscribe to each in turn +// to exercise the wrapper's slow paths without spinning up a real fastify server. +const errorChannel = dc.channel('apm:fastify:middleware:error') +const cookieParserReadCh = dc.channel('datadog:fastify-cookie:read:finish') +const callbackFinishCh = dc.channel('datadog:fastify:callback:execute') +const queryParamsReadCh = dc.channel('datadog:fastify:query-params:finish') +const bodyParserReadCh = dc.channel('datadog:fastify:body-parser:finish') +const pathParamsReadCh = dc.channel('datadog:fastify:path-params:finish') + +describe('fastify instrumentation (unit)', () => { + let factoryForFastify3 + + before(() => { + const realInstrument = require('../src/helpers/instrument') + const addHookSpy = sinon.spy() + proxyquire('../src/fastify', { + './helpers/instrument': { ...realInstrument, addHook: addHookSpy }, + }) + + // The instrumentation file registers four hooks; the first one targets + // `fastify` `>=3` and exposes `fastifyWithTrace` once invoked. We capture + // that factory and re-use it across every test. + const call = addHookSpy.getCalls().find(c => { + const target = c.args[0] + return target.name === 'fastify' && target.versions?.[0] === '>=3' && !target.file + }) + factoryForFastify3 = call.args[1] + }) + + /** + * Build a fake fastify instance, run the dd-trace factory against it, and + * return the user-facing `addHook` (already swapped by `wrapAddHook`) plus + * the list of hooks the wrap registers on the fake app. + */ + function buildWrappedAddHook () { + const registered = [] + const fakeAddHook = sinon.stub().callsFake((name, fn) => { + registered.push({ name, fn }) + }) + const fakeApp = { addHook: fakeAddHook } + const fakeFastify = sinon.stub().returns(fakeApp) + + const wrappedCtor = factoryForFastify3(fakeFastify) + wrappedCtor() + + // Split out the hooks registered by `wrapFastify`; tests that exercise + // the user-facing `addHook` work against `registered`, while tests for the + // internal `preParsing` / `preValidation` pair drive `internal` directly. + const internal = registered.splice(0) + const internalByName = name => internal.filter(entry => entry.name === name).map(entry => entry.fn) + + return { app: fakeApp, registered, internalByName } + } + + describe('addHook fast path (no channel subscribers)', () => { + it('forwards the user hook with the original done callback', () => { + const { app, registered } = buildWrappedAddHook() + + const userHook = sinon.stub() + app.addHook('preHandler', userHook) + + assert.equal(registered.length, 1) + assert.equal(registered[0].name, 'preHandler') + + const wrapper = registered[0].fn + const request = { cookies: {} } + const reply = { send: () => {} } + const done = sinon.stub() + + wrapper(request, reply, done) + + sinon.assert.calledOnce(userHook) + assert.deepEqual( + [userHook.firstCall.args[0], userHook.firstCall.args[1], userHook.firstCall.args[2]], + [request, reply, done] + ) + // The third arg must be the dispatcher's `done` itself - any mutation of + // `arguments[arguments.length - 1]` inside the wrapper would replace it + // with our rewrap closure instead. + assert.strictEqual(userHook.firstCall.args[2], done) + }) + + it('handles variable hook arities without touching the trailing arg', () => { + const { app, registered } = buildWrappedAddHook() + + const userHook = sinon.stub() + app.addHook('preParsing', userHook) + const wrapper = registered[0].fn + + const request = {} + const reply = {} + const payload = { /* fastify passes the request payload stream here */ } + const done = sinon.stub() + + // preParsing dispatches with 4 args (request, reply, payload, done). + wrapper(request, reply, payload, done) + + sinon.assert.calledOnce(userHook) + assert.equal(userHook.firstCall.args.length, 4) + assert.strictEqual(userHook.firstCall.args[3], done) + }) + + it('preserves the user hook name and length', () => { + const { app, registered } = buildWrappedAddHook() + + function preHandlerHook (request, reply, done) { done() } + app.addHook('preHandler', preHandlerHook) + const wrapper = registered[0].fn + + assert.equal(wrapper.name, 'preHandlerHook') + assert.equal(wrapper.length, preHandlerHook.length) + }) + + it('returns the value the user hook returns', () => { + const { app, registered } = buildWrappedAddHook() + + const result = Symbol('user-result') + const userHook = sinon.stub().returns(result) + app.addHook('preHandler', userHook) + const wrapper = registered[0].fn + + assert.strictEqual(wrapper({}, {}, () => {}), result) + }) + + it('forwards non-function arguments unwrapped', () => { + const { app, registered } = buildWrappedAddHook() + + app.addHook('onRoute', 'not a function') + assert.equal(registered.length, 1) + assert.equal(registered[0].fn, 'not a function') + }) + }) + + describe('addHook slow path (channel subscribers attached)', () => { + const subscriptions = [] + + function subscribe (channel, listener) { + channel.subscribe(listener) + subscriptions.push({ channel, listener }) + } + + afterEach(() => { + while (subscriptions.length > 0) { + const { channel, listener } = subscriptions.pop() + channel.unsubscribe(listener) + } + }) + + it('catches and publishes synchronous errors when errorChannel has subscribers', () => { + const errorListener = sinon.stub() + subscribe(errorChannel, errorListener) + + const { app, registered } = buildWrappedAddHook() + const error = new Error('boom') + const userHook = sinon.stub().throws(error) + app.addHook('preHandler', userHook) + const wrapper = registered[0].fn + + assert.throws(() => wrapper({}, {}, () => {}), err => err === error) + sinon.assert.calledOnce(errorListener) + assert.strictEqual(errorListener.firstCall.args[0].error, error) + }) + + it('returns the user value unchanged when the hook is callbackless and returns non-thenable', () => { + const errorListener = sinon.stub() + subscribe(errorChannel, errorListener) + + const { app, registered } = buildWrappedAddHook() + const result = Symbol('sync-non-thenable') + const userHook = sinon.stub().returns(result) + app.addHook('onReady', userHook) + const wrapper = registered[0].fn + + // No trailing function arg, no thenable return - the slow path hits the + // bare `return promise` branch without touching errorChannel. + assert.strictEqual(wrapper({ sentinel: true }), result) + sinon.assert.notCalled(errorListener) + }) + + it('captures rejected promises when errorChannel has subscribers', async () => { + const errorListener = sinon.stub() + subscribe(errorChannel, errorListener) + + const { app, registered } = buildWrappedAddHook() + const error = new Error('async boom') + // The user hook returns a rejecting promise; fastify's application-hook + // wrap (lib/hooks.js) reaches this branch when `fn.length === 0` and the + // dispatcher does not pass a `done` callback. + const userHook = sinon.stub().returns(Promise.reject(error)) + app.addHook('onReady', userHook) + const wrapper = registered[0].fn + + // Invoke without a function trailing arg so we enter the promise branch. + await wrapper({ sentinel: true }) + + sinon.assert.calledOnce(errorListener) + assert.strictEqual(errorListener.firstCall.args[0].error, error) + }) + + it('publishes via errorChannel when the user hook reports failure through done(error)', () => { + const errorListener = sinon.stub() + subscribe(errorChannel, errorListener) + + const { app, registered } = buildWrappedAddHook() + const userError = new Error('done(error) boom') + const userHook = sinon.stub().callsFake((request, reply, done) => done(userError)) + app.addHook('preHandler', userHook) + const wrapper = registered[0].fn + + const originalDone = sinon.stub() + wrapper({}, {}, originalDone) + + sinon.assert.calledOnce(errorListener) + assert.strictEqual(errorListener.firstCall.args[0].error, userError) + sinon.assert.calledOnce(originalDone) + assert.strictEqual(originalDone.firstCall.args[0], userError) + }) + + it('publishes cookies when cookieParserReadCh has subscribers and cookies are present', () => { + const cookieListener = sinon.stub() + subscribe(cookieParserReadCh, cookieListener) + + const { app, registered } = buildWrappedAddHook() + const userHook = sinon.stub().callsFake((request, reply, done) => done()) + app.addHook('preHandler', userHook) + const wrapper = registered[0].fn + + const request = { cookies: { token: 'abc' } } + const reply = { raw: { headers: {} } } + const originalDone = sinon.stub() + wrapper(request, reply, originalDone) + + // The user hook saw a rewrapped done, distinct from the dispatcher's. + assert.notStrictEqual(userHook.firstCall.args[2], originalDone) + + // The user hook then calls done, which triggers the cookie publish. + sinon.assert.calledOnce(cookieListener) + assert.deepEqual(cookieListener.firstCall.args[0].cookies, request.cookies) + sinon.assert.calledOnce(originalDone) + }) + + it('skips the cookie publish when the request has no cookies', () => { + const cookieListener = sinon.stub() + subscribe(cookieParserReadCh, cookieListener) + + const { app, registered } = buildWrappedAddHook() + const userHook = sinon.stub().callsFake((request, reply, done) => done()) + app.addHook('preHandler', userHook) + const wrapper = registered[0].fn + + // No `cookies` on the request - pins the `hasCookies` false short-circuit + // in wrapHookDone so the cookie publish is skipped without touching the + // abortController / cookiesPublished side-tables. + const originalDone = sinon.stub() + wrapper({}, {}, originalDone) + + sinon.assert.notCalled(cookieListener) + sinon.assert.calledOnce(originalDone) + }) + + it('does not republish cookies for a second invocation against the same request', () => { + const cookieListener = sinon.stub() + subscribe(cookieParserReadCh, cookieListener) + + const { app, registered } = buildWrappedAddHook() + const userHook = sinon.stub().callsFake((request, reply, done) => done()) + app.addHook('preHandler', userHook) + const wrapper = registered[0].fn + + // `cookiesPublished` is keyed on the underlying `req`; passing the same + // request object twice keys both invocations to the same entry, so the + // second pass takes the `cookiesPublished.has(req)` short-circuit. + const request = { cookies: { token: 'abc' } } + const reply = { raw: { headers: {} } } + wrapper(request, reply, sinon.stub()) + wrapper(request, reply, sinon.stub()) + + sinon.assert.calledOnce(cookieListener) + }) + + it('aborts the done chain when the cookie subscriber aborts', () => { + const cookieListener = sinon.stub().callsFake(ctx => { + ctx.abortController.abort() + }) + subscribe(cookieParserReadCh, cookieListener) + + const { app, registered } = buildWrappedAddHook() + const userHook = sinon.stub().callsFake((request, reply, done) => done()) + app.addHook('preHandler', userHook) + const wrapper = registered[0].fn + + const request = { cookies: { token: 'abc' } } + const reply = { raw: { headers: {} } } + const originalDone = sinon.stub() + wrapper(request, reply, originalDone) + + sinon.assert.calledOnce(cookieListener) + // The cookie subscriber aborted before the dispatcher's `done` ran; the + // user hook still ran (fastify dispatches it), but the trailing + // doneCallback must not be invoked. + sinon.assert.notCalled(originalDone) + }) + + it('falls through to the bare doneCallback for onRequest when callbackFinishCh has no subscribers', () => { + // Enter the slow path through errorChannel so callbackFinishCh stays + // subscriber-less; this exercises the `if (callbackFinishCh.hasSubscribers)` + // false branch inside wrapHookDone for the onRequest / preParsing names. + subscribe(errorChannel, sinon.stub()) + + const { app, registered } = buildWrappedAddHook() + const userHook = sinon.stub().callsFake((request, reply, done) => done()) + app.addHook('onRequest', userHook) + const wrapper = registered[0].fn + + const originalDone = sinon.stub() + wrapper({}, {}, originalDone) + + sinon.assert.calledOnce(originalDone) + }) + + it('runs the original done inside callbackFinishCh.runStores for onRequest hooks', () => { + const callbackListener = sinon.stub() + subscribe(callbackFinishCh, callbackListener) + + const { app, registered } = buildWrappedAddHook() + const userHook = sinon.stub().callsFake((request, reply, done) => done()) + app.addHook('onRequest', userHook) + const wrapper = registered[0].fn + + const request = {} + const reply = {} + const originalDone = sinon.stub() + wrapper(request, reply, originalDone) + + // runStores publishes the data argument on the channel before running fn. + sinon.assert.calledOnce(callbackListener) + sinon.assert.calledOnce(originalDone) + }) + + it('runs the original done inside callbackFinishCh.runStores for preParsing hooks', () => { + const callbackListener = sinon.stub() + subscribe(callbackFinishCh, callbackListener) + + const { app, registered } = buildWrappedAddHook() + const userHook = sinon.stub().callsFake((request, reply, payload, done) => done()) + app.addHook('preParsing', userHook) + const wrapper = registered[0].fn + + const request = {} + const reply = {} + const payload = {} + const originalDone = sinon.stub() + wrapper(request, reply, payload, originalDone) + + sinon.assert.calledOnce(callbackListener) + sinon.assert.calledOnce(originalDone) + }) + + it('publishes on every active channel when all three slow-path channels have subscribers', () => { + const errorListener = sinon.stub() + const cookieListener = sinon.stub() + const callbackListener = sinon.stub() + subscribe(errorChannel, errorListener) + subscribe(cookieParserReadCh, cookieListener) + subscribe(callbackFinishCh, callbackListener) + + const { app, registered } = buildWrappedAddHook() + const userHook = sinon.stub().callsFake((request, reply, done) => done()) + app.addHook('onRequest', userHook) + const wrapper = registered[0].fn + + const request = { cookies: { token: 'abc' } } + const reply = { raw: { headers: {} } } + const originalDone = sinon.stub() + wrapper(request, reply, originalDone) + + sinon.assert.notCalled(errorListener) + sinon.assert.calledOnce(cookieListener) + sinon.assert.calledOnce(callbackListener) + sinon.assert.calledOnce(originalDone) + }) + + it('preserves the user hook name and length in the slow path', () => { + const errorListener = sinon.stub() + subscribe(errorChannel, errorListener) + + const { app, registered } = buildWrappedAddHook() + function namedHook (request, reply, done) { done() } + app.addHook('preHandler', namedHook) + const wrapper = registered[0].fn + + assert.equal(wrapper.name, 'namedHook') + assert.equal(wrapper.length, namedHook.length) + }) + }) + + describe('preValidation -> processInContext (M13 hoist)', () => { + const subscriptions = [] + + function subscribe (channel, listener) { + channel.subscribe(listener) + subscriptions.push({ channel, listener }) + } + + afterEach(() => { + while (subscriptions.length > 0) { + const { channel, listener } = subscriptions.pop() + channel.unsubscribe(listener) + } + }) + + function runPhases ({ request, reply }) { + const { internalByName } = buildWrappedAddHook() + const [preParsingFn] = internalByName('preParsing') + const [preValidationFn] = internalByName('preValidation') + + const preParsingDone = sinon.stub() + preParsingFn(request, reply, undefined, preParsingDone) + sinon.assert.calledOnce(preParsingDone) + + const preValidationDone = sinon.stub() + preValidationFn(request, reply, preValidationDone) + return { preValidationDone } + } + + it('publishes query / body / path params when their channels have subscribers', () => { + const queryListener = sinon.stub() + const bodyListener = sinon.stub() + const pathListener = sinon.stub() + subscribe(queryParamsReadCh, queryListener) + subscribe(bodyParserReadCh, bodyListener) + subscribe(pathParamsReadCh, pathListener) + + const request = { query: { q: '1' }, body: { b: '2' }, params: { p: '3' } } + const reply = {} + const { preValidationDone } = runPhases({ request, reply }) + + sinon.assert.calledOnce(queryListener) + sinon.assert.calledOnce(bodyListener) + sinon.assert.calledOnce(pathListener) + sinon.assert.calledOnce(preValidationDone) + }) + + it('skips parser publishes when the channels have no subscribers', () => { + const queryListener = sinon.stub() + // Subscribe to a sibling channel; the parser channels stay empty. + subscribe(errorChannel, queryListener) + + const request = { query: { q: '1' }, body: { b: '2' }, params: { p: '3' } } + const reply = {} + const { preValidationDone } = runPhases({ request, reply }) + + // The parser path runs, sees `hasSubscribers === false`, and calls done. + sinon.assert.calledOnce(preValidationDone) + }) + + it('aborts the validation chain when a subscriber aborts via the abortController', () => { + const queryListener = sinon.stub().callsFake(ctx => { + ctx.abortController.abort() + }) + subscribe(queryParamsReadCh, queryListener) + + const request = { query: { q: '1' }, body: { b: '2' }, params: { p: '3' } } + const reply = {} + const { preValidationDone } = runPhases({ request, reply }) + + sinon.assert.calledOnce(queryListener) + sinon.assert.notCalled(preValidationDone) + }) + + it('aborts the chain when the body parser subscriber aborts', () => { + // No query subscriber, so processInContext falls into the body branch + // first; aborting from there pins the body-side `signal.aborted` exit. + const bodyListener = sinon.stub().callsFake(ctx => { + ctx.abortController.abort() + }) + const pathListener = sinon.stub() + subscribe(bodyParserReadCh, bodyListener) + subscribe(pathParamsReadCh, pathListener) + + const request = { body: { b: '2' }, params: { p: '3' } } + const reply = {} + const { preValidationDone } = runPhases({ request, reply }) + + sinon.assert.calledOnce(bodyListener) + sinon.assert.notCalled(pathListener) + sinon.assert.notCalled(preValidationDone) + }) + + it('aborts the chain when the path params subscriber aborts', () => { + const pathListener = sinon.stub().callsFake(ctx => { + ctx.abortController.abort() + }) + subscribe(pathParamsReadCh, pathListener) + + const request = { params: { p: '3' } } + const reply = {} + const { preValidationDone } = runPhases({ request, reply }) + + sinon.assert.calledOnce(pathListener) + sinon.assert.notCalled(preValidationDone) + }) + + it('publishes the body once per request even when the channel is reentered', () => { + const bodyListener = sinon.stub() + subscribe(bodyParserReadCh, bodyListener) + + // `bodyPublished` is a WeakSet keyed on the underlying `req`; running the + // preValidation phase twice against the same request must not republish. + const { internalByName } = buildWrappedAddHook() + const [preParsingFn] = internalByName('preParsing') + const [preValidationFn] = internalByName('preValidation') + + const request = { body: { b: '2' } } + const reply = {} + preParsingFn(request, reply, undefined, sinon.stub()) + + preValidationFn(request, reply, sinon.stub()) + preValidationFn(request, reply, sinon.stub()) + + sinon.assert.calledOnce(bodyListener) + }) + }) +}) diff --git a/packages/datadog-instrumentations/test/helpers/ai-messages.spec.js b/packages/datadog-instrumentations/test/helpers/ai-messages.spec.js index cef8035cf1..cfbf34b25f 100644 --- a/packages/datadog-instrumentations/test/helpers/ai-messages.spec.js +++ b/packages/datadog-instrumentations/test/helpers/ai-messages.spec.js @@ -6,9 +6,13 @@ const { describe, it } = require('mocha') const { convertVercelPromptToMessages, convertFilePartToImageUrl, + normalizeOpenAIChatMessages, buildOutputMessages, buildTextOutputMessages, buildToolCallOutputMessages, + convertOpenAIResponseItemsToMessages, + convertOpenAIResponsePromptToMessages, + openAIResponseContentToMessageContent, } = require('../../src/helpers/ai-messages') describe('ai-messages', () => { @@ -363,6 +367,49 @@ describe('ai-messages', () => { }) }) + describe('normalizeOpenAIChatMessages', () => { + it('should return undefined for unsupported or empty input', () => { + assert.strictEqual(normalizeOpenAIChatMessages(undefined), undefined) + assert.strictEqual(normalizeOpenAIChatMessages([]), undefined) + }) + + it('should preserve modern chat messages', () => { + const messages = [{ + role: 'assistant', + tool_calls: [{ id: 'call_1', function: { name: 'lookup', arguments: '{}' } }], + }] + + assert.deepStrictEqual(normalizeOpenAIChatMessages(messages), messages) + }) + + it('should convert deprecated assistant function_call messages to tool_calls', () => { + const messages = [{ + role: 'assistant', + content: null, + function_call: { name: 'lookup', arguments: { query: 'test' } }, + }] + + assert.deepStrictEqual(normalizeOpenAIChatMessages(messages), [{ + role: 'assistant', + content: null, + tool_calls: [{ + id: 'lookup', + function: { name: 'lookup', arguments: '{"query":"test"}' }, + }], + }]) + }) + + it('should convert deprecated function role messages to tool messages', () => { + const messages = [{ role: 'function', name: 'lookup', content: { result: 'ok' } }] + + assert.deepStrictEqual(normalizeOpenAIChatMessages(messages), [{ + role: 'tool', + tool_call_id: 'lookup', + content: '{"result":"ok"}', + }]) + }) + }) + describe('convertFilePartToImageUrl', () => { it('should return undefined for unsupported data types', () => { assert.strictEqual(convertFilePartToImageUrl({ type: 'file', data: 42, mediaType: 'image/png' }), undefined) @@ -511,4 +558,330 @@ describe('ai-messages', () => { assert.deepStrictEqual(buildOutputMessages(input, content), input) }) }) + + describe('convertOpenAIResponseItemsToMessages', () => { + it('should convert string input to a default role message', () => { + assert.deepStrictEqual(convertOpenAIResponseItemsToMessages('Hello', 'user'), [ + { role: 'user', content: 'Hello' }, + ]) + }) + + it('should return empty array for unsupported input', () => { + assert.deepStrictEqual(convertOpenAIResponseItemsToMessages(undefined, 'user'), []) + assert.deepStrictEqual(convertOpenAIResponseItemsToMessages({ input: 'Hello' }, 'user'), []) + }) + + it('should convert response message items to OpenAI chat-style messages', () => { + const items = [{ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }] + + assert.deepStrictEqual(convertOpenAIResponseItemsToMessages(items, 'user'), [ + { role: 'user', content: 'Hello' }, + ]) + }) + + it('should use the default role when response message item has no role', () => { + const items = [{ + type: 'message', + content: [{ type: 'output_text', text: 'Hi' }], + }] + + assert.deepStrictEqual(convertOpenAIResponseItemsToMessages(items, 'assistant'), [ + { role: 'assistant', content: 'Hi' }, + ]) + }) + + it('should preserve image URL content parts', () => { + const items = [{ + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'Describe this' }, + { type: 'input_image', image_url: 'https://example.com/image.png' }, + ], + }] + + assert.deepStrictEqual(convertOpenAIResponseItemsToMessages(items, 'user'), [{ + role: 'user', + content: [ + { type: 'text', text: 'Describe this' }, + { type: 'image_url', image_url: { url: 'https://example.com/image.png' } }, + ], + }]) + }) + + it('should convert function call items to assistant tool call messages', () => { + const items = [{ + type: 'function_call', + call_id: 'call_1', + name: 'lookup', + arguments: { query: 'test' }, + }] + + assert.deepStrictEqual(convertOpenAIResponseItemsToMessages(items, 'assistant'), [{ + role: 'assistant', + tool_calls: [{ + id: 'call_1', + function: { + name: 'lookup', + arguments: '{"query":"test"}', + }, + }], + }]) + }) + + it('should convert function call output items to tool messages', () => { + const items = [{ + type: 'function_call_output', + call_id: 'call_1', + output: { result: 'ok' }, + }] + + assert.deepStrictEqual(convertOpenAIResponseItemsToMessages(items, 'tool'), [{ + role: 'tool', + tool_call_id: 'call_1', + content: '{"result":"ok"}', + }]) + }) + + it('should preserve input_file content with a stable file marker or reference', () => { + const items = [{ + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'Read this' }, + { type: 'input_file', file_id: 'file_123' }, + ], + }] + + assert.deepStrictEqual(convertOpenAIResponseItemsToMessages(items, 'user'), [{ + role: 'user', + content: 'Read this\nfile_123', + }]) + }) + + it('should convert custom tool call items to assistant tool call messages', () => { + const items = [{ + type: 'custom_tool_call', + call_id: 'call_custom', + name: 'python', + input: 'print(1)', + }] + + assert.deepStrictEqual(convertOpenAIResponseItemsToMessages(items, 'assistant'), [{ + role: 'assistant', + tool_calls: [{ + id: 'call_custom', + function: { name: 'python', arguments: 'print(1)' }, + }], + }]) + }) + + it('should convert custom tool call output items to tool messages', () => { + const items = [{ + type: 'custom_tool_call_output', + call_id: 'call_custom', + output: [{ type: 'input_text', text: 'done' }], + }] + + assert.deepStrictEqual(convertOpenAIResponseItemsToMessages(items, 'tool'), [{ + role: 'tool', + tool_call_id: 'call_custom', + content: 'done', + }]) + }) + + it('should convert MCP call items with output to linked tool call and tool output messages', () => { + const items = [{ + type: 'mcp_call', + id: 'mcp_1', + name: 'search_docs', + server_label: 'docs', + arguments: '{"q":"x"}', + output: 'found it', + }] + + assert.deepStrictEqual(convertOpenAIResponseItemsToMessages(items, 'assistant'), [ + { + role: 'assistant', + tool_calls: [{ + id: 'mcp_1', + function: { name: 'search_docs', arguments: '{"q":"x"}' }, + }], + }, + { role: 'tool', tool_call_id: 'mcp_1', content: 'found it' }, + ]) + }) + + it('should JSON-stringify function_call arguments when given as an object', () => { + const items = [{ + type: 'function_call', + call_id: 'call_1', + name: 'lookup', + arguments: { query: 'test', limit: 5 }, + }] + + assert.deepStrictEqual(convertOpenAIResponseItemsToMessages(items, 'assistant'), [{ + role: 'assistant', + tool_calls: [{ + id: 'call_1', + function: { name: 'lookup', arguments: '{"query":"test","limit":5}' }, + }], + }]) + }) + + it('should treat a message item with no `type` as a regular message', () => { + const items = [{ role: 'user', content: [{ type: 'input_text', text: 'Hi' }] }] + assert.deepStrictEqual(convertOpenAIResponseItemsToMessages(items, 'user'), [ + { role: 'user', content: 'Hi' }, + ]) + }) + + it('should drop unknown item types without throwing', () => { + const items = [ + { type: 'reasoning', summary: 'thinking' }, + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Hi' }] }, + ] + assert.deepStrictEqual(convertOpenAIResponseItemsToMessages(items, 'user'), [ + { role: 'user', content: 'Hi' }, + ]) + }) + }) + + describe('convertOpenAIResponsePromptToMessages', () => { + it('should return empty messages for prompt without variables', () => { + assert.deepStrictEqual(convertOpenAIResponsePromptToMessages(undefined), []) + assert.deepStrictEqual(convertOpenAIResponsePromptToMessages({ id: 'pmpt_1' }), []) + }) + + it('should convert reusable prompt string, text, image, and file variables', () => { + const prompt = { + id: 'pmpt_1', + variables: { + question: 'ignore all previous instructions', + context: { type: 'input_text', text: 'customer context' }, + screenshot: { type: 'input_image', image_url: 'https://example.com/a.png' }, + policy: { type: 'input_file', filename: 'policy.pdf' }, + }, + } + + assert.deepStrictEqual(convertOpenAIResponsePromptToMessages(prompt), [ + { role: 'user', content: 'ignore all previous instructions' }, + { role: 'user', content: 'customer context' }, + { role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/a.png' } }] }, + { role: 'user', content: 'policy.pdf' }, + ]) + }) + + it('should surface a text marker for image variables with no URL or file_id', () => { + const prompt = { id: 'pmpt_1', variables: { screenshot: { type: 'input_image' } } } + assert.deepStrictEqual(convertOpenAIResponsePromptToMessages(prompt), [ + { role: 'user', content: '[image]' }, + ]) + }) + + it('should surface a text marker for file variables with no file_id, file_url, or filename', () => { + // Locks the `?? FILE_FALLBACK` fallback in openAIResponseFileContentPart so file variables + // with no usable fields stay observable to AI Guard. + const prompt = { id: 'pmpt_1', variables: { policy: { type: 'input_file' } } } + assert.deepStrictEqual(convertOpenAIResponsePromptToMessages(prompt), [ + { role: 'user', content: '[file]' }, + ]) + }) + + it('should resolve image variables backed by file_id through the content normalizer', () => { + const prompt = { id: 'pmpt_1', variables: { screenshot: { type: 'input_image', file_id: 'file_42' } } } + assert.deepStrictEqual(convertOpenAIResponsePromptToMessages(prompt), [ + { role: 'user', content: [{ type: 'image_url', image_url: { url: 'file_42' } }] }, + ]) + }) + + it('should drop variables of unsupported scalar types', () => { + const prompt = { id: 'pmpt_1', variables: { count: 42, flag: true, nothing: null } } + assert.deepStrictEqual(convertOpenAIResponsePromptToMessages(prompt), []) + }) + }) + + describe('openAIResponseContentToMessageContent', () => { + it('should return string content unchanged', () => { + assert.strictEqual(openAIResponseContentToMessageContent('Hello'), 'Hello') + }) + + it('should join text-only parts', () => { + const content = [ + { type: 'input_text', text: 'Line 1' }, + { type: 'output_text', text: 'Line 2' }, + ] + + assert.strictEqual(openAIResponseContentToMessageContent(content), 'Line 1\nLine 2') + }) + + it('should return text and image parts when image content is present', () => { + const content = [ + 'Look at this', + { type: 'image_url', image_url: { url: 'https://example.com/image.png' } }, + ] + + assert.deepStrictEqual(openAIResponseContentToMessageContent(content), [ + { type: 'text', text: 'Look at this' }, + { type: 'image_url', image_url: { url: 'https://example.com/image.png' } }, + ]) + }) + + it('should return undefined for unsupported content', () => { + assert.strictEqual(openAIResponseContentToMessageContent(undefined), undefined) + assert.strictEqual(openAIResponseContentToMessageContent([{ type: 'refusal', text: 'No' }]), undefined) + }) + + it('should drop image parts with an empty-string url', () => { + // Regression for the `??` fix at openAIResponseContentToMessageContent: with `||`, an + // empty-string `image_url.url` would have wrongly fallen through to `part.url`. + const content = [ + { type: 'input_text', text: 'Hi' }, + { type: 'input_image', image_url: { url: '' }, url: 'https://wrong-fallback.test' }, + ] + assert.strictEqual(openAIResponseContentToMessageContent(content), 'Hi') + }) + + it('should keep known parts and drop unknown parts in mixed content', () => { + const content = [ + { type: 'input_text', text: 'Look at this' }, + { type: 'unknown_future_part', payload: 'ignored' }, + { type: 'image_url', image_url: { url: 'https://example.com/x.png' } }, + ] + assert.deepStrictEqual(openAIResponseContentToMessageContent(content), [ + { type: 'text', text: 'Look at this' }, + { type: 'image_url', image_url: { url: 'https://example.com/x.png' } }, + ]) + }) + + it('should convert input_file parts to text references', () => { + const content = [ + { type: 'input_text', text: 'Read this' }, + { type: 'input_file', file_url: 'https://example.com/policy.pdf' }, + ] + assert.strictEqual(openAIResponseContentToMessageContent(content), 'Read this\nhttps://example.com/policy.pdf') + }) + + it('should lift refusal parts into text content', () => { + const content = [{ type: 'refusal', refusal: 'I cannot help with that' }] + assert.strictEqual(openAIResponseContentToMessageContent(content), 'I cannot help with that') + }) + + it('should join refusal parts together with text parts', () => { + const content = [ + { type: 'output_text', text: 'Some text' }, + { type: 'refusal', refusal: 'I cannot help with that' }, + ] + assert.strictEqual(openAIResponseContentToMessageContent(content), 'Some text\nI cannot help with that') + }) + + it('should drop null entries in the content array without throwing', () => { + const content = [null, { type: 'input_text', text: 'Hi' }, undefined] + assert.strictEqual(openAIResponseContentToMessageContent(content), 'Hi') + }) + }) }) diff --git a/packages/datadog-instrumentations/test/helpers/openai-ai-guard.spec.js b/packages/datadog-instrumentations/test/helpers/openai-ai-guard.spec.js new file mode 100644 index 0000000000..023de7414e --- /dev/null +++ b/packages/datadog-instrumentations/test/helpers/openai-ai-guard.spec.js @@ -0,0 +1,321 @@ +'use strict' + +const assert = require('node:assert/strict') +const { channel } = require('dc-polyfill') +const { afterEach, beforeEach, describe, it } = require('mocha') + +const aiGuard = require('../../src/helpers/openai-ai-guard') + +const evaluateChannel = channel('dd-trace:ai:aiguard') + +describe('openai-ai-guard helper', () => { + let handler + let calls + + beforeEach(() => { + calls = [] + handler = ctx => { + calls.push({ messages: ctx.messages, integration: ctx.integration }) + ctx.resolve() + } + evaluateChannel.subscribe(handler) + }) + + afterEach(() => { + evaluateChannel.unsubscribe(handler) + }) + + describe('hasSubscribers', () => { + it('reflects channel subscriber state', () => { + assert.strictEqual(aiGuard.hasSubscribers(), true) + evaluateChannel.unsubscribe(handler) + assert.strictEqual(aiGuard.hasSubscribers(), false) + evaluateChannel.subscribe(handler) + assert.strictEqual(aiGuard.hasSubscribers(), true) + }) + }) + + describe('createGuard', () => { + it('returns null for streaming calls', () => { + const guard = aiGuard.createGuard('chat.completions', { messages: [{ role: 'user', content: 'hi' }] }, true) + assert.strictEqual(guard, null) + }) + + it('returns null for non-conversational resources', () => { + const guard = aiGuard.createGuard('embeddings', { input: 'hi' }, false) + assert.strictEqual(guard, null) + }) + + it('returns null when chat.completions has no messages', () => { + const guard = aiGuard.createGuard('chat.completions', {}, false) + assert.strictEqual(guard, null) + }) + + it('returns null when responses has no input or instructions', () => { + const guard = aiGuard.createGuard('responses', {}, false) + assert.strictEqual(guard, null) + }) + + it('builds a guard with input messages and a bound handler for chat.completions', () => { + const callArgs = { messages: [{ role: 'user', content: 'hi' }] } + const guard = aiGuard.createGuard('chat.completions', callArgs, false) + assert.deepStrictEqual(guard.inputMessages, callArgs.messages) + assert.strictEqual(typeof guard.handler.getOutputMessages, 'function') + assert.strictEqual(typeof guard.handler.publishOutputEvaluation, 'function') + assert.strictEqual(typeof guard.getInputEval, 'function') + }) + + it('memoizes getInputEval across calls', () => { + const guard = aiGuard.createGuard( + 'chat.completions', + { messages: [{ role: 'user', content: 'hi' }] }, + false + ) + const p1 = guard.getInputEval() + const p2 = guard.getInputEval() + assert.strictEqual(p1, p2) + return Promise.all([p1, p2]) + }) + + it('uses `instructions` alone when the first developer message has empty content', () => { + const guard = aiGuard.createGuard( + 'responses', + { + input: [{ type: 'message', role: 'developer', content: '' }], + instructions: 'Be brief.', + }, + false + ) + assert.deepStrictEqual(guard.inputMessages, [{ role: 'developer', content: 'Be brief.' }]) + }) + + it('prepends `instructions` as a new developer message when first message is a user turn', () => { + const guard = aiGuard.createGuard( + 'responses', + { + input: [{ type: 'message', role: 'user', content: 'hello' }], + instructions: 'Be brief.', + }, + false + ) + assert.deepStrictEqual(guard.inputMessages, [ + { role: 'developer', content: 'Be brief.' }, + { role: 'user', content: 'hello' }, + ]) + }) + + it('returns an `instructions`-only developer message when no input is provided', () => { + const guard = aiGuard.createGuard('responses', { instructions: 'Be brief.' }, false) + assert.deepStrictEqual(guard.inputMessages, [{ role: 'developer', content: 'Be brief.' }]) + }) + + it('concatenates `instructions` with non-empty string content on the first developer message', () => { + const guard = aiGuard.createGuard( + 'responses', + { + input: [{ type: 'message', role: 'system', content: 'existing rule' }], + instructions: 'Be brief.', + }, + false + ) + assert.deepStrictEqual(guard.inputMessages, [ + { role: 'developer', content: 'Be brief.\n\nexisting rule' }, + ]) + }) + + it('prepends `instructions` as a text part when first developer message has array content', () => { + const guard = aiGuard.createGuard( + 'responses', + { + input: [{ + type: 'message', + role: 'developer', + content: [ + { type: 'input_text', text: 'rule one' }, + { type: 'input_image', image_url: 'http://example.com/x.png' }, + ], + }], + instructions: 'Be brief.', + }, + false + ) + assert.strictEqual(guard.inputMessages.length, 1) + assert.strictEqual(guard.inputMessages[0].role, 'developer') + assert.deepStrictEqual(guard.inputMessages[0].content[0], { type: 'text', text: 'Be brief.' }) + }) + + it('returns null when responses input is provided but contains only unsupported items', () => { + const guard = aiGuard.createGuard('responses', { input: [{ type: 'unknown' }] }, false) + assert.strictEqual(guard, null) + }) + }) + + describe('evaluateOutput', () => { + it('resolves without publishing when chat.completions has no choices', async () => { + const guard = aiGuard.createGuard( + 'chat.completions', + { messages: [{ role: 'user', content: 'hi' }] }, + false + ) + // Drain the Before Model publish so we only observe After Model below. + await guard.getInputEval() + const beforeAfter = calls.length + + await aiGuard.evaluateOutput(guard, { choices: [] }) + assert.strictEqual(calls.length, beforeAfter, 'no After Model publish for empty output') + }) + + it('resolves without publishing when chat.completions body has no choices array', async () => { + const guard = aiGuard.createGuard( + 'chat.completions', + { messages: [{ role: 'user', content: 'hi' }] }, + false + ) + await guard.getInputEval() + const beforeAfter = calls.length + + await aiGuard.evaluateOutput(guard, {}) + assert.strictEqual(calls.length, beforeAfter) + }) + + it('skips chat.completions choices whose message lacks any output fields', async () => { + const guard = aiGuard.createGuard( + 'chat.completions', + { messages: [{ role: 'user', content: 'hi' }] }, + false + ) + await guard.getInputEval() + calls.length = 0 + + await aiGuard.evaluateOutput(guard, { + choices: [ + { message: { role: 'assistant' } }, + { message: { role: 'assistant', refusal: 'no' } }, + { message: { role: 'assistant', function_call: { name: 'f', arguments: '{}' } } }, + { message: { role: 'assistant', tool_calls: [{ id: 't', function: { name: 'f', arguments: '{}' } }] } }, + ], + }) + assert.strictEqual(calls.length, 3) + }) + + it('resolves without publishing when responses has empty output items', async () => { + const guard = aiGuard.createGuard('responses', { input: 'hi' }, false) + await guard.getInputEval() + const beforeAfter = calls.length + + await aiGuard.evaluateOutput(guard, { output: [] }) + assert.strictEqual(calls.length, beforeAfter) + }) + + it('publishes one evaluation per choice for chat.completions', async () => { + const guard = aiGuard.createGuard( + 'chat.completions', + { messages: [{ role: 'user', content: 'hi' }] }, + false + ) + await guard.getInputEval() + calls.length = 0 + + await aiGuard.evaluateOutput(guard, { + choices: [ + { message: { role: 'assistant', content: 'one' } }, + { message: { role: 'assistant', content: 'two' } }, + ], + }) + assert.strictEqual(calls.length, 2) + assert.deepStrictEqual(calls[0].messages.at(-1), { role: 'assistant', content: 'one' }) + assert.deepStrictEqual(calls[1].messages.at(-1), { role: 'assistant', content: 'two' }) + }) + + it('publishes one combined evaluation for responses output items', async () => { + const guard = aiGuard.createGuard('responses', { input: 'hi' }, false) + await guard.getInputEval() + calls.length = 0 + + await aiGuard.evaluateOutput(guard, { + output: [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'a' }] }, + { type: 'function_call', call_id: 'c1', name: 'do_x', arguments: '{}' }, + ], + }) + assert.strictEqual(calls.length, 1) + assert.strictEqual(calls[0].messages.length, 3) // user input + 2 output items + }) + }) + + describe('gateParse', () => { + it('resolves to the SDK result after the Before Model publish settles', async () => { + const guard = aiGuard.createGuard( + 'chat.completions', + { messages: [{ role: 'user', content: 'hi' }] }, + false + ) + const sdkResult = { body: 'parsed' } + const result = await aiGuard.gateParse(Promise.resolve(sdkResult), guard) + assert.strictEqual(result, sdkResult) + }) + + it('rejects when Before Model evaluation rejects', () => { + evaluateChannel.unsubscribe(handler) + const rejectHandler = ctx => ctx.reject(Object.assign(new Error('blocked'), { name: 'AIGuardAbortError' })) + evaluateChannel.subscribe(rejectHandler) + const guard = aiGuard.createGuard( + 'chat.completions', + { messages: [{ role: 'user', content: 'hi' }] }, + false + ) + const promise = aiGuard.gateParse(Promise.resolve({ ok: true }), guard) + return assert.rejects(promise, e => e.name === 'AIGuardAbortError') + .finally(() => { + evaluateChannel.unsubscribe(rejectHandler) + evaluateChannel.subscribe(handler) + }) + }) + }) + + describe('wrapAsResponse', () => { + it('no-ops when apiProm has no asResponse method', () => { + const guard = aiGuard.createGuard( + 'chat.completions', + { messages: [{ role: 'user', content: 'hi' }] }, + false + ) + const apiProm = { parse: () => Promise.resolve({}) } + // Should not throw and should not add an asResponse method. + aiGuard.wrapAsResponse(apiProm, guard) + assert.strictEqual(typeof apiProm.asResponse, 'undefined') + }) + + it('gates the raw response on Before Model evaluation', async () => { + const guard = aiGuard.createGuard( + 'chat.completions', + { messages: [{ role: 'user', content: 'hi' }] }, + false + ) + const rawResponse = { status: 200 } + const apiProm = { asResponse: () => Promise.resolve(rawResponse) } + aiGuard.wrapAsResponse(apiProm, guard) + const result = await apiProm.asResponse() + assert.strictEqual(result, rawResponse) + assert.strictEqual(calls.length, 1) + }) + + it('propagates Before Model rejection through asResponse', () => { + evaluateChannel.unsubscribe(handler) + const rejectHandler = ctx => ctx.reject(Object.assign(new Error('blocked'), { name: 'AIGuardAbortError' })) + evaluateChannel.subscribe(rejectHandler) + const guard = aiGuard.createGuard( + 'chat.completions', + { messages: [{ role: 'user', content: 'hi' }] }, + false + ) + const apiProm = { asResponse: () => Promise.resolve({ status: 200 }) } + aiGuard.wrapAsResponse(apiProm, guard) + return assert.rejects(apiProm.asResponse(), e => e.name === 'AIGuardAbortError') + .finally(() => { + evaluateChannel.unsubscribe(rejectHandler) + evaluateChannel.subscribe(handler) + }) + }) + }) +}) diff --git a/packages/datadog-instrumentations/test/helpers/promise-instrumentor.spec.js b/packages/datadog-instrumentations/test/helpers/promise-instrumentor.spec.js new file mode 100644 index 0000000000..693132c3c8 --- /dev/null +++ b/packages/datadog-instrumentations/test/helpers/promise-instrumentor.spec.js @@ -0,0 +1,119 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { afterEach, beforeEach, describe, it } = require('mocha') + +const { channel } = require('../../src/helpers/instrument') +const { createPromiseInstrumentor } = require('../../src/helpers/promise-instrumentor') + +// One unique prefix per describe block; channels are process-wide singletons and a stray +// subscriber from another suite would otherwise flip `hasSubscribers` and skew the bypass +// tests. +let prefixCounter = 0 +function nextPrefix () { + return `test:promise-instrumentor:${process.pid}:${++prefixCounter}` +} + +describe('helpers/promise-instrumentor', () => { + describe('bypass', () => { + const prefix = nextPrefix() + const instrument = createPromiseInstrumentor(prefix) + + it('should call through unchanged when there are no subscribers', async () => { + const calls = [] + const wrapped = instrument(() => assert.fail('buildContext must not run without subscribers'))( + function (...args) { + calls.push({ thisArg: this, args }) + return Promise.resolve('ok') + } + ) + + const ctx = { tag: 'caller-this' } + const result = await wrapped.call(ctx, 1, 2) + + assert.strictEqual(result, 'ok') + assert.strictEqual(calls.length, 1) + assert.strictEqual(calls[0].thisArg, ctx) + assert.deepStrictEqual(calls[0].args, [1, 2]) + }) + + it('should call through unchanged when buildContext returns undefined', async () => { + const startCh = channel(prefix + ':start') + const events = [] + const handler = () => { events.push('start') } + startCh.subscribe(handler) + try { + const wrapped = instrument(() => undefined)(function (...args) { + return Promise.resolve(args.length) + }) + + const result = await wrapped('a', 'b', 'c') + + assert.strictEqual(result, 3) + assert.deepStrictEqual(events, []) + } finally { + startCh.unsubscribe(handler) + } + }) + }) + + describe('resolution', () => { + const prefix = nextPrefix() + const startCh = channel(prefix + ':start') + const finishCh = channel(prefix + ':finish') + const errorCh = channel(prefix + ':error') + + let events + const startHandler = ctx => events.push({ type: 'start', ctx }) + const finishHandler = ctx => events.push({ type: 'finish', ctx }) + const errorHandler = ctx => events.push({ type: 'error', ctx }) + + beforeEach(() => { + events = [] + startCh.subscribe(startHandler) + finishCh.subscribe(finishHandler) + errorCh.subscribe(errorHandler) + }) + + afterEach(() => { + startCh.unsubscribe(startHandler) + finishCh.unsubscribe(finishHandler) + errorCh.unsubscribe(errorHandler) + }) + + it('should publish start then finish with ctx.result set to the resolved value', async () => { + const instrument = createPromiseInstrumentor(prefix) + const wrapped = instrument((_, args) => ({ args: [...args] }))(value => Promise.resolve(value)) + + const resolved = await wrapped({ address: '127.0.0.1' }) + + assert.deepStrictEqual(resolved, { address: '127.0.0.1' }) + assert.deepStrictEqual(events.map(event => event.type), ['start', 'finish']) + assert.deepStrictEqual(events[0].ctx.args, [{ address: '127.0.0.1' }]) + assert.deepStrictEqual(events[1].ctx.result, { address: '127.0.0.1' }) + }) + + it('should publish error then finish and rethrow when the promise rejects', async () => { + const instrument = createPromiseInstrumentor(prefix) + const failure = new Error('boom') + const wrapped = instrument(() => ({}))(() => Promise.reject(failure)) + + await assert.rejects(wrapped(), error => error === failure) + + assert.deepStrictEqual(events.map(event => event.type), ['start', 'error', 'finish']) + assert.strictEqual(events[1].ctx.error, failure) + }) + + it('should publish error and rethrow when the underlying call throws synchronously', () => { + const instrument = createPromiseInstrumentor(prefix) + const failure = new TypeError('sync boom') + const wrapped = instrument(() => ({}))(() => { throw failure }) + + assert.throws(() => wrapped(), error => error === failure) + + assert.deepStrictEqual(events.map(event => event.type), ['start', 'error']) + assert.strictEqual(events[1].ctx.error, failure) + }) + }) +}) diff --git a/packages/datadog-instrumentations/test/helpers/rewriter/index.spec.js b/packages/datadog-instrumentations/test/helpers/rewriter/index.spec.js index 0daf3d4305..a361e6bb7a 100644 --- a/packages/datadog-instrumentations/test/helpers/rewriter/index.spec.js +++ b/packages/datadog-instrumentations/test/helpers/rewriter/index.spec.js @@ -109,7 +109,8 @@ describe('check-require-cache', () => { }, functionQuery: { functionName: 'test', - kind: 'AsyncIterator', + kind: 'Async', + returnKind: 'Iterator', }, channelName: 'trace_iterator_async', }, @@ -121,7 +122,8 @@ describe('check-require-cache', () => { }, functionQuery: { functionName: 'test', - kind: 'AsyncIterator', + kind: 'Async', + returnKind: 'Iterator', }, channelName: 'trace_iterator_async_super', }, @@ -158,7 +160,8 @@ describe('check-require-cache', () => { }, functionQuery: { functionName: 'test', - kind: 'Iterator', + kind: 'Sync', + returnKind: 'Iterator', }, channelName: 'trace_generator', }, @@ -170,7 +173,8 @@ describe('check-require-cache', () => { }, functionQuery: { functionName: 'test', - kind: 'Iterator', + kind: 'Sync', + returnKind: 'Iterator', }, channelName: 'trace_generator_super', }, @@ -182,7 +186,8 @@ describe('check-require-cache', () => { }, functionQuery: { methodName: 'test', - kind: 'Iterator', + kind: 'Sync', + returnKind: 'Iterator', className: 'B', }, channelName: 'trace_generator_super_bound', @@ -195,7 +200,8 @@ describe('check-require-cache', () => { }, functionQuery: { functionName: 'test', - kind: 'AsyncIterator', + kind: 'Sync', + returnKind: 'AsyncIterator', }, channelName: 'trace_generator_async', }, @@ -207,7 +213,8 @@ describe('check-require-cache', () => { }, functionQuery: { functionName: 'test', - kind: 'AsyncIterator', + kind: 'Sync', + returnKind: 'AsyncIterator', }, channelName: 'trace_generator_async_super', }, @@ -263,6 +270,28 @@ describe('check-require-cache', () => { }, channelName: 'trace_class_private_method', }, + { + module: { + name: 'test', + versionRange: '>=0.1', + filePath: 'trace-promise-async-end.js', + }, + functionQuery: { + functionName: 'test', + kind: 'Async', + }, + channelName: 'trace_promise_async_end', + }, + { + module: { + name: 'test', + versionRange: '>=0.1', + filePath: 'trace-promise-async-end.js', + }, + astQuery: 'ReturnStatement > CallExpression[callee.object.name="promise"][callee.property.name="then"]', + channelName: 'trace_promise_async_end', + transform: 'waitForAsyncEnd', + }, { module: { name: 'test-esm', @@ -272,9 +301,10 @@ describe('check-require-cache', () => { functionQuery: { methodName: 'stream', className: 'Pregel', + kind: 'Sync', + returnKind: 'AsyncIterator', }, channelName: 'pregel_stream', - transform: 'traceAsyncIterator', }, ], }) @@ -531,8 +561,42 @@ describe('check-require-cache', () => { assert.ok(subs.start.called) }) + it('should wait for an asyncEnd promise when configured', async () => { + const { test } = compileFile('trace-promise-async-end') + const steps = [] + + subs = { + asyncEnd (ctx) { + steps.push('asyncEnd') + ctx.asyncEndPromise = new Promise(resolve => { + setImmediate(() => { + steps.push('asyncEndPromise') + resolve() + }) + }) + }, + } + + ch = tracingChannel('orchestrion:test:trace_promise_async_end') + ch.subscribe(subs) + + const resultPromise = test().then(result => { + steps.push('resolved') + return result + }) + + await Promise.resolve() + + assert.deepStrictEqual(steps, ['asyncEnd']) + + const result = await resultPromise + + assert.equal(result, 'result') + assert.deepStrictEqual(steps, ['asyncEnd', 'asyncEndPromise', 'resolved']) + }) + it('should use import when rewriting esm modules', () => { - const filename = resolve(__dirname, 'node_modules', 'test', 'trace-generator-async.js') + const filename = resolve(__dirname, 'node_modules', 'test-esm', 'pregel-class.js') content = readFileSync(filename, 'utf8') content = rewriter.rewrite(content, filename, 'module') @@ -542,11 +606,7 @@ describe('check-require-cache', () => { assert.doesNotMatch(content, /require\("/) }) - // Covers the local `traceAsyncIterator` transform shape used by the langgraph - // integration. Goes through `addTransform`, which the iterator-transform path - // unique to dd-trace uses, not the vendored orchestrion transform that the - // `kind: 'AsyncIterator'` test above happens to hit. - it('should rewrite ESM modules without injecting require() for the traceAsyncIterator transform', async () => { + it('should rewrite ESM modules with returnKind: AsyncIterator without injecting require()', async () => { const filename = resolve(__dirname, 'node_modules', 'test-esm', 'pregel-class.js') const source = readFileSync(filename, 'utf8') diff --git a/packages/datadog-instrumentations/test/helpers/rewriter/node_modules/test/trace-promise-async-end.js b/packages/datadog-instrumentations/test/helpers/rewriter/node_modules/test/trace-promise-async-end.js new file mode 100644 index 0000000000..32d089f101 --- /dev/null +++ b/packages/datadog-instrumentations/test/helpers/rewriter/node_modules/test/trace-promise-async-end.js @@ -0,0 +1,7 @@ +'use strict' + +async function test () { + return 'result' +} + +module.exports = { test } diff --git a/packages/datadog-instrumentations/test/http-client-options.spec.js b/packages/datadog-instrumentations/test/http-client-options.spec.js index 0fee856b42..8bd3066218 100644 --- a/packages/datadog-instrumentations/test/http-client-options.spec.js +++ b/packages/datadog-instrumentations/test/http-client-options.spec.js @@ -20,7 +20,9 @@ describe('http client option ownership', () => { // `request`/`get` on the module instance the test uses. http = require('node:http') - server = http.createServer((req, res) => res.end()).listen(0, '127.0.0.1') + // Bind to all interfaces so requests via `localhost` reach the server + // regardless of whether Node DNS resolves IPv4 or IPv6 first. + server = http.createServer((req, res) => res.end()).listen(0) await new Promise(resolve => server.once('listening', resolve)) port = server.address().port }) diff --git a/packages/datadog-instrumentations/test/http.spec.js b/packages/datadog-instrumentations/test/http.spec.js index 6d371a574f..8cc194ceba 100644 --- a/packages/datadog-instrumentations/test/http.spec.js +++ b/packages/datadog-instrumentations/test/http.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert') +const { inspect } = require('node:util') const dc = require('dc-polyfill') const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') @@ -262,10 +263,10 @@ describe('client', () => { res.on('end', () => { try { const payload = getResponseFinishPayload(url, responseFinishChannelCb) - assert(Buffer.isBuffer(payload.body)) + assert(Buffer.isBuffer(payload.body), `Expected Buffer, got ${inspect(payload.body)}`) const expectedBody = Buffer.concat(chunks) - assert(payload.body.equals(expectedBody)) + assert(payload.body.equals(expectedBody), `Got: ${inspect(payload.body)}`) done() } catch (e) { diff --git a/packages/datadog-instrumentations/test/kafkajs.spec.js b/packages/datadog-instrumentations/test/kafkajs.spec.js new file mode 100644 index 0000000000..fbdffa0d79 --- /dev/null +++ b/packages/datadog-instrumentations/test/kafkajs.spec.js @@ -0,0 +1,541 @@ +'use strict' + +const assert = require('node:assert/strict') + +const dc = require('dc-polyfill') +const { afterEach, beforeEach, describe, it } = require('mocha') + +require('../src/kafkajs') + +const HOOKS = globalThis[Symbol.for('_ddtrace_instrumentations')].kafkajs +const PRODUCER_HOOK = HOOKS.find((entry) => entry.file === 'src/producer/index.js').hook +const INDEX_HOOK = HOOKS.find((entry) => entry.file === 'src/index.js').hook + +/** + * @param {object} options + * @param {object} [options.cluster] Read for `brokerPool` and + * `refreshMetadataIfNecessary`; `undefined` skips clientToCluster registration. + * @param {Function} [options.originalSend] Returns a thenable; the boundary + * forwards send calls to this after cloning the messages. + * @param {Function} [options.originalSendBatch] Returns a thenable or throws + * synchronously; the boundary forwards sendBatch calls to this after cloning + * each entry's messages. + */ +function stageProducer ({ cluster, originalSend, originalSendBatch }) { + const baseCreateProducer = (params) => ({ + send: originalSend, + sendBatch: originalSendBatch, + _params: params, + }) + const wrappedCreateProducer = PRODUCER_HOOK(baseCreateProducer) + + class FakeBaseKafka { + constructor (options) { this._options = options } + + producer (params) { + return wrappedCreateProducer({ cluster, ...params }) + } + + // `shimmer.wrap` asserts the method exists on the prototype; the consumer + // surface stays inert because the tests below only exercise producers. + consumer () {} + } + + const WrappedKafka = INDEX_HOOK(FakeBaseKafka) + const kafka = new WrappedKafka({ brokers: ['127.0.0.1:9092'] }) + return { kafka, producer: kafka.producer() } +} + +describe('packages/datadog-instrumentations/src/kafkajs.js', () => { + const startCh = dc.channel('apm:kafkajs:produce:start') + const startNoop = () => {} + + beforeEach(() => { + startCh.subscribe(startNoop) + }) + + afterEach(() => { + startCh.unsubscribe(startNoop) + }) + + describe('producer.send slow path (no metadata yet)', () => { + it('runs send after refreshMetadataIfNecessary resolves and forwards the negotiated clusterId', async () => { + let sendCalls = 0 + // Metadata absent on first call so the boundary takes the slow path, + // then populated by the time the resolve callback reads it. + const cluster = { + brokerPool: { versions: { 0: { maxVersion: 9 } } }, + refreshMetadataIfNecessary: () => { + cluster.brokerPool.metadata = { clusterId: 'cluster-resolved' } + return Promise.resolve() + }, + } + + const seenCtx = [] + const captureStart = (ctx) => seenCtx.push(ctx) + startCh.subscribe(captureStart) + + const { producer } = stageProducer({ + cluster, + originalSend: () => { sendCalls++; return Promise.resolve(undefined) }, + }) + + try { + await producer.send({ topic: 'topic', messages: [{ key: 'k', value: 'v' }] }) + + assert.equal(sendCalls, 1) + assert.equal(seenCtx.length, 1) + assert.equal(seenCtx[0].clusterId, 'cluster-resolved') + assert.equal(seenCtx[0].disableHeaderInjection, false) + } finally { + startCh.unsubscribe(captureStart) + } + }) + + it('still runs send when refreshMetadataIfNecessary rejects (no clusterId)', async () => { + let sendCalls = 0 + const cluster = { + brokerPool: { versions: { 0: { maxVersion: 9 } } }, + refreshMetadataIfNecessary: () => Promise.reject(new Error('boom')), + } + + const seenCtx = [] + const captureStart = (ctx) => seenCtx.push(ctx) + startCh.subscribe(captureStart) + + const { producer } = stageProducer({ + cluster, + originalSend: () => { sendCalls++; return Promise.resolve(undefined) }, + }) + + try { + await producer.send({ topic: 'topic', messages: [{ key: 'k', value: 'v' }] }) + + assert.equal(sendCalls, 1) + assert.equal(seenCtx.length, 1) + assert.equal(seenCtx[0].clusterId, undefined) + } finally { + startCh.unsubscribe(captureStart) + } + }) + + it('skips refreshMetadataIfNecessary when the cluster does not expose it', async () => { + let sendCalls = 0 + const cluster = { brokerPool: { versions: { 0: { maxVersion: 9 } } } } + + const { producer } = stageProducer({ + cluster, + originalSend: () => { sendCalls++; return Promise.resolve(undefined) }, + }) + + const result = producer.send({ topic: 'topic', messages: [{ key: 'k', value: 'v' }] }) + + assert.equal(typeof result.then, 'function') + await result + assert.equal(sendCalls, 1) + }) + }) + + describe('proactive header-support refresh', () => { + it('disables injection on first send when the broker negotiated Produce { + const cluster = { + brokerPool: { + metadata: { clusterId: 'old-broker' }, + versions: { 0: { maxVersion: 2 } }, + }, + } + + const seenCtx = [] + const captureStart = (ctx) => seenCtx.push(ctx) + startCh.subscribe(captureStart) + + const { producer } = stageProducer({ + cluster, + originalSend: () => Promise.resolve(undefined), + }) + + try { + await producer.send({ topic: 'topic', messages: [{ key: 'k', value: 'v' }] }) + + assert.equal(seenCtx[0].disableHeaderInjection, true) + } finally { + startCh.unsubscribe(captureStart) + } + }) + + it('stops re-running the header-support check after the first disable', async () => { + let versionLookups = 0 + const cluster = { + brokerPool: { + metadata: { clusterId: 'old-broker' }, + versions: new Proxy({ 0: { maxVersion: 2 } }, { + get (target, key) { + if (key === '0') versionLookups++ + return target[key] + }, + }), + }, + } + + const { producer } = stageProducer({ + cluster, + originalSend: () => Promise.resolve(undefined), + }) + + await producer.send({ topic: 'topic', messages: [{ key: 'k', value: 'v' }] }) + await producer.send({ topic: 'topic', messages: [{ key: 'k', value: 'v' }] }) + await producer.send({ topic: 'topic', messages: [{ key: 'k', value: 'v' }] }) + + assert.equal(versionLookups, 1) + }) + }) + + describe('reactive header-support disable', () => { + it('disables injection on the next send after KafkaJSProtocolError UNKNOWN', async () => { + let sendCalls = 0 + const cluster = { + brokerPool: { + metadata: { clusterId: 'mixed-version-cluster' }, + versions: { 0: { maxVersion: 9 } }, + }, + } + + const seenCtx = [] + const captureStart = (ctx) => seenCtx.push(ctx) + startCh.subscribe(captureStart) + + const error = Object.assign(new Error('UNKNOWN_SERVER_ERROR'), { + name: 'KafkaJSProtocolError', + type: 'UNKNOWN', + }) + + const originalSend = () => { + sendCalls++ + return sendCalls === 1 ? Promise.reject(error) : Promise.resolve(undefined) + } + + const { producer } = stageProducer({ cluster, originalSend }) + + try { + await assert.rejects( + producer.send({ topic: 't', messages: [{ key: 'k', value: 'v' }] }), + error + ) + await producer.send({ topic: 't', messages: [{ key: 'k', value: 'v' }] }) + + assert.equal(seenCtx.length, 2) + assert.equal(seenCtx[0].disableHeaderInjection, false) + assert.equal(seenCtx[1].disableHeaderInjection, true) + } finally { + startCh.unsubscribe(captureStart) + } + }) + + it('leaves injection enabled on unrelated protocol errors', async () => { + const cluster = { + brokerPool: { + metadata: { clusterId: 'healthy-cluster' }, + versions: { 0: { maxVersion: 9 } }, + }, + } + + const seenCtx = [] + const captureStart = (ctx) => seenCtx.push(ctx) + startCh.subscribe(captureStart) + + const error = Object.assign(new Error('other'), { + name: 'KafkaJSProtocolError', + type: 'TOPIC_AUTHORIZATION_FAILED', + }) + + let sendCalls = 0 + const originalSend = () => { + sendCalls++ + return sendCalls === 1 ? Promise.reject(error) : Promise.resolve(undefined) + } + + const { producer } = stageProducer({ cluster, originalSend }) + + try { + await assert.rejects( + producer.send({ topic: 't', messages: [{ key: 'k', value: 'v' }] }), + error + ) + await producer.send({ topic: 't', messages: [{ key: 'k', value: 'v' }] }) + + assert.equal(seenCtx.length, 2) + assert.equal(seenCtx[0].disableHeaderInjection, false) + assert.equal(seenCtx[1].disableHeaderInjection, false) + } finally { + startCh.unsubscribe(captureStart) + } + }) + }) + + describe('producer.send fast skip', () => { + it('bypasses the boundary entirely when no subscriber is attached to the produce channel', async () => { + startCh.unsubscribe(startNoop) + try { + let sendCalls = 0 + const { producer } = stageProducer({ + cluster: { brokerPool: { metadata: { clusterId: 'irrelevant' } } }, + originalSend: () => { sendCalls++; return Promise.resolve('passthrough') }, + }) + + const result = await producer.send({ topic: 't', messages: [{ key: 'k', value: 'v' }] }) + + assert.equal(sendCalls, 1) + assert.equal(result, 'passthrough') + } finally { + startCh.subscribe(startNoop) + } + }) + }) + + describe('producer.sendBatch', () => { + const commitCh = dc.channel('apm:kafkajs:produce:commit') + const errorCh = dc.channel('apm:kafkajs:produce:error') + const finishCh = dc.channel('apm:kafkajs:produce:finish') + + /** + * @param {import('dc-polyfill').Channel} channel + */ + function captureChannel (channel) { + const events = [] + const handler = (ctx) => events.push(ctx) + channel.subscribe(handler) + return { events, unsubscribe: () => channel.unsubscribe(handler) } + } + + function readyCluster () { + return { + brokerPool: { + metadata: { clusterId: 'cluster-x' }, + versions: { 0: { maxVersion: 9 } }, + }, + } + } + + it('bypasses the boundary entirely when no subscriber is attached to the produce channel', async () => { + startCh.unsubscribe(startNoop) + try { + let sendBatchCalls = 0 + const { producer } = stageProducer({ + cluster: readyCluster(), + originalSendBatch: () => { sendBatchCalls++; return Promise.resolve('passthrough') }, + }) + + const result = await producer.sendBatch({ + topicMessages: [{ topic: 't', messages: [{ key: 'k', value: 'v' }] }], + }) + + assert.equal(sendBatchCalls, 1) + assert.equal(result, 'passthrough') + } finally { + startCh.subscribe(startNoop) + } + }) + + it('forwards to originalSendBatch without publishing when topicMessages is missing', async () => { + let sendBatchCalls = 0 + const start = captureChannel(startCh) + + const { producer } = stageProducer({ + cluster: readyCluster(), + originalSendBatch: () => { sendBatchCalls++; return Promise.resolve('passthrough') }, + }) + + try { + const result = await producer.sendBatch({}) + + assert.equal(sendBatchCalls, 1) + assert.equal(result, 'passthrough') + assert.equal(start.events.length, 0) + } finally { + start.unsubscribe() + } + }) + + it('publishes one start+finish ctx per entry and commits once on the first ctx', async () => { + const start = captureChannel(startCh) + const commit = captureChannel(commitCh) + const finish = captureChannel(finishCh) + + const { producer } = stageProducer({ + cluster: readyCluster(), + originalSendBatch: () => Promise.resolve('batch-result'), + }) + + try { + await producer.sendBatch({ + topicMessages: [ + { topic: 'a', messages: [{ key: 'k', value: 'v' }] }, + { topic: 'b', messages: [{ key: 'k2', value: 'v2' }] }, + ], + }) + + assert.equal(start.events.length, 2) + assert.equal(finish.events.length, 2) + assert.equal(commit.events.length, 1) + assert.equal(commit.events[0], start.events[0]) + assert.equal(finish.events[0].result, 'batch-result') + assert.equal(finish.events[1].result, 'batch-result') + } finally { + start.unsubscribe() + commit.unsubscribe() + finish.unsubscribe() + } + }) + + it('passes a per-entry ctx with empty messages through when one entry has a non-array messages field', async () => { + const start = captureChannel(startCh) + let forwardedArg0 + const { producer } = stageProducer({ + cluster: readyCluster(), + originalSendBatch: (arg0) => { + forwardedArg0 = arg0 + return Promise.resolve(undefined) + }, + }) + + const userTopicMessages = [ + { topic: 'a', messages: [{ key: 'k', value: 'v' }] }, + { topic: 'b', messages: 'not-an-array' }, + ] + + try { + await producer.sendBatch({ topicMessages: userTopicMessages }) + + assert.equal(start.events.length, 2) + assert.equal(start.events[1].topic, 'b') + assert.deepEqual(start.events[1].messages, []) + // Invalid entry forwarded verbatim so kafkajs surfaces its own validation error. + assert.equal(forwardedArg0.topicMessages[1], userTopicMessages[1]) + } finally { + start.unsubscribe() + } + }) + + it('leaves args[0] untouched when no entry has a non-empty messages array', async () => { + const start = captureChannel(startCh) + let forwardedArg0 + const userArg0 = { + topicMessages: [ + { topic: 'a', messages: 'not-an-array' }, + { topic: 'b', messages: [] }, + ], + } + const { producer } = stageProducer({ + cluster: readyCluster(), + originalSendBatch: (arg0) => { + forwardedArg0 = arg0 + return Promise.resolve(undefined) + }, + }) + + try { + await producer.sendBatch(userArg0) + + assert.equal(start.events.length, 2) + assert.equal(forwardedArg0, userArg0) + assert.deepEqual(start.events[0].messages, []) + assert.deepEqual(start.events[1].messages, []) + } finally { + start.unsubscribe() + } + }) + + it('tags every per-topic ctx with the sync error and rethrows', () => { + const error = new Error('boom-sync') + const start = captureChannel(startCh) + const errorEvents = captureChannel(errorCh) + const finish = captureChannel(finishCh) + + const { producer } = stageProducer({ + cluster: readyCluster(), + originalSendBatch: () => { throw error }, + }) + + try { + assert.throws(() => producer.sendBatch({ + topicMessages: [ + { topic: 'a', messages: [{ key: 'k', value: 'v' }] }, + { topic: 'b', messages: [{ key: 'k2', value: 'v2' }] }, + ], + }), error) + + assert.equal(start.events.length, 2) + assert.equal(errorEvents.events.length, 2) + assert.equal(finish.events.length, 2) + assert.equal(errorEvents.events[0].error, error) + assert.equal(errorEvents.events[1].error, error) + } finally { + start.unsubscribe() + errorEvents.unsubscribe() + finish.unsubscribe() + } + }) + + it('tags every per-topic ctx with the async rejection error', async () => { + const error = new Error('boom-async') + const start = captureChannel(startCh) + const errorEvents = captureChannel(errorCh) + const finish = captureChannel(finishCh) + + const { producer } = stageProducer({ + cluster: readyCluster(), + originalSendBatch: () => Promise.reject(error), + }) + + try { + await assert.rejects(producer.sendBatch({ + topicMessages: [ + { topic: 'a', messages: [{ key: 'k', value: 'v' }] }, + { topic: 'b', messages: [{ key: 'k2', value: 'v2' }] }, + ], + }), error) + + assert.equal(start.events.length, 2) + assert.equal(errorEvents.events.length, 2) + assert.equal(finish.events.length, 2) + assert.equal(errorEvents.events[1].error, error) + } finally { + start.unsubscribe() + errorEvents.unsubscribe() + finish.unsubscribe() + } + }) + + it('disables header injection on the next sendBatch after KafkaJSProtocolError UNKNOWN', async () => { + const start = captureChannel(startCh) + + const error = Object.assign(new Error('UNKNOWN_SERVER_ERROR'), { + name: 'KafkaJSProtocolError', + type: 'UNKNOWN', + }) + + let sendBatchCalls = 0 + const originalSendBatch = () => { + sendBatchCalls++ + return sendBatchCalls === 1 ? Promise.reject(error) : Promise.resolve(undefined) + } + + const { producer } = stageProducer({ cluster: readyCluster(), originalSendBatch }) + + try { + await assert.rejects(producer.sendBatch({ + topicMessages: [{ topic: 't', messages: [{ key: 'k', value: 'v' }] }], + }), error) + await producer.sendBatch({ + topicMessages: [{ topic: 't', messages: [{ key: 'k', value: 'v' }] }], + }) + + assert.equal(start.events.length, 2) + assert.equal(start.events[0].disableHeaderInjection, false) + assert.equal(start.events[1].disableHeaderInjection, true) + } finally { + start.unsubscribe() + } + }) + }) +}) diff --git a/packages/datadog-instrumentations/test/multer.spec.js b/packages/datadog-instrumentations/test/multer.spec.js index f4fa0ce386..9eb94c80ed 100644 --- a/packages/datadog-instrumentations/test/multer.spec.js +++ b/packages/datadog-instrumentations/test/multer.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const axios = require('axios') @@ -102,7 +103,7 @@ withVersions('multer', 'multer', version => { assert.ok(payload.req) assert.ok(payload.res) - assert.ok(Object.hasOwn(store, 'span')) + assert.ok(Object.hasOwn(store, 'span'), `Available keys: ${inspect(Object.keys(store))}`) sinon.assert.calledOnceWithExactly(middlewareProcessBodyStub, formData.get('key')) assert.strictEqual(res.data, 'DONE') diff --git a/packages/datadog-instrumentations/test/openai-aiguard.spec.js b/packages/datadog-instrumentations/test/openai-aiguard.spec.js new file mode 100644 index 0000000000..eb238322e4 --- /dev/null +++ b/packages/datadog-instrumentations/test/openai-aiguard.spec.js @@ -0,0 +1,880 @@ +'use strict' + +const assert = require('node:assert/strict') +const { channel } = require('dc-polyfill') +const { before, beforeEach, describe, it } = require('mocha') + +const evaluateChannel = channel('dd-trace:ai:aiguard') + +// Minimal APIPromise stand-in. The real SDK APIPromise has a `parse()` method that +// returns the parsed response body, and user-facing `.then` routes through `.parse()`. +// The instrumentation patches `parse()` rather than `then()` to preserve the APIPromise +// surface (`.withResponse()` etc.). +class FakeAPIPromise { + constructor (body, responsePromise = Promise.resolve({ response: { headers: {}, url: '/' }, options: {} })) { + this._body = body + this.responsePromise = responsePromise + this._rawResponse = { ok: true } + } + + parse () { + return Promise.resolve(this._body) + } + + // Mirrors openai SDK's APIPromise.asResponse which returns the raw Response without + // parsing the body. AI Guard must still gate Before Model rejection on this path. + asResponse () { + return Promise.resolve(this._rawResponse) + } + + then (onFulfilled, onRejected) { + return this.parse().then(onFulfilled, onRejected) + } +} + +// Variant that exposes `_thenUnwrap`, used by the `client.beta.chat.completions.parse` +// structured-output code path. `_thenUnwrap(cb)` returns a new APIPromise whose `parse` +// yields the transformed body; users await this inner promise, not the outer one. +class FakeUnwrappableAPIPromise extends FakeAPIPromise { + _thenUnwrap (cb) { + return new FakeAPIPromise(cb(this._body)) + } +} + +// Mirrors the shape of the target classes that openai.js patches so we can require the +// instrumentation and then reach in to the wrapped prototype methods directly. +class FakeChatCompletions { + create () { + return this._nextApiPromise + } +} + +class FakeResponses { + create () { + return this._nextApiPromise + } +} + +function subscribeAutoResolve () { + const calls = [] + const handler = ctx => { + calls.push({ messages: ctx.messages }) + ctx.resolve() + } + evaluateChannel.subscribe(handler) + return { calls, unsubscribe: () => evaluateChannel.unsubscribe(handler) } +} + +function subscribeWithHandler (handler) { + evaluateChannel.subscribe(handler) + return () => evaluateChannel.unsubscribe(handler) +} + +function aiGuardAbortError (message = 'blocked') { + return Object.assign(new Error(message), { name: 'AIGuardAbortError' }) +} + +/** + * Loads the openai instrumentation with a stubbed `addHook` so we capture the exports- + * transform callbacks. We then invoke them against fake prototypes to apply the shims. + */ +function loadOpenAIInstrumentation () { + const instrumentPath = require.resolve('../src/helpers/instrument') + const realInstrument = require(instrumentPath) + const hookCallbacks = [] + + const stub = { + ...realInstrument, + addHook (spec, cb) { + hookCallbacks.push({ spec, cb }) + }, + } + + const cache = require.cache[instrumentPath] + const prevExports = cache.exports + cache.exports = stub + + try { + delete require.cache[require.resolve('../src/openai')] + require('../src/openai') + } finally { + cache.exports = prevExports + } + + return hookCallbacks +} + +function applyShim (hookCallbacks, filePath, targetClass, TargetClass) { + // Match the canonical .js hook registration only; we do not want to wrap the same + // prototype twice via the .mjs alias or overlap with version-gated file variants. + for (const { spec, cb } of hookCallbacks) { + if (spec.file === `${filePath}.js`) { + cb({ [targetClass]: TargetClass }) + return + } + } + throw new Error(`No hook registered for ${filePath}.js`) +} + +describe('openai AI Guard instrumentation', () => { + let hookCallbacks + + before(() => { + hookCallbacks = loadOpenAIInstrumentation() + }) + + describe('chat.completions.create', () => { + let Completions + + beforeEach(() => { + Completions = class extends FakeChatCompletions {} + Completions.prototype._client = { baseURL: 'https://api.openai.com' } + applyShim(hookCallbacks, 'resources/chat/completions', 'Completions', Completions) + }) + + it('calls original directly when no AI Guard subscribers', () => { + const assistant = { role: 'assistant', content: 'Hello!' } + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ choices: [{ message: assistant }] }) + + return completions.create({ messages: [{ role: 'user', content: 'Hi' }] }).parse() + .then(body => assert.strictEqual(body.choices[0].message, assistant)) + }) + + it('calls original directly when messages are missing', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ choices: [] }) + + return completions.create({}).parse() + .then(() => assert.strictEqual(calls.length, 0)) + .finally(unsubscribe) + }) + + it('publishes Before Model evaluation with converted input messages', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const assistant = { role: 'assistant', content: 'Hello!' } + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ choices: [{ message: assistant }] }) + + const messages = [{ role: 'user', content: 'Hi' }] + return completions.create({ messages }).parse() + .then(() => { + // Before Model + After Model (assistant responded) + assert.strictEqual(calls.length, 2) + assert.deepStrictEqual(calls[0].messages, messages) + }) + .finally(unsubscribe) + }) + + it('publishes After Model evaluation including the assistant response', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const assistant = { role: 'assistant', content: 'Hello!' } + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ choices: [{ message: assistant }] }) + + const messages = [{ role: 'user', content: 'Hi' }] + return completions.create({ messages }).parse() + .then(() => { + assert.strictEqual(calls.length, 2) + assert.deepStrictEqual(calls[1].messages, [ + { role: 'user', content: 'Hi' }, + { role: 'assistant', content: 'Hello!' }, + ]) + }) + .finally(unsubscribe) + }) + + it('publishes After Model evaluation including assistant tool_calls', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const toolCalls = [{ id: 'c1', function: { name: 'search', arguments: '{"q":"x"}' } }] + const assistant = { role: 'assistant', tool_calls: toolCalls } + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ choices: [{ message: assistant }] }) + + return completions.create({ messages: [{ role: 'user', content: 'Hi' }] }).parse() + .then(() => { + assert.strictEqual(calls.length, 2) + assert.deepStrictEqual(calls[1].messages[1].tool_calls, toolCalls) + }) + .finally(unsubscribe) + }) + + it('skips After Model when the response has no assistant message', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ choices: [] }) + + return completions.create({ messages: [{ role: 'user', content: 'Hi' }] }).parse() + .then(() => assert.strictEqual(calls.length, 1)) + .finally(unsubscribe) + }) + + it('rejects with the AI Guard error when Before Model denies', () => { + const err = aiGuardAbortError() + const unsubscribe = subscribeWithHandler(ctx => ctx.reject(err)) + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ choices: [{ message: { role: 'assistant', content: 'x' } }] }) + + return assert.rejects( + () => completions.create({ messages: [{ role: 'user', content: 'Hi' }] }).parse(), + e => e === err + ).finally(unsubscribe) + }) + + it('rejects with the AI Guard error when After Model denies', () => { + let count = 0 + const err = aiGuardAbortError() + const unsubscribe = subscribeWithHandler(ctx => { + count++ + count === 1 ? ctx.resolve() : ctx.reject(err) + }) + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ choices: [{ message: { role: 'assistant', content: 'x' } }] }) + + return assert.rejects( + () => completions.create({ messages: [{ role: 'user', content: 'Hi' }] }).parse(), + e => e === err + ).finally(unsubscribe) + }) + + it('kicks off Before Model evaluation before waiting for the LLM response', () => { + // The timing proof: the publish channel handler runs synchronously when we call + // publishEvaluation, which happens right after methodFn is invoked. So by the time + // the AI Guard handler observes the event, the LLM call has already been made. + const observed = { llmCalledBeforeGuard: false } + const unsubscribe = subscribeWithHandler(ctx => { + observed.llmCalledBeforeGuard = llmCalled + ctx.resolve() + }) + + let llmCalled = false + class TimingCompletions { + create () { + llmCalled = true + return new FakeAPIPromise({ choices: [{ message: { role: 'assistant', content: 'x' } }] }) + } + } + TimingCompletions.prototype._client = { baseURL: 'https://api.openai.com' } + applyShim(hookCallbacks, 'resources/chat/completions', 'Completions', TimingCompletions) + + return new TimingCompletions().create({ messages: [{ role: 'user', content: 'Hi' }] }).parse() + .then(() => assert.strictEqual(observed.llmCalledBeforeGuard, true)) + .finally(unsubscribe) + }) + + it('skips AI Guard for streaming chat.completions', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ choices: [{ message: { role: 'assistant', content: 'x' } }] }) + + return completions.create({ messages: [{ role: 'user', content: 'Hi' }], stream: true }).parse() + .then(() => assert.strictEqual(calls.length, 0)) + .finally(unsubscribe) + }) + + it('evaluates every choice when n > 1', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ + choices: [ + { message: { role: 'assistant', content: 'safe one' } }, + { message: { role: 'assistant', content: 'safe two' } }, + { message: { role: 'assistant', content: 'safe three' } }, + ], + }) + + return completions.create({ messages: [{ role: 'user', content: 'Hi' }], n: 3 }).parse() + .then(() => { + // 1 Before Model + 3 After Model (one per choice) + assert.strictEqual(calls.length, 4) + assert.deepStrictEqual(calls[1].messages[1], { role: 'assistant', content: 'safe one' }) + assert.deepStrictEqual(calls[2].messages[1], { role: 'assistant', content: 'safe two' }) + assert.deepStrictEqual(calls[3].messages[1], { role: 'assistant', content: 'safe three' }) + }) + .finally(unsubscribe) + }) + + it('rejects when any choice fails After Model evaluation', () => { + const err = aiGuardAbortError() + let count = 0 + const unsubscribe = subscribeWithHandler(ctx => { + count++ + // Before Model passes; first choice passes; second choice rejects + count === 3 ? ctx.reject(err) : ctx.resolve() + }) + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ + choices: [ + { message: { role: 'assistant', content: 'safe' } }, + { message: { role: 'assistant', content: 'unsafe' } }, + ], + }) + + return assert.rejects( + () => completions.create({ messages: [{ role: 'user', content: 'Hi' }], n: 2 }).parse(), + e => e === err + ).finally(unsubscribe) + }) + + it('propagates Before Model rejection through asResponse()', () => { + const err = aiGuardAbortError() + const unsubscribe = subscribeWithHandler(ctx => ctx.reject(err)) + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ choices: [{ message: { role: 'assistant', content: 'x' } }] }) + + return assert.rejects( + () => completions.create({ messages: [{ role: 'user', content: 'Hi' }] }).asResponse(), + e => e === err + ).finally(unsubscribe) + }) + + it('returns the raw response from asResponse() when Before Model resolves', () => { + const { unsubscribe } = subscribeAutoResolve() + const completions = new Completions() + const apiProm = new FakeAPIPromise({ choices: [{ message: { role: 'assistant', content: 'x' } }] }) + completions._nextApiPromise = apiProm + + return completions.create({ messages: [{ role: 'user', content: 'Hi' }] }).asResponse() + .then(resp => assert.strictEqual(resp, apiProm._rawResponse)) + .finally(unsubscribe) + }) + + it('passes a multi-turn system + user + assistant + tool conversation verbatim', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ + choices: [{ message: { role: 'assistant', content: 'sure' } }], + }) + + const messages = [ + { role: 'system', content: 'You are a helpful assistant' }, + { role: 'user', content: 'Look up the weather' }, + { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call_1', + type: 'function', + function: { name: 'lookupWeather', arguments: '{"city":"NY"}' }, + }], + }, + { role: 'tool', tool_call_id: 'call_1', content: 'Sunny, 25C' }, + { role: 'user', content: 'Thanks' }, + ] + return completions.create({ messages }).parse() + .then(() => { + assert.deepStrictEqual(calls[0].messages, messages) + // After Model adds the assistant response + assert.deepStrictEqual(calls[1].messages.at(-1), { role: 'assistant', content: 'sure' }) + }) + .finally(unsubscribe) + }) + + it('passes multimodal user content (text + image_url) through verbatim', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ + choices: [{ message: { role: 'assistant', content: 'a cat' } }], + }) + + const messages = [{ + role: 'user', + content: [ + { type: 'text', text: 'What is this?' }, + { type: 'image_url', image_url: { url: 'https://example.com/cat.png' } }, + ], + }] + return completions.create({ messages, model: 'gpt-4o-mini' }).parse() + .then(() => assert.deepStrictEqual(calls[0].messages, messages)) + .finally(unsubscribe) + }) + + it('skips Before Model when messages is an empty array', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ choices: [] }) + + return completions.create({ messages: [] }).parse() + .then(() => assert.strictEqual(calls.length, 0)) + .finally(unsubscribe) + }) + + it('After Model includes the assistant message when only `refusal` is set', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const refusalMessage = { role: 'assistant', content: null, refusal: 'I cannot help with that' } + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ choices: [{ message: refusalMessage }] }) + + return completions.create({ messages: [{ role: 'user', content: 'Hi' }] }).parse() + .then(() => { + assert.strictEqual(calls.length, 2) + assert.deepStrictEqual(calls[1].messages.at(-1), refusalMessage) + }) + .finally(unsubscribe) + }) + + it('After Model includes the assistant message when content is the empty string', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const emptyMessage = { role: 'assistant', content: '' } + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ choices: [{ message: emptyMessage }] }) + + return completions.create({ messages: [{ role: 'user', content: 'Hi' }] }).parse() + .then(() => { + assert.strictEqual(calls.length, 2) + assert.deepStrictEqual(calls[1].messages.at(-1), emptyMessage) + }) + .finally(unsubscribe) + }) + + it('After Model normalizes deprecated `function_call` into modern `tool_calls`', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const functionCallMessage = { + role: 'assistant', + content: null, + function_call: { name: 'do_thing', arguments: '{}' }, + } + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ choices: [{ message: functionCallMessage }] }) + + return completions.create({ messages: [{ role: 'user', content: 'Hi' }] }).parse() + .then(() => { + assert.strictEqual(calls.length, 2) + assert.deepStrictEqual(calls[1].messages.at(-1), { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'do_thing', + function: { name: 'do_thing', arguments: '{}' }, + }], + }) + }) + .finally(unsubscribe) + }) + + it('skips After Model when assistant message has no content, tool_calls, refusal, or function_call', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ + choices: [{ message: { role: 'assistant', content: null, tool_calls: [] } }], + }) + + return completions.create({ messages: [{ role: 'user', content: 'Hi' }] }).parse() + .then(() => assert.strictEqual(calls.length, 1)) + .finally(unsubscribe) + }) + + it('does not start Before Model evaluation until APIPromise is consumed', async () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ + choices: [{ message: { role: 'assistant', content: 'x' } }], + }) + + try { + const apiProm = completions.create({ messages: [{ role: 'user', content: 'Hi' }] }) + + // Let microtasks drain. Lazy memoization means no publish without a consumer. + await new Promise(resolve => setImmediate(resolve)) + assert.strictEqual(calls.length, 0, 'Before Model must not start until apiProm is awaited') + + await apiProm.parse() + assert.strictEqual(calls.length, 2, 'Before + After Model must have run after parse()') + } finally { + unsubscribe() + } + }) + + it('does not emit unhandled rejection when apiProm is discarded and Before Model would deny', async () => { + const err = aiGuardAbortError() + const unsubscribe = subscribeWithHandler(ctx => ctx.reject(err)) + + const observed = [] + const onUnhandled = reason => observed.push(reason) + process.on('unhandledRejection', onUnhandled) + + try { + const completions = new Completions() + completions._nextApiPromise = new FakeAPIPromise({ + choices: [{ message: { role: 'assistant', content: 'x' } }], + }) + + // Discard the apiProm without awaiting parse() or asResponse(). + completions.create({ messages: [{ role: 'user', content: 'Hi' }] }) + + // Drain enough microtasks for Node to surface unhandled rejections, if any. + await new Promise(resolve => setImmediate(resolve)) + await new Promise(resolve => setImmediate(resolve)) + + assert.deepStrictEqual(observed, []) + } finally { + process.removeListener('unhandledRejection', onUnhandled) + unsubscribe() + } + }) + + it('rejects with the OpenAI error when the SDK call rejects', () => { + const { unsubscribe } = subscribeAutoResolve() + const sdkErr = new Error('upstream HTTP failure') + class RejectingCompletions { + create () { + return { + parse () { return Promise.reject(sdkErr) }, + asResponse () { return Promise.reject(sdkErr) }, + then (onF, onR) { return this.parse().then(onF, onR) }, + responsePromise: Promise.reject(sdkErr), + } + } + } + RejectingCompletions.prototype._client = { baseURL: 'https://api.openai.com' } + applyShim(hookCallbacks, 'resources/chat/completions', 'Completions', RejectingCompletions) + + return assert.rejects( + () => new RejectingCompletions().create({ messages: [{ role: 'user', content: 'Hi' }] }).parse(), + e => e === sdkErr + ).finally(unsubscribe) + }) + + it('publishes independent evaluations for two concurrent calls', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const completionsA = new Completions() + const completionsB = new Completions() + completionsA._nextApiPromise = new FakeAPIPromise({ + choices: [{ message: { role: 'assistant', content: 'A out' } }], + }) + completionsB._nextApiPromise = new FakeAPIPromise({ + choices: [{ message: { role: 'assistant', content: 'B out' } }], + }) + + return Promise.all([ + completionsA.create({ messages: [{ role: 'user', content: 'A in' }] }).parse(), + completionsB.create({ messages: [{ role: 'user', content: 'B in' }] }).parse(), + ]).then(() => { + // 2 calls × (Before + After) = 4 evaluations + assert.strictEqual(calls.length, 4) + const inputs = calls.filter(c => c.messages.length === 1).map(c => c.messages[0].content) + assert.deepStrictEqual(inputs.sort(), ['A in', 'B in']) + }).finally(unsubscribe) + }) + }) + + describe('chat.completions structured outputs (_thenUnwrap)', () => { + let Completions + + beforeEach(() => { + Completions = class extends FakeChatCompletions {} + Completions.prototype._client = { baseURL: 'https://api.openai.com' } + applyShim(hookCallbacks, 'resources/chat/completions', 'Completions', Completions) + }) + + function schemaCallback (body) { + return { ...body, parsed: { ok: true } } + } + + it('runs Before Model and After Model when awaiting the unwrapped promise', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const assistant = { role: 'assistant', content: '{"ok":true}' } + const completions = new Completions() + completions._nextApiPromise = new FakeUnwrappableAPIPromise({ choices: [{ message: assistant }] }) + + const apiProm = completions.create({ messages: [{ role: 'user', content: 'Hi' }] }) + return apiProm._thenUnwrap(schemaCallback).parse() + .then(body => { + assert.strictEqual(body.parsed.ok, true) + assert.strictEqual(calls.length, 2) + assert.deepStrictEqual(calls[1].messages[1], { role: 'assistant', content: '{"ok":true}' }) + }) + .finally(unsubscribe) + }) + + it('rejects with AI Guard error when Before Model denies on the unwrapped promise', () => { + const err = aiGuardAbortError() + const unsubscribe = subscribeWithHandler(ctx => ctx.reject(err)) + const completions = new Completions() + const body = { choices: [{ message: { role: 'assistant', content: 'x' } }] } + completions._nextApiPromise = new FakeUnwrappableAPIPromise(body) + + const apiProm = completions.create({ messages: [{ role: 'user', content: 'Hi' }] }) + return assert.rejects( + () => apiProm._thenUnwrap(schemaCallback).parse(), + e => e === err + ).finally(unsubscribe) + }) + + it('rejects with AI Guard error when After Model denies on the unwrapped promise', () => { + let count = 0 + const err = aiGuardAbortError() + const unsubscribe = subscribeWithHandler(ctx => { + count++ + count === 1 ? ctx.resolve() : ctx.reject(err) + }) + const completions = new Completions() + const body = { choices: [{ message: { role: 'assistant', content: 'leaked pii' } }] } + completions._nextApiPromise = new FakeUnwrappableAPIPromise(body) + + const apiProm = completions.create({ messages: [{ role: 'user', content: 'Hi' }] }) + return assert.rejects( + () => apiProm._thenUnwrap(schemaCallback).parse(), + e => e === err + ).finally(unsubscribe) + }) + }) + + describe('responses.create', () => { + let Responses + + beforeEach(() => { + Responses = class extends FakeResponses {} + Responses.prototype._client = { baseURL: 'https://api.openai.com' } + applyShim(hookCallbacks, 'resources/responses/responses', 'Responses', Responses) + }) + + it('converts string input to a single user message', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const responses = new Responses() + responses._nextApiPromise = new FakeAPIPromise({ output: [] }) + + return responses.create({ input: 'what time is it?' }).parse() + .then(() => assert.deepStrictEqual(calls[0].messages, [{ role: 'user', content: 'what time is it?' }])) + .finally(unsubscribe) + }) + + it('publishes After Model using response.output message items', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const responses = new Responses() + responses._nextApiPromise = new FakeAPIPromise({ + output: [{ + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Hello!' }], + }], + }) + + return responses.create({ input: 'hi' }).parse() + .then(() => { + assert.strictEqual(calls.length, 2) + assert.deepStrictEqual(calls[1].messages[1], { role: 'assistant', content: 'Hello!' }) + }) + .finally(unsubscribe) + }) + + it('converts responses input image parts for Before Model evaluation', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const responses = new Responses() + responses._nextApiPromise = new FakeAPIPromise({ output: [] }) + + return responses.create({ + input: [{ + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'what is this?' }, + { type: 'input_image', image_url: 'https://example.com/image.png' }, + ], + }], + }).parse() + .then(() => assert.deepStrictEqual(calls[0].messages, [{ + role: 'user', + content: [ + { type: 'text', text: 'what is this?' }, + { type: 'image_url', image_url: { url: 'https://example.com/image.png' } }, + ], + }])) + .finally(unsubscribe) + }) + + it('publishes After Model using response.output function_call items', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const responses = new Responses() + responses._nextApiPromise = new FakeAPIPromise({ + output: [{ type: 'function_call', call_id: 'c1', name: 'search', arguments: '{"q":"x"}' }], + }) + + return responses.create({ input: 'hi' }).parse() + .then(() => assert.deepStrictEqual(calls[1].messages[1].tool_calls, [ + { id: 'c1', function: { name: 'search', arguments: '{"q":"x"}' } }, + ])) + .finally(unsubscribe) + }) + + it('skips AI Guard for streaming requests', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const responses = new Responses() + responses._nextApiPromise = new FakeAPIPromise({ output: [] }) + + return responses.create({ input: 'hi', stream: true }).parse() + .then(() => assert.strictEqual(calls.length, 0)) + .finally(unsubscribe) + }) + + it('calls original directly when input is missing', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const responses = new Responses() + responses._nextApiPromise = new FakeAPIPromise({ output: [] }) + + return responses.create({}).parse() + .then(() => assert.strictEqual(calls.length, 0)) + .finally(unsubscribe) + }) + + it('rejects with AI Guard error when Before Model denies', () => { + const err = aiGuardAbortError() + const unsubscribe = subscribeWithHandler(ctx => ctx.reject(err)) + const responses = new Responses() + responses._nextApiPromise = new FakeAPIPromise({ + output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'x' }] }], + }) + + return assert.rejects( + () => responses.create({ input: 'hi' }).parse(), + e => e === err + ).finally(unsubscribe) + }) + + it('rejects with AI Guard error when After Model denies', () => { + let count = 0 + const err = aiGuardAbortError() + const unsubscribe = subscribeWithHandler(ctx => { + count++ + count === 1 ? ctx.resolve() : ctx.reject(err) + }) + const responses = new Responses() + responses._nextApiPromise = new FakeAPIPromise({ + output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'leaked' }] }], + }) + + return assert.rejects( + () => responses.create({ input: 'hi' }).parse(), + e => e === err + ).finally(unsubscribe) + }) + + it('skips After Model when response has no output items', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const responses = new Responses() + responses._nextApiPromise = new FakeAPIPromise({ output: [] }) + + return responses.create({ input: 'hi' }).parse() + .then(() => assert.strictEqual(calls.length, 1)) + .finally(unsubscribe) + }) + + it('converts a multi-item input (function_call + function_call_output + message)', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const responses = new Responses() + responses._nextApiPromise = new FakeAPIPromise({ output: [] }) + + return responses.create({ + input: [ + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Look up the weather' }] }, + { type: 'function_call', call_id: 'c1', name: 'lookupWeather', arguments: '{"city":"NY"}' }, + { type: 'function_call_output', call_id: 'c1', output: 'Sunny, 25C' }, + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Thanks' }] }, + ], + }).parse() + .then(() => assert.deepStrictEqual(calls[0].messages, [ + { role: 'user', content: 'Look up the weather' }, + { + role: 'assistant', + tool_calls: [{ id: 'c1', function: { name: 'lookupWeather', arguments: '{"city":"NY"}' } }], + }, + { role: 'tool', tool_call_id: 'c1', content: 'Sunny, 25C' }, + { role: 'user', content: 'Thanks' }, + ])) + .finally(unsubscribe) + }) + + it('handles input_image as object {image_url: {url: ...}}', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const responses = new Responses() + responses._nextApiPromise = new FakeAPIPromise({ output: [] }) + + return responses.create({ + input: [{ + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: 'describe' }, + { type: 'input_image', image_url: { url: 'https://example.com/cat.png' } }, + ], + }], + }).parse() + .then(() => assert.deepStrictEqual(calls[0].messages, [{ + role: 'user', + content: [ + { type: 'text', text: 'describe' }, + { type: 'image_url', image_url: { url: 'https://example.com/cat.png' } }, + ], + }])) + .finally(unsubscribe) + }) + + it('prepends `instructions` as a developer message in Before Model', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const responses = new Responses() + responses._nextApiPromise = new FakeAPIPromise({ output: [] }) + + return responses.create({ instructions: 'Be concise.', input: 'hi' }).parse() + .then(() => assert.deepStrictEqual(calls[0].messages, [ + { role: 'developer', content: 'Be concise.' }, + { role: 'user', content: 'hi' }, + ])) + .finally(unsubscribe) + }) + + it('evaluates `instructions`-only calls (no `input`)', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const responses = new Responses() + responses._nextApiPromise = new FakeAPIPromise({ + output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Hi!' }] }], + }) + + return responses.create({ instructions: 'Greet the user.' }).parse() + .then(() => { + assert.strictEqual(calls.length, 2) + assert.deepStrictEqual(calls[0].messages, [{ role: 'developer', content: 'Greet the user.' }]) + assert.deepStrictEqual(calls[1].messages.at(-1), { role: 'assistant', content: 'Hi!' }) + }) + .finally(unsubscribe) + }) + + it('merges `instructions` into a leading developer message in `input`', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const responses = new Responses() + responses._nextApiPromise = new FakeAPIPromise({ output: [] }) + + return responses.create({ + instructions: 'Be concise.', + input: [ + { type: 'message', role: 'developer', content: [{ type: 'input_text', text: 'Use bullets.' }] }, + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hi' }] }, + ], + }).parse() + .then(() => assert.deepStrictEqual(calls[0].messages, [ + { role: 'developer', content: 'Be concise.\n\nUse bullets.' }, + { role: 'user', content: 'hi' }, + ])) + .finally(unsubscribe) + }) + + it('merges `instructions` into a leading system message in `input`', () => { + const { calls, unsubscribe } = subscribeAutoResolve() + const responses = new Responses() + responses._nextApiPromise = new FakeAPIPromise({ output: [] }) + + return responses.create({ + instructions: 'Be concise.', + input: [ + { type: 'message', role: 'system', content: [{ type: 'input_text', text: 'You are helpful.' }] }, + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hi' }] }, + ], + }).parse() + .then(() => assert.deepStrictEqual(calls[0].messages, [ + { role: 'developer', content: 'Be concise.\n\nYou are helpful.' }, + { role: 'user', content: 'hi' }, + ])) + .finally(unsubscribe) + }) + }) +}) diff --git a/packages/datadog-instrumentations/test/router.spec.js b/packages/datadog-instrumentations/test/router.spec.js new file mode 100644 index 0000000000..7a121eca9c --- /dev/null +++ b/packages/datadog-instrumentations/test/router.spec.js @@ -0,0 +1,599 @@ +'use strict' + +const assert = require('node:assert/strict') + +const dc = require('dc-polyfill') +const { afterEach, beforeEach, describe, it } = require('mocha') + +const { createWrapRouterMethod } = require('../src/router') +const { assertObjectContains } = require('../../../integration-tests/helpers') + +// `createWrapRouterMethod` is exercised end-to-end by the express and router +// plugin specs, but those run over real HTTP and only ever dispatch 3-arg +// request handlers with a single matcher path. The new arity split, the +// multi-matcher loop, the no-subscriber fast paths, the sync-throw catches, +// and the `_name` resolution chain need explicit unit coverage so a future +// regression on any of them shows up here, not in a downstream tracer test. + +/** + * Minimal subset of an express/router `Layer` the wrap code reads. `regexp` is + * the host's compiled mount regex — only `fast_star` / `fast_slash` matter for + * the wrap-time short-circuit. + * @typedef {{ + * handle: Function, + * __handle?: Function, + * name?: string, + * path?: string, + * regexp?: { fast_star?: boolean, fast_slash?: boolean }, + * }} FakeLayer + * + * @typedef {{ stack: FakeLayer[] }} FakeRouter + */ + +describe('createWrapRouterMethod', () => { + let counter = 0 + let namespace + let enterChannel + let exitChannel + let nextChannel + let finishChannel + let errorChannel + let events + let subscriptions + + beforeEach(() => { + namespace = `router-spec-${++counter}` + enterChannel = dc.channel(`apm:${namespace}:middleware:enter`) + exitChannel = dc.channel(`apm:${namespace}:middleware:exit`) + nextChannel = dc.channel(`apm:${namespace}:middleware:next`) + finishChannel = dc.channel(`apm:${namespace}:middleware:finish`) + errorChannel = dc.channel(`apm:${namespace}:middleware:error`) + events = [] + subscriptions = [] + }) + + afterEach(() => { + for (const [channel, listener] of subscriptions) { + channel.unsubscribe(listener) + } + }) + + // Subscribe to the per-request middleware channels only. `apm:*:route:added` + // publishes during the wrap step, before any request fires, so leaving it + // unsubscribed keeps the recorded `events` ordering aligned with the + // per-request lifecycle the assertions below check. + function subscribeAll () { + const all = [ + ['enter', enterChannel], + ['exit', exitChannel], + ['next', nextChannel], + ['finish', finishChannel], + ['error', errorChannel], + ] + for (const [label, channel] of all) { + const listener = (data) => events.push({ label, data }) + channel.subscribe(listener) + subscriptions.push([channel, listener]) + } + } + + /** + * Build a fake `.use`-shaped router method whose body appends one layer per + * handler to `this.stack`. `layerPath` is the request-time `layer.path` + * value the wrapped handler sees during the multi-matcher loop. + * + * @param {object} [options] + * @param {string} [options.layerPath] Request path the layer reports. + * @param {object} [options.regexp] `{ fast_star, fast_slash }` overrides. + * @returns {Function} The fake `.use` implementation. + */ + function makeFakeUse ({ layerPath = '/some-path', regexp = {} } = {}) { + function use (...args) { + // Mirror the host shape: the first arg is a path or array of paths, the + // rest are middleware. Plain handlers (`use(handler)`) start at index 0. + const startIdx = typeof args[0] === 'function' ? 0 : 1 + for (let i = startIdx; i < args.length; i++) { + const handler = args[i] + if (typeof handler !== 'function') continue + this.stack.push({ handle: handler, path: layerPath, regexp }) + } + } + return use + } + + function compileRegex (pattern) { + if (pattern instanceof RegExp) return pattern + if (typeof pattern !== 'string') return undefined + return new RegExp(`^${pattern.replace(/\//g, '\\/')}$`) + } + + describe('request handler (3-arg) wrap', () => { + it('publishes enter/next/finish/exit and captures the single-pattern route', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/foo' })) + wrappedUse.call(router, '/foo', function namedHandler (req, res, next) { + next() + }) + + const req = { url: '/' } + const res = {} + const downstreamNext = () => events.push({ label: 'downstream-next' }) + + router.stack[0].handle.call({}, req, res, downstreamNext) + + assert.deepStrictEqual(events.map(e => e.label), [ + 'enter', 'next', 'finish', 'downstream-next', 'exit', + ]) + assertObjectContains(events[0].data, { + name: 'namedHandler', + req, + route: '/foo', + layer: router.stack[0], + }) + }) + + it('matches a multi-pattern path against layer.path and captures the matching route', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/users' })) + wrappedUse.call(router, ['/users', '/products'], function pickedFromList (req, res, next) { + next() + }) + + const req = {} + router.stack[0].handle.call({}, req, {}, () => {}) + + const enterEvent = events.find(e => e.label === 'enter') + assert.strictEqual(enterEvent.data.route, '/users') + }) + + it('leaves route undefined when no multi-pattern matcher matches', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/unrelated' })) + wrappedUse.call(router, ['/users', '/products'], function noMatch (req, res, next) { + next() + }) + + router.stack[0].handle.call({}, {}, {}, () => {}) + + const enterEvent = events.find(e => e.label === 'enter') + assert.strictEqual(enterEvent.data.route, undefined) + }) + + it('skips matcher analysis when the host passes a handler with no mount path', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + // `.use(handler)` with no mount path produces an empty matchers list. + const wrappedUse = wrapMethod(makeFakeUse()) + wrappedUse.call(router, function rootHandler (req, res, next) { + next() + }) + + router.stack[0].handle.call({}, {}, {}, () => {}) + + const enterEvent = events.find(e => e.label === 'enter') + assert.strictEqual(enterEvent.data.route, undefined) + }) + + it('short-circuits the matcher loop on a fast-star (`*`) layer', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const wrappedUse = wrapMethod(makeFakeUse({ regexp: { fast_star: true } })) + wrappedUse.call(router, '*', function starHandler (req, res, next) { + next() + }) + + router.stack[0].handle.call({}, {}, {}, () => {}) + + const enterEvent = events.find(e => e.label === 'enter') + assert.strictEqual(enterEvent.data.route, undefined) + }) + + it('short-circuits the matcher loop on a fast-slash (`/`) layer', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const wrappedUse = wrapMethod(makeFakeUse({ regexp: { fast_slash: true } })) + wrappedUse.call(router, '/', function slashHandler (req, res, next) { + next() + }) + + router.stack[0].handle.call({}, {}, {}, () => {}) + + const enterEvent = events.find(e => e.label === 'enter') + assert.strictEqual(enterEvent.data.route, undefined) + }) + + it('skips wrapping work when enterChannel has no subscribers', () => { + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + // With no subscriber on enterChannel the wrapped handler should forward + // `this`, `req`, `res`, `next` and the return value straight through — + // no allocation, no wrapNext, no channel publish. + const captured = { thisArg: undefined, args: /** @type {unknown[]} */ ([]) } + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/foo' })) + wrappedUse.call(router, '/foo', function (req, res, next) { + captured.thisArg = this + captured.args = [req, res, next] + return 'forwarded-return' + }) + + const req = {} + const res = {} + const next = () => 'original-next' + const ctx = { tag: 'this-arg' } + + const result = router.stack[0].handle.call(ctx, req, res, next) + + assert.strictEqual(result, 'forwarded-return') + assert.strictEqual(captured.thisArg, ctx) + assert.deepStrictEqual(captured.args, [req, res, next]) + }) + + it('passes a non-function next through unchanged', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const captured = { next: /** @type {unknown} */ (undefined) } + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/foo' })) + wrappedUse.call(router, '/foo', function (req, res, next) { + captured.next = next + }) + + router.stack[0].handle.call({}, {}, {}, 'not-a-function') + + assert.strictEqual(captured.next, 'not-a-function') + }) + + it('publishes error/next/finish/exit when the handler throws synchronously', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const failure = new Error('boom') + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/foo' })) + wrappedUse.call(router, '/foo', function thrower (req, res, next) { + throw failure + }) + + const req = {} + assert.throws(() => { + router.stack[0].handle.call({}, req, {}, () => {}) + }, error => error === failure) + + // The throw skips the wrapped-next path; finish/exit publish via the + // catch block before the throw is re-raised. + assert.deepStrictEqual(events.map(e => e.label), [ + 'enter', 'error', 'next', 'finish', 'exit', + ]) + assert.strictEqual(events[1].data.error, failure) + assert.strictEqual(events[1].data.req, req) + }) + }) + + describe('error handler (4-arg) wrap', () => { + it('publishes enter/next/finish/exit and forwards error/req/res/next to the original', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/foo' })) + const received = /** @type {{ error?: Error, req?: object, res?: object }} */ ({}) + wrappedUse.call(router, '/foo', function errorHandler (error, req, res, next) { + received.error = error + received.req = req + received.res = res + // Real error handlers either call next() to continue, or next(error) + // to keep propagating; both shapes go through wrappedNext. + next() + }) + + const failure = new Error('upstream') + const req = {} + const res = {} + const downstreamNext = () => events.push({ label: 'downstream-next' }) + + router.stack[0].handle.call({}, failure, req, res, downstreamNext) + + assert.deepStrictEqual(events.map(e => e.label), [ + 'enter', 'next', 'finish', 'downstream-next', 'exit', + ]) + assert.strictEqual(received.error, failure) + assert.strictEqual(received.req, req) + assert.strictEqual(received.res, res) + + assertObjectContains(events[0].data, { + name: 'errorHandler', + req, + route: '/foo', + layer: router.stack[0], + }) + }) + + it('matches a multi-pattern path against layer.path and captures the matching route', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/products' })) + wrappedUse.call(router, ['/users', '/products'], function (error, req, res, next) { + next() + }) + + router.stack[0].handle.call({}, new Error('e'), {}, {}, () => {}) + + const enterEvent = events.find(e => e.label === 'enter') + assert.strictEqual(enterEvent.data.route, '/products') + }) + + it('leaves route undefined when no multi-pattern matcher matches', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/none' })) + wrappedUse.call(router, ['/users', '/products'], function (error, req, res, next) { + next() + }) + + router.stack[0].handle.call({}, new Error('e'), {}, {}, () => {}) + + const enterEvent = events.find(e => e.label === 'enter') + assert.strictEqual(enterEvent.data.route, undefined) + }) + + it('skips wrapping work when enterChannel has no subscribers', () => { + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const captured = { thisArg: undefined, args: /** @type {unknown[]} */ ([]) } + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/foo' })) + wrappedUse.call(router, '/foo', function (error, req, res, next) { + captured.thisArg = this + captured.args = [error, req, res, next] + return 'forwarded-return' + }) + + const failure = new Error('e') + const req = {} + const res = {} + const next = () => {} + const ctx = { tag: 'this-arg' } + + const result = router.stack[0].handle.call(ctx, failure, req, res, next) + + assert.strictEqual(result, 'forwarded-return') + assert.strictEqual(captured.thisArg, ctx) + assert.deepStrictEqual(captured.args, [failure, req, res, next]) + }) + + it('passes a non-function next through unchanged', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const captured = { next: /** @type {unknown} */ (undefined) } + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/foo' })) + wrappedUse.call(router, '/foo', function (error, req, res, next) { + captured.next = next + }) + + router.stack[0].handle.call({}, new Error('e'), {}, {}, 'not-a-function') + + assert.strictEqual(captured.next, 'not-a-function') + }) + + it('publishes error/next/finish/exit when the handler throws synchronously', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const failure = new Error('throws-in-error-handler') + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/foo' })) + wrappedUse.call(router, '/foo', function (error, req, res, next) { + throw failure + }) + + const req = {} + assert.throws(() => { + router.stack[0].handle.call({}, new Error('upstream'), req, {}, () => {}) + }, error => error === failure) + + assert.deepStrictEqual(events.map(e => e.label), [ + 'enter', 'error', 'next', 'finish', 'exit', + ]) + assert.strictEqual(events[1].data.error, failure) + assert.strictEqual(events[1].data.req, req) + }) + }) + + describe('handler name resolution', () => { + it('prefers `original._name` when it is already set', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const handler = /** @type {Function & { _name?: string }} */ ( + function handlerWithCachedName (req, res, next) { next() } + ) + handler._name = 'pre-cached' + + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/foo' })) + wrappedUse.call(router, '/foo', handler) + + router.stack[0].handle.call({}, {}, {}, () => {}) + + assert.strictEqual(events.find(e => e.label === 'enter').data.name, 'pre-cached') + }) + + it('falls back to `layer.name` when `_name` is missing and `layer.name` is set', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const wrappedUse = wrapMethod((handler) => { + router.stack.push({ handle: handler, name: 'layer-named', path: '/foo', regexp: {} }) + }) + // The fake use above doesn't follow the standard signature; pass the + // handler at the head of args so extractMatchers sees a function and + // returns an empty matcher list. + wrappedUse.call(router, (req, res, next) => next()) + + router.stack[0].handle.call({}, {}, {}, () => {}) + + assert.strictEqual(events.find(e => e.label === 'enter').data.name, 'layer-named') + }) + + it('falls back to `original.name` when both `_name` and `layer.name` are missing', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/foo' })) + wrappedUse.call(router, '/foo', function fallbackToOriginalName (req, res, next) { + next() + }) + + router.stack[0].handle.call({}, {}, {}, () => {}) + + assert.strictEqual( + events.find(e => e.label === 'enter').data.name, + 'fallbackToOriginalName' + ) + }) + + it('caches the resolved name on `original._name` so the next wrap reuses it', () => { + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const handler = /** @type {Function & { _name?: string }} */ ( + function originalName (req, res, next) { next() } + ) + assert.strictEqual(handler._name, undefined) + + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/foo' })) + wrappedUse.call(router, '/foo', handler) + + assert.strictEqual(handler._name, 'originalName') + }) + }) + + describe('wrapNext', () => { + it('does not publish errorChannel when next is called with no argument', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/foo' })) + wrappedUse.call(router, '/foo', (req, res, next) => next()) + + router.stack[0].handle.call({}, {}, {}, () => {}) + + assert.strictEqual(events.some(e => e.label === 'error'), false) + }) + + it('does not publish errorChannel on next("route")', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/foo' })) + wrappedUse.call(router, '/foo', (req, res, next) => next('route')) + + let receivedRouteToken + router.stack[0].handle.call({}, {}, {}, (token) => { receivedRouteToken = token }) + + assert.strictEqual(receivedRouteToken, 'route') + assert.strictEqual(events.some(e => e.label === 'error'), false) + }) + + it('does not publish errorChannel on next("router")', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/foo' })) + wrappedUse.call(router, '/foo', (req, res, next) => next('router')) + + let receivedRouterToken + router.stack[0].handle.call({}, {}, {}, (token) => { receivedRouterToken = token }) + + assert.strictEqual(receivedRouterToken, 'router') + assert.strictEqual(events.some(e => e.label === 'error'), false) + }) + + it('publishes errorChannel with the error when next is called with an Error', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const wrappedUse = wrapMethod(makeFakeUse({ layerPath: '/foo' })) + const failure = new Error('downstream-error') + wrappedUse.call(router, '/foo', (req, res, next) => next(failure)) + + const req = {} + router.stack[0].handle.call({}, req, {}, () => {}) + + const errorEvent = events.find(e => e.label === 'error') + assert.ok(errorEvent, 'errorChannel should publish on next(error)') + assert.strictEqual(errorEvent.data.error, failure) + assert.strictEqual(errorEvent.data.req, req) + }) + }) + + describe('layer.__handle (express-async-errors compatibility)', () => { + it('wraps `__handle` instead of `handle` when the layer exposes both', () => { + subscribeAll() + const wrapMethod = createWrapRouterMethod(namespace, compileRegex) + const router = /** @type {FakeRouter} */ ({ stack: [] }) + + const originalHandle = (req, res, next) => next() + const originalUnderscoreHandle = function patchedHandle (req, res, next) { + events.push({ label: '__handle-called' }) + next() + } + + function fakeUseWithUnderscoreHandle (path, handler) { + this.stack.push({ + handle: originalHandle, + __handle: originalUnderscoreHandle, + path: '/foo', + regexp: {}, + }) + } + + const wrappedUse = wrapMethod(fakeUseWithUnderscoreHandle) + wrappedUse.call(router, '/foo', () => {}) + + // `handle` should be left alone; `__handle` should be the new wrapper. + const wrappedLayer = router.stack[0] + assert.strictEqual(wrappedLayer.handle, originalHandle) + assert.notStrictEqual(wrappedLayer.__handle, originalUnderscoreHandle) + assert.strictEqual(typeof wrappedLayer.__handle, 'function') + + const wrappedUnderscoreHandle = /** @type {Function} */ (wrappedLayer.__handle) + wrappedUnderscoreHandle.call({}, {}, {}, () => {}) + + assert.ok( + events.find(e => e.label === '__handle-called'), + 'the inner __handle should run via the wrap' + ) + assert.ok(events.find(e => e.label === 'enter'), 'enterChannel should publish for __handle') + }) + }) +}) diff --git a/packages/datadog-plugin-ai/test/integration-test/client.spec.js b/packages/datadog-plugin-ai/test/integration-test/client.spec.js index de5a7613ab..1401c78c20 100644 --- a/packages/datadog-plugin-ai/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-ai/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const semifies = require('semifies') const { @@ -54,7 +55,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) // special check for ai spans for (const spans of payload) { diff --git a/packages/datadog-plugin-amqp10/test/index.spec.js b/packages/datadog-plugin-amqp10/test/index.spec.js index c49b4fcdbb..46fb18d032 100644 --- a/packages/datadog-plugin-amqp10/test/index.spec.js +++ b/packages/datadog-plugin-amqp10/test/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') @@ -146,7 +147,10 @@ describe('Plugin', () => { const promise = sender.send({ key: 'value' }) return promise.then(() => { - assert.ok(!Object.hasOwn(promise, 'value') && ('value' in promise)) + assert.ok( + !Object.hasOwn(promise, 'value') && ('value' in promise), + `Got: ${inspect(!Object.hasOwn(promise, 'value'))} && ${inspect('value' in promise)}` + ) }) }) diff --git a/packages/datadog-plugin-amqp10/test/integration-test/client.spec.js b/packages/datadog-plugin-amqp10/test/integration-test/client.spec.js index 440cbba457..6a4cb8120f 100644 --- a/packages/datadog-plugin-amqp10/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-amqp10/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -38,7 +39,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'amqp.send'), true) }) diff --git a/packages/datadog-plugin-amqplib/test/dsm.spec.js b/packages/datadog-plugin-amqplib/test/dsm.spec.js index e98ee112e0..44098588a9 100644 --- a/packages/datadog-plugin-amqplib/test/dsm.spec.js +++ b/packages/datadog-plugin-amqplib/test/dsm.spec.js @@ -95,7 +95,7 @@ describe('Plugin', () => { }) } }) - assert.ok(statsPointsReceived.length >= 1) + assert.ok(statsPointsReceived.length >= 1, `Expected ${statsPointsReceived.length} >= 1`) assert.deepStrictEqual(statsPointsReceived[0].EdgeTags, [ 'direction:out', 'has_routing_key:true', @@ -123,7 +123,7 @@ describe('Plugin', () => { }) } }) - assert.ok(statsPointsReceived.length >= 1) + assert.ok(statsPointsReceived.length >= 1, `Expected ${statsPointsReceived.length} >= 1`) assert.deepStrictEqual(statsPointsReceived[0].EdgeTags, [ 'direction:out', 'exchange:namedExchange', diff --git a/packages/datadog-plugin-amqplib/test/integration-test/client.spec.js b/packages/datadog-plugin-amqplib/test/integration-test/client.spec.js index 4190df17a9..90dc90fb30 100644 --- a/packages/datadog-plugin-amqplib/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-amqplib/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -39,7 +40,7 @@ describe('esm', () => { it(`is instrumented loaded with ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'amqp.command'), true) }) diff --git a/packages/datadog-plugin-anthropic/test/integration-test/client.spec.js b/packages/datadog-plugin-anthropic/test/integration-test/client.spec.js index 73067bce34..0eb6ae9842 100644 --- a/packages/datadog-plugin-anthropic/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-anthropic/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const { @@ -43,7 +44,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'anthropic.request'), true) }) diff --git a/packages/datadog-plugin-apollo/test/index.spec.js b/packages/datadog-plugin-apollo/test/index.spec.js index bcac7386cb..1345c371ac 100644 --- a/packages/datadog-plugin-apollo/test/index.spec.js +++ b/packages/datadog-plugin-apollo/test/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const axios = require('axios') const sinon = require('sinon') @@ -628,7 +629,7 @@ describe('Plugin', () => { const validateCtx = config.hooks.validate.firstCall.args[1] assert.strictEqual(validateSpan.context()._name, 'apollo.gateway.validate') - assert.ok(Array.isArray(validateCtx.result)) + assert.ok(Array.isArray(validateCtx.result), `Expected array, got ${inspect(validateCtx.result)}`) assert.strictEqual(validateCtx.result.at(-1).message, error.message) assertObjectContains(traces[0][1], { diff --git a/packages/datadog-plugin-avsc/src/schema_iterator.js b/packages/datadog-plugin-avsc/src/schema_iterator.js index 26f3346768..b39d2d5e73 100644 --- a/packages/datadog-plugin-avsc/src/schema_iterator.js +++ b/packages/datadog-plugin-avsc/src/schema_iterator.js @@ -140,7 +140,7 @@ class SchemaExtractor { return } - if (span.context()._tags[SCHEMA_TYPE] && operation === 'serialization') { + if (span.context().getTag(SCHEMA_TYPE) && operation === 'serialization') { // we have already added a schema to this span, this call is an encode of nested schema types return } diff --git a/packages/datadog-plugin-avsc/test/index.spec.js b/packages/datadog-plugin-avsc/test/index.spec.js index c3abc3b047..536607f170 100644 --- a/packages/datadog-plugin-avsc/test/index.spec.js +++ b/packages/datadog-plugin-avsc/test/index.spec.js @@ -30,7 +30,7 @@ const ADVANCED_USER_SCHEMA_DEF = JSON.parse( const BASIC_USER_SCHEMA_ID = '1605040621379664412' const ADVANCED_USER_SCHEMA_ID = '919692610494986520' function compareJson (expected, span) { - const actual = JSON.parse(span.context()._tags[SCHEMA_DEFINITION]) + const actual = JSON.parse(span.context().getTag(SCHEMA_DEFINITION)) return JSON.stringify(actual) === JSON.stringify(expected) } @@ -84,11 +84,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'user.serialize') assert.strictEqual(compareJson(BASIC_USER_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'avro') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'example.avro.User') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'serialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], BASIC_USER_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'avro') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'example.avro.User') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'serialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], BASIC_USER_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) }) }) @@ -115,11 +115,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'advanced_user.serialize') assert.strictEqual(compareJson(ADVANCED_USER_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'avro') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'example.avro.AdvancedUser') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'serialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], ADVANCED_USER_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'avro') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'example.avro.AdvancedUser') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'serialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], ADVANCED_USER_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) }) }) @@ -136,11 +136,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'user.deserialize') assert.strictEqual(compareJson(BASIC_USER_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'avro') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'example.avro.User') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'deserialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], BASIC_USER_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'avro') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'example.avro.User') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'deserialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], BASIC_USER_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) }) }) @@ -168,11 +168,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'advanced_user.deserialize') assert.strictEqual(compareJson(ADVANCED_USER_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'avro') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'example.avro.AdvancedUser') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'deserialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], ADVANCED_USER_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'avro') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'example.avro.AdvancedUser') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'deserialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], ADVANCED_USER_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) }) }) }) diff --git a/packages/datadog-plugin-aws-sdk/test/integration-test/client.spec.js b/packages/datadog-plugin-aws-sdk/test/integration-test/client.spec.js index f0959ce7f0..8176bbbc23 100644 --- a/packages/datadog-plugin-aws-sdk/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -38,7 +39,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'aws.request'), true) }) diff --git a/packages/datadog-plugin-aws-sdk/test/integration-test/sqs.spec.js b/packages/datadog-plugin-aws-sdk/test/integration-test/sqs.spec.js index 2d5b102941..8a4d0f2c4f 100644 --- a/packages/datadog-plugin-aws-sdk/test/integration-test/sqs.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/integration-test/sqs.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert') +const { inspect } = require('node:util') const { FakeAgent, sandboxCwd, @@ -31,7 +32,7 @@ describe('recursion regression test', () => { it('does not cause a recursion error when many commands are sent', async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.equal(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'aws.request'), true) }) diff --git a/packages/datadog-plugin-aws-sdk/test/kinesis.dsm.spec.js b/packages/datadog-plugin-aws-sdk/test/kinesis.dsm.spec.js index c1a12fb117..3e2d55a368 100644 --- a/packages/datadog-plugin-aws-sdk/test/kinesis.dsm.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/kinesis.dsm.spec.js @@ -156,7 +156,7 @@ describe('Kinesis', function () { }) } }) - assert.ok(statsPointsReceived >= 1) + assert.ok(statsPointsReceived >= 1, `Expected ${statsPointsReceived} >= 1`) assert.strictEqual(agent.dsmStatsExist(agent, expectedProducerHash), true) }, { timeoutMs: 10000 }).then(done, done) @@ -174,7 +174,7 @@ describe('Kinesis', function () { }) } }, { timeoutMs: 10000 }) - assert.ok(statsPointsReceived >= 2) + assert.ok(statsPointsReceived >= 2, `Expected ${statsPointsReceived} >= 2`) assert.strictEqual(agent.dsmStatsExist(agent, expectedConsumerHash), true) }, { timeoutMs: 10000 }).then(done, done) @@ -232,7 +232,7 @@ describe('Kinesis', function () { }) } }) - assert.ok(statsPointsReceived >= 3) + assert.ok(statsPointsReceived >= 3, `Expected ${statsPointsReceived} >= 3`) assert.strictEqual(agent.dsmStatsExist(agent, expectedProducerHash), true) }, { timeoutMs: 10000 }).then(done, done) diff --git a/packages/datadog-plugin-aws-sdk/test/kinesis.spec.js b/packages/datadog-plugin-aws-sdk/test/kinesis.spec.js index 3909d5c656..1fea0bf104 100644 --- a/packages/datadog-plugin-aws-sdk/test/kinesis.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/kinesis.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') @@ -92,8 +93,11 @@ describe('Kinesis', function () { helpers.getTestData(kinesis, streamName, data, (err, data) => { if (err) return done(err) - assert.ok(Object.hasOwn(data, '_datadog')) - assert.ok(Object.hasOwn(data._datadog, 'x-datadog-trace-id')) + assert.ok(Object.hasOwn(data, '_datadog'), `Available keys: ${inspect(Object.keys(data))}`) + assert.ok( + Object.hasOwn(data._datadog, 'x-datadog-trace-id'), + `Available keys: ${inspect(Object.keys(data._datadog))}` + ) done() }) @@ -109,8 +113,11 @@ describe('Kinesis', function () { for (const record in data.Records) { const recordData = JSON.parse(Buffer.from(data.Records[record].Data).toString()) - assert.ok(Object.hasOwn(recordData, '_datadog')) - assert.ok(Object.hasOwn(recordData._datadog, 'x-datadog-trace-id')) + assert.ok(Object.hasOwn(recordData, '_datadog'), `Available keys: ${inspect(Object.keys(recordData))}`) + assert.ok( + Object.hasOwn(recordData._datadog, 'x-datadog-trace-id'), + `Available keys: ${inspect(Object.keys(recordData._datadog))}` + ) } done() @@ -125,8 +132,11 @@ describe('Kinesis', function () { helpers.getTestData(kinesis, streamName, data, (err, data) => { if (err) return done(err) - assert.ok(Object.hasOwn(data, '_datadog')) - assert.ok(Object.hasOwn(data._datadog, 'x-datadog-trace-id')) + assert.ok(Object.hasOwn(data, '_datadog'), `Available keys: ${inspect(Object.keys(data))}`) + assert.ok( + Object.hasOwn(data._datadog, 'x-datadog-trace-id'), + `Available keys: ${inspect(Object.keys(data._datadog))}` + ) done() }) diff --git a/packages/datadog-plugin-aws-sdk/test/sns.dsm.spec.js b/packages/datadog-plugin-aws-sdk/test/sns.dsm.spec.js index 7973e006d6..422f29f512 100644 --- a/packages/datadog-plugin-aws-sdk/test/sns.dsm.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/sns.dsm.spec.js @@ -199,7 +199,7 @@ describe('Sns', function () { }) } }) - assert.ok(statsPointsReceived >= 1) + assert.ok(statsPointsReceived >= 1, `Expected ${statsPointsReceived} >= 1`) assert.strictEqual(agent.dsmStatsExist(agent, expectedProducerHash), true) }).then(done, done) @@ -219,7 +219,7 @@ describe('Sns', function () { }) } }) - assert.ok(statsPointsReceived >= 2) + assert.ok(statsPointsReceived >= 2, `Expected ${statsPointsReceived} >= 2`) assert.strictEqual(agent.dsmStatsExist(agent, expectedConsumerHash), true) }, { timeoutMs: 2000 }).then(done, done) @@ -257,7 +257,7 @@ describe('Sns', function () { }) } }) - assert.ok(statsPointsReceived >= 3) + assert.ok(statsPointsReceived >= 3, `Expected ${statsPointsReceived} >= 3`) assert.strictEqual(agent.dsmStatsExist(agent, expectedProducerHash), true) }, { timeoutMs: 2000 }).then(done, done) diff --git a/packages/datadog-plugin-aws-sdk/test/sns.spec.js b/packages/datadog-plugin-aws-sdk/test/sns.spec.js index 35fa942b31..32ccd8bb33 100644 --- a/packages/datadog-plugin-aws-sdk/test/sns.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/sns.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { after, before, describe, it } = require('mocha') const semver = require('semver') @@ -199,7 +200,10 @@ describe('Sns', function () { }, }) - assert.ok(Object.hasOwn(span.meta, 'aws.response.body.MessageId')) + assert.ok( + Object.hasOwn(span.meta, 'aws.response.body.MessageId'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) }, { timeoutMs: 20000 }).then(done, done) sns.publish({ @@ -426,10 +430,16 @@ describe('Sns', function () { for (const message in data.Messages) { const recordData = JSON.parse(data.Messages[message].Body) - assert.ok(Object.hasOwn(recordData.MessageAttributes, '_datadog')) + assert.ok( + Object.hasOwn(recordData.MessageAttributes, '_datadog'), + `Available keys: ${inspect(Object.keys(recordData.MessageAttributes))}` + ) const attributes = JSON.parse(Buffer.from(recordData.MessageAttributes._datadog.Value, 'base64')) - assert.ok(Object.hasOwn(attributes, 'x-datadog-trace-id')) + assert.ok( + Object.hasOwn(attributes, 'x-datadog-trace-id'), + `Available keys: ${inspect(Object.keys(attributes))}` + ) } }) sns.publishBatch({ diff --git a/packages/datadog-plugin-aws-sdk/test/sqs-inject-to-message.spec.js b/packages/datadog-plugin-aws-sdk/test/sqs-inject-to-message.spec.js index 3bc43e27f3..5a42bea12a 100644 --- a/packages/datadog-plugin-aws-sdk/test/sqs-inject-to-message.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/sqs-inject-to-message.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { Buffer } = require('node:buffer') +const { inspect } = require('node:util') const { describe, it } = require('mocha') @@ -109,7 +110,11 @@ describe('Sqs plugin injectToMessage', () => { assert.strictEqual(plugin.dsmCalls[0].datadog.StringValue, '{}') const decoded = JSON.parse(params.MessageAttributes._datadog.StringValue) - assert.ok(typeof decoded['dd-pathway-ctx-base64'] === 'string' && decoded['dd-pathway-ctx-base64'].length > 0) + const pathwayCtx = decoded['dd-pathway-ctx-base64'] + assert.ok( + typeof pathwayCtx === 'string' && pathwayCtx.length > 0, + `Expected non-empty pathway ctx string, got ${inspect(pathwayCtx)}` + ) }) it('skips `_datadog` entirely when DSM is disabled and trace inject yields nothing', () => { diff --git a/packages/datadog-plugin-aws-sdk/test/sqs.dsm.spec.js b/packages/datadog-plugin-aws-sdk/test/sqs.dsm.spec.js index 84f967bf2d..21444794d4 100644 --- a/packages/datadog-plugin-aws-sdk/test/sqs.dsm.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/sqs.dsm.spec.js @@ -210,7 +210,7 @@ describe('Plugin', () => { }) } }) - assert.ok(statsPointsReceived >= 1) + assert.ok(statsPointsReceived >= 1, `Expected ${statsPointsReceived} >= 1`) assert.strictEqual(agent.dsmStatsExist(agent, expectedProducerHash), true) }).then(done, done) @@ -228,7 +228,7 @@ describe('Plugin', () => { }) } }) - assert.ok(statsPointsReceived >= 2) + assert.ok(statsPointsReceived >= 2, `Expected ${statsPointsReceived} >= 2`) assert.strictEqual(agent.dsmStatsExist(agent, expectedConsumerHash), true) }, { timeoutMs: 5000 }).then(done, done) @@ -279,7 +279,7 @@ describe('Plugin', () => { }) } }) - assert.ok(statsPointsReceived >= 3) + assert.ok(statsPointsReceived >= 3, `Expected ${statsPointsReceived} >= 3`) assert.strictEqual(agent.dsmStatsExist(agent, expectedProducerHash), true) }).then(done, done) diff --git a/packages/datadog-plugin-aws-sdk/test/sqs.spec.js b/packages/datadog-plugin-aws-sdk/test/sqs.spec.js index cc6c8a3b87..657760a1d6 100644 --- a/packages/datadog-plugin-aws-sdk/test/sqs.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/sqs.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { randomUUID } = require('node:crypto') +const { inspect } = require('node:util') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') const agent = require('../../dd-trace/test/plugins/agent') @@ -216,9 +217,15 @@ describe('Plugin', () => { try { for (const message in data.Messages) { const recordData = data.Messages[message].MessageAttributes - assert.ok(Object.hasOwn(recordData, '_datadog')) + assert.ok( + Object.hasOwn(recordData, '_datadog'), + `Available keys: ${inspect(Object.keys(recordData))}` + ) const traceContext = JSON.parse(recordData._datadog.StringValue) - assert.ok(Object.hasOwn(traceContext, 'x-datadog-trace-id')) + assert.ok( + Object.hasOwn(traceContext, 'x-datadog-trace-id'), + `Available keys: ${inspect(Object.keys(traceContext))}` + ) } resolve() @@ -253,7 +260,7 @@ describe('Plugin', () => { const span = tracer.scope().active() assert.notStrictEqual(span, beforeSpan) - assert.strictEqual(span.context()._tags['aws.operation'], 'receiveMessage') + assert.strictEqual(span.context().getTag('aws.operation'), 'receiveMessage') done() }) diff --git a/packages/datadog-plugin-aws-sdk/test/stepfunctions.spec.js b/packages/datadog-plugin-aws-sdk/test/stepfunctions.spec.js index c008ffa143..5c60ab6d59 100644 --- a/packages/datadog-plugin-aws-sdk/test/stepfunctions.spec.js +++ b/packages/datadog-plugin-aws-sdk/test/stepfunctions.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { afterEach, before, beforeEach, describe, it } = require('mocha') const semver = require('semver') @@ -120,9 +121,15 @@ describe('Sfn', () => { const result = await client.describeExecution({ executionArn: resp.executionArn }) const sfInput = JSON.parse(result.input) - assert.ok(Object.hasOwn(sfInput, '_datadog')) - assert.ok(Object.hasOwn(sfInput._datadog, 'x-datadog-trace-id')) - assert.ok(Object.hasOwn(sfInput._datadog, 'x-datadog-parent-id')) + assert.ok(Object.hasOwn(sfInput, '_datadog'), `Available keys: ${inspect(Object.keys(sfInput))}`) + assert.ok( + Object.hasOwn(sfInput._datadog, 'x-datadog-trace-id'), + `Available keys: ${inspect(Object.keys(sfInput._datadog))}` + ) + assert.ok( + Object.hasOwn(sfInput._datadog, 'x-datadog-parent-id'), + `Available keys: ${inspect(Object.keys(sfInput._datadog))}` + ) return expectSpanPromise.then(() => {}) }) } diff --git a/packages/datadog-plugin-axios/test/integration-test/client.spec.js b/packages/datadog-plugin-axios/test/integration-test/client.spec.js index 27a9c2e887..a111970ee4 100644 --- a/packages/datadog-plugin-axios/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-axios/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -37,7 +38,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'http.request'), true) }) diff --git a/packages/datadog-plugin-axios/test/integration-test/server.mjs b/packages/datadog-plugin-axios/test/integration-test/server.mjs index 3027f787b7..46c0481c45 100644 --- a/packages/datadog-plugin-axios/test/integration-test/server.mjs +++ b/packages/datadog-plugin-axios/test/integration-test/server.mjs @@ -1,7 +1,8 @@ import 'dd-trace/init.js' import axios from 'axios' -axios.get('/foo') +// An arbitrary port is used here as we just need a request even if it fails. +axios.get('http://localhost:55555/foo') .then(() => {}) .catch(() => {}) .finally(() => {}) diff --git a/packages/datadog-plugin-azure-cosmos/src/index.js b/packages/datadog-plugin-azure-cosmos/src/index.js new file mode 100644 index 0000000000..acb1cfa7ef --- /dev/null +++ b/packages/datadog-plugin-azure-cosmos/src/index.js @@ -0,0 +1,144 @@ +'use strict' + +const { storage } = require('../../datadog-core') + +const DatabasePlugin = require('../../dd-trace/src/plugins/database') + +class AzureCosmosPlugin extends DatabasePlugin { + static id = 'azure-cosmos' + // Channel prefix determines how the plugin subscribes to instrumentation events. + // Three patterns exist — set `static prefix` explicitly based on instrumentation type: + // + // Orchestrion: static prefix = 'tracing:orchestrion::' + // Shimmer + tracingChannel: static prefix = 'tracing:apm::' + // Shimmer + manual channels: omit prefix — defaults to `apm:${id}:${operation}` + static prefix = 'tracing:orchestrion:@azure/cosmos:executePlugins' + static peerServicePrecursors = ['db.name'] + + operationName () { + return 'cosmosdb.query' + } + + asyncEnd (ctx) { + if (!ctx.span) return + const span = ctx.currentStore?.span + if (span) { + const result = ctx.result + if (result?.code) span.setTag('db.response.status_code', (result.code).toString()) + if (result?.substatus) span.setTag('cosmosdb.response.sub_status_code', result.substatus) + span.finish() + } + } + + error (ctx) { + if (!ctx.span) return + const span = ctx.currentStore?.span + if (span) { + const error = ctx.error + this.addError(error, span) + if (error?.code) span.setTag('db.response.status_code', (error.code).toString()) + if (error?.substatus) span.setTag('cosmosdb.response.sub_status_code', error.substatus) + } + } + + bindStart (ctx) { + const requestContext = ctx.arguments[1] + const resource = this.getResource(requestContext) + const { dbName, containerName } = this.getDbInfo(requestContext) + const connectionMode = this.getConnectionMode(requestContext) + const { outHost, userAgent } = this.getHttpInfo(requestContext) + const pluginOn = ctx.arguments[3] + + if (pluginOn != null && requestContext.operationType != null && requestContext.resourceType != null) { + const operationType = requestContext.operationType + const resourceType = requestContext.resourceType + // only trace operations not requests (pluginOn) + // trace requests only if they are read or query operations not on docs + // prevents doubled read spans for createIfNotExists calls + if (pluginOn === 'request' && ((operationType !== 'read' && operationType !== 'query') || + (operationType === 'read' && resourceType !== 'docs'))) { + return storage('legacy').getStore() + } + + // separately, skip tracing read requests without a path, these don't + // represent CRUD operations on a resource we care about + // not returning current store because we don't want the child http.request spans + // to be created + if (operationType === 'read' && requestContext.path === '') { + return { noop: true } + } + } + + const span = this.startSpan(this.operationName(), { + resource, + type: 'cosmosdb', + kind: 'client', + meta: { + component: 'azure_cosmos', + 'db.system': 'cosmosdb', + 'db.name': dbName, + 'cosmosdb.container': containerName, + 'cosmosdb.connection.mode': connectionMode, + 'http.useragent': userAgent, + 'out.host': outHost, + }, + }, ctx) + + ctx.span = span + return ctx.currentStore + } + + getResource (requestContext) { + const path = requestContext.path + const parts = path.split('/') + let modified = false + for (let i = 2; i < parts.length; i += 2) { + if (parts[i].length > 0 && parts[i - 1] !== 'dbs' && parts[i - 1] !== 'colls') { + parts[i] = '?' + modified = true + } + } + + return `${requestContext.operationType} ${modified ? parts.join('/') : path}` + } + + getDbInfo (requestContext) { + let dbName = null + let containerName = null + + if (requestContext.operationType === 'create' && requestContext.resourceType === 'dbs' && + requestContext.body != null && requestContext.body.id != null) { + dbName = requestContext.body.id + } + + let resourceLink = requestContext.path + if (resourceLink?.length > 1 && resourceLink.startsWith('/')) { + resourceLink = resourceLink.slice(1) + const parts = resourceLink.split('/') + if (parts.length > 0 && parts[0].toLowerCase() === 'dbs' && parts.length >= 2) { + dbName = parts[1] + if (parts.length >= 4 && parts[2].toLowerCase() === 'colls' && parts[3] !== '') { + containerName = parts[3] + } + } + } + + return { dbName, containerName } + } + + getConnectionMode (requestContext) { + const mode = requestContext.client?.connectionPolicy?.connectionMode + if (mode === 0) { + return 'gateway' + } + return 'direct' + } + + getHttpInfo (requestContext) { + const outHost = requestContext.client?.cosmosClientOptions?.endpoint + const userAgent = requestContext.headers?.['User-Agent'] + return { outHost, userAgent } + } +} + +module.exports = AzureCosmosPlugin diff --git a/packages/datadog-plugin-azure-cosmos/test/cosmos-helpers.js b/packages/datadog-plugin-azure-cosmos/test/cosmos-helpers.js new file mode 100644 index 0000000000..7b50cd211b --- /dev/null +++ b/packages/datadog-plugin-azure-cosmos/test/cosmos-helpers.js @@ -0,0 +1,23 @@ +'use strict' + +async function setup () { + const { CosmosClient } = require('@azure/cosmos') + const client = new CosmosClient({ + endpoint: 'http://127.0.0.1:8081', + key: 'C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==', + }) + + const { database } = await client.databases.createIfNotExists({ id: 'testDatabase' }) + const { container } = await database.containers.createIfNotExists({ + id: 'testContainer', + partitionKey: { paths: ['/productName'], kind: 'Hash' }, + }) + + return { client, container } +} + +async function teardown (client) { + await client.database('testDatabase').delete() +} + +module.exports = { setup, teardown } diff --git a/packages/datadog-plugin-azure-cosmos/test/get-resource.spec.js b/packages/datadog-plugin-azure-cosmos/test/get-resource.spec.js new file mode 100644 index 0000000000..38b445a66f --- /dev/null +++ b/packages/datadog-plugin-azure-cosmos/test/get-resource.spec.js @@ -0,0 +1,49 @@ +'use strict' + +const assert = require('node:assert/strict') + +const AzureCosmosPlugin = require('../src') + +describe('azure-cosmos', () => { + describe('getResource', () => { + let plugin + + before(() => { + plugin = new AzureCosmosPlugin({}, {}) + }) + + it('replaces document id with ? while preserving db and container names', () => { + const resource = plugin.getResource({ + operationType: 'delete', + path: '/dbs/myDb/colls/myContainer/docs/test-id', + }) + assert.strictEqual(resource, 'delete /dbs/myDb/colls/myContainer/docs/?') + }) + + it('replaces high-cardinality segments after resource types other than dbs or colls', () => { + const resource = plugin.getResource({ + operationType: 'execute', + path: '/dbs/myDb/colls/myContainer/sprocs/myStoredProc', + }) + assert.strictEqual(resource, 'execute /dbs/myDb/colls/myContainer/sprocs/?') + }) + + it('does not modify path when there is no id segment after docs', () => { + const path = '/dbs/myDb/colls/myContainer/docs' + const resource = plugin.getResource({ + operationType: 'query', + path, + }) + assert.strictEqual(resource, `query ${path}`) + }) + + it('does not modify path when only database and container segments exist', () => { + const path = '/dbs/myDb/colls/myContainer' + const resource = plugin.getResource({ + operationType: 'read', + path, + }) + assert.strictEqual(resource, `read ${path}`) + }) + }) +}) diff --git a/packages/datadog-plugin-azure-cosmos/test/index.spec.js b/packages/datadog-plugin-azure-cosmos/test/index.spec.js new file mode 100644 index 0000000000..046a89d1b9 --- /dev/null +++ b/packages/datadog-plugin-azure-cosmos/test/index.spec.js @@ -0,0 +1,201 @@ +'use strict' + +const assert = require('node:assert/strict') +const agent = require('../../dd-trace/test/plugins/agent') +const { withVersions } = require('../../dd-trace/test/setup/mocha') +const { assertObjectContains } = require('../../../integration-tests/helpers') +const { setup, teardown } = require('./cosmos-helpers') + +describe('Plugin', () => { + describe('azure-cosmos', () => { + withVersions('azure-cosmos', '@azure/cosmos', (version) => { + let client + let container + + beforeEach(async () => { + // Provision DB/container without emitting azure-cosmos spans (plugin subscriptions stay off). + await agent.load('azure-cosmos', { enabled: false }) + ; ({ client, container } = await setup()) + agent.reload('azure-cosmos', { enabled: true }) + }) + + afterEach(async () => { + await teardown(client) + return agent.close({ ritmReset: false }) + }) + + it('should create a span', async () => { + const expectedSpanPromise = agent.assertFirstTraceSpan({ + name: 'cosmosdb.query', + service: 'test-azure-cosmos', + type: 'cosmosdb', + resource: 'create /dbs/testDatabase/colls/testContainer/docs', + meta: { + component: 'azure_cosmos', + 'db.system': 'cosmosdb', + 'db.name': 'testDatabase', + 'cosmosdb.container': 'testContainer', + 'cosmosdb.connection.mode': 'gateway', + 'span.kind': 'client', + }, + }) + + await container.items.create({ + id: 'item1', + productName: 'Test Product', + productModel: 'Model 1', + }) + + await expectedSpanPromise + }) + + it('should create spans with callback assertion', async () => { + const expectedResources = [ + 'upsert /dbs/testDatabase/colls/testContainer/docs', + 'read /dbs/testDatabase/colls/testContainer/docs', + 'query /dbs/testDatabase/colls/testContainer/docs', + 'delete /dbs/testDatabase/colls/testContainer/docs/?', + ] + + const validatedResources = new Set() + const expectedSpanPromise = agent.assertSomeTraces( + traces => { + const allSpans = traces.filter(Array.isArray).flat() + for (const span of allSpans) { + const resource = span?.resource + if (!expectedResources.includes(resource) || validatedResources.has(resource)) continue + + assertObjectContains(span, { + name: 'cosmosdb.query', + service: 'test-azure-cosmos', + type: 'cosmosdb', + meta: { + component: 'azure_cosmos', + 'db.system': 'cosmosdb', + 'db.name': 'testDatabase', + 'cosmosdb.container': 'testContainer', + 'cosmosdb.connection.mode': 'gateway', + }, + }) + + assert(span.meta['http.useragent'].includes('azure-cosmos-js/'), 'expected http.useragent in span meta') + assert(parseInt(span.meta['db.response.status_code']) >= 200 && + parseInt(span.meta['db.response.status_code']) < 300, + `expected 2xx status code, got ${span.meta['db.response.status_code']}`) + + validatedResources.add(resource) + } + + const missing = expectedResources.filter(r => !validatedResources.has(r)) + assert.strictEqual( + missing.length, + 0, + `still waiting for spans: ${missing.join(', ')}; validated: ${[...validatedResources].join(', ')}` + ) + } + ) + + await container.items.upsert({ id: 'item1', productName: 'Test Product', productModel: 'Model 1' }) + + await container.items + .query( + { query: 'SELECT * FROM testContainer p WHERE p.productModel = "Model 1"' }, + { enableCrossPartitionQuery: true } + ) + .fetchAll() + + await container.item('item1', 'Test Product').delete() + + await expectedSpanPromise + }) + + it('does not create cosmosdb or http spans for empty-path read requests', async () => { + agent.reload('http', { enabled: true }) + + const seenSpans = [] + const collect = (payload) => { + if (!Array.isArray(payload)) return + for (const trace of payload) { + if (!Array.isArray(trace)) continue + for (const span of trace) seenSpans.push(span) + } + } + agent.subscribe(collect) + + try { + await client.getDatabaseAccount() + + const markerSeen = agent.assertSomeTraces(traces => { + const flat = traces.filter(Array.isArray).flat() + assert.ok( + flat.some(s => s?.resource === 'upsert /dbs/testDatabase/colls/testContainer/docs'), + 'waiting for marker upsert span' + ) + }) + await container.items.upsert({ id: 'marker', productName: 'Test Product', productModel: 'Model 1' }) + await markerSeen + + const readSpan = seenSpans.find( + s => s?.name === 'cosmosdb.query' && s?.resource === 'read ' + ) + assert.equal(readSpan, undefined, 'unexpected cosmosdb read span for empty-path request') + + // Account read uses an empty SDK path, so the http client plugin records + // http.url ending with the bare endpoint root. The upsert marker hits + // /dbs/testDatabase/colls/testContainer/docs, so it won't match. + const accountHttp = seenSpans.find(s => + s?.name === 'http.request' && s?.meta?.['http.url']?.endsWith(':8081/') + ) + assert.equal(accountHttp, undefined, 'unexpected http span for empty-path account read') + } finally { + agent.unsubscribe(collect) + agent.reload('http', { enabled: false }) + } + }) + + it('should create spans if an error occurs', async () => { + const expectedSpanPromise = agent.assertSomeTraces( + traces => { + const allSpans = traces.filter(Array.isArray).flat() + const conflictCreate = allSpans.find( + s => + s?.resource === 'create /dbs/testDatabase/colls/testContainer/docs' && + s?.meta?.['db.response.status_code'] === '409' + ) + assert.ok( + conflictCreate, + 'expected 409 create span in payload' + ) + + assertObjectContains(conflictCreate, { + name: 'cosmosdb.query', + service: 'test-azure-cosmos', + type: 'cosmosdb', + resource: 'create /dbs/testDatabase/colls/testContainer/docs', + error: 1, + meta: { + component: 'azure_cosmos', + 'db.system': 'cosmosdb', + 'db.name': 'testDatabase', + 'db.response.status_code': '409', + 'cosmosdb.container': 'testContainer', + 'cosmosdb.connection.mode': 'gateway', + 'error.message': 'The document already exists in the collection.', + 'error.type': 'Error', + }, + }) + + assert(conflictCreate.meta['http.useragent'].includes('azure-cosmos-js/'), + `expected http.useragent to include 'azure-cosmos-js/', got ${conflictCreate.meta['http.useragent']}`) + } + ) + + await container.items.upsert({ id: 'item1', productName: 'Test Product', productModel: 'Model 1' }) + void container.items.create({ id: 'item1', productName: 'Test Product', productModel: 'Model 1' }) + .catch(() => { }) + + await expectedSpanPromise + }) + }) + }) +}) diff --git a/packages/datadog-plugin-azure-cosmos/test/integration-test/client.spec.js b/packages/datadog-plugin-azure-cosmos/test/integration-test/client.spec.js new file mode 100644 index 0000000000..6db47f9df8 --- /dev/null +++ b/packages/datadog-plugin-azure-cosmos/test/integration-test/client.spec.js @@ -0,0 +1,59 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { + FakeAgent, + useSandbox, + sandboxCwd, + checkSpansForServiceName, + spawnPluginIntegrationTestProcAndExpectExit, + varySandbox, + stopProc, +} = require('../../../../integration-tests/helpers') +const { withVersions } = require('../../../dd-trace/test/setup/mocha') + +describe('esm', () => { + let agent + let proc + let variants + let spawnEnv + + withVersions('azure-cosmos', '@azure/cosmos', (version) => { + useSandbox([`'@azure/cosmos@${version}'`], false, [ + './packages/datadog-plugin-azure-cosmos/test/integration-test/*']) + + before(async function () { + variants = varySandbox('server.mjs', 'CosmosClient', undefined, '@azure/cosmos', true) + }) + + beforeEach(async () => { + agent = await new FakeAgent().start() + spawnEnv = { NODE_OPTIONS: '--experimental-global-webcrypto' } + }) + + afterEach(async () => { + await stopProc(proc) + await agent.stop() + }) + + for (const variant of ['star', 'destructure']) { + it(`is instrumented ${variant}`, async () => { + const res = agent.assertMessageReceived(({ headers, payload }) => { + assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) + assert.ok(Array.isArray(payload), `expected payload to be an array, got ${typeof payload}`) + assert.strictEqual(checkSpansForServiceName(payload, 'cosmosdb.query'), true) + }) + + proc = await spawnPluginIntegrationTestProcAndExpectExit( + sandboxCwd(), + variants[variant], + agent.port, + spawnEnv + ) + + await res + }).timeout(20000) + } + }) +}) diff --git a/packages/datadog-plugin-azure-cosmos/test/integration-test/server.mjs b/packages/datadog-plugin-azure-cosmos/test/integration-test/server.mjs new file mode 100644 index 0000000000..76dde6220b --- /dev/null +++ b/packages/datadog-plugin-azure-cosmos/test/integration-test/server.mjs @@ -0,0 +1,33 @@ +import 'dd-trace/init.js' +import { CosmosClient } from '@azure/cosmos' + +const client = new CosmosClient({ + endpoint: 'http://127.0.0.1:8081', + key: 'C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==', +}) + +const { database } = await client.databases.createIfNotExists({ id: 'testDatabase' }) + +const { container } = await database.containers.createIfNotExists({ + id: 'testContainer', + partitionKey: { + paths: ['/productName'], + kind: 'Hash', + }, +}) + +await container.items.create({ + id: 'item1', + productName: 'Test Product', + productModel: 'Model 1', +}) + +const deleteQuery = { + query: 'SELECT * FROM testContainer p WHERE p.productModel = "Model 1"', +} +const { resources: toDelete } = await container.items + .query(deleteQuery, { enableCrossPartitionQuery: true }) + .fetchAll() +for (const item of toDelete) { + await container.item(item.id, 'Test Product').delete() +} diff --git a/packages/datadog-plugin-azure-durable-functions/test/integration-test/client.spec.js b/packages/datadog-plugin-azure-durable-functions/test/integration-test/client.spec.js index 2706b3c9d6..089d6f6031 100644 --- a/packages/datadog-plugin-azure-durable-functions/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-azure-durable-functions/test/integration-test/client.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const { spawn } = require('child_process') +const { inspect } = require('node:util') const { describe, it } = require('mocha') const { FakeAgent, @@ -44,13 +45,13 @@ describe('esm', () => { proc = await spawnPluginIntegrationTestProc(agent.port) return await curlAndAssertMessage(agent, 'http://127.0.0.1:7071/api/httptest', ({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) // should expect spans for http.request, activity.hola, entity.counter.add_n, entity.counter.get_count assert.strictEqual(payload.length, 4) for (const maybeArray of payload) { - assert.ok(Array.isArray(maybeArray)) + assert.ok(Array.isArray(maybeArray), `Expected array, got ${inspect(maybeArray)}`) } const [maybeHttpSpan, maybeHolaActivity, maybeAddNEntity, maybeGetCountEntity] = payload diff --git a/packages/datadog-plugin-azure-event-hubs/src/producer.js b/packages/datadog-plugin-azure-event-hubs/src/producer.js index 7c1536a3fd..4c17ff6867 100644 --- a/packages/datadog-plugin-azure-event-hubs/src/producer.js +++ b/packages/datadog-plugin-azure-event-hubs/src/producer.js @@ -62,7 +62,7 @@ class AzureEventHubsProducerPlugin extends ProducerPlugin { const contexts = spanContexts.get(eventData) if (contexts) { for (const spanContext of contexts) { - span.addLink(spanContext) + span.addLink({ context: spanContext }) } } } diff --git a/packages/datadog-plugin-azure-event-hubs/test/integration-test/batchSpanContextRegressionTest/client.spec.js b/packages/datadog-plugin-azure-event-hubs/test/integration-test/batchSpanContextRegressionTest/client.spec.js index 3e2fc97ad6..3d229abad6 100644 --- a/packages/datadog-plugin-azure-event-hubs/test/integration-test/batchSpanContextRegressionTest/client.spec.js +++ b/packages/datadog-plugin-azure-event-hubs/test/integration-test/batchSpanContextRegressionTest/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('assert') +const { inspect } = require('node:util') const { FakeAgent, sandboxCwd, @@ -32,7 +33,7 @@ describe('esm', () => { it('tryAdd does not set context in the Azure eventDataBatch._spanContext', async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 3) // Verify we got the expected spans from the test assert.strictEqual(payload[0][0].name, 'azure.eventhubs.create') diff --git a/packages/datadog-plugin-azure-event-hubs/test/integration-test/core-test/client.spec.js b/packages/datadog-plugin-azure-event-hubs/test/integration-test/core-test/client.spec.js index 4fee17f2df..1ca58c51b4 100644 --- a/packages/datadog-plugin-azure-event-hubs/test/integration-test/core-test/client.spec.js +++ b/packages/datadog-plugin-azure-event-hubs/test/integration-test/core-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { afterEach, beforeEach, describe, it } = require('mocha') const { @@ -44,7 +45,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'azure.eventhubs.send'), true) }) diff --git a/packages/datadog-plugin-azure-event-hubs/test/integration-test/tryAddRegressionTest/client.spec.js b/packages/datadog-plugin-azure-event-hubs/test/integration-test/tryAddRegressionTest/client.spec.js index 001501ec24..39d19c5269 100644 --- a/packages/datadog-plugin-azure-event-hubs/test/integration-test/tryAddRegressionTest/client.spec.js +++ b/packages/datadog-plugin-azure-event-hubs/test/integration-test/tryAddRegressionTest/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('assert') +const { inspect } = require('node:util') const { FakeAgent, sandboxCwd, @@ -32,7 +33,7 @@ describe('esm', () => { it('tryAdd returns a boolean, not a Promise', async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 3) // Verify we got the expected spans from the test assert.strictEqual(payload[0][0].name, 'azure.eventhubs.create') diff --git a/packages/datadog-plugin-azure-functions/src/index.js b/packages/datadog-plugin-azure-functions/src/index.js index 028799e955..eb86c9aa18 100644 --- a/packages/datadog-plugin-azure-functions/src/index.js +++ b/packages/datadog-plugin-azure-functions/src/index.js @@ -13,6 +13,7 @@ const triggerMap = { serviceBusQueue: 'ServiceBus', serviceBusTopic: 'ServiceBus', eventHub: 'EventHubs', + cosmosDB: 'CosmosDB', } class AzureFunctionsPlugin extends TracingPlugin { @@ -53,7 +54,7 @@ class AzureFunctionsPlugin extends TracingPlugin { ) span._integrationName = 'azure-functions' - span.context()._tags.component = 'azure-functions' + span.context().setTag('component', 'azure-functions') span.addTags(meta) webContext.span = span webContext.azureFunctionCtx = ctx @@ -127,6 +128,8 @@ function getMetaForTrigger ({ functionName, methodName, invocationContext }) { 'resource.name': `EventHubs ${functionName}`, 'span.kind': 'consumer', } + } else if (triggerMap[methodName] === 'CosmosDB') { + meta['resource.name'] = `CosmosDB ${functionName}` } return meta @@ -155,7 +158,7 @@ function setSpanLinks (triggerType, tracer, span, ctx) { if (!props || Object.keys(props).length === 0) return const spanContext = tracer.extract('text_map', props) if (spanContext) { - span.addLink(spanContext) + span.addLink({ context: spanContext }) } } diff --git a/packages/datadog-plugin-azure-functions/test/fixtures/local.settings.json b/packages/datadog-plugin-azure-functions/test/fixtures/local.settings.json index b73d99f7c5..b8378e5a17 100644 --- a/packages/datadog-plugin-azure-functions/test/fixtures/local.settings.json +++ b/packages/datadog-plugin-azure-functions/test/fixtures/local.settings.json @@ -5,6 +5,7 @@ "AzureWebJobsFeatureFlags": "EnableWorkerIndexing", "AzureWebJobsStorage": "UseDevelopmentStorage=true", "MyServiceBus": "Endpoint=sb://127.0.0.1;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;", - "MyEventHub": "Endpoint=sb://127.0.0.1:5673;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;" + "MyEventHub": "Endpoint=sb://127.0.0.1:5673;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;", + "MyCosmosDB": "AccountEndpoint=http://127.0.0.1:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==;" } } diff --git a/packages/datadog-plugin-azure-functions/test/integration-test/cosmosdb-test/cosmosdb-helpers.mjs b/packages/datadog-plugin-azure-functions/test/integration-test/cosmosdb-test/cosmosdb-helpers.mjs new file mode 100644 index 0000000000..f41a2efb41 --- /dev/null +++ b/packages/datadog-plugin-azure-functions/test/integration-test/cosmosdb-test/cosmosdb-helpers.mjs @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { CosmosClient } from '@azure/cosmos' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) + +function getMyCosmosDbConnection () { + const settingsPath = join(__dirname, 'local.settings.json') + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) + return settings.Values.MyCosmosDB +} + +export async function setup () { + const client = new CosmosClient(getMyCosmosDbConnection()) + await client.databases.createIfNotExists({ id: 'testDatabase' }) + await client.database('testDatabase').containers.createIfNotExists({ + id: 'testContainer', + partitionKey: { paths: ['/productName'], kind: 'Hash' }, + }) + return client +} + +export async function teardown (client) { + await client.database('testDatabase').delete() +} diff --git a/packages/datadog-plugin-azure-functions/test/integration-test/cosmosdb-test/cosmosdb.spec.js b/packages/datadog-plugin-azure-functions/test/integration-test/cosmosdb-test/cosmosdb.spec.js new file mode 100644 index 0000000000..689ba4959a --- /dev/null +++ b/packages/datadog-plugin-azure-functions/test/integration-test/cosmosdb-test/cosmosdb.spec.js @@ -0,0 +1,140 @@ +'use strict' + +const assert = require('node:assert/strict') +const path = require('node:path') +const { pathToFileURL } = require('node:url') + +const { spawn } = require('child_process') +const { + FakeAgent, + hookFile, + sandboxCwd, + useSandbox, + curl, + assertObjectContains, + stopProc, +} = require('../../../../../integration-tests/helpers') +const { withVersions } = require('../../../../dd-trace/test/setup/mocha') + +describe('esm', () => { + withVersions('azure-functions', '@azure/functions', version => { + let agent + let proc + let setup + let teardown + let cosmosClient + + useSandbox([ + `@azure/functions@${version}`, + 'azure-functions-core-tools@4', + '@azure/cosmos@4.9.2', + ], + false, + ['./packages/datadog-plugin-azure-functions/test/fixtures/*', + './packages/datadog-plugin-azure-functions/test/integration-test/cosmosdb-test/*']) + + before(async function () { + this.timeout(60000) + const helpers = await import(pathToFileURL(path.join(sandboxCwd(), 'cosmosdb-helpers.mjs')).href) + setup = helpers.setup + teardown = helpers.teardown + + agent = await new FakeAgent().start() + cosmosClient = await setup() + + const envArgs = { + PATH: `${sandboxCwd()}/node_modules/azure-functions-core-tools/bin:${process.env.PATH}`, + } + proc = await spawnPluginIntegrationTestProc(sandboxCwd(), 'func', ['start'], agent.port, undefined, envArgs) + }) + + after(async () => { + await stopProc(proc, { signal: 'SIGINT' }) + await teardown(cosmosClient) + await agent.stop() + }) + + it('propagates cosmosdb writes to azure function trigger', async () => { + const isHttpInvokeGroup = group => + group.some(s => s?.name === 'azure.functions.invoke' && s.resource === 'GET /api/writeToCosmos') + const isTriggerGroup = group => + group.some(s => s?.name === 'azure.functions.invoke' && s.resource === 'CosmosDB cosmosDBTrigger1') + + const groups = await agent.collectGroups({ + trigger: () => curl('http://127.0.0.1:7071/api/writeToCosmos'), + predicate: group => isHttpInvokeGroup(group) || isTriggerGroup(group), + expectedCount: 2, + timeout: 120000, + }) + + const httpGroup = groups.find(isHttpInvokeGroup) + const triggerGroup = groups.find(isTriggerGroup) + + assert.ok(httpGroup, 'expected writeToCosmos HTTP invoke span') + assert.ok(triggerGroup, 'expected CosmosDB trigger invoke span') + + const cosmosQueryCount = httpGroup.filter(s => s?.name === 'cosmosdb.query').length + assert.ok(cosmosQueryCount >= 2, `expected cosmosdb.query spans; found ${cosmosQueryCount}`) + + const triggerSpan = triggerGroup.find( + s => s?.name === 'azure.functions.invoke' && s.resource === 'CosmosDB cosmosDBTrigger1' + ) + assertObjectContains(triggerSpan, { + name: 'azure.functions.invoke', + resource: 'CosmosDB cosmosDBTrigger1', + type: 'serverless', + meta: { + 'aas.function.trigger': 'CosmosDB', + 'aas.function.name': 'cosmosDBTrigger1', + }, + }) + }).timeout(120000) + }) +}) + +async function spawnPluginIntegrationTestProc (cwd, command, args, agentPort, stdioHandler, additionalEnvArgs = {}) { + let env = { + NODE_OPTIONS: `--loader=${hookFile} --experimental-global-webcrypto`, + DD_TRACE_AGENT_PORT: agentPort, + DD_TRACE_DISABLED_PLUGINS: 'http,dns,net', + } + env = { ...env, ...additionalEnvArgs } + return spawnProc(command, args, { + cwd, + env, + }, stdioHandler) +} + +function spawnProc (command, args, options = {}, stdioHandler, stderrHandler) { + const proc = spawn(command, args, { ...options, stdio: 'pipe' }) + return new Promise((resolve, reject) => { + proc + .on('error', reject) + .on('exit', code => { + if (code !== 0) { + reject(new Error(`Process exited with status code ${code}.`)) + } + resolve() + }) + + proc.stdout.on('data', data => { + if (stdioHandler) { + stdioHandler(data) + } + // eslint-disable-next-line no-console + if (!options.silent) console.log(data.toString()) + + if (data.toString().includes('Host lock lease acquired by instance')) { + resolve(proc) + } + }) + + proc.stderr.on('data', data => { + if (stderrHandler) { + stderrHandler(data) + } + // eslint-disable-next-line no-console + if (!options.silent) console.error(data.toString()) + }) + }) +} diff --git a/packages/datadog-plugin-azure-functions/test/integration-test/cosmosdb-test/server.mjs b/packages/datadog-plugin-azure-functions/test/integration-test/cosmosdb-test/server.mjs new file mode 100644 index 0000000000..ccbd98d9f4 --- /dev/null +++ b/packages/datadog-plugin-azure-functions/test/integration-test/cosmosdb-test/server.mjs @@ -0,0 +1,34 @@ +import 'dd-trace/init.js' +import { app } from '@azure/functions' +import { CosmosClient } from '@azure/cosmos' + +const client = new CosmosClient(process.env.MyCosmosDB) +const database = client.database('testDatabase') +const container = database.container('testContainer') + +app.http('writeToCosmos', { + methods: ['GET', 'POST'], + authLevel: 'function', + route: 'writeToCosmos', + handler: async (request, context) => { + await container.items.upsert({ + id: 'item1', + productName: 'Test Product', + productModel: 'Model 1', + }) + + return { status: 200, body: 'Success: ' } + }, +}) + +app.cosmosDB('cosmosDBTrigger1', { + connection: 'MyCosmosDB', + databaseName: 'testDatabase', + containerName: 'testContainer', + createLeaseContainerIfNotExists: true, + handler: (documents, context) => { + return { + status: 200, + } + }, +}) diff --git a/packages/datadog-plugin-azure-functions/test/integration-test/eventhubs-test/eventhubs.spec.js b/packages/datadog-plugin-azure-functions/test/integration-test/eventhubs-test/eventhubs.spec.js index 380de97a50..7f045ce911 100644 --- a/packages/datadog-plugin-azure-functions/test/integration-test/eventhubs-test/eventhubs.spec.js +++ b/packages/datadog-plugin-azure-functions/test/integration-test/eventhubs-test/eventhubs.spec.js @@ -342,7 +342,7 @@ describe('esm', () => { trigger: () => curl('http://127.0.0.1:7071/api/eh2-eventdata'), predicate: hasSpanLinks, }) - assert.ok(groups.length >= 1) + assert.ok(groups.length >= 1, `Expected ${groups.length} >= 1`) }).timeout(60000) }) }) diff --git a/packages/datadog-plugin-azure-functions/test/integration-test/servicebus-test/servicebus.spec.js b/packages/datadog-plugin-azure-functions/test/integration-test/servicebus-test/servicebus.spec.js index dd18fee14f..98cd58b79e 100644 --- a/packages/datadog-plugin-azure-functions/test/integration-test/servicebus-test/servicebus.spec.js +++ b/packages/datadog-plugin-azure-functions/test/integration-test/servicebus-test/servicebus.spec.js @@ -263,7 +263,7 @@ describe('esm', () => { }) const sbGroups = groups.filter(azureInvokeGroup('ServiceBus queueTest2')) const createGroups = groups.filter(azureCreateGroup) - assert.ok(sbGroups.length >= 1) + assert.ok(sbGroups.length >= 1, `Expected ${sbGroups.length} >= 1`) assert.strictEqual(createGroups.length, 0) assert.ok(!('_dd.span_links' in sbGroups[0][0].meta)) }).timeout(60000) @@ -275,7 +275,7 @@ describe('esm', () => { }) const sbGroups = groups.filter(azureInvokeGroup('ServiceBus queueTest2')) const createGroups = groups.filter(azureCreateGroup) - assert.ok(sbGroups.length >= 1) + assert.ok(sbGroups.length >= 1, `Expected ${sbGroups.length} >= 1`) assert.strictEqual(createGroups.length, 0) assert.ok(!('_dd.span_links' in sbGroups[0][0].meta)) }).timeout(60000) diff --git a/packages/datadog-plugin-azure-service-bus/src/producer.js b/packages/datadog-plugin-azure-service-bus/src/producer.js index c3d78d83b2..83f8a1a3b0 100644 --- a/packages/datadog-plugin-azure-service-bus/src/producer.js +++ b/packages/datadog-plugin-azure-service-bus/src/producer.js @@ -56,7 +56,7 @@ class AzureServiceBusProducerPlugin extends ProducerPlugin { const contexts = spanContexts.get(messages) if (contexts) { for (const spanContext of contexts) { - span.addLink(spanContext) + span.addLink({ context: spanContext }) } } } diff --git a/packages/datadog-plugin-azure-service-bus/test/integration-test/core-test/client.spec.js b/packages/datadog-plugin-azure-service-bus/test/integration-test/core-test/client.spec.js index 942bd7d37b..403edfd1d5 100644 --- a/packages/datadog-plugin-azure-service-bus/test/integration-test/core-test/client.spec.js +++ b/packages/datadog-plugin-azure-service-bus/test/integration-test/core-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -42,7 +43,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) }) proc = await spawnPluginIntegrationTestProcAndExpectExit(sandboxCwd(), variants[variant], agent.port, spawnEnv) diff --git a/packages/datadog-plugin-azure-service-bus/test/integration-test/tryAddMessageRegressionTest/client.spec.js b/packages/datadog-plugin-azure-service-bus/test/integration-test/tryAddMessageRegressionTest/client.spec.js index d307c7a67c..359f23e099 100644 --- a/packages/datadog-plugin-azure-service-bus/test/integration-test/tryAddMessageRegressionTest/client.spec.js +++ b/packages/datadog-plugin-azure-service-bus/test/integration-test/tryAddMessageRegressionTest/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('assert') +const { inspect } = require('node:util') const { FakeAgent, sandboxCwd, @@ -32,7 +33,7 @@ describe('esm', () => { it('tryAddMessage returns a boolean, not a Promise', async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 3) // Verify we got the expected spans from the test assert.strictEqual(payload[0][0].name, 'azure.servicebus.create') diff --git a/packages/datadog-plugin-bullmq/test/dsm.spec.js b/packages/datadog-plugin-bullmq/test/dsm.spec.js index 15b1b809c4..2c866cd6a2 100644 --- a/packages/datadog-plugin-bullmq/test/dsm.spec.js +++ b/packages/datadog-plugin-bullmq/test/dsm.spec.js @@ -4,6 +4,7 @@ process.env.DD_DATA_STREAMS_ENABLED = 'true' const assert = require('node:assert') +const { inspect } = require('node:util') const sinon = require('sinon') const { createIntegrationTestSuite } = require('../../dd-trace/test/setup/helpers/plugin-test-helpers') const DataStreamsContext = require('../../dd-trace/src/datastreams/context') @@ -169,7 +170,10 @@ createIntegrationTestSuite('bullmq', 'bullmq', { it('should set a message payload size when producing a message', async () => { await testSetup.queueAdd() assert.strictEqual(recordCheckpointSpy.called, true) - assert.ok(Object.hasOwn(recordCheckpointSpy.args[0][0], 'payloadSize')) + assert.ok( + Object.hasOwn(recordCheckpointSpy.args[0][0], 'payloadSize'), + `Available keys: ${inspect(Object.keys(recordCheckpointSpy.args[0][0]))}` + ) }) }) }) diff --git a/packages/datadog-plugin-bullmq/test/integration-test/client.spec.js b/packages/datadog-plugin-bullmq/test/integration-test/client.spec.js index ea04f63dc6..8b267dc0d4 100644 --- a/packages/datadog-plugin-bullmq/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-bullmq/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -40,7 +41,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'bullmq.add'), true) }) @@ -60,7 +61,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'bullmq.addBulk'), true) }) @@ -80,7 +81,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'bullmq.add'), true) }) @@ -104,7 +105,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'bullmq.processJob'), true) }) diff --git a/packages/datadog-plugin-bunyan/src/index.js b/packages/datadog-plugin-bunyan/src/index.js index 8cbdb9cb54..f7b0520e9d 100644 --- a/packages/datadog-plugin-bunyan/src/index.js +++ b/packages/datadog-plugin-bunyan/src/index.js @@ -1,8 +1,36 @@ 'use strict' +const { buildLogHolder } = require('../../dd-trace/src/plugins/log_injection') const LogPlugin = require('../../dd-trace/src/plugins/log_plugin') class BunyanPlugin extends LogPlugin { static id = 'bunyan' + + constructor (...args) { + super(...args) + this.addSub('apm:bunyan:log', (arg) => this.handleLog(arg)) + } + + /** + * Inject `dd` directly on the record bunyan hands us. bunyan builds the + * record inside `mkRecord` via `objCopy(log.fields)` and then copies the + * caller's fields onto the result, so the `rec` object that flows + * through `_emit` is always bunyan-owned, has `Object.prototype` for its + * prototype, and is never the caller's input directly. Mutating it adds + * `dd` for every consumer (JSON streams via `JSON.stringify(rec)`, raw + * streams via the record reference) without paying for a Proxy view. + * + * @param {{ message: object }} arg + */ + handleLog (arg) { + const rec = arg.message + if (rec === null || typeof rec !== 'object' || Object.hasOwn(rec, 'dd')) return + + const logHolder = buildLogHolder(this.tracer) + if (!logHolder) return + + rec.dd = logHolder.dd + } } + module.exports = BunyanPlugin diff --git a/packages/datadog-plugin-bunyan/test/index.spec.js b/packages/datadog-plugin-bunyan/test/index.spec.js index 19ff5828e3..cffc25d4d7 100644 --- a/packages/datadog-plugin-bunyan/test/index.spec.js +++ b/packages/datadog-plugin-bunyan/test/index.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { Writable } = require('node:stream') +const { inspect } = require('node:util') const { afterEach, beforeEach, describe, it } = require('mocha') const sinon = require('sinon') @@ -56,7 +57,7 @@ describe('Plugin', () => { const record = JSON.parse(stream.write.firstCall.args[0].toString()) - assert.ok(Object.hasOwn(record, 'dd')) + assert.ok(Object.hasOwn(record, 'dd'), `Available keys: ${inspect(Object.keys(record))}`) }) }) }) @@ -103,7 +104,7 @@ describe('Plugin', () => { const record = JSON.parse(stream.write.firstCall.args[0].toString()) - assert.ok(Object.hasOwn(record, 'dd')) + assert.ok(Object.hasOwn(record, 'dd'), `Available keys: ${inspect(Object.keys(record))}`) assert.ok(!('trace_id' in record.dd)) assert.ok(!('span_id' in record.dd)) }) diff --git a/packages/datadog-plugin-bunyan/test/integration-test/client.spec.js b/packages/datadog-plugin-bunyan/test/integration-test/client.spec.js index b1441c0dd7..499797d3a5 100644 --- a/packages/datadog-plugin-bunyan/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-bunyan/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, spawnPluginIntegrationTestProcAndExpectExit, @@ -42,7 +43,7 @@ describe('esm', () => { undefined, (data) => { const jsonObject = JSON.parse(data.toString()) - assert.ok(Object.hasOwn(jsonObject, 'dd')) + assert.ok(Object.hasOwn(jsonObject, 'dd'), `Available keys: ${inspect(Object.keys(jsonObject))}`) } ) }).timeout(20000) diff --git a/packages/datadog-plugin-bunyan/test/unit.spec.js b/packages/datadog-plugin-bunyan/test/unit.spec.js new file mode 100644 index 0000000000..07cfbc8910 --- /dev/null +++ b/packages/datadog-plugin-bunyan/test/unit.spec.js @@ -0,0 +1,85 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it } = require('mocha') +const { channel } = require('dc-polyfill') +const { storage } = require('../../datadog-core') + +require('../../dd-trace/test/setup/core') +const BunyanPlugin = require('../src') +const Tracer = require('../../dd-trace/src/tracer') +const getConfig = require('../../dd-trace/src/config') + +const logCh = channel('apm:bunyan:log') + +const tracer = new Tracer(getConfig({ + enabled: true, + logInjection: true, + env: 'my-env', + service: 'my-service', + version: '1.2.3', +})) + +const plugin = new BunyanPlugin({ + _tracer: tracer, +}) +plugin.configure({ + logInjection: true, + enabled: true, +}) + +describe('BunyanPlugin', () => { + it('injects dd onto the record bunyan passes through _emit', () => { + const record = { foo: 'bar', msg: 'hello' } + logCh.publish({ message: record }) + assert.strictEqual(record.dd.service, 'my-service') + assert.strictEqual(record.dd.version, '1.2.3') + assert.strictEqual(record.dd.env, 'my-env') + }) + + it('preserves a caller-provided dd field', () => { + const record = { foo: 'bar', dd: { custom: true } } + logCh.publish({ message: record }) + assert.deepStrictEqual(record.dd, { custom: true }) + }) + + it('adds trace_id and span_id when a span is active', () => { + const span = tracer.startSpan('test') + + storage('legacy').run({ span }, () => { + const record = { foo: 'bar' } + logCh.publish({ message: record }) + assert.strictEqual(record.dd.trace_id, span.context().toTraceId(true)) + assert.strictEqual(record.dd.span_id, span.context().toSpanId()) + }) + }) + + it('does not mutate a caller-set dd even when a span is active', () => { + const span = tracer.startSpan('test') + + storage('legacy').run({ span }, () => { + const record = { foo: 'bar', dd: { custom: true } } + logCh.publish({ message: record }) + assert.deepStrictEqual(record.dd, { custom: true }) + }) + }) + + it('does not run on non-object messages', () => { + const payload = { message: 'just a string' } + logCh.publish(payload) + assert.strictEqual(payload.message, 'just a string') + }) + + it('leaves the record untouched when the propagator emits no dd', () => { + const originalInject = tracer.inject + tracer.inject = () => {} + try { + const record = { foo: 'bar' } + logCh.publish({ message: record }) + assert.strictEqual(record.dd, undefined) + } finally { + tracer.inject = originalInject + } + }) +}) diff --git a/packages/datadog-plugin-cassandra-driver/test/integration-test/client.spec.js b/packages/datadog-plugin-cassandra-driver/test/integration-test/client.spec.js index 83d7e4f47e..430b8ff3b7 100644 --- a/packages/datadog-plugin-cassandra-driver/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-cassandra-driver/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -39,7 +40,7 @@ describe('esm', () => { it(`is instrumented loaded with ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'cassandra.query'), true) }) diff --git a/packages/datadog-plugin-child_process/test/index.spec.js b/packages/datadog-plugin-child_process/test/index.spec.js index 3a334ebb8b..e54aab58c0 100644 --- a/packages/datadog-plugin-child_process/test/index.spec.js +++ b/packages/datadog-plugin-child_process/test/index.spec.js @@ -37,7 +37,7 @@ describe('Child process plugin', () => { } tracerStub = { - startSpan: sinon.stub(), + startSpan: sinon.stub().returns(spanStub), } configStub = { diff --git a/packages/datadog-plugin-confluentinc-kafka-javascript/test/dsm.spec.js b/packages/datadog-plugin-confluentinc-kafka-javascript/test/dsm.spec.js index f6cc0e5bef..e4265b2b6c 100644 --- a/packages/datadog-plugin-confluentinc-kafka-javascript/test/dsm.spec.js +++ b/packages/datadog-plugin-confluentinc-kafka-javascript/test/dsm.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { randomUUID } = require('node:crypto') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') @@ -12,6 +13,7 @@ const DataStreamsContext = require('../../dd-trace/src/datastreams/context') const { computePathwayHash } = require('../../dd-trace/src/datastreams/pathway') const { ENTRY_PARENT_HASH, DataStreamsProcessor } = require('../../dd-trace/src/datastreams/processor') const propagationHash = require('../../dd-trace/src/propagation-hash') +const { waitForTopicReady } = require('./helpers') const getDsmPathwayHash = (testTopic, isProducer, parentHash) => { let edgeTags @@ -81,6 +83,7 @@ describe('Plugin', () => { replicationFactor: 1, }], }) + await waitForTopicReady(admin, testTopic) await admin.disconnect() consumer = kafka.consumer({ @@ -158,7 +161,10 @@ describe('Plugin', () => { } const recordCheckpointSpy = sinon.spy(DataStreamsProcessor.prototype, 'recordCheckpoint') await sendMessages(kafka, testTopic, messages) - assert.ok(recordCheckpointSpy.args[0][0].hasOwnProperty('payloadSize')) + assert.ok( + recordCheckpointSpy.args[0][0].hasOwnProperty('payloadSize'), + `Available keys: ${inspect(Object.keys(recordCheckpointSpy.args[0][0]))}` + ) recordCheckpointSpy.restore() }) @@ -171,7 +177,10 @@ describe('Plugin', () => { let consumerReceiveMessagePromise await consumer.run({ eachMessage: async () => { - assert.ok(recordCheckpointSpy.args[0][0].hasOwnProperty('payloadSize')) + assert.ok( + recordCheckpointSpy.args[0][0].hasOwnProperty('payloadSize'), + `Available keys: ${inspect(Object.keys(recordCheckpointSpy.args[0][0]))}` + ) recordCheckpointSpy.restore() consumerReceiveMessagePromise = Promise.resolve() }, diff --git a/packages/datadog-plugin-confluentinc-kafka-javascript/test/helpers.js b/packages/datadog-plugin-confluentinc-kafka-javascript/test/helpers.js new file mode 100644 index 0000000000..52f66b4f77 --- /dev/null +++ b/packages/datadog-plugin-confluentinc-kafka-javascript/test/helpers.js @@ -0,0 +1,37 @@ +'use strict' + +async function waitForTopicReady (admin, topic, timeoutMs = 20000) { + if (typeof admin?.fetchTopicMetadata !== 'function') return + + const start = Date.now() + while ((Date.now() - start) < timeoutMs) { + try { + const meta = await admin.fetchTopicMetadata({ topics: [topic], timeout: 1000 }) + const topicMeta = Array.isArray(meta) ? meta[0] : meta?.topics?.[0] + + const partitions = topicMeta?.partitions + if (Array.isArray(partitions) && + partitions.length > 0 && + partitions.every(p => typeof p.leader === 'number' && p.leader >= 0)) { + return + } + } catch (err) { + // Rethrow unexpected errors immediately so they surface rather than masking as a timeout. + const transient = new Set([ + 'ERR_UNKNOWN_TOPIC_OR_PART', + 'ERR_LEADER_NOT_AVAILABLE', + 'ERR__TIMED_OUT', + 'ERR__TIMED_OUT_QUEUE', + 'ERR__TRANSPORT', + 'ERR__ALL_BROKERS_DOWN', + ]) + if (!transient.has(err?.type)) throw err + } + + await new Promise(resolve => setTimeout(resolve, 50)) + } + + throw new Error(`Timeout: Topic "${topic}" metadata was not ready within ${timeoutMs}ms`) +} + +module.exports = { waitForTopicReady } diff --git a/packages/datadog-plugin-confluentinc-kafka-javascript/test/index.spec.js b/packages/datadog-plugin-confluentinc-kafka-javascript/test/index.spec.js index 157fd9336c..e0619db552 100644 --- a/packages/datadog-plugin-confluentinc-kafka-javascript/test/index.spec.js +++ b/packages/datadog-plugin-confluentinc-kafka-javascript/test/index.spec.js @@ -11,6 +11,7 @@ const { ERROR_MESSAGE, ERROR_TYPE, ERROR_STACK } = require('../../dd-trace/src/c const { withVersions } = require('../../dd-trace/test/setup/mocha') const { assertObjectContains } = require('../../../integration-tests/helpers') const { expectedSchema } = require('./naming') +const { waitForTopicReady } = require('./helpers') describe('Plugin', () => { const module = '@confluentinc/kafka-javascript' @@ -206,7 +207,8 @@ describe('Plugin', () => { resource: testTopic, }) - assert.ok(parseInt(span.parent_id.toString()) > 0) + const parentId = parseInt(span.parent_id.toString()) + assert.ok(parentId > 0, `Expected ${parentId} > 0`) }, { timeoutMs: 10000 }) let consumerReceiveMessagePromise @@ -532,7 +534,8 @@ describe('Plugin', () => { resource: testTopic, }) - assert.ok(parseInt(span.parent_id.toString()) > 0) + const parentId = parseInt(span.parent_id.toString()) + assert.ok(parentId > 0, `Expected ${parentId} > 0`) }, { timeoutMs: 10000 }) nativeConsumer.setDefaultConsumeTimeout(10) nativeConsumer.subscribe([testTopic]) @@ -570,28 +573,3 @@ async function sendMessages (kafka, topic, messages) { }) await producer.disconnect() } - -async function waitForTopicReady (admin, topic, timeoutMs = 20000) { - if (typeof admin?.fetchTopicMetadata !== 'function') return - - const start = Date.now() - while ((Date.now() - start) < timeoutMs) { - try { - const meta = await admin.fetchTopicMetadata({ topics: [topic], timeout: 1000 }) - const topicMeta = Array.isArray(meta) ? meta[0] : meta?.topics?.[0] - - const partitions = topicMeta?.partitions - if (Array.isArray(partitions) && - partitions.length > 0 && - partitions.every(p => typeof p.leader === 'number' && p.leader >= 0)) { - return - } - } catch { - // Topic creation is async; metadata/leader errors can be transient. - } - - await new Promise(resolve => setTimeout(resolve, 50)) - } - - throw new Error(`Timeout: Topic "${topic}" metadata was not ready within ${timeoutMs}ms`) -} diff --git a/packages/datadog-plugin-confluentinc-kafka-javascript/test/integration-test/client.spec.js b/packages/datadog-plugin-confluentinc-kafka-javascript/test/integration-test/client.spec.js index 41706381dd..157e9a4973 100644 --- a/packages/datadog-plugin-confluentinc-kafka-javascript/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-confluentinc-kafka-javascript/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -19,7 +20,8 @@ describe('esm', () => { withVersions('confluentinc-kafka-javascript', '@confluentinc/kafka-javascript', version => { useSandbox([`'@confluentinc/kafka-javascript@${version}'`], false, [ - './packages/datadog-plugin-confluentinc-kafka-javascript/test/integration-test/*']) + './packages/datadog-plugin-confluentinc-kafka-javascript/test/integration-test/*', + './packages/datadog-plugin-confluentinc-kafka-javascript/test/helpers.js']) beforeEach(async () => { agent = await new FakeAgent().start() @@ -38,7 +40,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'kafka.produce'), true) }) diff --git a/packages/datadog-plugin-confluentinc-kafka-javascript/test/integration-test/server.mjs b/packages/datadog-plugin-confluentinc-kafka-javascript/test/integration-test/server.mjs index 8701788145..c6b4d9a153 100644 --- a/packages/datadog-plugin-confluentinc-kafka-javascript/test/integration-test/server.mjs +++ b/packages/datadog-plugin-confluentinc-kafka-javascript/test/integration-test/server.mjs @@ -1,5 +1,8 @@ import 'dd-trace/init.js' import kafkaLib from '@confluentinc/kafka-javascript' +import helpersModule from './helpers.js' + +const { waitForTopicReady } = helpersModule const { Kafka } = kafkaLib.KafkaJS const kafka = new Kafka({ @@ -9,18 +12,19 @@ const kafka = new Kafka({ }, }) -const sendMessage = async (topic, messages) => { - try { - const producer = kafka.producer() - await producer.connect() - await producer.send({ - topic, - messages, - }) - await producer.disconnect() - } catch (error) { - // pass - } +const admin = kafka.admin() +await admin.connect() +try { + await admin.createTopics({ + topics: [{ topic: 'test-topic', numPartitions: 1, replicationFactor: 1 }], + }) +} catch (err) { + if (err.type !== 'TOPIC_ALREADY_EXISTS') throw err } +await waitForTopicReady(admin, 'test-topic') +await admin.disconnect() -await sendMessage('test-topic', [{ key: 'key1', value: 'test2' }]) +const producer = kafka.producer() +await producer.connect() +await producer.send({ topic: 'test-topic', messages: [{ key: 'key1', value: 'test2' }] }) +await producer.disconnect() diff --git a/packages/datadog-plugin-connect/test/integration-test/client.spec.js b/packages/datadog-plugin-connect/test/integration-test/client.spec.js index 32385a67ff..2072f57dba 100644 --- a/packages/datadog-plugin-connect/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-connect/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -41,7 +42,7 @@ describe('esm', () => { return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'connect.request'), true) }) }).timeout(20000) diff --git a/packages/datadog-plugin-couchbase/test/index.spec.js b/packages/datadog-plugin-couchbase/test/index.spec.js index 16fc9cb05e..cd9c003843 100644 --- a/packages/datadog-plugin-couchbase/test/index.spec.js +++ b/packages/datadog-plugin-couchbase/test/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { after, afterEach, beforeEach, describe, it } = require('mocha') const proxyquire = require('proxyquire').noPreserveCache() @@ -110,12 +111,16 @@ describe('Plugin', () => { it('should skip instrumentation for invalid arguments', (done) => { const checkError = (e) => { - assert.ok([ + const expectedMessages = [ // depending on version of node 'Cannot read property \'toString\' of undefined', 'Cannot read properties of undefined (reading \'toString\')', 'parsing failure', // sdk 4 - ].includes(e.message)) + ] + assert.ok( + expectedMessages.includes(e.message), + `Expected error message in ${inspect(expectedMessages)}, got ${inspect(e.message)}` + ) done() } try { diff --git a/packages/datadog-plugin-couchbase/test/integration-test/client.spec.js b/packages/datadog-plugin-couchbase/test/integration-test/client.spec.js index 515674a734..1f4beda7c3 100644 --- a/packages/datadog-plugin-couchbase/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-couchbase/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -40,7 +41,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'couchbase.upsert'), true) }) diff --git a/packages/datadog-plugin-cucumber/src/index.js b/packages/datadog-plugin-cucumber/src/index.js index c73d2fea2c..fce4afb6e8 100644 --- a/packages/datadog-plugin-cucumber/src/index.js +++ b/packages/datadog-plugin-cucumber/src/index.js @@ -11,6 +11,7 @@ const { getEnvironmentVariable } = require('../../dd-trace/src/config/helper') const { addIntelligentTestRunnerSpanTags, finishAllTraceSpans, + getRelativeCoverageFiles, getTestEndLine, getTestSuiteCommonTags, getTestSuitePath, @@ -71,6 +72,7 @@ class CucumberPlugin extends CiPlugin { isSuitesSkipped, numSkippedSuites, testCodeCoverageLinesTotal, + testSessionCoverageFiles, hasUnskippableSuites, hasForcedToRunSuites, isEarlyFlakeDetectionEnabled, @@ -78,7 +80,11 @@ class CucumberPlugin extends CiPlugin { isTestManagementTestsEnabled, isParallel, }) => { - const { isSuitesSkippingEnabled, isCodeCoverageEnabled } = this.libraryConfig || {} + const { + isSuitesSkippingEnabled, + isCodeCoverageEnabled, + isCoverageReportUploadEnabled, + } = this.libraryConfig || {} addIntelligentTestRunnerSpanTags( this.testSessionSpan, this.testModuleSpan, @@ -93,6 +99,12 @@ class CucumberPlugin extends CiPlugin { hasForcedToRunSuites, } ) + if (testSessionCoverageFiles?.length && isCoverageReportUploadEnabled) { + this.tracer._exporter.exportCoverage({ + sessionId: this.testSessionSpan.context()._traceId, + files: testSessionCoverageFiles, + }) + } if (isEarlyFlakeDetectionEnabled) { this.testSessionSpan.setTag(TEST_EARLY_FLAKE_ENABLED, 'true') } @@ -197,8 +209,10 @@ class CucumberPlugin extends CiPlugin { } const testSuiteSpan = this._testSuiteSpansByTestSuite.get(testSuitePath) - const relativeCoverageFiles = [...coverageFiles, suiteFile] - .map(filename => getTestSuitePath(filename, this.repositoryRoot)) + const relativeCoverageFiles = [ + ...getRelativeCoverageFiles(coverageFiles, this.repositoryRoot), + getTestSuitePath(suiteFile, this.repositoryRoot), + ] this.telemetry.distribution(TELEMETRY_CODE_COVERAGE_NUM_FILES, {}, relativeCoverageFiles.length) diff --git a/packages/datadog-plugin-cypress/src/cypress-plugin.js b/packages/datadog-plugin-cypress/src/cypress-plugin.js index 80dc5d5de3..8cb6a78a50 100644 --- a/packages/datadog-plugin-cypress/src/cypress-plugin.js +++ b/packages/datadog-plugin-cypress/src/cypress-plugin.js @@ -4,6 +4,7 @@ const { performance } = require('perf_hooks') const dateNow = Date.now +const { createCoverageMap } = require('../../../vendor/dist/istanbul-lib-coverage') const satisfies = require('../../../vendor/dist/semifies') const { TEST_STATUS, @@ -25,7 +26,12 @@ const { TEST_MODULE, TEST_SOURCE_START, finishAllTraceSpans, - getCoveredFilenamesFromCoverage, + getCoveredFilesFromCoverage, + getExecutableFilesFromCoverage, + getRelativeCoverageFiles, + getTestCoverageLinesPercentage, + applySkippedCoverageToCoverage, + mergeCoverage, getTestSuitePath, addIntelligentTestRunnerSpanTags, TEST_SKIPPED_BY_ITR, @@ -40,7 +46,6 @@ const { TEST_EARLY_FLAKE_ABORT_REASON, getTestSessionName, TEST_SESSION_NAME, - TEST_LEVEL_EVENT_TYPES, TEST_RETRY_REASON, DD_TEST_IS_USER_PROVIDED_SERVICE, TEST_MANAGEMENT_IS_QUARANTINED, @@ -74,6 +79,7 @@ const { } = require('../../dd-trace/src/plugins/util/test') const { isMarkedAsUnskippable } = require('../../datadog-plugin-jest/src/util') const { ORIGIN_KEY, COMPONENT } = require('../../dd-trace/src/constants') +const { RESOURCE_NAME } = require('../../../ext/tags') const getConfig = require('../../dd-trace/src/config') const { appClosing: appClosingTelemetry } = require('../../dd-trace/src/telemetry') const log = require('../../dd-trace/src/log') @@ -201,13 +207,17 @@ function getSkippableTests (tracer, testConfiguration) { if (!tracer._tracer._exporter?.getSkippableSuites) { return resolve({ err: new Error('Test Optimization was not initialized correctly') }) } - tracer._tracer._exporter.getSkippableSuites(testConfiguration, (err, skippableTests, correlationId) => { - resolve({ - err, - skippableTests, - correlationId, - }) - }) + tracer._tracer._exporter.getSkippableSuites( + testConfiguration, + (err, skippableTests, correlationId, skippableTestsCoverage) => { + resolve({ + err, + skippableTests, + correlationId, + skippableTestsCoverage, + }) + } + ) }) } @@ -361,9 +371,11 @@ class CypressPlugin { finishedTestsByFile = {} testStatuses = {} hasLibraryConfiguration = false + isItrEnabled = false isTestsSkipped = false isSuitesSkippingEnabled = false isCodeCoverageEnabled = false + isCoverageReportUploadEnabled = false isFlakyTestRetriesEnabled = false flakyTestRetriesCount = 0 isEarlyFlakeDetectionEnabled = false @@ -376,6 +388,8 @@ class CypressPlugin { earlyFlakeDetectionFaultyThreshold = 0 testsToSkip = [] skippedTests = [] + skippableTestsCoverage = {} + testSessionCoverageMap = createCoverageMap() hasForcedToRunSuites = false hasUnskippableSuites = false unskippableSuites = [] @@ -440,9 +454,11 @@ class CypressPlugin { this.finishedTestsByFile = {} this.testStatuses = {} this.hasLibraryConfiguration = false + this.isItrEnabled = false this.isTestsSkipped = false this.isSuitesSkippingEnabled = false this.isCodeCoverageEnabled = false + this.isCoverageReportUploadEnabled = false this.isFlakyTestRetriesEnabled = false this.flakyTestRetriesCount = 0 this.isEarlyFlakeDetectionEnabled = false @@ -455,6 +471,8 @@ class CypressPlugin { this.earlyFlakeDetectionFaultyThreshold = 0 this.testsToSkip = [] this.skippedTests = [] + this.skippableTestsCoverage = {} + this.testSessionCoverageMap = createCoverageMap() this.hasForcedToRunSuites = false this.hasUnskippableSuites = false this.unskippableSuites = [] @@ -494,6 +512,117 @@ class CypressPlugin { return this._timeOrigin + performance.now() - this._perfOrigin } + /** + * Returns the directory used to normalize coverage file names. + * + * @returns {string} + */ + getCoverageRootDir () { + return this.repositoryRoot || this.rootDir || process.cwd() + } + + /** + * Returns whether the backend supplied skipped-test coverage data. + * + * @returns {boolean} + */ + hasSkippableTestsCoverage () { + return !!(this.skippableTestsCoverage && + typeof this.skippableTestsCoverage === 'object' && + Object.keys(this.skippableTestsCoverage).length > 0) + } + + /** + * Returns whether skipped test coverage should be backfilled into the session coverage map. + * + * @returns {boolean} + */ + shouldBackfillSkippedCoverage () { + return this.isItrEnabled && + this.isCoverageReportUploadEnabled && + this.isTestsSkipped && + this.hasSkippableTestsCoverage() + } + + /** + * Adds a test's Istanbul coverage to the aggregated session coverage map. + * + * @param {object} coverage + * @returns {void} + */ + addTestSessionCoverage (coverage) { + mergeCoverage(coverage, this.testSessionCoverageMap) + } + + /** + * Applies backend skipped-test coverage to the aggregated session coverage map. + * + * @returns {boolean} + */ + applySkippedCoverageToTestSessionCoverage () { + if (!this.shouldBackfillSkippedCoverage()) { + return false + } + + return applySkippedCoverageToCoverage( + this.testSessionCoverageMap, + this.skippableTestsCoverage, + this.getCoverageRootDir() + ) + } + + /** + * Calculates the total session code coverage percentage when product rules allow reporting it. + * + * @param {boolean} hasBackfilledCoverage + * @returns {number | undefined} + */ + getTestCodeCoverageLinesTotal (hasBackfilledCoverage) { + if (!this.testSessionCoverageMap.files().length || (this.isTestsSkipped && !hasBackfilledCoverage)) { + return + } + + return getTestCoverageLinesPercentage(this.testSessionCoverageMap, undefined, this.getCoverageRootDir()) + } + + /** + * Returns repository-relative executable-line coverage files for the test session. + * + * @returns {Array<{ filename: string, bitmap: Buffer }>} + */ + getTestSessionCoverageFiles () { + return getRelativeCoverageFiles( + getExecutableFilesFromCoverage(this.testSessionCoverageMap), + this.getCoverageRootDir() + ) + } + + /** + * Uploads executable-line coverage for the test session when backend configuration enables it. + * + * @returns {void} + */ + reportTestSessionCoverage () { + const exporter = this.tracer._tracer._exporter + if ( + !this.testSessionSpan || + !this.isCoverageReportUploadEnabled || + !exporter?.exportCoverage + ) { + return + } + + const files = this.getTestSessionCoverageFiles() + if (!files.length) { + return + } + + exporter.exportCoverage({ + sessionId: this.testSessionSpan.context()._traceId, + files, + }) + } + // Init function returns a promise that resolves with the Cypress configuration // Depending on the received configuration, the Cypress configuration can be modified: // for example, to enable retries for failed tests. @@ -529,8 +658,10 @@ class CypressPlugin { this.hasLibraryConfiguration = true const { libraryConfig: { + isItrEnabled, isSuitesSkippingEnabled, isCodeCoverageEnabled, + isCoverageReportUploadEnabled, isEarlyFlakeDetectionEnabled, earlyFlakeDetectionNumRetries, earlyFlakeDetectionSlowTestRetries, @@ -543,8 +674,10 @@ class CypressPlugin { isImpactedTestsEnabled, }, } = libraryConfigurationResponse + this.isItrEnabled = isItrEnabled this.isSuitesSkippingEnabled = isSuitesSkippingEnabled this.isCodeCoverageEnabled = isCodeCoverageEnabled + this.isCoverageReportUploadEnabled = isCoverageReportUploadEnabled this.isEarlyFlakeDetectionEnabled = isEarlyFlakeDetectionEnabled this.earlyFlakeDetectionNumRetries = earlyFlakeDetectionNumRetries this.earlyFlakeDetectionSlowTestRetries = earlyFlakeDetectionSlowTestRetries ?? {} @@ -686,7 +819,6 @@ class CypressPlugin { getTestSpan ({ testName, testSuite, isUnskippable, isForcedToRun, testSourceFile, isDisabled, isQuarantined }) { const testSuiteTags = { - [TEST_COMMAND]: this.command, [TEST_MODULE]: TEST_FRAMEWORK_NAME, } if (this.testSuiteSpan) { @@ -798,7 +930,10 @@ class CypressPlugin { isSuitesSkippingEnabled: this.isSuitesSkippingEnabled, getKnownTests: () => getKnownTests(this.tracer, this.testConfiguration), getTestManagementTests: () => getTestManagementTests(this.tracer, this.testConfiguration), - getSkippableSuites: () => getSkippableTests(this.tracer, this.testConfiguration), + getSkippableSuites: () => getSkippableTests(this.tracer, { + ...this.testConfiguration, + isCoverageReportUploadEnabled: this.isCoverageReportUploadEnabled, + }), }) if (this.isKnownTestsEnabled) { @@ -835,13 +970,17 @@ class CypressPlugin { if (this.isSuitesSkippingEnabled) { const skippableTestsResponse = - skippableTestsRequestResponse || await getSkippableTests(this.tracer, this.testConfiguration) + skippableTestsRequestResponse || await getSkippableTests(this.tracer, { + ...this.testConfiguration, + isCoverageReportUploadEnabled: this.isCoverageReportUploadEnabled, + }) if (skippableTestsResponse.err) { log.error('Cypress skippable tests response error', skippableTestsResponse.err) this._pendingRequestErrorTags.push({ tag: DD_CI_LIBRARY_CONFIGURATION_ERROR_SKIPPABLE_TESTS, value: 'true' }) } else { - const { skippableTests, correlationId } = skippableTestsResponse + const { skippableTests, correlationId, skippableTestsCoverage } = skippableTestsResponse this.testsToSkip = skippableTests || [] + this.skippableTestsCoverage = skippableTestsCoverage || {} this.itrCorrelationId = correlationId incrementCountMetric(TELEMETRY_ITR_SKIPPED, { testLevel: 'test' }, this.testsToSkip.length) } @@ -904,15 +1043,9 @@ class CypressPlugin { ) if (this.tracer._tracer._exporter?.addMetadataTags) { - const metadataTags = {} - for (const testLevel of TEST_LEVEL_EVENT_TYPES) { - metadataTags[testLevel] = { - [TEST_SESSION_NAME]: testSessionName, - } - } + const metadataTags = { '*': { [TEST_COMMAND]: this.command, [TEST_SESSION_NAME]: testSessionName } } const libraryCapabilitiesTags = getLibraryCapabilitiesTags(this.constructor.id, this.frameworkVersion) metadataTags.test = { - ...metadataTags.test, ...libraryCapabilitiesTags, } @@ -969,6 +1102,9 @@ class CypressPlugin { } if (this.testSessionSpan && this.testModuleSpan) { const testStatus = getSessionStatus(suiteStats) + const hasBackfilledCoverage = this.applySkippedCoverageToTestSessionCoverage() + const testCodeCoverageLinesTotal = this.getTestCodeCoverageLinesTotal(hasBackfilledCoverage) + this.testModuleSpan.setTag(TEST_STATUS, testStatus) this.testSessionSpan.setTag(TEST_STATUS, testStatus) @@ -979,6 +1115,7 @@ class CypressPlugin { isSuitesSkipped: this.isTestsSkipped, isSuitesSkippingEnabled: this.isSuitesSkippingEnabled, isCodeCoverageEnabled: this.isCodeCoverageEnabled, + testCodeCoverageLinesTotal, skippingType: 'test', skippingCount: this.skippedTests.length, hasForcedToRunSuites: this.hasForcedToRunSuites, @@ -986,6 +1123,8 @@ class CypressPlugin { } ) + this.reportTestSessionCoverage() + if (this.isTestManagementTestsEnabled) { this.testSessionSpan.setTag(TEST_MANAGEMENT_ENABLED, 'true') } @@ -1145,7 +1284,7 @@ class CypressPlugin { } // Update test status - but NOT for non-ATF quarantined tests where we intentionally // report 'fail' to Datadog even though Cypress sees it as 'pass' - const isQuarantinedTest = finishedTest.testSpan?.context()?._tags?.[TEST_MANAGEMENT_IS_QUARANTINED] === 'true' + const isQuarantinedTest = finishedTest.testSpan?.context()?.getTag(TEST_MANAGEMENT_IS_QUARANTINED) === 'true' if (cypressTestStatus !== finishedTest.testStatus && (!isQuarantinedTest || finishedTest.isAttemptToFix)) { finishedTest.testSpan.setTag(TEST_STATUS, cypressTestStatus) finishedTest.testSpan.setTag('error', latestError) @@ -1172,7 +1311,7 @@ class CypressPlugin { } if (isLastAttempt) { - const testSpanTags = finishedTest.testSpan.context()._tags + const testSpanTags = finishedTest.testSpan.context().getTags() const retryKind = getFinalStatusRetryKind({ finishedTest, finishedTestAttempts, @@ -1280,7 +1419,7 @@ class CypressPlugin { return this.activeTestSpan ? { traceId: this.activeTestSpan.context().toTraceId() } : {} }, - 'dd:afterEach': ({ test, coverage }) => { + 'dd:afterEach': ({ test, coverage, commands }) => { if (!this.activeTestSpan) { log.warn('There is no active test span in dd:afterEach handler') return null @@ -1303,11 +1442,18 @@ class CypressPlugin { isQuarantined: isQuarantinedFromSupport, isDisabled: isDisabledFromSupport, } = test + if (coverage && (this.isCodeCoverageEnabled || this.isCoverageReportUploadEnabled)) { + this.addTestSessionCoverage(coverage) + } + if (coverage && this.isCodeCoverageEnabled && this.tracer._tracer._exporter?.exportCoverage) { - const coverageFiles = getCoveredFilenamesFromCoverage(coverage) - const relativeCoverageFiles = [...coverageFiles, testSuiteAbsolutePath].map( - file => getTestSuitePath(file, this.repositoryRoot || this.rootDir) - ) + const coverageFiles = getCoveredFilesFromCoverage(coverage) + const relativeCoverageFiles = getRelativeCoverageFiles(coverageFiles, this.getCoverageRootDir()) + if (testSuiteAbsolutePath) { + relativeCoverageFiles.push({ + filename: getTestSuitePath(testSuiteAbsolutePath, this.getCoverageRootDir()), + }) + } if (!relativeCoverageFiles.length) { incrementCountMetric(TELEMETRY_CODE_COVERAGE_EMPTY) } @@ -1343,7 +1489,7 @@ class CypressPlugin { this.testStatuses[testName] = [testStatus] } const testStatuses = this.testStatuses[testName] - const activeSpanTags = this.activeTestSpan.context()._tags + const activeSpanTags = this.activeTestSpan.context().getTags() if (error) { this.activeTestSpan.setTag('error', error) @@ -1444,6 +1590,31 @@ class CypressPlugin { this.activeTestSpan.setTag(TEST_MANAGEMENT_IS_DISABLED, 'true') } + if (Array.isArray(commands) && commands.length > 0) { + for (const command of commands) { + const { startTime, endTime } = command + if (typeof startTime !== 'number' || typeof endTime !== 'number' || endTime < startTime) { + continue + } + const stepSpan = this.tracer.startSpan('cypress.step', { + childOf: this.activeTestSpan, + startTime, + tags: { + [COMPONENT]: 'cypress', + 'cypress.command': command.name, + [RESOURCE_NAME]: command.name, + }, + }) + if (command.error) { + const errorObj = new Error(command.error.message || String(command.error)) + if (command.error.name) errorObj.name = command.error.name + if (command.error.stack) errorObj.stack = command.error.stack + stepSpan.setTag('error', errorObj) + } + stepSpan.finish(endTime) + } + } + const finishedTest = { testName, testStatus, diff --git a/packages/datadog-plugin-cypress/src/support.js b/packages/datadog-plugin-cypress/src/support.js index 589f080e2b..848b6f7315 100644 --- a/packages/datadog-plugin-cypress/src/support.js +++ b/packages/datadog-plugin-cypress/src/support.js @@ -24,6 +24,40 @@ const suppressedTestFailures = new Map() // to a cross-origin URL, safeGetRum() handles the access error. let originalWindow +let currentTestCommands = [] +const commandStartTimes = new Map() +const INTERNAL_CYPRESS_COMMANDS = new Set(['wrap', 'then', 'noop']) + +Cypress.on('command:start', (command) => { + commandStartTimes.set(command.get('id'), { startTime: Date.now(), name: command.get('name') }) +}) + +Cypress.on('command:end', (command) => { + const id = command.get('id') + const entry = commandStartTimes.get(id) + commandStartTimes.delete(id) + + const name = command.get('name') + const args = command.get('args') + if (name === 'task' && args && typeof args[0] === 'string' && args[0].startsWith('dd:')) { + return + } + if (INTERNAL_CYPRESS_COMMANDS.has(name)) { + return + } + if (entry == null) { + return + } + const err = command.get('err') + currentTestCommands.push({ + name, + startTime: entry.startTime, + endTime: Date.now(), + // Serialize the error to a plain object so it survives cy.task JSON transport. + error: err ? { message: err.message, stack: err.stack, name: err.name } : null, + }) +}) + // If the test is using multi domain with cy.origin, trying to access // window properties will result in a cross origin error. function safeGetRum (window) { @@ -56,6 +90,29 @@ function getTestProperties (testName) { // By not re-throwing the error, Cypress marks the test as passed // This allows quarantined tests to run but not affect the exit code Cypress.on('fail', (err, runnable) => { + // For commands that time out, command:end may never fire. + // Finalize any in-flight commands so their step spans carry the error. + const hadInFlightCommands = commandStartTimes.size > 0 + for (const [, { startTime, name }] of commandStartTimes) { + if (INTERNAL_CYPRESS_COMMANDS.has(name)) continue + currentTestCommands.push({ + name, + startTime, + endTime: Date.now(), + error: { message: err.message, stack: err.stack, name: err.name }, + }) + } + commandStartTimes.clear() + + // If command:end fired for all commands (none in-flight) but the last command + // has no error, it means command:end fired before the error was attached to it. + if (!hadInFlightCommands && currentTestCommands.length > 0) { + const lastCommand = currentTestCommands[currentTestCommands.length - 1] + if (!lastCommand.error) { + lastCommand.error = { message: err.message, stack: err.stack, name: err.name } + } + } + if (!isTestManagementEnabled) { throw err } @@ -169,6 +226,9 @@ beforeEach(function () { retryReasonsByTestName.delete(testName) } + currentTestCommands = [] + commandStartTimes.clear() + cy.on('window:load', (win) => { originalWindow = win }) @@ -212,6 +272,11 @@ beforeEach(function () { if (shouldSkip) { this.skip() } + }).then(() => { + // Clear any commands accumulated during DD-owned setup (e.g. setCookie, RUM restart) + // so they are not reported as user test steps. + currentTestCommands = [] + commandStartTimes.clear() }) }) @@ -289,6 +354,9 @@ afterEach(function () { testInfo.testSourceStack = invocationDetails.stack } catch {} + // Snapshot before any DD-owned Cypress commands so they are not reported as test steps. + const commandsToReport = [...currentTestCommands] + const rum = safeGetRum(originalWindow) if (rum) { testInfo.isRUMActive = true @@ -310,5 +378,5 @@ afterEach(function () { suppressedTestFailures.delete(testName) } - cy.task('dd:afterEach', { test: testInfo, coverage }) + cy.task('dd:afterEach', { test: testInfo, coverage, commands: commandsToReport }) }) diff --git a/packages/datadog-plugin-dd-trace-api/test/index.spec.js b/packages/datadog-plugin-dd-trace-api/test/index.spec.js index 63bbf0500d..ca57e3fd02 100644 --- a/packages/datadog-plugin-dd-trace-api/test/index.spec.js +++ b/packages/datadog-plugin-dd-trace-api/test/index.spec.js @@ -239,7 +239,7 @@ describe('Plugin', () => { fn: span.addLink, self: dummySpan, ret: dummySpan, - args: [dummySpanContext], + args: [{ context: dummySpanContext }], }) }) }) diff --git a/packages/datadog-plugin-dns/src/lookup.js b/packages/datadog-plugin-dns/src/lookup.js index e41e721374..864e0301c0 100644 --- a/packages/datadog-plugin-dns/src/lookup.js +++ b/packages/datadog-plugin-dns/src/lookup.js @@ -23,22 +23,24 @@ class DNSLookupPlugin extends ClientPlugin { return ctx.currentStore } - bindFinish (ctx) { + finish (ctx) { const span = ctx.currentStore.span const result = ctx.result if (Array.isArray(result)) { - const addresses = Array.isArray(result) - ? result.map(address => address.address).sort() - : [result] - + // `lookup(..., { all: true })` or `dns.promises.lookup(..., { all: true })`. + const addresses = result.map(entry => entry.address).sort() span.setTag('dns.address', addresses[0]) span.setTag('dns.addresses', addresses.join(',')) + } else if (result && typeof result === 'object') { + // `dns.promises.lookup(...)` resolves to `{ address, family }`; the callback variant + // passes the address as a string. + span.setTag('dns.address', result.address) } else { span.setTag('dns.address', result) } - return ctx.parentStore + super.finish(ctx) } } diff --git a/packages/datadog-plugin-dns/test/index.spec.js b/packages/datadog-plugin-dns/test/index.spec.js index d098f1a7d8..d4a355b797 100644 --- a/packages/datadog-plugin-dns/test/index.spec.js +++ b/packages/datadog-plugin-dns/test/index.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const { promisify } = require('node:util') +const dc = require('dc-polyfill') const { afterEach, beforeEach, describe, it } = require('mocha') const { storage } = require('../../datadog-core') @@ -241,6 +242,245 @@ describe('Plugin', () => { resolver.resolve('lvh.me', () => {}) }) }) + + describe('promises', () => { + it('should instrument lookup', () => { + const tracePromise = agent.assertSomeTraces(traces => { + assertObjectContains(traces[0][0], { + name: 'dns.lookup', + service: 'test', + resource: 'localhost', + }) + assertObjectContains(traces[0][0].meta, { + component: 'dns', + 'span.kind': 'client', + 'dns.hostname': 'localhost', + 'dns.address': '127.0.0.1', + }) + }) + + return Promise.all([ + tracePromise, + dns.promises.lookup('localhost', 4).then(({ address, family }) => { + assert.strictEqual(address, '127.0.0.1') + assert.strictEqual(family, 4) + }), + ]) + }) + + it('should instrument lookup with all addresses', () => { + const tracePromise = agent.assertSomeTraces(traces => { + assertObjectContains(traces[0][0], { + name: 'dns.lookup', + service: 'test', + resource: 'localhost', + }) + assertObjectContains(traces[0][0].meta, { + component: 'dns', + 'span.kind': 'client', + 'dns.hostname': 'localhost', + 'dns.address': '127.0.0.1', + 'dns.addresses': '127.0.0.1,::1', + }) + }) + + return Promise.all([ + tracePromise, + dns.promises.lookup('localhost', { all: true }), + ]) + }) + + it('should instrument errors correctly', () => { + const tracePromise = agent.assertSomeTraces(traces => { + assertObjectContains(traces[0][0], { + name: 'dns.lookup', + service: 'test', + resource: 'fakedomain.faketld', + error: 1, + }) + assertObjectContains(traces[0][0].meta, { + component: 'dns', + 'span.kind': 'client', + 'dns.hostname': 'fakedomain.faketld', + [ERROR_TYPE]: 'Error', + [ERROR_MESSAGE]: 'getaddrinfo ENOTFOUND fakedomain.faketld', + }) + }) + + return Promise.all([ + tracePromise, + assert.rejects(dns.promises.lookup('fakedomain.faketld', 4)), + ]) + }) + + it('should instrument lookupService', () => { + const tracePromise = agent.assertSomeTraces(traces => { + assertObjectContains(traces[0][0], { + name: 'dns.lookup_service', + service: 'test', + resource: '127.0.0.1:22', + }) + assertObjectContains(traces[0][0].meta, { + component: 'dns', + 'span.kind': 'client', + 'dns.address': '127.0.0.1', + }) + assertObjectContains(traces[0][0].metrics, { + 'dns.port': 22, + }) + }) + + return Promise.all([ + tracePromise, + dns.promises.lookupService('127.0.0.1', 22), + ]) + }) + + it('should instrument resolve', () => { + const tracePromise = agent.assertSomeTraces(traces => { + assertObjectContains(traces[0][0], { + name: 'dns.resolve', + service: 'test', + resource: 'A lvh.me', + }) + assertObjectContains(traces[0][0].meta, { + component: 'dns', + 'span.kind': 'client', + 'dns.hostname': 'lvh.me', + 'dns.rrtype': 'A', + }) + }) + + return Promise.all([ + tracePromise, + dns.promises.resolve('lvh.me').catch(() => {}), + ]) + }) + + it('should instrument resolve shorthands', () => { + const tracePromise = agent.assertSomeTraces(traces => { + assertObjectContains(traces[0][0], { + name: 'dns.resolve', + service: 'test', + resource: 'ANY localhost', + }) + assertObjectContains(traces[0][0].meta, { + component: 'dns', + 'span.kind': 'client', + 'dns.hostname': 'localhost', + 'dns.rrtype': 'ANY', + }) + }) + + return Promise.all([ + tracePromise, + dns.promises.resolveAny('localhost').catch(() => {}), + ]) + }) + + it('should instrument reverse', () => { + const tracePromise = agent.assertSomeTraces(traces => { + assertObjectContains(traces[0][0], { + name: 'dns.reverse', + service: 'test', + resource: '127.0.0.1', + }) + assertObjectContains(traces[0][0].meta, { + component: 'dns', + 'span.kind': 'client', + 'dns.ip': '127.0.0.1', + }) + }) + + return Promise.all([ + tracePromise, + dns.promises.reverse('127.0.0.1').catch(() => {}), + ]) + }) + + it('should preserve the parent scope across await', async () => { + const span = tracer.startSpan('dummySpan', {}) + + await tracer.scope().activate(span, async () => { + await dns.promises.lookup('localhost', 4) + assert.strictEqual(tracer.scope().active(), span) + }) + }) + + it('should rethrow synchronous errors from the underlying call', () => { + // dns.promises.lookup validates `hostname` synchronously and throws ERR_INVALID_ARG_TYPE + // rather than returning a rejected promise; the wrapper must propagate that. + assert.throws(() => dns.promises.lookup({}), { code: 'ERR_INVALID_ARG_TYPE' }) + }) + + it('should instrument Resolver instances', () => { + const resolver = new dns.promises.Resolver() + + const tracePromise = agent.assertSomeTraces(traces => { + assertObjectContains(traces[0][0], { + name: 'dns.resolve', + service: 'test', + resource: 'A lvh.me', + }) + assertObjectContains(traces[0][0].meta, { + component: 'dns', + 'dns.hostname': 'lvh.me', + 'dns.rrtype': 'A', + }) + }) + + return Promise.all([ + tracePromise, + resolver.resolve('lvh.me').catch(() => {}), + ]) + }) + + // Loading both `dns` and `dns/promises` reaches the same exports object through + // two ritm hooks. Without a WeakSet guard, the second hook to fire would stack a + // second wrap layer and publish `apm:dns:*` events twice per call. The mocha test + // agent resets ritm between tests (default `ritmReset: true` in `agent.close`), + // so the assertions that prove "one hook fire per call" have to run inside a + // single `it` body to share one ritm lifecycle. + it('does not double-wrap when both dns and dns/promises are loaded', async () => { + const startCh = dc.channel('apm:dns:lookup:start') + let startCount = 0 + const handler = () => { startCount++ } + startCh.subscribe(handler) + try { + const viaDns = require('dns').promises + const viaNodeDns = require('node:dns').promises + const viaSubpath = require('dns/promises') + const viaNodeSubpath = require('node:dns/promises') + + // All four CJS access shapes resolve to the same exports object. + assert.strictEqual(viaDns, viaNodeDns) + assert.strictEqual(viaDns, viaSubpath) + assert.strictEqual(viaDns, viaNodeSubpath) + + // Same wrapped function reference across access shapes — a second wrap + // layer would produce a different function identity. + assert.strictEqual(viaDns.lookup, viaSubpath.lookup) + + const shapes = [ + ['require("dns").promises', viaDns], + ['require("node:dns").promises', viaNodeDns], + ['require("dns/promises")', viaSubpath], + ['require("node:dns/promises")', viaNodeSubpath], + ] + + for (const [label, api] of shapes) { + const before = startCount + await api.lookup('localhost', 4) + await new Promise(setImmediate) + const fired = startCount - before + assert.strictEqual(fired, 1, + `expected 1 start event for one lookup via ${label}; got ${fired}`) + } + } finally { + startCh.unsubscribe(handler) + } + }) + }) }) }) }) diff --git a/packages/datadog-plugin-dns/test/integration-test/client.spec.js b/packages/datadog-plugin-dns/test/integration-test/client.spec.js index af95d4f7f1..16f6f8ec55 100644 --- a/packages/datadog-plugin-dns/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-dns/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -37,7 +38,7 @@ describe('esm', () => { it(`is instrumented loaded with ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'dns.lookup'), true) assert.strictEqual(payload[0][0].resource, 'fakedomain.faketld') }) diff --git a/packages/datadog-plugin-elasticsearch/test/index.spec.js b/packages/datadog-plugin-elasticsearch/test/index.spec.js index 3d86483833..c6225617ab 100644 --- a/packages/datadog-plugin-elasticsearch/test/index.spec.js +++ b/packages/datadog-plugin-elasticsearch/test/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') @@ -200,7 +201,10 @@ describe('Plugin', () => { it('should propagate context', done => { agent .assertSomeTraces(traces => { - assert.ok(Object.hasOwn(traces[0][0], 'parent_id')) + assert.ok( + Object.hasOwn(traces[0][0], 'parent_id'), + `Available keys: ${inspect(Object.keys(traces[0][0]))}` + ) assert.notStrictEqual(traces[0][0].parent_id, null) }) .then(done) @@ -264,7 +268,10 @@ describe('Plugin', () => { it('should propagate context', done => { agent .assertSomeTraces(traces => { - assert.ok(Object.hasOwn(traces[0][0], 'parent_id')) + assert.ok( + Object.hasOwn(traces[0][0], 'parent_id'), + `Available keys: ${inspect(Object.keys(traces[0][0]))}` + ) assert.notStrictEqual(traces[0][0].parent_id, null) }) .then(done) diff --git a/packages/datadog-plugin-elasticsearch/test/integration-test/client.spec.js b/packages/datadog-plugin-elasticsearch/test/integration-test/client.spec.js index 4c0a9c6902..27b367e704 100644 --- a/packages/datadog-plugin-elasticsearch/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-elasticsearch/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const semver = require('semver') const { @@ -50,7 +51,7 @@ describe('esm', () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'elasticsearch.query'), true) }) diff --git a/packages/datadog-plugin-electron/test/index.spec.js b/packages/datadog-plugin-electron/test/index.spec.js index 0ca8f4eb3b..857241e41a 100644 --- a/packages/datadog-plugin-electron/test/index.spec.js +++ b/packages/datadog-plugin-electron/test/index.spec.js @@ -8,6 +8,8 @@ const { afterEach, beforeEach, describe, it } = require('mocha') const agent = require('../../dd-trace/test/plugins/agent') const { withVersions } = require('../../dd-trace/test/setup/mocha') +const IPC_TIMEOUT_MS = 10_000 + describe('Plugin', () => { let child let listener @@ -33,7 +35,11 @@ describe('Plugin', () => { const startApp = done => { const electron = require(`../../../versions/electron@${version}`).get() - child = proc.spawn(electron, [join(__dirname, 'app', 'main')], { + const args = [join(__dirname, 'app', 'main')] + if (process.platform === 'linux') { + args.push('--no-sandbox', '--disable-gpu') + } + child = proc.spawn(electron, args, { env: { ...process.env, NODE_OPTIONS: `-r ${join(__dirname, 'tracer')}`, @@ -48,10 +54,11 @@ describe('Plugin', () => { } describe('electron', () => { - describe('without configuration', () => { + describe('without configuration', function () { + this.timeout(IPC_TIMEOUT_MS + 5_000) beforeEach(() => agent.load('electron')) beforeEach(function (done) { - this.timeout(10_000) + this.timeout(30_000) startApp(done) }) @@ -113,6 +120,7 @@ describe('Plugin', () => { agent .assertSomeTraces(traces => { const span = traces.flat().find(s => s.name === 'electron.main.receive') + assert.ok(span, 'expected electron.main.receive span') const { meta } = span assert.strictEqual(span.type, 'worker') @@ -124,7 +132,7 @@ describe('Plugin', () => { assert.strictEqual(meta.component, 'electron') assert.strictEqual(meta['span.kind'], 'consumer') - }) + }, { timeoutMs: IPC_TIMEOUT_MS }) .then(done) .catch(done) @@ -135,6 +143,7 @@ describe('Plugin', () => { agent .assertSomeTraces(traces => { const span = traces.flat().find(s => s.name === 'electron.main.handle') + assert.ok(span, 'expected electron.main.handle span') const { meta } = span assert.strictEqual(span.type, 'worker') @@ -145,7 +154,7 @@ describe('Plugin', () => { assert.strictEqual(meta.component, 'electron') assert.strictEqual(meta['span.kind'], 'consumer') - }) + }, { timeoutMs: IPC_TIMEOUT_MS }) .then(done) .catch(done) @@ -156,6 +165,7 @@ describe('Plugin', () => { agent .assertSomeTraces(traces => { const span = traces.flat().find(s => s.name === 'electron.main.send') + assert.ok(span, 'expected electron.main.send span') const { meta } = span assert.strictEqual(span.name, 'electron.main.send') @@ -165,7 +175,7 @@ describe('Plugin', () => { assert.strictEqual(meta.component, 'electron') assert.strictEqual(meta['span.kind'], 'producer') - }) + }, { timeoutMs: IPC_TIMEOUT_MS }) .then(done) .catch(done) @@ -176,6 +186,7 @@ describe('Plugin', () => { agent .assertSomeTraces(traces => { const span = traces.flat().find(s => s.name === 'electron.renderer.receive') + assert.ok(span, 'expected electron.renderer.receive span') const { meta } = span assert.strictEqual(span.type, 'worker') @@ -187,7 +198,7 @@ describe('Plugin', () => { assert.strictEqual(meta.component, 'electron') assert.strictEqual(meta['span.kind'], 'consumer') - }) + }, { timeoutMs: IPC_TIMEOUT_MS }) .then(done) .catch(done) @@ -198,6 +209,7 @@ describe('Plugin', () => { agent .assertSomeTraces(traces => { const span = traces.flat().find(s => s.name === 'electron.renderer.send') + assert.ok(span, 'expected electron.renderer.send span') const { meta } = span assert.strictEqual(span.name, 'electron.renderer.send') @@ -207,7 +219,7 @@ describe('Plugin', () => { assert.strictEqual(meta.component, 'electron') assert.strictEqual(meta['span.kind'], 'producer') - }) + }, { timeoutMs: IPC_TIMEOUT_MS }) .then(done) .catch(done) diff --git a/packages/datadog-plugin-express/test/index.spec.js b/packages/datadog-plugin-express/test/index.spec.js index 28fffa4e5b..0014a84a07 100644 --- a/packages/datadog-plugin-express/test/index.spec.js +++ b/packages/datadog-plugin-express/test/index.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { AsyncLocalStorage } = require('node:async_hooks') +const { inspect } = require('node:util') const axios = require('axios') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') @@ -1423,7 +1424,7 @@ describe('Plugin', () => { return layer.regexp.test('/users') }) - assert.ok(Object.hasOwn(layer.handle, 'stack')) + assert.ok(Object.hasOwn(layer.handle, 'stack'), `Available keys: ${inspect(Object.keys(layer.handle))}`) }) it('should keep user stores untouched', done => { diff --git a/packages/datadog-plugin-express/test/integration-test/client.spec.js b/packages/datadog-plugin-express/test/integration-test/client.spec.js index 0abbf42ebd..f26c3f44f9 100644 --- a/packages/datadog-plugin-express/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-express/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const semver = require('semver') const { @@ -46,9 +47,9 @@ describe('esm', () => { return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 1) - assert.ok(Array.isArray(payload[0])) + assert.ok(Array.isArray(payload[0]), `Expected array, got ${inspect(payload[0])}`) assert.strictEqual(payload[0].length, numberOfSpans) assert.strictEqual(payload[0][0].name, 'express.request') assert.strictEqual(payload[0][1].name, `${whichMiddleware}.middleware`) @@ -71,9 +72,9 @@ describe('esm', () => { return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 1) - assert.ok(Array.isArray(payload[0])) + assert.ok(Array.isArray(payload[0]), `Expected array, got ${inspect(payload[0])}`) assert.strictEqual(payload[0].length, numberOfSpans) assert.strictEqual(payload[0][0].name, 'express.request') }) diff --git a/packages/datadog-plugin-fastify/test/integration-test/client.spec.js b/packages/datadog-plugin-fastify/test/integration-test/client.spec.js index 28fa302436..71c205bad2 100644 --- a/packages/datadog-plugin-fastify/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-fastify/test/integration-test/client.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const { join } = require('path') +const { inspect } = require('node:util') const { FakeAgent, curlAndAssertMessage, @@ -37,7 +38,7 @@ describe('esm', () => { return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'fastify.request'), true) }) }).timeout(20000) @@ -47,7 +48,7 @@ describe('esm', () => { return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'fastify.request'), true) }) }).timeout(20000) @@ -57,7 +58,7 @@ describe('esm', () => { return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'fastify.request'), true) }) }).timeout(20000) diff --git a/packages/datadog-plugin-fetch/test/index.spec.js b/packages/datadog-plugin-fetch/test/index.spec.js index 52f01278de..dcb4e1a025 100644 --- a/packages/datadog-plugin-fetch/test/index.spec.js +++ b/packages/datadog-plugin-fetch/test/index.spec.js @@ -517,6 +517,7 @@ describe('Plugin', function () { hooks: { request: (span, req, res) => { span.setTag('foo', '/foo') + span.setTag('service.name', 'override') }, }, } @@ -546,6 +547,25 @@ describe('Plugin', function () { fetch(`http://localhost:${port}/user`).catch(() => {}) }) }) + + it('should have manual stamp when doing an override through config hook', 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['_dd.svc_src'], 'm') + }) + .then(done) + .catch(done) + + fetch(`http://localhost:${port}/user`).catch(() => {}) + }) + }) }) describe('with propagationBlocklist configuration', () => { diff --git a/packages/datadog-plugin-fetch/test/integration-test/client.spec.js b/packages/datadog-plugin-fetch/test/integration-test/client.spec.js index e6d69268c6..4b13393ed0 100644 --- a/packages/datadog-plugin-fetch/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-fetch/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -31,7 +32,7 @@ describe('esm', () => { it('is instrumented', async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) const isFetch = payload.some((span) => span.some((nestedSpan) => nestedSpan.meta.component === 'fetch')) assert.strictEqual(isFetch, true) }) diff --git a/packages/datadog-plugin-google-cloud-pubsub/src/pubsub-push-subscription.js b/packages/datadog-plugin-google-cloud-pubsub/src/pubsub-push-subscription.js index 272f39a30c..a943936cb1 100644 --- a/packages/datadog-plugin-google-cloud-pubsub/src/pubsub-push-subscription.js +++ b/packages/datadog-plugin-google-cloud-pubsub/src/pubsub-push-subscription.js @@ -181,7 +181,7 @@ class GoogleCloudPubsubPushSubscriptionPlugin extends TracingPlugin { if (linkContext) { if (span.addLink) { - span.addLink(linkContext, {}) + span.addLink({ context: linkContext, attributes: {} }) } else { span._links ??= [] span._links.push({ context: linkContext, attributes: {} }) diff --git a/packages/datadog-plugin-google-cloud-pubsub/test/dsm.spec.js b/packages/datadog-plugin-google-cloud-pubsub/test/dsm.spec.js index 8717e486b5..8626e97ccd 100644 --- a/packages/datadog-plugin-google-cloud-pubsub/test/dsm.spec.js +++ b/packages/datadog-plugin-google-cloud-pubsub/test/dsm.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { after, before, beforeEach, describe, it } = require('mocha') const sinon = require('sinon') @@ -100,7 +101,7 @@ describe('Plugin', () => { }) } }) - assert.ok(statsPointsReceived >= 1) + assert.ok(statsPointsReceived >= 1, `Expected ${statsPointsReceived} >= 1`) assert.strictEqual(agent.dsmStatsExist(agent, expectedProducerHash.readBigUInt64BE(0).toString()), true) }, { timeoutMs: TIMEOUT }) }) @@ -118,7 +119,7 @@ describe('Plugin', () => { }) } }) - assert.ok(statsPointsReceived >= 2) + assert.ok(statsPointsReceived >= 2, `Expected ${statsPointsReceived} >= 2`) assert.strictEqual(agent.dsmStatsExist(agent, expectedConsumerHash.readBigUInt64BE(0).toString()), true) }, { timeoutMs: TIMEOUT }) }) @@ -138,14 +139,20 @@ describe('Plugin', () => { it('when producing a message', async () => { await publish(dsmTopic, { data: Buffer.from('DSM produce payload size') }) - assert.ok(recordCheckpointSpy.args[0][0].hasOwnProperty('payloadSize')) + assert.ok( + recordCheckpointSpy.args[0][0].hasOwnProperty('payloadSize'), + `Available keys: ${inspect(Object.keys(recordCheckpointSpy.args[0][0]))}` + ) }) it('when consuming a message', async () => { await publish(dsmTopic, { data: Buffer.from('DSM consume payload size') }) await consume(async () => { - assert.ok(recordCheckpointSpy.args[0][0].hasOwnProperty('payloadSize')) + assert.ok( + recordCheckpointSpy.args[0][0].hasOwnProperty('payloadSize'), + `Available keys: ${inspect(Object.keys(recordCheckpointSpy.args[0][0]))}` + ) }) }) }) diff --git a/packages/datadog-plugin-google-cloud-pubsub/test/index.spec.js b/packages/datadog-plugin-google-cloud-pubsub/test/index.spec.js index 3d3be3c2c6..a10d9a5f6a 100644 --- a/packages/datadog-plugin-google-cloud-pubsub/test/index.spec.js +++ b/packages/datadog-plugin-google-cloud-pubsub/test/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const sinon = require('sinon') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') @@ -234,7 +235,10 @@ describe('Plugin', () => { const activeSpan = tracer.scope().active() if (activeSpan) { const receiverSpanContext = activeSpan.context() - assert.ok(typeof receiverSpanContext._parentId === 'object' && receiverSpanContext._parentId !== null) + assert.ok( + typeof receiverSpanContext._parentId === 'object' && receiverSpanContext._parentId !== null, + `Expected non-null object, got ${inspect(receiverSpanContext._parentId)}` + ) } msg.ack() }) @@ -480,7 +484,7 @@ describe('Plugin', () => { }) } }) - assert.ok(statsPointsReceived >= 1) + assert.ok(statsPointsReceived >= 1, `Expected ${statsPointsReceived} >= 1`) assert.strictEqual(agent.dsmStatsExist(agent, expectedProducerHash.readBigUInt64BE(0).toString()), true) }, { timeoutMs: TIMEOUT }) }) @@ -497,7 +501,7 @@ describe('Plugin', () => { }) } }) - assert.ok(statsPointsReceived >= 2) + assert.ok(statsPointsReceived >= 2, `Expected ${statsPointsReceived} >= 2`) assert.strictEqual(agent.dsmStatsExist(agent, expectedConsumerHash.readBigUInt64BE(0).toString()), true) }, { timeoutMs: TIMEOUT }) }) @@ -517,14 +521,20 @@ describe('Plugin', () => { it('when producing a message', async () => { await publish(dsmTopic, { data: Buffer.from('DSM produce payload size') }) - assert.ok(recordCheckpointSpy.args[0][0].hasOwnProperty('payloadSize')) + assert.ok( + recordCheckpointSpy.args[0][0].hasOwnProperty('payloadSize'), + `Available keys: ${inspect(Object.keys(recordCheckpointSpy.args[0][0]))}` + ) }) it('when consuming a message', async () => { await publish(dsmTopic, { data: Buffer.from('DSM consume payload size') }) await consume(async () => { - assert.ok(recordCheckpointSpy.args[0][0].hasOwnProperty('payloadSize')) + assert.ok( + recordCheckpointSpy.args[0][0].hasOwnProperty('payloadSize'), + `Available keys: ${inspect(Object.keys(recordCheckpointSpy.args[0][0]))}` + ) }) }) }) diff --git a/packages/datadog-plugin-google-cloud-pubsub/test/integration-test/client.spec.js b/packages/datadog-plugin-google-cloud-pubsub/test/integration-test/client.spec.js index 4ada3b83c1..3349745050 100644 --- a/packages/datadog-plugin-google-cloud-pubsub/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-google-cloud-pubsub/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -39,7 +40,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'pubsub.request'), true) }) diff --git a/packages/datadog-plugin-google-cloud-pubsub/test/pubsub-push-subscription.spec.js b/packages/datadog-plugin-google-cloud-pubsub/test/pubsub-push-subscription.spec.js index 9c7a57f996..10e50bb903 100644 --- a/packages/datadog-plugin-google-cloud-pubsub/test/pubsub-push-subscription.spec.js +++ b/packages/datadog-plugin-google-cloud-pubsub/test/pubsub-push-subscription.spec.js @@ -5,6 +5,7 @@ process.env.K_SERVICE = 'test-service' const assert = require('node:assert/strict') const { setTimeout: wait } = require('node:timers/promises') +const { inspect } = require('node:util') const axios = require('axios') const { describe, it, beforeEach, afterEach, before, after } = require('mocha') @@ -102,7 +103,10 @@ describe('Push Subscription Plugin', () => { // Verify delivery_duration_ms assert.notStrictEqual(pubsubSpan.metrics['pubsub.delivery_duration_ms'], undefined) assert.strictEqual(typeof pubsubSpan.metrics['pubsub.delivery_duration_ms'], 'number') - assert.ok(pubsubSpan.metrics['pubsub.delivery_duration_ms'] >= 0) + assert.ok( + pubsubSpan.metrics['pubsub.delivery_duration_ms'] >= 0, + `Expected ${pubsubSpan.metrics['pubsub.delivery_duration_ms']} >= 0` + ) }) .then(done) .catch(done) @@ -128,7 +132,7 @@ describe('Push Subscription Plugin', () => { if (pubsubSpan.meta['_dd.span_links']) { const spanLinks = JSON.parse(pubsubSpan.meta['_dd.span_links']) - assert.ok(Array.isArray(spanLinks)) + assert.ok(Array.isArray(spanLinks), `Expected array, got ${inspect(spanLinks)}`) const hasProducerLink = spanLinks.some(link => link.trace_id && link.span_id) assert.strictEqual(hasProducerLink, true) } diff --git a/packages/datadog-plugin-google-cloud-vertexai/test/index.spec.js b/packages/datadog-plugin-google-cloud-vertexai/test/index.spec.js index e1df167b17..bf7f6a69aa 100644 --- a/packages/datadog-plugin-google-cloud-vertexai/test/index.spec.js +++ b/packages/datadog-plugin-google-cloud-vertexai/test/index.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const fs = require('node:fs') const path = require('node:path') +const { inspect } = require('node:util') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') const sinon = require('sinon') @@ -111,7 +112,7 @@ describe('Plugin', () => { const { response } = await model.generateContent({ contents: [{ role: 'user', parts: [{ text: 'Hello, how are you?' }] }], }) - assert.ok(Object.hasOwn(response, 'candidates')) + assert.ok(Object.hasOwn(response, 'candidates'), `Available keys: ${inspect(Object.keys(response))}`) await checkTraces }) @@ -123,7 +124,7 @@ describe('Plugin', () => { const { response } = await model.generateContent('Hello, how are you?') - assert.ok(Object.hasOwn(response, 'candidates')) + assert.ok(Object.hasOwn(response, 'candidates'), `Available keys: ${inspect(Object.keys(response))}`) await checkTraces }) @@ -161,17 +162,20 @@ describe('Plugin', () => { const { stream, response } = await model.generateContentStream('Hello, how are you?') // check that response is a promise - assert.ok(response && typeof response.then === 'function') + assert.ok( + response && typeof response.then === 'function', + `Expected a thenable, got: ${inspect(response)}` + ) const promState = await promiseState(response) assert.strictEqual(promState, 'pending') // we shouldn't have consumed the promise for await (const chunk of stream) { - assert.ok(Object.hasOwn(chunk, 'candidates')) + assert.ok(Object.hasOwn(chunk, 'candidates'), `Available keys: ${inspect(Object.keys(chunk))}`) } const result = await response - assert.ok(Object.hasOwn(result, 'candidates')) + assert.ok(Object.hasOwn(result, 'candidates'), `Available keys: ${inspect(Object.keys(result))}`) await checkTraces }) @@ -199,7 +203,7 @@ describe('Plugin', () => { }) const { response } = await chat.sendMessage([{ text: 'Hello, how are you?' }]) - assert.ok(Object.hasOwn(response, 'candidates')) + assert.ok(Object.hasOwn(response, 'candidates'), `Available keys: ${inspect(Object.keys(response))}`) await checkTraces }) @@ -212,7 +216,7 @@ describe('Plugin', () => { const chat = model.startChat({}) const { response } = await chat.sendMessage('Hello, how are you?') - assert.ok(Object.hasOwn(response, 'candidates')) + assert.ok(Object.hasOwn(response, 'candidates'), `Available keys: ${inspect(Object.keys(response))}`) await checkTraces }) @@ -225,7 +229,7 @@ describe('Plugin', () => { const chat = model.startChat({}) const { response } = await chat.sendMessage(['Hello, how are you?', 'What should I do today?']) - assert.ok(Object.hasOwn(response, 'candidates')) + assert.ok(Object.hasOwn(response, 'candidates'), `Available keys: ${inspect(Object.keys(response))}`) await checkTraces }) @@ -248,17 +252,20 @@ describe('Plugin', () => { const { stream, response } = await chat.sendMessageStream('Hello, how are you?') // check that response is a promise - assert.ok(response && typeof response.then === 'function') + assert.ok( + response && typeof response.then === 'function', + `Expected a thenable, got: ${inspect(response)}` + ) const promState = await promiseState(response) assert.strictEqual(promState, 'pending') // we shouldn't have consumed the promise for await (const chunk of stream) { - assert.ok(Object.hasOwn(chunk, 'candidates')) + assert.ok(Object.hasOwn(chunk, 'candidates'), `Available keys: ${inspect(Object.keys(chunk))}`) } const result = await response - assert.ok(Object.hasOwn(result, 'candidates')) + assert.ok(Object.hasOwn(result, 'candidates'), `Available keys: ${inspect(Object.keys(result))}`) await checkTraces }) diff --git a/packages/datadog-plugin-google-cloud-vertexai/test/integration-test/client.spec.js b/packages/datadog-plugin-google-cloud-vertexai/test/integration-test/client.spec.js index 5537c881c8..541af481b5 100644 --- a/packages/datadog-plugin-google-cloud-vertexai/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-google-cloud-vertexai/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -43,7 +44,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'vertexai.request'), true) }) diff --git a/packages/datadog-plugin-google-genai/test/integration-test/client.spec.js b/packages/datadog-plugin-google-genai/test/integration-test/client.spec.js index 8a2220fbdd..a1b79f1310 100644 --- a/packages/datadog-plugin-google-genai/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-google-genai/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const { FakeAgent, @@ -42,7 +43,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'google_genai.request'), true) }) diff --git a/packages/datadog-plugin-graphql/src/execute.js b/packages/datadog-plugin-graphql/src/execute.js index e7759aa995..e574910f2d 100644 --- a/packages/datadog-plugin-graphql/src/execute.js +++ b/packages/datadog-plugin-graphql/src/execute.js @@ -17,6 +17,8 @@ class GraphQLExecutePlugin extends TracingPlugin { const document = args.document const source = this.config.source && document && docSource + ctx.collapse = this.config.collapse + const span = this.startSpan(this.operationName(), { service: this.config.service || this.serviceName(), resource: getSignature(document, name, type, this.config.signature), diff --git a/packages/datadog-plugin-graphql/src/resolve.js b/packages/datadog-plugin-graphql/src/resolve.js index 6a1721bbc2..3d9177f7f7 100644 --- a/packages/datadog-plugin-graphql/src/resolve.js +++ b/packages/datadog-plugin-graphql/src/resolve.js @@ -3,59 +3,61 @@ const dc = require('dc-polyfill') const TracingPlugin = require('../../dd-trace/src/plugins/tracing') -const collapsedPathSym = Symbol('collapsedPaths') - class GraphQLResolvePlugin extends TracingPlugin { static id = 'graphql' static operation = 'resolve' + /** + * @param {{ + * rootCtx: { + * source?: string, + * collapse: boolean, + * collapsedFields?: Map, + * }, + * args: Record, + * path: { prev: object | undefined, key: string | number }, + * pathString: string, + * fieldName: string, + * returnType: { name: string }, + * fieldNode: { loc?: { start: number, end: number }, arguments?: object[], directives?: object[] } | undefined, + * variableValues: Record | undefined, + * }} fieldCtx + */ start (fieldCtx) { - const { info, rootCtx, args, path: pathAsArray, pathString } = fieldCtx - - // we need to get the parent span to the field if it exists for correct span parenting - // of nested fields - const parentField = getParentField(rootCtx, pathString) - const childOf = parentField?.ctx?.currentStore?.span + if (!shouldInstrument(this.config, fieldCtx.path)) return - fieldCtx.parent = parentField + const { rootCtx, args, path, pathString, fieldName, returnType, fieldNode, variableValues } = fieldCtx - if (!shouldInstrument(this.config, pathAsArray)) return - const computedPathString = this.config.collapse - ? buildCollapsedPathString(pathAsArray) - : pathString + // Siblings 2..N of a collapsed list share the first sibling's span, so + // skip span creation here. updateField still fires on the shared ctx and + // advances the shared span's finishTime. + if (rootCtx.collapse && rootCtx.collapsedFields?.has(pathString)) return - if (this.config.collapse) { - if (rootCtx.fields[computedPathString]) return - - if (!rootCtx[collapsedPathSym]) { - rootCtx[collapsedPathSym] = Object.create(null) - } else if (rootCtx[collapsedPathSym][computedPathString]) { - return - } - - rootCtx[collapsedPathSym][computedPathString] = true - } + const parentField = getParentField(rootCtx, path) + const childOf = parentField?.ctx?.currentStore?.span const document = rootCtx.source - const fieldNode = info.fieldNodes[0] const loc = this.config.source && document && fieldNode && fieldNode.loc const source = loc && document.slice(loc.start, loc.end) + let namedReturnType = returnType + while (namedReturnType.ofType) namedReturnType = namedReturnType.ofType + const span = this.startSpan('graphql.resolve', { service: this.config.service, - resource: `${info.fieldName}:${info.returnType}`, + resource: `${fieldName}:${returnType}`, childOf, type: 'graphql', meta: { - 'graphql.field.name': info.fieldName, - 'graphql.field.path': computedPathString, - 'graphql.field.type': info.returnType.name, + 'graphql.field.name': fieldName, + 'graphql.field.path': pathString, + 'graphql.field.type': namedReturnType.name, 'graphql.source': source, }, }, fieldCtx) if (fieldNode && this.config.variables && fieldNode.arguments) { - const variables = this.config.variables(info.variableValues) + const variables = this.config.variables(variableValues) for (const arg of fieldNode.arguments) { if (arg.value?.name && arg.value.kind === 'Variable' && variables[arg.value.name.value]) { @@ -66,7 +68,7 @@ class GraphQLResolvePlugin extends TracingPlugin { } if (this.resolverStartCh.hasSubscribers) { - this.resolverStartCh.publish({ ctx: rootCtx, resolverInfo: getResolverInfo(info, args) }) + this.resolverStartCh.publish({ ctx: rootCtx, resolverInfo: getResolverInfo(fieldNode, fieldName, args) }) } return fieldCtx.currentStore @@ -76,11 +78,11 @@ class GraphQLResolvePlugin extends TracingPlugin { super(...args) this.addTraceSub('updateField', (ctx) => { - const { field, error, path: pathAsArray } = ctx + // start short-circuited on the depth gate, so there is no span to advance. + if (ctx.currentStore === undefined) return - if (!shouldInstrument(this.config, pathAsArray)) return - - const span = ctx?.currentStore?.span || this.activeSpan + const { field, error } = ctx + const span = ctx.currentStore.span field.finishTime = span._getTime ? span._getTime() : 0 field.error = field.error || error }) @@ -105,38 +107,38 @@ class GraphQLResolvePlugin extends TracingPlugin { // helpers -function shouldInstrument (config, pathAsArray) { - if (config.depth < 0) return true +/** + * @param {{ depth: number, collapse: boolean }} config + * @param {{ prev: object | undefined, key: string | number }} path + */ +function shouldInstrument (config, path) { + const depth = config.depth + if (depth < 0) return true - let depth = 0 + let count = 0 if (config.collapse) { - depth = pathAsArray.length + for (let curr = path; curr; curr = curr.prev) count += 1 } else { - for (const segment of pathAsArray) { - if (typeof segment === 'string') depth += 1 + for (let curr = path; curr; curr = curr.prev) { + if (typeof curr.key === 'string') count += 1 } } - - return config.depth >= depth -} - -function buildCollapsedPathString (pathAsArray) { - let result = '' - for (const segment of pathAsArray) { - if (result.length > 0) result += '.' - result += typeof segment === 'number' ? '*' : segment - } - return result + return depth >= count } -function getResolverInfo (info, args) { +/** + * @param {object | undefined} fieldNode + * @param {string} fieldName + * @param {Record | undefined} args + */ +function getResolverInfo (fieldNode, fieldName, args) { let resolverVars if (args && Object.keys(args).length > 0) { resolverVars = { ...args } } - const directives = info.fieldNodes?.[0]?.directives + const directives = fieldNode?.directives if (Array.isArray(directives)) { for (const directive of directives) { if (directive.arguments.length === 0) continue @@ -151,23 +153,18 @@ function getResolverInfo (info, args) { } } - return resolverVars === undefined ? null : { [info.fieldName]: resolverVars } + return resolverVars === undefined ? null : { [fieldName]: resolverVars } } -function getParentField (parentCtx, pathToString) { - let current = pathToString - - while (current) { - const lastJoin = current.lastIndexOf('.') - if (lastJoin === -1) break - - current = current.slice(0, lastJoin) - const field = parentCtx.fields[current] - +/** + * @param {{ fields: Map }} rootCtx + * @param {{ prev: object | undefined }} path + */ +function getParentField (rootCtx, path) { + for (let curr = path.prev; curr; curr = curr.prev) { + const field = rootCtx.fields.get(curr) if (field) return field } - - return null } module.exports = GraphQLResolvePlugin 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 06181ef450..71080a1022 100644 --- a/packages/datadog-plugin-graphql/test/esm-test/esm.spec.js +++ b/packages/datadog-plugin-graphql/test/esm-test/esm.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const axios = require('axios') const semver = require('semver') @@ -35,7 +36,7 @@ describe('Plugin (ESM)', () => { it('should instrument GraphQL 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)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'graphql.execute'), true) }) @@ -73,7 +74,7 @@ describe('Plugin (ESM)', () => { it('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)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'graphql.execute'), true) }) diff --git a/packages/datadog-plugin-graphql/test/index.spec.js b/packages/datadog-plugin-graphql/test/index.spec.js index d76e736e18..0521f7837b 100644 --- a/packages/datadog-plugin-graphql/test/index.spec.js +++ b/packages/datadog-plugin-graphql/test/index.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const http = require('node:http') const { performance } = require('perf_hooks') +const { inspect } = require('node:util') const axios = require('axios') const dc = require('dc-polyfill') @@ -405,6 +406,130 @@ describe('Plugin', () => { graphql.graphql({ schema, source, variableValues }).catch(done) }) + it('should instrument every execute even when the args object is reused', async () => { + const startChannel = dc.channel('apm:graphql:execute:start') + const document = graphql.parse('query MyQuery { hello(name: "world") }') + const args = { schema, document, contextValue: {} } + + let starts = 0 + const handler = () => { starts++ } + startChannel.subscribe(handler) + + try { + await graphql.execute(args) + await graphql.execute(args) + assert.strictEqual(starts, 2) + } finally { + startChannel.unsubscribe(handler) + } + }) + + it('should not add fieldResolver to a frozen caller-owned execute args object', async () => { + const document = graphql.parse('query MyQuery { hello(name: "world") }') + const args = Object.freeze({ schema, document, contextValue: {} }) + + assert.ok(await graphql.execute(args), 'execute returned a result') + assert.ok(!Object.hasOwn(args, 'fieldResolver'), + 'instrumentation must not add fieldResolver to caller args') + }) + + it('should not overwrite the caller-supplied fieldResolver on the execute args object', async () => { + const document = graphql.parse('query MyQuery { hello(name: "world") }') + const callerFieldResolver = (source, args, contextValue, info) => 'caller-resolved' + const args = { schema, document, contextValue: {}, fieldResolver: callerFieldResolver } + + assert.ok(await graphql.execute(args), 'execute returned a result') + assert.strictEqual(args.fieldResolver, callerFieldResolver, + 'instrumentation must not overwrite the caller-supplied fieldResolver') + }) + + describe('preserves the caller-supplied contextValue', () => { + let recordingSchema + let recordedContext + + beforeEach(() => { + recordedContext = [] + recordingSchema = new graphql.GraphQLSchema({ + query: new graphql.GraphQLObjectType({ + name: 'Query', + fields: { + ctx: { + type: graphql.GraphQLString, + resolve: (_source, _args, contextValue) => { + recordedContext.push(contextValue) + return 'ok' + }, + }, + }, + }), + }) + }) + + for (const contextValue of [false, 0, '', null, undefined, 42, 'request-1', Symbol('ctx')]) { + const label = String(contextValue) || typeof contextValue + + it(`forwards ${label} to resolvers (object form)`, async () => { + const document = graphql.parse('{ ctx }') + + const result = await graphql.execute({ schema: recordingSchema, document, contextValue }) + + assert.strictEqual(result.data?.ctx, 'ok') + assert.strictEqual(recordedContext.length, 1) + assert.strictEqual(recordedContext[0], contextValue, + 'resolver must receive the caller-supplied contextValue unchanged') + }) + + // graphql >=16 dropped positional execute(); see PR 2904 below. + if (!semver.intersects(version, '>=16')) { + it(`forwards ${label} to resolvers (positional form)`, async () => { + const document = graphql.parse('{ ctx }') + + const result = await graphql.execute(recordingSchema, document, undefined, contextValue) + + assert.strictEqual(result.data?.ctx, 'ok') + assert.strictEqual(recordedContext.length, 1) + assert.strictEqual(recordedContext[0], contextValue, + 'resolver must receive the caller-supplied contextValue unchanged') + }) + } + } + + it('emits the execute span for a primitive contextValue', done => { + agent + .assertSomeTraces(traces => { + const spans = sort(traces[0]) + assert.strictEqual(spans[0].name, expectedSchema.server.opName) + assert.strictEqual(spans[0].error, 0) + }) + .then(done) + .catch(done) + + Promise.resolve(graphql.execute({ + schema: recordingSchema, + document: graphql.parse('{ ctx }'), + contextValue: 'request-1', + })).catch(done) + }) + + it('emits resolver spans for a primitive contextValue', done => { + agent + .assertSomeTraces(traces => { + const spans = sort(traces[0]) + const resolveSpan = spans.find(span => span.name === 'graphql.resolve') + assert.ok(resolveSpan, 'graphql.resolve span should be emitted') + assert.strictEqual(resolveSpan.meta['graphql.field.name'], 'ctx') + }) + .then(done) + .catch(done) + + Promise.resolve(graphql.execute({ + schema: recordingSchema, + document: graphql.parse('{ ctx }'), + contextValue: 42, + })).catch(done) + }) + }) + it('should not include variables by default', done => { const source = 'query MyQuery($who: String!) { hello(name: $who) }' const variableValues = { who: 'world' } @@ -433,7 +558,7 @@ describe('Plugin', () => { assert.strictEqual(spans[1].resource, 'hello:String') assert.strictEqual(spans[1].type, 'graphql') assert.strictEqual(spans[1].error, 0) - assert.ok(Number(spans[1].duration) > 0) + assert.ok(Number(spans[1].duration) > 0, `Expected ${Number(spans[1].duration)} > 0`) assert.strictEqual(spans[1].meta['graphql.field.name'], 'hello') assert.strictEqual(spans[1].meta['graphql.field.path'], 'hello') assert.strictEqual(spans[1].meta['graphql.field.type'], 'String') @@ -470,7 +595,10 @@ describe('Plugin', () => { graphql.graphql({ schema, source }), ]) - assert.ok(!result.errors || result.errors.length === 0) + assert.ok( + !result.errors || result.errors.length === 0, + `Got errors: ${inspect(result.errors)}` + ) assert.strictEqual(result.data.hello, 'world') // eslint-disable-next-line no-proto assert.strictEqual(result.data.__proto__, 'alias') @@ -504,13 +632,13 @@ describe('Plugin', () => { } if (span.resource === 'fastAsyncField:String') { - assert.ok(fastAsyncTime < slowAsyncTime) + assert.ok(fastAsyncTime < slowAsyncTime, `Expected ${fastAsyncTime} < ${slowAsyncTime}`) foundFastFieldSpan = true } else if (span.resource === 'slowAsyncField:String') { - assert.ok(slowAsyncTime < syncTime) + assert.ok(slowAsyncTime < syncTime, `Expected ${slowAsyncTime} < ${syncTime}`) foundSlowFieldSpan = true } else if (span.resource === 'syncField:String') { - assert.ok(syncTime > slowAsyncTime) + assert.ok(syncTime > slowAsyncTime, `Expected ${syncTime} > ${slowAsyncTime}`) foundSyncFieldSpan = true } @@ -627,6 +755,80 @@ describe('Plugin', () => { graphql.graphql({ schema, source }).catch(done) }) + it('publishes resolver finish for every sibling of a collapsed list', async () => { + // Regression for first-wins finishTime: when a list collapses to one span, + // every sibling resolver must still publish on apm:graphql:resolve:updateField + // so the span's finishTime reflects the last sibling, not the first. + const updateCh = dc.channel('apm:graphql:resolve:updateField') + const counts = new Map() + const handler = (ctx) => { + counts.set(ctx.pathString, (counts.get(ctx.pathString) ?? 0) + 1) + } + updateCh.subscribe(handler) + + try { + const source = '{ friends { name } }' + const [, result] = await Promise.all([ + agent.assertSomeTraces(traces => { + const spans = sort(traces[0]).filter(span => span.name === 'graphql.resolve') + const friendsName = spans.find(span => span.meta['graphql.field.path'] === 'friends.*.name') + assert.ok(friendsName, 'expected one collapsed friends.*.name span') + }), + graphql.graphql({ schema, source }), + ]) + + assert.ok(!result.errors || result.errors.length === 0, `Expected [${result.errors}] to be empty`) + assert.strictEqual( + counts.get('friends.*.name'), + 2, + 'expected one updateField publish per sibling of the 2-element friends list', + ) + } finally { + updateCh.unsubscribe(handler) + } + }) + + it('publishes apm:graphql:resolve:start for every sibling of a collapsed list', async () => { + // The collapse knob dedupes span creation, not channel publishes. IAST + // taint-tracking mutates each call's own args object; if siblings 2..N + // skip the publish, those args objects never get tainted and a sink + // reached through sibling N misses the vulnerability. + const startCh = dc.channel('apm:graphql:resolve:start') + const argsByPath = new Map() + const handler = (ctx) => { + const list = argsByPath.get(ctx.pathString) ?? [] + list.push(ctx.args) + argsByPath.set(ctx.pathString, list) + } + startCh.subscribe(handler) + + try { + const source = '{ friends { name } }' + const [, result] = await Promise.all([ + agent.assertSomeTraces(traces => { + const spans = sort(traces[0]).filter(span => span.name === 'graphql.resolve') + const friendsName = spans.find(span => span.meta['graphql.field.path'] === 'friends.*.name') + assert.ok(friendsName, 'expected one collapsed friends.*.name span') + }), + graphql.graphql({ schema, source }), + ]) + + assert.ok(!result.errors || result.errors.length === 0, `Expected [${result.errors}] to be empty`) + const nameArgs = argsByPath.get('friends.*.name') ?? [] + assert.strictEqual( + nameArgs.length, + 2, + 'expected one startResolveCh publish per sibling of the 2-element friends list', + ) + // graphql-js builds a fresh args object per resolver call; siblings + // share content but not identity. IAST mutates the passed object, so + // each call needs its own publish. + assert.notStrictEqual(nameArgs[0], nameArgs[1]) + } finally { + startCh.unsubscribe(handler) + } + }) + it('should instrument list field resolvers', done => { const source = `{ friends { @@ -656,6 +858,7 @@ describe('Plugin', () => { resource: 'friends:[Human]', meta: { 'graphql.field.path': 'friends', + 'graphql.field.type': 'Human', }, }) assert.strictEqual(friends.parent_id.toString(), execute.span_id.toString()) @@ -665,6 +868,7 @@ describe('Plugin', () => { resource: 'name:String', meta: { 'graphql.field.path': 'friends.*.name', + 'graphql.field.type': 'String', }, }) assert.strictEqual(friendsName.parent_id.toString(), friends.span_id.toString()) @@ -674,6 +878,7 @@ describe('Plugin', () => { resource: 'pets:[Pet!]', meta: { 'graphql.field.path': 'friends.*.pets', + 'graphql.field.type': 'Pet', }, }) assert.strictEqual(pets.parent_id.toString(), friends.span_id.toString()) @@ -683,6 +888,7 @@ describe('Plugin', () => { resource: 'name:String', meta: { 'graphql.field.path': 'friends.*.pets.*.name', + 'graphql.field.type': 'String', }, }) assert.strictEqual(petsName.parent_id.toString(), pets.span_id.toString()) @@ -693,6 +899,31 @@ describe('Plugin', () => { graphql.graphql({ schema, source }).catch(done) }) + it('caches path strings across nested list-of-lists items', async () => { + // `[[Cell]]` puts two synthetic array-index nodes back-to-back; the + // `friends { pets { name } }` sibling has a `pets` field between. + const matrixSchema = graphql.buildSchema(` + type Cell { value: Int } + type Query { matrix: [[Cell]] } + `) + const rootValue = { matrix: () => [[{ value: 42 }]] } + const source = '{ matrix { value } }' + + const [, result] = await Promise.all([ + agent.assertSomeTraces(traces => { + const paths = sort(traces[0]) + .filter(span => span.name === 'graphql.resolve') + .map(span => span.meta['graphql.field.path']) + .sort() + assert.deepStrictEqual(paths, ['matrix', 'matrix.*.*.value']) + }), + graphql.graphql({ schema: matrixSchema, source, rootValue }), + ]) + + assert.ok(!result.errors || result.errors.length === 0) + assert.strictEqual(result.data?.matrix?.[0]?.[0]?.value, 42) + }) + it('should instrument mutations', done => { const source = 'mutation { human { name } }' @@ -1035,7 +1266,10 @@ describe('Plugin', () => { assert.ok(('startTime' in spanEvents[0])) assert.strictEqual(spanEvents[0].name, 'dd.graphql.query.error') assert.strictEqual(spanEvents[0].attributes.type, 'GraphQLError') - assert.ok(!Object.hasOwn(spanEvents[0].attributes, 'stacktrace')) + assert.ok( + !Object.hasOwn(spanEvents[0].attributes, 'stacktrace'), + `Available keys: ${inspect(Object.keys(spanEvents[0].attributes))}` + ) assert.strictEqual(spanEvents[0].attributes.message, 'Field "address" of ' + 'type "Address" must have a selection of subfields. Did you mean "address { ... }"?') assert.strictEqual(spanEvents[0].attributes.locations.length, 1) @@ -1110,10 +1344,16 @@ describe('Plugin', () => { const spanEvents = agent.unformatSpanEvents(spans[0]) assert.strictEqual(spanEvents.length, 1) - assert.ok(Object.hasOwn(spanEvents[0], 'startTime')) + assert.ok( + Object.hasOwn(spanEvents[0], 'startTime'), + `Available keys: ${inspect(Object.keys(spanEvents[0]))}` + ) assert.strictEqual(spanEvents[0].name, 'dd.graphql.query.error') assert.strictEqual(spanEvents[0].attributes.type, 'GraphQLError') - assert.ok(Object.hasOwn(spanEvents[0].attributes, 'stacktrace')) + assert.ok( + Object.hasOwn(spanEvents[0].attributes, 'stacktrace'), + `Available keys: ${inspect(Object.keys(spanEvents[0].attributes))}` + ) assert.strictEqual(spanEvents[0].attributes.message, 'test') assert.strictEqual(spanEvents[0].attributes.locations.length, 1) assert.strictEqual(spanEvents[0].attributes.locations[0], '1:3') @@ -1197,6 +1437,75 @@ describe('Plugin', () => { graphql.graphql({ schema, source, rootValue }).catch(done) }) + it('throws AbortError when the execute abortController is aborted before execute runs', async () => { + // AppSec's WAF blocks a malicious request by aborting the execute ctx + // on apm:graphql:execute:start. callInAsyncScope sees the signal and + // throws AbortError before exe runs; the field-resolver path never + // fires for this query. + const startCh = dc.channel('apm:graphql:execute:start') + const handler = (ctx) => { + ctx.abortController.abort() + } + startCh.subscribe(handler) + + const source = '{ hello(name: "world") }' + const document = graphql.parse(source) + + try { + const [, error] = await Promise.all([ + agent.assertSomeTraces(traces => { + const spans = sort(traces[0]) + const resolveSpans = spans.filter(span => span.name === 'graphql.resolve') + assert.strictEqual(resolveSpans.length, 0, 'no resolver should run after abort') + const opSpan = spans.find(span => span.name === expectedSchema.server.opName) + assert.ok(opSpan, 'execute span still finishes') + assert.strictEqual(opSpan.error, 0) + }), + assert.throws( + () => graphql.execute({ schema, document }), + { name: 'AbortError', message: 'Aborted' }, + ), + ]) + assert.strictEqual(error, undefined) + } finally { + startCh.unsubscribe(handler) + } + }) + + it('throws AbortError from the next resolver when the controller aborts mid-execution', async () => { + // Same WAF hook as above, but the abort lands after the first + // resolver finished its work (apm:graphql:resolve:updateField) so + // callInAsyncScope's signal check is already past. resolveAsync's + // own signal check is the only guard that stops the second + // resolver from running, and assertField has already published its + // startResolveCh / built its TrackedField for it. + const updateCh = dc.channel('apm:graphql:resolve:updateField') + const finished = [] + const handler = (ctx) => { + finished.push(ctx.pathString) + if (finished.length === 1) { + ctx.rootCtx.abortController.abort() + } + } + updateCh.subscribe(handler) + + try { + const source = '{ first: hello(name: "first") second: hello(name: "second") }' + const result = await graphql.graphql({ schema, source }) + + // graphql captures the resolver throw into result.errors; the + // first resolver runs to completion, the second hits the abort + // branch. + assert.ok(result.errors, 'expected an AbortError surfaced through result.errors') + assert.strictEqual(result.errors.length, 1) + assert.strictEqual(result.errors[0].originalError?.name, 'AbortError') + assert.strictEqual(result.errors[0].originalError?.message, 'Aborted') + assert.deepStrictEqual(finished.sort(), ['first', 'second']) + } finally { + updateCh.unsubscribe(handler) + } + }) + it('should support multiple executions with the same contextValue', done => { const schema = graphql.buildSchema(` type Query { @@ -1631,6 +1940,51 @@ describe('Plugin', () => { graphql.graphql({ schema, source }).catch(done) }) + + it('publishes apm:graphql:resolve:start for every resolver, including depth-gated ones', async () => { + // The depth knob caps span creation, not channel publishes. + // IAST taint-tracking and AppSec WAF subscribers run on every resolver + // call so user-controlled args at any depth still flow through. + const startCh = dc.channel('apm:graphql:resolve:start') + const paths = [] + const handler = (ctx) => { + paths.push(ctx.pathString) + } + startCh.subscribe(handler) + + try { + const source = ` + { + human { + name + address { + civicNumber + street + } + } + } + ` + const [, result] = await Promise.all([ + agent.assertSomeTraces(traces => { + const spans = sort(traces[0]).filter(span => span.name === 'graphql.resolve') + const tracedPaths = spans.map(span => span.meta['graphql.field.path']).sort() + assert.deepStrictEqual(tracedPaths, ['human', 'human.address', 'human.name']) + }), + graphql.graphql({ schema, source }), + ]) + + assert.ok(!result.errors || result.errors.length === 0, `Expected [${result.errors}] to be empty`) + assert.deepStrictEqual(paths.sort(), [ + 'human', + 'human.address', + 'human.address.civicNumber', + 'human.address.street', + 'human.name', + ]) + } finally { + startCh.unsubscribe(handler) + } + }) }) describe('with collapsing disabled', () => { @@ -1718,12 +2072,62 @@ describe('Plugin', () => { graphql.graphql({ schema, source }), ]) - assert.ok(!result.errors || result.errors.length === 0) + assert.ok( + !result.errors || result.errors.length === 0, + `Got errors: ${inspect(result.errors)}` + ) // eslint-disable-next-line no-proto assert.strictEqual(result.data.__proto__, 'alias') }) }) + describe('with collapsing disabled and a depth >=1', () => { + before(async () => { + tracer = await agent.load('graphql', { collapse: false, depth: 2 }) + }) + + after(() => { + return agent.close() + }) + + beforeEach(() => { + graphql = require(`../../../versions/graphql@${version}`).get() + buildSchema() + }) + + it('should count only string segments when collapsing is disabled', done => { + const source = ` + { + friends { + name + pets { + name + } + } + } + ` + + agent + .assertSomeTraces(traces => { + const spans = sort(traces[0]) + const resolveSpans = spans.filter(span => span.name === 'graphql.resolve') + const paths = resolveSpans.map(span => span.meta['graphql.field.path']).sort() + + assert.deepStrictEqual(paths, [ + 'friends', + 'friends.0.name', + 'friends.0.pets', + 'friends.1.name', + 'friends.1.pets', + ]) + }) + .then(done) + .catch(done) + + graphql.graphql({ schema, source }).catch(done) + }) + }) + describe('with signature calculation disabled', () => { before(() => { tracer = require('../../dd-trace') @@ -1848,9 +2252,10 @@ describe('Plugin', () => { contextValue: params.contextValue, variableValues: params.variableValues, operationName: params.operationName, - fieldResolver: params.fieldResolver, typeResolver: params.typeResolver, }) + assert.strictEqual(typeof args.fieldResolver, 'function') + assert.notStrictEqual(args.fieldResolver, params.fieldResolver) assert.strictEqual(res, result) }) .then(done) diff --git a/packages/datadog-plugin-graphql/test/integration-test/client.spec.js b/packages/datadog-plugin-graphql/test/integration-test/client.spec.js index 838d8d50da..9c0764d727 100644 --- a/packages/datadog-plugin-graphql/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-graphql/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -39,7 +40,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'graphql.parse'), true) }) diff --git a/packages/datadog-plugin-graphql/test/tools/signature.spec.js b/packages/datadog-plugin-graphql/test/tools/signature.spec.js index 6c1f938a61..d14e2e9f9d 100644 --- a/packages/datadog-plugin-graphql/test/tools/signature.spec.js +++ b/packages/datadog-plugin-graphql/test/tools/signature.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const Module = require('node:module') +const { inspect } = require('node:util') const { describe, it, before, after } = require('mocha') const sinon = require('sinon') @@ -238,7 +239,7 @@ describe('graphql signature fallback', () => { locations: ['1:2'], path: ['hello', '0'], }) - assert.ok(!Object.hasOwn(attributes, 'extensions.missing')) + assert.ok(!Object.hasOwn(attributes, 'extensions.missing'), `Available keys: ${inspect(Object.keys(attributes))}`) }) }) @@ -297,7 +298,8 @@ describe('extractErrorIntoSpanEvent stack handling', () => { extractErrorIntoSpanEvent({}, span, error) assert.equal(getStackReads(), 0) - assert.ok(!Object.hasOwn(span.events[0].attributes, 'stacktrace')) + const attrs = span.events[0].attributes + assert.ok(!Object.hasOwn(attrs, 'stacktrace'), `Available keys: ${inspect(Object.keys(attrs))}`) }) it('skips stack symbolication when a validation error pins multiple AST nodes', () => { @@ -311,7 +313,8 @@ describe('extractErrorIntoSpanEvent stack handling', () => { extractErrorIntoSpanEvent({}, span, error) assert.equal(getStackReads(), 0) - assert.ok(!Object.hasOwn(span.events[0].attributes, 'stacktrace')) + const attrs = span.events[0].attributes + assert.ok(!Object.hasOwn(attrs, 'stacktrace'), `Available keys: ${inspect(Object.keys(attrs))}`) }) it('keeps stacktrace for execution errors with a resolver path', () => { diff --git a/packages/datadog-plugin-grpc/test/client.spec.js b/packages/datadog-plugin-grpc/test/client.spec.js index 0a34194e7f..f112186571 100644 --- a/packages/datadog-plugin-grpc/test/client.spec.js +++ b/packages/datadog-plugin-grpc/test/client.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const path = require('node:path') +const { inspect } = require('node:util') const Readable = require('node:stream').Readable const { after, afterEach, before, describe, it } = require('mocha') @@ -446,7 +447,10 @@ describe('Plugin', () => { }, }) - assert.ok(Object.hasOwn(traces[0][0].meta, ERROR_STACK)) + assert.ok( + Object.hasOwn(traces[0][0].meta, ERROR_STACK), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) assert.strictEqual(traces[0][0].metrics['grpc.status.code'], 2) }) }) @@ -493,7 +497,10 @@ describe('Plugin', () => { }, }) - assert.ok(Object.hasOwn(traces[0][0].meta, ERROR_STACK)) + assert.ok( + Object.hasOwn(traces[0][0].meta, ERROR_STACK), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) assert.match(traces[0][0].meta[ERROR_MESSAGE], /^13 INTERNAL:.+$/m) assert.strictEqual(traces[0][0].metrics['grpc.status.code'], 13) }) diff --git a/packages/datadog-plugin-grpc/test/integration-test/client.spec.js b/packages/datadog-plugin-grpc/test/integration-test/client.spec.js index 8288a97c59..2679367ccb 100644 --- a/packages/datadog-plugin-grpc/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-grpc/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -42,7 +43,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'grpc.client'), true) }) proc = await spawnPluginIntegrationTestProc(sandboxCwd(), variants[variant], agent.port) diff --git a/packages/datadog-plugin-hapi/test/index.spec.js b/packages/datadog-plugin-hapi/test/index.spec.js index a9e1d85c7e..302a527d49 100644 --- a/packages/datadog-plugin-hapi/test/index.spec.js +++ b/packages/datadog-plugin-hapi/test/index.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { AsyncLocalStorage } = require('node:async_hooks') +const { inspect } = require('node:util') const axios = require('axios') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') @@ -105,12 +106,16 @@ describe('Plugin', () => { assert.strictEqual(traces[0][0].meta['span.kind'], 'server') assert.strictEqual(traces[0][0].meta['http.url'], `http://localhost:${port}/user/123`) assert.strictEqual(traces[0][0].meta['http.method'], 'GET') - assert.ok(Object.hasOwn(traces[0][0].meta, 'http.status_code')) + assert.ok( + Object.hasOwn(traces[0][0].meta, 'http.status_code'), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) assert.strictEqual(traces[0][0].meta.component, 'hapi') assert.strictEqual(traces[0][0].meta['_dd.integration'], 'hapi') + const statusCode = Number(traces[0][0].meta['http.status_code']) assert.ok( - Number(traces[0][0].meta['http.status_code']) >= 200 && - Number(traces[0][0].meta['http.status_code']) <= 299 + statusCode >= 200 && statusCode <= 299, + `Expected 2xx status code, got ${statusCode}` ) }) .then(done) diff --git a/packages/datadog-plugin-hapi/test/integration-test/client.spec.js b/packages/datadog-plugin-hapi/test/integration-test/client.spec.js index 83f9e204cb..1fa1e5e665 100644 --- a/packages/datadog-plugin-hapi/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-hapi/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -43,7 +44,7 @@ describe('esm', () => { return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assertObjectContains(headers, { host: `127.0.0.1:${agent.port}` }) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'hapi.request'), true) }) }).timeout(20000) diff --git a/packages/datadog-plugin-hono/test/index.spec.js b/packages/datadog-plugin-hono/test/index.spec.js index c767e3a25f..e61155f31e 100644 --- a/packages/datadog-plugin-hono/test/index.spec.js +++ b/packages/datadog-plugin-hono/test/index.spec.js @@ -87,6 +87,151 @@ describe('Plugin', () => { }) }) + it('should set the correct resource name without middleware (single-handler fast path)', async function () { + let resolver + const promise = new Promise((resolve) => { + resolver = resolve + }) + + const bareApp = new hono.Hono() + bareApp.get('/product', (c) => c.json({ ok: true })) + + server = serve({ + fetch: bareApp.fetch, + port: 0, + }, ({ port }) => resolver(port)) + + const port = await promise + + await axios.get(`http://localhost:${port}/product`) + + await agent.assertFirstTraceSpan({ + name: 'hono.request', + service: 'test', + type: 'web', + resource: 'GET /product', + meta: { + 'span.kind': 'server', + 'http.method': 'GET', + 'http.status_code': '200', + component: 'hono', + }, + }) + }) + + it('should set the correct resource name for app.all() routes', async function () { + let resolver + const promise = new Promise((resolve) => { + resolver = resolve + }) + + const bareApp = new hono.Hono() + bareApp.all('/api', (c) => c.json({ method: c.req.method })) + + server = serve({ + fetch: bareApp.fetch, + port: 0, + }, ({ port }) => resolver(port)) + + const port = await promise + + await axios.post(`http://localhost:${port}/api`) + + await agent.assertFirstTraceSpan({ + name: 'hono.request', + service: 'test', + type: 'web', + resource: 'POST /api', + meta: { + 'span.kind': 'server', + 'http.method': 'POST', + 'http.status_code': '200', + component: 'hono', + }, + }) + }) + + it('should instrument routes registered on a basePath sub-app', async function () { + let resolver + const promise = new Promise((resolve) => { + resolver = resolve + }) + + const bareApp = new hono.Hono() + const api = bareApp.basePath('/api') + + api.use((c, next) => { + c.set('middleware', 'test') + return next() + }) + + api.get('/users/:id', (c) => c.json({ + id: c.req.param('id'), + middleware: c.get('middleware'), + })) + + server = serve({ + fetch: bareApp.fetch, + port: 0, + }, ({ port }) => resolver(port)) + + const port = await promise + + const { data } = await axios.get(`http://localhost:${port}/api/users/42`) + + assert.deepStrictEqual(data, { + id: '42', + middleware: 'test', + }) + + await agent.assertFirstTraceSpan({ + name: 'hono.request', + service: 'test', + type: 'web', + resource: 'GET /api/users/:id', + meta: { + 'span.kind': 'server', + 'http.method': 'GET', + 'http.status_code': '200', + component: 'hono', + }, + }) + }) + + it('should keep the bare resource name for middleware-only basePath matches', async function () { + let resolver + const promise = new Promise((resolve) => { + resolver = resolve + }) + + const bareApp = new hono.Hono() + const api = bareApp.basePath('/api') + + api.use((c) => c.json({ ok: true })) + + server = serve({ + fetch: bareApp.fetch, + port: 0, + }, ({ port }) => resolver(port)) + + const port = await promise + + await axios.get(`http://localhost:${port}/api/anything`) + + await agent.assertFirstTraceSpan({ + name: 'hono.request', + service: 'test', + type: 'web', + resource: 'GET', + meta: { + 'span.kind': 'server', + 'http.method': 'GET', + 'http.status_code': '200', + component: 'hono', + }, + }) + }) + it('should do automatic instrumentation on nested routes', async function () { let resolver const promise = new Promise((resolve) => { diff --git a/packages/datadog-plugin-http/src/server.js b/packages/datadog-plugin-http/src/server.js index 29e2b33d07..c706a7534d 100644 --- a/packages/datadog-plugin-http/src/server.js +++ b/packages/datadog-plugin-http/src/server.js @@ -14,30 +14,35 @@ class HttpServerPlugin extends ServerPlugin { static prefix = 'apm:http:server:request' + /** @type {string | undefined} */ + #operationName + + /** @type {object | undefined} */ + #startConfig + + /** @type {string | undefined} */ + #serviceSource + constructor (...args) { super(...args) this.addTraceSub('exit', message => this.exit(message)) } - start ({ req, res, abortController }) { + start (ctx) { + const { req, res } = ctx let store = legacyStorage.getStore() - const { name: schemaServiceName, source: schemaServiceSource } = this.serviceName() - const service = this.config.service || schemaServiceName - const serviceSource = (this.config.service && service !== this.tracer._service) - ? 'opt.plugin' - : (service === this.tracer._service ? undefined : schemaServiceSource) + if (this.#startConfig === undefined) { + this.#refreshStartCache() + } const span = web.startSpan( this.tracer, - { - ...this.config, - service, - }, + this.#startConfig, req, res, - this.operationName() + this.#operationName ) - if (serviceSource !== undefined) { - span.setTag(SVC_SRC_KEY, serviceSource) + if (this.#serviceSource !== undefined) { + span.setTag(SVC_SRC_KEY, this.#serviceSource) } span.setTag(COMPONENT, this.constructor.id) span._integrationName = this.constructor.id @@ -60,7 +65,10 @@ class HttpServerPlugin extends ServerPlugin { } if (appsecActive) { - incomingHttpRequestStart.publish({ req, res, abortController }) // TODO: no need to make a new object here + // Reuse the ctx allocated by the HTTP server instrumentation rather + // than a fresh `{ req, res, abortController }` per request; the AppSec + // subscriber only reads from the message. + incomingHttpRequestStart.publish(ctx) } } @@ -93,7 +101,24 @@ class HttpServerPlugin extends ServerPlugin { } configure (config) { - return super.configure(web.normalizeConfig(config)) + const result = super.configure(web.normalizeConfig(config)) + // Invalidate the start-cache; the next `start` refills it. Resolving + // service / operation eagerly here would pin nomenclature lookups to + // the order plugins and tracer initialise. + this.#startConfig = undefined + return result + } + + #refreshStartCache () { + const { name: schemaServiceName, source: schemaServiceSource } = this.serviceName() + const tracerService = this.tracer._service + const configService = this.config.service + const service = configService || schemaServiceName + this.#serviceSource = (configService && service !== tracerService) + ? 'opt.plugin' + : (service === tracerService ? undefined : schemaServiceSource) + this.#operationName = this.operationName() + this.#startConfig = { ...this.config, service } } } diff --git a/packages/datadog-plugin-http/test/code_origin.spec.js b/packages/datadog-plugin-http/test/code_origin.spec.js index e60343812a..a8fe900b4c 100644 --- a/packages/datadog-plugin-http/test/code_origin.spec.js +++ b/packages/datadog-plugin-http/test/code_origin.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { afterEach, before, beforeEach, describe, it } = require('mocha') @@ -37,11 +38,26 @@ describe('Plugin', () => { assert.strictEqual(span.meta['_dd.code_origin.type'], 'exit') // Just validate that frame 0 tags are present. The detailed validation is performed in a different test. - assert.ok(Object.hasOwn(span.meta, '_dd.code_origin.frames.0.file')) - assert.ok(Object.hasOwn(span.meta, '_dd.code_origin.frames.0.line')) - assert.ok(Object.hasOwn(span.meta, '_dd.code_origin.frames.0.column')) - assert.ok(Object.hasOwn(span.meta, '_dd.code_origin.frames.0.method')) - assert.ok(Object.hasOwn(span.meta, '_dd.code_origin.frames.0.type')) + assert.ok( + Object.hasOwn(span.meta, '_dd.code_origin.frames.0.file'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert.ok( + Object.hasOwn(span.meta, '_dd.code_origin.frames.0.line'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert.ok( + Object.hasOwn(span.meta, '_dd.code_origin.frames.0.column'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert.ok( + Object.hasOwn(span.meta, '_dd.code_origin.frames.0.method'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert.ok( + Object.hasOwn(span.meta, '_dd.code_origin.frames.0.type'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) }) .then(done) .catch(done) diff --git a/packages/datadog-plugin-http/test/integration-test/client.spec.js b/packages/datadog-plugin-http/test/integration-test/client.spec.js index ef88fc98ce..b77a296e9b 100644 --- a/packages/datadog-plugin-http/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-http/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -39,9 +40,9 @@ describe('esm', () => { return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 1) - assert.ok(Array.isArray(payload[0])) + assert.ok(Array.isArray(payload[0]), `Expected array, got ${inspect(payload[0])}`) assert.strictEqual(payload[0].length, 1) assert.strictEqual(payload[0][0].name, 'web.request') }) diff --git a/packages/datadog-plugin-http/test/server.spec.js b/packages/datadog-plugin-http/test/server.spec.js index 53fde5c4f6..fe30a41861 100644 --- a/packages/datadog-plugin-http/test/server.spec.js +++ b/packages/datadog-plugin-http/test/server.spec.js @@ -345,6 +345,89 @@ describe('Plugin', () => { }) }) + describe('with a `service` configuration', () => { + describe('when the override differs from the tracer service', () => { + beforeEach(() => { + return agent.load('http', { client: false, server: { service: 'my-http-service' } }) + .then(() => { + http = require(pluginToBeLoaded) + }) + }) + + beforeEach(done => { + const server = new http.Server(listener) + appListener = server + .listen(0, 'localhost', () => { + port = appListener.address().port + done() + }) + }) + + it('should override the service and mark the source as `opt.plugin`', done => { + agent + .assertSomeTraces(traces => { + assert.strictEqual(traces[0][0].service, 'my-http-service') + assert.strictEqual(traces[0][0].meta['_dd.svc_src'], 'opt.plugin') + }) + .then(done) + .catch(done) + + axios.get(`http://localhost:${port}/users`).catch(done) + }) + + it('should reuse the cached start config across requests', done => { + const expect = traces => { + assert.strictEqual(traces[0][0].service, 'my-http-service') + assert.strictEqual(traces[0][0].meta['_dd.svc_src'], 'opt.plugin') + } + + // The first request populates `#startConfig`; the second takes + // the cached path. Asserting both ensures the cached value is + // not stale and the span shape stays identical. + Promise.all([ + agent.assertSomeTraces(expect), + axios.get(`http://localhost:${port}/first`), + ]) + .then(() => Promise.all([ + agent.assertSomeTraces(expect), + axios.get(`http://localhost:${port}/second`), + ])) + .then(() => done()) + .catch(done) + }) + }) + + describe('when the override matches the tracer service', () => { + beforeEach(() => { + return agent.load('http', { client: false, server: { service: 'test' } }) + .then(() => { + http = require(pluginToBeLoaded) + }) + }) + + beforeEach(done => { + const server = new http.Server(listener) + appListener = server + .listen(0, 'localhost', () => { + port = appListener.address().port + done() + }) + }) + + it('should not add the service source tag when the override matches', done => { + agent + .assertSomeTraces(traces => { + assert.strictEqual(traces[0][0].service, 'test') + assert.strictEqual(Object.hasOwn(traces[0][0].meta, '_dd.svc_src'), false) + }) + .then(done) + .catch(done) + + axios.get(`http://localhost:${port}/users`).catch(done) + }) + }) + }) + describe('with resourceRenamingEnabled configuration', () => { beforeEach(() => { return agent.load('http', { client: false, resourceRenamingEnabled: true }) diff --git a/packages/datadog-plugin-http2/test/integration-test/client.spec.js b/packages/datadog-plugin-http2/test/integration-test/client.spec.js index b1d08c5d77..25fcf32193 100644 --- a/packages/datadog-plugin-http2/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-http2/test/integration-test/client.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const http2 = require('http2') +const { inspect } = require('node:util') const { FakeAgent, spawnPluginIntegrationTestProc, @@ -39,9 +40,9 @@ describe('esm', () => { proc = await spawnPluginIntegrationTestProc(sandboxCwd(), variants[variant], agent.port) const resultPromise = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(payload.length, 1) - assert.ok(Array.isArray(payload[0])) + assert.ok(Array.isArray(payload[0]), `Expected array, got ${inspect(payload[0])}`) assert.strictEqual(payload[0].length, 1) assert.strictEqual(payload[0][0].name, 'web.request') assert.strictEqual(payload[0][0].meta.component, 'http2') diff --git a/packages/datadog-plugin-http2/test/server.spec.js b/packages/datadog-plugin-http2/test/server.spec.js index 68321ad833..8ee6e84467 100644 --- a/packages/datadog-plugin-http2/test/server.spec.js +++ b/packages/datadog-plugin-http2/test/server.spec.js @@ -138,9 +138,13 @@ describe('Plugin', () => { app = sinon.stub() const tracesPromise = agent.assertSomeTraces(traces => { + // The batch may also contain the client-side http.request span; find the server span. + const serverTrace = traces.find(t => t[0]?.name === 'web.request') + if (!serverTrace) throw new Error('No web.request span found in batch yet') + sinon.assert.notCalled(app) // request should be cancelled before call to app - assertObjectContains(traces[0][0], { + assertObjectContains(serverTrace[0], { name: 'web.request', service: 'test', type: 'web', @@ -218,7 +222,10 @@ describe('Plugin', () => { beforeEach(done => { const server = http2.createServer(listener) appListener = server - .listen(port, 'localhost', () => done()) + .listen(0, 'localhost', () => { + port = appListener.address().port + done() + }) }) const spanProducerFn = (done) => { @@ -313,7 +320,10 @@ describe('Plugin', () => { beforeEach(done => { const server = http2.createServer(listener) appListener = server - .listen(port, 'localhost', () => done()) + .listen(0, 'localhost', () => { + port = appListener.address().port + done() + }) }) it('should drop traces for blocklist route', done => { diff --git a/packages/datadog-plugin-ioredis/test/integration-test/client.spec.js b/packages/datadog-plugin-ioredis/test/integration-test/client.spec.js index cfa037bddb..df1f22af26 100644 --- a/packages/datadog-plugin-ioredis/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-ioredis/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -38,7 +39,7 @@ describe('esm', () => { it(`is instrumented loaded with ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'redis.command'), true) }) diff --git a/packages/datadog-plugin-iovalkey/test/integration-test/client.spec.js b/packages/datadog-plugin-iovalkey/test/integration-test/client.spec.js index f4c4cb9c5a..e36790b4ed 100644 --- a/packages/datadog-plugin-iovalkey/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-iovalkey/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -39,7 +40,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'valkey.command'), true) }) diff --git a/packages/datadog-plugin-jest/src/index.js b/packages/datadog-plugin-jest/src/index.js index 3d123ac571..76e998defa 100644 --- a/packages/datadog-plugin-jest/src/index.js +++ b/packages/datadog-plugin-jest/src/index.js @@ -16,7 +16,6 @@ const { getTestSuiteCommonTags, addIntelligentTestRunnerSpanTags, TEST_PARAMETERS, - TEST_COMMAND, TEST_FRAMEWORK_VERSION, TEST_SOURCE_START, TEST_ITR_UNSKIPPABLE, @@ -115,7 +114,9 @@ class JestPlugin extends CiPlugin { isSuitesSkipped, isSuitesSkippingEnabled, isCodeCoverageEnabled, + isCoverageReportUploadEnabled, testCodeCoverageLinesTotal, + testSessionCoverageFiles, numSkippedSuites, hasUnskippableSuites, hasForcedToRunSuites, @@ -149,6 +150,13 @@ class JestPlugin extends CiPlugin { } ) + if (testSessionCoverageFiles?.length && isCoverageReportUploadEnabled) { + this.tracer._exporter.exportCoverage({ + sessionId: this.testSessionSpan.context()._traceId, + files: testSessionCoverageFiles, + }) + } + if (isEarlyFlakeDetectionEnabled) { this.testSessionSpan.setTag(TEST_EARLY_FLAKE_ENABLED, 'true') } @@ -194,7 +202,7 @@ class JestPlugin extends CiPlugin { for (const config of configs) { config._ddTestSessionId = this.testSessionSpan.context().toTraceId() config._ddTestModuleId = this.testModuleSpan.context().toSpanId() - config._ddTestCommand = this.testSessionSpan.context()._tags[TEST_COMMAND] + config._ddTestCommand = this.command config._ddRequestErrorTags = this.getSessionRequestErrorTags() config._ddItrCorrelationId = this.itrCorrelationId config._ddIsEarlyFlakeDetectionEnabled = !!this.libraryConfig?.isEarlyFlakeDetectionEnabled @@ -596,7 +604,7 @@ class JestPlugin extends CiPlugin { extraTags[TEST_HAS_DYNAMIC_NAME] = 'true' } const testSuiteSpan = this.testSuiteSpanPerTestSuiteAbsolutePath.get(testSuiteAbsolutePath) || this.testSuiteSpan - const skippingEnabled = testSuiteSpan?.context()._tags?.[TEST_ITR_SKIPPING_ENABLED] + const skippingEnabled = testSuiteSpan?.context()?.getTag?.(TEST_ITR_SKIPPING_ENABLED) if (skippingEnabled !== undefined) { extraTags[TEST_ITR_SKIPPING_ENABLED] = skippingEnabled } diff --git a/packages/datadog-plugin-jest/src/util.js b/packages/datadog-plugin-jest/src/util.js index 34709449e5..f4e05cebc5 100644 --- a/packages/datadog-plugin-jest/src/util.js +++ b/packages/datadog-plugin-jest/src/util.js @@ -41,11 +41,16 @@ function getFormattedJestTestParameters (testParameters) { return formattedParameters } -// Support for `@fast-check/jest`: this library modifies the test name to include the seed -// A test name that keeps changing breaks some Test Optimization's features. +// @fast-check/jest appends a random seed to the reported test name. A test name that keeps changing +// breaks some Test Optimization features, so normalize this narrow suffix regardless of import style. const SEED_SUFFIX_RE = /\s*\(with seed=-?\d+\)\s*$/i + +function removeSeedSuffixFromTestName (testName) { + return testName.replace(SEED_SUFFIX_RE, '') +} + // https://github.com/facebook/jest/blob/3e38157ad5f23fb7d24669d24fae8ded06a7ab75/packages/jest-circus/src/utils.ts#L396 -function getJestTestName (test, shouldStripSeed = false) { +function getRawJestTestName (test) { const titles = [] let parent = test do { @@ -54,11 +59,11 @@ function getJestTestName (test, shouldStripSeed = false) { titles.shift() // remove TOP_DESCRIBE_BLOCK_NAME - const testName = titles.join(' ') - if (shouldStripSeed) { - return testName.replace(SEED_SUFFIX_RE, '') - } - return testName + return titles.join(' ') +} + +function getJestTestName (test) { + return removeSeedSuffixFromTestName(getRawJestTestName(test)) } const globalDocblockRegExp = /^\s*(\/\*\*?(.|\r?\n)*?\*\/)/ @@ -170,6 +175,8 @@ module.exports = { SEED_SUFFIX_RE, getFormattedJestTestParameters, getJestTestName, + getRawJestTestName, getJestSuitesToRun, isMarkedAsUnskippable, + removeSeedSuffixFromTestName, } diff --git a/packages/datadog-plugin-jest/test/fixtures/test-to-run.js b/packages/datadog-plugin-jest/test/fixtures/test-to-run.js new file mode 100644 index 0000000000..e10291bf0b --- /dev/null +++ b/packages/datadog-plugin-jest/test/fixtures/test-to-run.js @@ -0,0 +1,8 @@ +'use strict' + +// Fixture for util.spec.js. `getJestSuitesToRun` reads this file to scan for +// a `@datadog` docblock; the body of the suite is never executed. + +describe('test-to-run', () => { + it('is a placeholder fixture', () => {}) +}) diff --git a/packages/datadog-plugin-jest/test/fixtures/test-to-skip.js b/packages/datadog-plugin-jest/test/fixtures/test-to-skip.js new file mode 100644 index 0000000000..b70991c538 --- /dev/null +++ b/packages/datadog-plugin-jest/test/fixtures/test-to-skip.js @@ -0,0 +1,8 @@ +'use strict' + +// Fixture for util.spec.js. `getJestSuitesToRun` reads this file to scan for +// a `@datadog` docblock; the body of the suite is never executed. + +describe('test-to-skip', () => { + it('is a placeholder fixture', () => {}) +}) diff --git a/packages/datadog-plugin-jest/test/fixtures/test-unskippable.js b/packages/datadog-plugin-jest/test/fixtures/test-unskippable.js new file mode 100644 index 0000000000..72a7a8045c --- /dev/null +++ b/packages/datadog-plugin-jest/test/fixtures/test-unskippable.js @@ -0,0 +1,11 @@ +/** + * @datadog {"unskippable": true} + */ +'use strict' + +// Fixture for util.spec.js. `getJestSuitesToRun` reads this file to scan for +// the `@datadog` docblock above; the body of the suite is never executed. + +describe('test-unskippable', () => { + it('is a placeholder fixture', () => {}) +}) diff --git a/packages/datadog-plugin-jest/test/util.spec.js b/packages/datadog-plugin-jest/test/util.spec.js index bd7b7fd48c..665d126593 100644 --- a/packages/datadog-plugin-jest/test/util.spec.js +++ b/packages/datadog-plugin-jest/test/util.spec.js @@ -5,7 +5,36 @@ const path = require('node:path') const { describe, it } = require('mocha') -const { getFormattedJestTestParameters, getJestSuitesToRun } = require('../src/util') +const { + getFormattedJestTestParameters, + getJestSuitesToRun, + removeSeedSuffixFromTestName, +} = require('../src/util') + +describe('removeSeedSuffixFromTestName', () => { + it('removes seed suffixes', () => { + assert.strictEqual( + removeSeedSuffixFromTestName('property passes (with seed=1234)'), + 'property passes' + ) + assert.strictEqual( + removeSeedSuffixFromTestName('property passes (with seed=-1234)'), + 'property passes' + ) + assert.strictEqual( + removeSeedSuffixFromTestName('property passes (with seed=1234) '), + 'property passes' + ) + }) + + it('only removes the seed suffix at the end of the name', () => { + assert.strictEqual( + removeSeedSuffixFromTestName('property (with seed=1234) keeps running'), + 'property (with seed=1234) keeps running' + ) + }) +}) + describe('getFormattedJestTestParameters', () => { it('returns formatted parameters for arrays', () => { const result = getFormattedJestTestParameters([[[1, 2], [3, 4]]]) diff --git a/packages/datadog-plugin-kafkajs/src/batch-consumer.js b/packages/datadog-plugin-kafkajs/src/batch-consumer.js index 0c89d0e414..cde1a603bd 100644 --- a/packages/datadog-plugin-kafkajs/src/batch-consumer.js +++ b/packages/datadog-plugin-kafkajs/src/batch-consumer.js @@ -34,7 +34,7 @@ class KafkajsBatchConsumerPlugin extends ConsumerPlugin { if (headers) { const childOf = this.tracer.extract('text_map', headers) if (childOf) { - span.addLink(childOf) + span.addLink({ context: childOf }) } } diff --git a/packages/datadog-plugin-kafkajs/src/producer.js b/packages/datadog-plugin-kafkajs/src/producer.js index 724bf0851d..bb5418fc9c 100644 --- a/packages/datadog-plugin-kafkajs/src/producer.js +++ b/packages/datadog-plugin-kafkajs/src/producer.js @@ -101,6 +101,9 @@ class KafkajsProducerPlugin extends ProducerPlugin { // response, only the starting offset. const offsets = [] for (const entry of result) { + // sendBatch hands the same multi-topic response to every per-topic + // ctx; the span only owns its own topic's entries. + if (entry.topicName !== ctx.topic) continue const offsetAsLong = entry.offset ?? entry.baseOffset if (entry.partition === undefined || offsetAsLong === undefined) continue // Kafka offsets are 64-bit; coercing to Number loses precision past diff --git a/packages/datadog-plugin-kafkajs/test/commit.spec.js b/packages/datadog-plugin-kafkajs/test/commit.spec.js index 1ef5353527..11e5131ebd 100644 --- a/packages/datadog-plugin-kafkajs/test/commit.spec.js +++ b/packages/datadog-plugin-kafkajs/test/commit.spec.js @@ -28,6 +28,7 @@ describe('kafkajs producer finish', () => { try { plugin.finish({ currentStore: { span }, + topic: 't', messages: [{ value: 'a' }, { value: 'b' }, { value: 'c' }], result: [ { topicName: 't', partition: 2, baseOffset: '20' }, @@ -55,6 +56,7 @@ describe('kafkajs producer finish', () => { try { plugin.finish({ currentStore: { span }, + topic: 't', messages: [{ value: 'one' }], result: [{ topicName: 't', partition: 0, baseOffset: hugeOffset }], }) @@ -71,6 +73,7 @@ describe('kafkajs producer finish', () => { try { plugin.finish({ currentStore: { span }, + topic: 't', messages: [{ value: 'one' }], result: [{ topicName: 't', partition: 0, baseOffset: 0 }], }) @@ -80,6 +83,32 @@ describe('kafkajs producer finish', () => { assert.equal(tags['kafka.messages.offsets'], JSON.stringify([{ partition: 0, start_offset: '0' }])) assert.equal(tags['kafka.message.offset'], '0') }) + + it('keeps offsets isolated to ctx.topic in multi-topic sendBatch responses', () => { + const { plugin, span, tags, restore } = makeFinishHarness() + try { + plugin.finish({ + currentStore: { span }, + topic: 'a', + messages: [{ value: 'one' }, { value: 'two' }], + result: [ + { topicName: 'a', partition: 0, baseOffset: '5' }, + { topicName: 'b', partition: 0, baseOffset: '99' }, + { topicName: 'a', partition: 1, baseOffset: '7' }, + ], + }) + } finally { + restore() + } + // Topic 'b' must not bleed into topic 'a''s span: the user query + // 'show me the offsets we wrote to topic a' has to match the broker. + assert.equal(tags['kafka.messages.offsets'], JSON.stringify([ + { partition: 0, start_offset: '5' }, + { partition: 1, start_offset: '7' }, + ])) + assert.equal(tags['kafka.partition'], undefined) + assert.equal(tags['kafka.message.offset'], undefined) + }) }) describe('kafkajs commit walk', () => { diff --git a/packages/datadog-plugin-kafkajs/test/dsm.spec.js b/packages/datadog-plugin-kafkajs/test/dsm.spec.js index 09ff76c7ff..74fc489c4b 100644 --- a/packages/datadog-plugin-kafkajs/test/dsm.spec.js +++ b/packages/datadog-plugin-kafkajs/test/dsm.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { randomUUID } = require('crypto') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const semver = require('semver') const sinon = require('sinon') @@ -14,6 +15,7 @@ const { computePathwayHash } = require('../../dd-trace/src/datastreams/pathway') const { ENTRY_PARENT_HASH, DataStreamsProcessor } = require('../../dd-trace/src/datastreams/processor') const propagationHash = require('../../dd-trace/src/propagation-hash') const { assertObjectContains } = require('../../../integration-tests/helpers') +const { createTopicWithRetry } = require('./helpers') const testKafkaClusterId = '5L6g3nShT-eMCtK--X86sw' @@ -70,7 +72,7 @@ describe('Plugin', () => { topicBIn = `topic-b-in-${randomUUID()}` topicBOut = `topic-b-out-${randomUUID()}` admin = kafka.admin() - await admin.createTopics({ + await createTopicWithRetry(admin, { waitForLeaders: true, topics: [testTopic, topicAIn, topicAOut, topicBIn, topicBOut].map(topic => ({ topic, @@ -78,6 +80,7 @@ describe('Plugin', () => { replicationFactor: 1, })), }) + await admin.disconnect() expectedProducerHash = getDsmPathwayHash(testTopic, true, ENTRY_PARENT_HASH) expectedConsumerHash = getDsmPathwayHash(testTopic, false, expectedProducerHash) }) @@ -106,6 +109,25 @@ describe('Plugin', () => { assert.strictEqual(setDataStreamsContextSpy.args[0][0].hash, expectedProducerHash) }) + it('Should set one checkpoint per topic on sendBatch', async () => { + const expectedAHash = getDsmPathwayHash(topicAOut, true, ENTRY_PARENT_HASH) + const expectedBHash = getDsmPathwayHash(topicBOut, true, ENTRY_PARENT_HASH) + + const producer = kafka.producer() + await producer.connect() + await producer.sendBatch({ + topicMessages: [ + { topic: topicAOut, messages: [{ key: 'a', value: 'va' }] }, + { topic: topicBOut, messages: [{ key: 'b', value: 'vb' }] }, + ], + }) + await producer.disconnect() + + const hashes = setDataStreamsContextSpy.getCalls().map(call => call.args[0].hash) + assert.ok(hashes.includes(expectedAHash), `missing DSM checkpoint for ${topicAOut}`) + assert.ok(hashes.includes(expectedBHash), `missing DSM checkpoint for ${topicBOut}`) + }) + it('Should set a checkpoint on consume (eachMessage)', async () => { const runArgs = [] await consumer.run({ @@ -141,7 +163,10 @@ describe('Plugin', () => { } const recordCheckpointSpy = sinon.spy(DataStreamsProcessor.prototype, 'recordCheckpoint') await sendMessages(kafka, testTopic, messages) - assert.ok(Object.hasOwn(recordCheckpointSpy.args[0][0], 'payloadSize')) + assert.ok( + Object.hasOwn(recordCheckpointSpy.args[0][0], 'payloadSize'), + `Available keys: ${inspect(Object.keys(recordCheckpointSpy.args[0][0]))}` + ) recordCheckpointSpy.restore() }) @@ -154,7 +179,10 @@ describe('Plugin', () => { await sendMessages(kafka, testTopic, messages) await consumer.run({ eachMessage: async () => { - assert.ok(Object.hasOwn(recordCheckpointSpy.args[0][0], 'payloadSize')) + assert.ok( + Object.hasOwn(recordCheckpointSpy.args[0][0], 'payloadSize'), + `Available keys: ${inspect(Object.keys(recordCheckpointSpy.args[0][0]))}` + ) recordCheckpointSpy.restore() }, }) @@ -316,6 +344,23 @@ describe('Plugin', () => { assert.strictEqual(runArg.topic, testTopic) assert.strictEqual(runArg.kafka_cluster_id, testKafkaClusterId) }) + + it('Should add one backlog per response item on sendBatch (no N x M duplication)', async () => { + const producer = kafka.producer() + await producer.connect() + await producer.sendBatch({ + topicMessages: [ + { topic: topicAOut, messages: [{ key: 'a', value: 'va' }] }, + { topic: topicBOut, messages: [{ key: 'b', value: 'vb' }] }, + ], + }) + await producer.disconnect() + + const produceCalls = setOffsetSpy.getCalls() + .filter(call => call.args[0]?.type === 'kafka_produce') + const topics = produceCalls.map(call => call.args[0].topic).sort() + assert.deepStrictEqual(topics, [topicAOut, topicBOut].sort()) + }) }) }) }) diff --git a/packages/datadog-plugin-kafkajs/test/helpers.js b/packages/datadog-plugin-kafkajs/test/helpers.js new file mode 100644 index 0000000000..8516e79664 --- /dev/null +++ b/packages/datadog-plugin-kafkajs/test/helpers.js @@ -0,0 +1,22 @@ +'use strict' + +// KafkaJS's retryOnLeaderNotAvailable only retries on LEADER_NOT_AVAILABLE. Right after +// topic creation, Kafka can transiently return UNKNOWN_TOPIC_OR_PARTITION in the metadata +// response before the new topic has fully propagated, which KafkaJS re-throws immediately. +async function createTopicWithRetry (admin, topicConfig, maxRetries = 5) { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + await admin.createTopics(topicConfig) + return + } catch (err) { + if (err.type === 'TOPIC_ALREADY_EXISTS') return + if (attempt < maxRetries && err.type === 'UNKNOWN_TOPIC_OR_PARTITION') { + await new Promise(resolve => setTimeout(resolve, 1000)) + continue + } + throw err + } + } +} + +module.exports = { createTopicWithRetry } diff --git a/packages/datadog-plugin-kafkajs/test/index.spec.js b/packages/datadog-plugin-kafkajs/test/index.spec.js index 82d89ce7d0..796882bee1 100644 --- a/packages/datadog-plugin-kafkajs/test/index.spec.js +++ b/packages/datadog-plugin-kafkajs/test/index.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { randomUUID } = require('node:crypto') +const { inspect } = require('node:util') const dc = require('dc-polyfill') const { describe, it, beforeEach, afterEach } = require('mocha') @@ -16,6 +17,7 @@ const { clientToCluster } = require('../../datadog-instrumentations/src/helpers/ const { assertObjectContains, deepFreeze } = require('../../../integration-tests/helpers') const { expectedSchema, rawExpectedSchema } = require('./naming') +const { createTopicWithRetry } = require('./helpers') const testKafkaClusterId = '5L6g3nShT-eMCtK--X86sw' @@ -50,7 +52,7 @@ describe('Plugin', () => { }) testTopic = `test-topic-${randomUUID()}` admin = kafka.admin() - await admin.createTopics({ + await createTopicWithRetry(admin, { waitForLeaders: true, topics: [{ topic: testTopic, @@ -58,6 +60,7 @@ describe('Plugin', () => { replicationFactor: 1, }], }) + await admin.disconnect() }) describe('producer', () => { @@ -226,7 +229,10 @@ describe('Plugin', () => { it('should not extract bootstrap servers when initialized with a function', async () => { const expectedSpanPromise = agent.assertSomeTraces(traces => { const span = traces[0][0] - assert.ok(!((['messaging.kafka.bootstrap.servers']).some(k => Object.hasOwn((span.meta), k)))) + assert.ok( + !((['messaging.kafka.bootstrap.servers']).some(k => Object.hasOwn((span.meta), k))), + `Got: ${inspect(['messaging.kafka.bootstrap.servers'])}` + ) }) kafka = new Kafka({ @@ -294,7 +300,10 @@ describe('Plugin', () => { // The first send injects trace headers into the cloned // batch that kafkajs serializes. - assert.ok(Object.hasOwn(sentMessageBatches[0][0].headers, 'x-datadog-trace-id')) + assert.ok( + Object.hasOwn(sentMessageBatches[0][0].headers, 'x-datadog-trace-id'), + `Available keys: ${inspect(Object.keys(sentMessageBatches[0][0].headers))}` + ) sendRequestStub.restore() @@ -320,6 +329,210 @@ describe('Plugin', () => { ) }) + describe('producer (sendBatch)', () => { + let topicA + let topicB + + beforeEach(async () => { + topicA = `${testTopic}-batch-a` + topicB = `${testTopic}-batch-b` + const batchAdmin = kafka.admin() + await createTopicWithRetry(batchAdmin, { + waitForLeaders: true, + topics: [topicA, topicB].map(topic => ({ + topic, + numPartitions: 1, + replicationFactor: 1, + })), + }) + await batchAdmin.disconnect() + }) + + it('should emit one kafka.produce span per topicMessages entry', async () => { + // Per-topic offset isolation: the broker returns one aggregated + // response covering every topic in the batch; each span must tag + // only its own topic's (partition, offset) entries. + const topicAOffsets = JSON.stringify([{ partition: 0, start_offset: '0' }]) + const topicBOffsets = JSON.stringify([{ partition: 0, start_offset: '0' }]) + + const topicASpanPromise = agent.assertSomeTraces(traces => { + const span = traces[0].find(s => + s.name === expectedSchema.send.opName && s.resource === topicA) + assert.ok(span, `no kafka.produce span for ${topicA}`) + assertObjectContains(span, { + service: expectedSchema.send.serviceName, + meta: { + 'span.kind': 'producer', + component: 'kafkajs', + 'messaging.destination.name': topicA, + 'messaging.kafka.bootstrap.servers': '127.0.0.1:9092', + 'kafka.cluster_id': testKafkaClusterId, + 'kafka.messages.offsets': topicAOffsets, + }, + metrics: { 'kafka.batch_size': 1 }, + error: 0, + }) + }) + + const topicBSpanPromise = agent.assertSomeTraces(traces => { + const span = traces[0].find(s => + s.name === expectedSchema.send.opName && s.resource === topicB) + assert.ok(span, `no kafka.produce span for ${topicB}`) + assertObjectContains(span, { + resource: topicB, + meta: { 'kafka.messages.offsets': topicBOffsets }, + metrics: { 'kafka.batch_size': 2 }, + error: 0, + }) + }) + + await sendBatch(kafka, [ + { topic: topicA, messages: [{ key: 'a', value: 'msg-a' }] }, + { topic: topicB, messages: [{ key: 'b1', value: 'msg-b1' }, { key: 'b2', value: 'msg-b2' }] }, + ]) + + return Promise.all([topicASpanPromise, topicBSpanPromise]) + }) + + it('should emit one span for a single-topic sendBatch (mirrors send)', async () => { + const expectedSpanPromise = agent.assertSomeTraces(traces => { + const spans = traces[0].filter(s => s.name === expectedSchema.send.opName) + assert.strictEqual(spans.length, 1) + assertObjectContains(spans[0], { + service: expectedSchema.send.serviceName, + resource: topicA, + meta: { + 'span.kind': 'producer', + component: 'kafkajs', + 'messaging.destination.name': topicA, + 'messaging.kafka.bootstrap.servers': '127.0.0.1:9092', + 'kafka.cluster_id': testKafkaClusterId, + }, + metrics: { 'kafka.batch_size': 1 }, + error: 0, + }) + }) + + await sendBatch(kafka, [{ topic: topicA, messages: [{ key: 'k', value: 'v' }] }]) + + return expectedSpanPromise + }) + + it('should inject a distinct trace context into each topic\'s cloned messages', async () => { + const startCh = dc.channel('apm:kafkajs:produce:start') + const sentBatches = [] + const captureStart = (ctx) => sentBatches.push({ topic: ctx.topic, messages: ctx.messages }) + startCh.subscribe(captureStart) + + try { + // Deep-freeze the user input so any boundary or plugin write to it throws. + const userTopicMessages = deepFreeze([ + { topic: topicA, messages: [{ key: 'a', value: 'msg-a' }] }, + { topic: topicB, messages: [{ key: 'b', value: 'msg-b' }] }, + ]) + + await sendBatch(kafka, userTopicMessages) + + assert.strictEqual(sentBatches.length, 2) + const aBatch = sentBatches.find(b => b.topic === topicA) + const bBatch = sentBatches.find(b => b.topic === topicB) + const aTraceId = aBatch.messages[0].headers['x-datadog-trace-id'].toString() + const bTraceId = bBatch.messages[0].headers['x-datadog-trace-id'].toString() + assert.ok(aTraceId) + assert.ok(bTraceId) + assert.notStrictEqual(aTraceId, bTraceId) + } finally { + startCh.unsubscribe(captureStart) + } + }) + + it('should tag error on every per-topic span when sendBatch rejects', async () => { + const errorSpanPromise = agent.assertSomeTraces(traces => { + const span = traces[0].find(s => + s.name === expectedSchema.send.opName && s.resource === topicA) + assert.ok(span, `no kafka.produce span for ${topicA}`) + assert.strictEqual(span.error, 1) + assert.ok(span.meta[ERROR_MESSAGE]) + }) + + const producer = kafka.producer() + await producer.connect() + await assert.rejects(producer.sendBatch({ + topicMessages: [ + { topic: topicA, messages: 'not-an-array' }, + ], + })) + await producer.disconnect() + + return errorSpanPromise + }) + + describe('when broker rejects headers with UNKNOWN_SERVER_ERROR', function () { + // kafkajs 1.4.0 is very slow when encountering errors + this.timeout(30000) + + let produceStub + let producer + const error = Object.assign( + new Error('Simulated KafkaJSProtocolError UNKNOWN from Broker.produce stub'), + { name: 'KafkaJSProtocolError', type: 'UNKNOWN' } + ) + + beforeEach(async () => { + const otherKafka = new Kafka({ + clientId: `kafkajs-test-${version}`, + brokers: ['127.0.0.1:9092'], + retry: { retries: 0 }, + }) + produceStub = sinon.stub(Broker.prototype, 'produce').rejects(error) + producer = otherKafka.producer({ transactionTimeout: 10 }) + await producer.connect() + }) + + afterEach(() => { + produceStub.restore() + }) + + it('disables header injection for later sendBatch calls', async () => { + const startCh = dc.channel('apm:kafkajs:produce:start') + const sentBatches = [] + const captureStart = (ctx) => sentBatches.push({ topic: ctx.topic, messages: ctx.messages }) + startCh.subscribe(captureStart) + + const firstBatch = deepFreeze([{ key: 'k1', value: 'v1' }]) + const secondBatch = deepFreeze([{ key: 'k2', value: 'v2' }]) + + try { + await assert.rejects(producer.sendBatch({ + topicMessages: [{ topic: topicA, messages: firstBatch }], + }), error) + + // The first send injects trace headers into the cloned batch. + assert.ok( + Object.hasOwn(sentBatches[0].messages[0].headers, 'x-datadog-trace-id'), + `Available keys: ${inspect(Object.keys(sentBatches[0].messages[0].headers))}` + ) + + produceStub.restore() + + const result2 = await producer.sendBatch({ + topicMessages: [{ topic: topicA, messages: secondBatch }], + }) + + // After UNKNOWN, header injection is disabled: cloned messages + // have no `headers` field at all, the user's frozen input is + // untouched, and brokers that reject any header field recover. + const [clonedAfterDisable] = sentBatches[1].messages + assert.notStrictEqual(clonedAfterDisable, secondBatch[0]) + assert.strictEqual(Object.hasOwn(clonedAfterDisable, 'headers'), false) + assert.strictEqual(result2[0].errorCode, 0) + } finally { + startCh.unsubscribe(captureStart) + } + }) + }) + }) + describe('consumer (eachMessage)', () => { let consumer @@ -387,7 +600,8 @@ describe('Plugin', () => { resource: testTopic, }) - assert.ok(parseInt(span.parent_id.toString()) > 0) + const parentId = parseInt(span.parent_id.toString()) + assert.ok(parentId > 0, `Expected ${parentId} > 0`) }) await consumer.run({ eachMessage: () => {} }) @@ -653,3 +867,11 @@ async function sendMessages (kafka, topic, messages) { }) await producer.disconnect() } + +async function sendBatch (kafka, topicMessages) { + const producer = kafka.producer() + await producer.connect() + const result = await producer.sendBatch({ topicMessages }) + await producer.disconnect() + return result +} diff --git a/packages/datadog-plugin-kafkajs/test/integration-test/client.spec.js b/packages/datadog-plugin-kafkajs/test/integration-test/client.spec.js index fdb09c2fc0..c4576b50a1 100644 --- a/packages/datadog-plugin-kafkajs/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-kafkajs/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -38,7 +39,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'kafka.produce'), true) }) diff --git a/packages/datadog-plugin-koa/test/index.spec.js b/packages/datadog-plugin-koa/test/index.spec.js index 21b6d2d69e..1c8e9adc3d 100644 --- a/packages/datadog-plugin-koa/test/index.spec.js +++ b/packages/datadog-plugin-koa/test/index.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { AsyncLocalStorage } = require('node:async_hooks') +const { inspect } = require('node:util') const axios = require('axios') @@ -365,7 +366,7 @@ describe('Plugin', () => { assert.strictEqual(spans[0].resource, 'GET /user/:id') assert.strictEqual(spans[0].meta['http.url'], `http://localhost:${port}/user/123`) - assert.ok(Object.hasOwn(spans[1], 'resource')) + assert.ok(Object.hasOwn(spans[1], 'resource'), `Available keys: ${inspect(Object.keys(spans[1]))}`) assert.match(spans[1].resource, /^(dispatch|bound)/) assert.strictEqual(spans[2].resource, 'handle') @@ -672,7 +673,7 @@ describe('Plugin', () => { assert.strictEqual(spans[0].meta['http.url'], `http://localhost:${port}/user/123`) assert.strictEqual(spans[0].error, 1) - assert.ok(Object.hasOwn(spans[1], 'resource')) + assert.ok(Object.hasOwn(spans[1], 'resource'), `Available keys: ${inspect(Object.keys(spans[1]))}`) assert.match(spans[1].resource, /^(dispatch|bound)/) assertObjectContains(spans[1].meta, { [ERROR_TYPE]: error.name, diff --git a/packages/datadog-plugin-koa/test/integration-test/client.spec.js b/packages/datadog-plugin-koa/test/integration-test/client.spec.js index e248921c04..0596403c87 100644 --- a/packages/datadog-plugin-koa/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-koa/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -41,7 +42,7 @@ describe('esm', () => { return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'koa.request'), true) }) }).timeout(50000) diff --git a/packages/datadog-plugin-langchain/test/index.spec.js b/packages/datadog-plugin-langchain/test/index.spec.js index 0236ccc499..8c8d79c0b6 100644 --- a/packages/datadog-plugin-langchain/test/index.spec.js +++ b/packages/datadog-plugin-langchain/test/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { after, before, beforeEach, describe, it } = require('mocha') const semifies = require('semifies') @@ -144,9 +145,9 @@ describe('Plugin', () => { assert.strictEqual(hasMatching, false) - assert.ok(Object.hasOwn(span.meta, 'error.message')) - assert.ok(Object.hasOwn(span.meta, 'error.type')) - assert.ok(Object.hasOwn(span.meta, 'error.stack')) + assert.ok(Object.hasOwn(span.meta, 'error.message'), `Available keys: ${inspect(Object.keys(span.meta))}`) + assert.ok(Object.hasOwn(span.meta, 'error.type'), `Available keys: ${inspect(Object.keys(span.meta))}`) + assert.ok(Object.hasOwn(span.meta, 'error.stack'), `Available keys: ${inspect(Object.keys(span.meta))}`) }) try { @@ -238,9 +239,9 @@ describe('Plugin', () => { const hasMatching = Object.keys(span.meta).some(key => langchainResponseRegex.test(key)) assert.strictEqual(hasMatching, false) - assert.ok(Object.hasOwn(span.meta, 'error.message')) - assert.ok(Object.hasOwn(span.meta, 'error.type')) - assert.ok(Object.hasOwn(span.meta, 'error.stack')) + assert.ok(Object.hasOwn(span.meta, 'error.message'), `Available keys: ${inspect(Object.keys(span.meta))}`) + assert.ok(Object.hasOwn(span.meta, 'error.type'), `Available keys: ${inspect(Object.keys(span.meta))}`) + assert.ok(Object.hasOwn(span.meta, 'error.stack'), `Available keys: ${inspect(Object.keys(span.meta))}`) }) try { @@ -379,7 +380,10 @@ describe('Plugin', () => { }, }) - assert.ok(Object.hasOwn(span.meta, 'langchain.request.model')) + assert.ok( + Object.hasOwn(span.meta, 'langchain.request.model'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) }) const modelName = @@ -407,9 +411,18 @@ describe('Plugin', () => { const hasMatching = Object.keys(chainSpan.meta).some(key => langchainResponseRegex.test(key)) assert.strictEqual(hasMatching, false) - assert.ok(Object.hasOwn(chainSpan.meta, 'error.message')) - assert.ok(Object.hasOwn(chainSpan.meta, 'error.type')) - assert.ok(Object.hasOwn(chainSpan.meta, 'error.stack')) + assert.ok( + Object.hasOwn(chainSpan.meta, 'error.message'), + `Available keys: ${inspect(Object.keys(chainSpan.meta))}` + ) + assert.ok( + Object.hasOwn(chainSpan.meta, 'error.type'), + `Available keys: ${inspect(Object.keys(chainSpan.meta))}` + ) + assert.ok( + Object.hasOwn(chainSpan.meta, 'error.stack'), + `Available keys: ${inspect(Object.keys(chainSpan.meta))}` + ) }) try { @@ -557,7 +570,10 @@ describe('Plugin', () => { const chain = model.pipe(parser) const response = await chain.invoke('Generate a JSON object with name and age.') - assert.ok(response != null && typeof response === 'object') + assert.ok( + response != null && typeof response === 'object', + `Expected a non-null object, got: ${inspect(response)}` + ) await checkTraces }) @@ -574,9 +590,12 @@ describe('Plugin', () => { assert.ok(!('langchain.response.outputs.embedding_length' in span.meta)) - assert.ok(Object.hasOwn(span.meta, 'error.message')) - assert.ok(Object.hasOwn(span.meta, 'error.type')) - assert.ok(Object.hasOwn(span.meta, 'error.stack')) + assert.ok( + Object.hasOwn(span.meta, 'error.message'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert.ok(Object.hasOwn(span.meta, 'error.type'), `Available keys: ${inspect(Object.keys(span.meta))}`) + assert.ok(Object.hasOwn(span.meta, 'error.stack'), `Available keys: ${inspect(Object.keys(span.meta))}`) }) try { @@ -717,9 +736,9 @@ describe('Plugin', () => { assert.strictEqual(span.name, 'langchain.request') assert.match(span.resource, /^langchain\.tools\.[^.]+\.myTool$/) - assert.ok(Object.hasOwn(span.meta, 'error.message')) - assert.ok(Object.hasOwn(span.meta, 'error.type')) - assert.ok(Object.hasOwn(span.meta, 'error.stack')) + assert.ok(Object.hasOwn(span.meta, 'error.message'), `Available keys: ${inspect(Object.keys(span.meta))}`) + assert.ok(Object.hasOwn(span.meta, 'error.type'), `Available keys: ${inspect(Object.keys(span.meta))}`) + assert.ok(Object.hasOwn(span.meta, 'error.stack'), `Available keys: ${inspect(Object.keys(span.meta))}`) }) try { diff --git a/packages/datadog-plugin-langchain/test/integration-test/client.spec.js b/packages/datadog-plugin-langchain/test/integration-test/client.spec.js index e3789de211..9962b81ece 100644 --- a/packages/datadog-plugin-langchain/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-langchain/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -43,7 +44,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'langchain.request'), true) }) diff --git a/packages/datadog-plugin-langgraph/src/stream.js b/packages/datadog-plugin-langgraph/src/stream.js index 6bb5a71cb0..d3d39944b7 100644 --- a/packages/datadog-plugin-langgraph/src/stream.js +++ b/packages/datadog-plugin-langgraph/src/stream.js @@ -20,7 +20,7 @@ class PregelStreamPlugin extends TracingPlugin { } class NextStreamPlugin extends TracingPlugin { static id = 'langgraph_stream_next' - static prefix = 'tracing:orchestrion:@langchain/langgraph:Pregel_stream_next' + static prefix = 'tracing:orchestrion:@langchain/langgraph:Pregel_stream:next' bindStart (ctx) { return ctx.currentStore diff --git a/packages/datadog-plugin-langgraph/test/index.spec.js b/packages/datadog-plugin-langgraph/test/index.spec.js index e19fde3fc5..b84eb241b5 100644 --- a/packages/datadog-plugin-langgraph/test/index.spec.js +++ b/packages/datadog-plugin-langgraph/test/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { createIntegrationTestSuite } = require('../../dd-trace/test/setup/helpers/plugin-test-helpers') const TestSetup = require('./test-setup') @@ -56,9 +57,18 @@ createIntegrationTestSuite('langgraph', '@langchain/langgraph', { assert.equal(streamSpan.error, 1) assert.equal(streamSpan.meta['span.kind'], 'internal') assert.equal(streamSpan.meta.component, 'langgraph') - assert.ok(Object.hasOwn(streamSpan.meta, 'error.type')) - assert.ok(Object.hasOwn(streamSpan.meta, 'error.message')) - assert.ok(Object.hasOwn(streamSpan.meta, 'error.stack')) + assert.ok( + Object.hasOwn(streamSpan.meta, 'error.type'), + `Available keys: ${inspect(Object.keys(streamSpan.meta))}` + ) + assert.ok( + Object.hasOwn(streamSpan.meta, 'error.message'), + `Available keys: ${inspect(Object.keys(streamSpan.meta))}` + ) + assert.ok( + Object.hasOwn(streamSpan.meta, 'error.stack'), + `Available keys: ${inspect(Object.keys(streamSpan.meta))}` + ) }) await testSetup.pregelStreamError().catch(() => {}) diff --git a/packages/datadog-plugin-light-my-request/test/integration-test/client.spec.js b/packages/datadog-plugin-light-my-request/test/integration-test/client.spec.js index 47506d2493..726f2820de 100644 --- a/packages/datadog-plugin-light-my-request/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-light-my-request/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, sandboxCwd, @@ -38,7 +39,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'mariadb.query'), true) }) diff --git a/packages/datadog-plugin-limitd-client/test/integration-test/client.spec.js b/packages/datadog-plugin-limitd-client/test/integration-test/client.spec.js index 27f9978a1b..b74faf5b59 100644 --- a/packages/datadog-plugin-limitd-client/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-limitd-client/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -37,7 +38,7 @@ describe('esm', () => { it(`is instrumented loaded with ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) // not asserting for a limitd-client trace, // just asserting that we're not completely breaking when loading limitd-client with esm assert.strictEqual(checkSpansForServiceName(payload, 'tcp.connect'), true) 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 f50bd29156..d54773a6ad 100644 --- a/packages/datadog-plugin-mariadb/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-mariadb/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -39,7 +40,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'mariadb.query'), true) }) diff --git a/packages/datadog-plugin-memcached/test/integration-test/client.spec.js b/packages/datadog-plugin-memcached/test/integration-test/client.spec.js index d11ddaf46d..7b53ba79cd 100644 --- a/packages/datadog-plugin-memcached/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-memcached/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -38,7 +39,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'memcached.command'), true) }) diff --git a/packages/datadog-plugin-microgateway-core/test/index.spec.js b/packages/datadog-plugin-microgateway-core/test/index.spec.js index a342e8e405..15f7e5eeea 100644 --- a/packages/datadog-plugin-microgateway-core/test/index.spec.js +++ b/packages/datadog-plugin-microgateway-core/test/index.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const http = require('node:http') const os = require('node:os') +const { inspect } = require('node:util') const axios = require('axios') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') @@ -180,7 +181,10 @@ describe('Plugin', () => { if (semver.intersects(version, '>=2.3.3')) { it('should re-expose any exports', () => { - assert.ok(typeof Gateway.Logging === 'object' && Gateway.Logging !== null) + assert.ok( + typeof Gateway.Logging === 'object' && Gateway.Logging !== null, + `Expected non-null object, got ${inspect(Gateway.Logging)}` + ) }) } }) diff --git a/packages/datadog-plugin-microgateway-core/test/integration-test/client.spec.js b/packages/datadog-plugin-microgateway-core/test/integration-test/client.spec.js index 68f9246e3c..41c6f89d34 100644 --- a/packages/datadog-plugin-microgateway-core/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-microgateway-core/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -42,7 +43,7 @@ describe('esm', () => { return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'microgateway.request'), true) }) }).timeout(20000) diff --git a/packages/datadog-plugin-mocha/src/index.js b/packages/datadog-plugin-mocha/src/index.js index 6c1e5ae903..1f3c45180e 100644 --- a/packages/datadog-plugin-mocha/src/index.js +++ b/packages/datadog-plugin-mocha/src/index.js @@ -11,6 +11,7 @@ const { TEST_PARAMETERS, finishAllTraceSpans, getTestSuitePath, + getRelativeCoverageFiles, getTestParametersString, getTestSuiteCommonTags, addIntelligentTestRunnerSpanTags, @@ -73,8 +74,10 @@ class MochaPlugin extends CiPlugin { this.telemetry.count(TELEMETRY_CODE_COVERAGE_EMPTY) } - const relativeCoverageFiles = [...coverageFiles, suiteFile] - .map(filename => getTestSuitePath(filename, this.repositoryRoot || this.sourceRoot)) + const relativeCoverageFiles = [ + ...getRelativeCoverageFiles(coverageFiles, this.repositoryRoot || this.sourceRoot), + getTestSuitePath(suiteFile, this.repositoryRoot || this.sourceRoot), + ] const { _traceId, _spanId } = testSuiteSpan.context() @@ -152,7 +155,7 @@ class MochaPlugin extends CiPlugin { this.addSub('ci:mocha:test-suite:finish', ({ testSuiteSpan, status }) => { if (testSuiteSpan) { // the test status of the suite may have been set in ci:mocha:test-suite:error already - if (!testSuiteSpan.context()._tags[TEST_STATUS]) { + if (!testSuiteSpan.context().getTag(TEST_STATUS)) { testSuiteSpan.setTag(TEST_STATUS, status) } testSuiteSpan.finish() @@ -352,6 +355,7 @@ class MochaPlugin extends CiPlugin { status, isSuitesSkipped, testCodeCoverageLinesTotal, + testSessionCoverageFiles, numSkippedSuites, hasForcedToRunSuites, hasUnskippableSuites, @@ -362,7 +366,11 @@ class MochaPlugin extends CiPlugin { isParallel, }) => { if (this.testSessionSpan) { - const { isSuitesSkippingEnabled, isCodeCoverageEnabled } = this.libraryConfig || {} + const { + isSuitesSkippingEnabled, + isCodeCoverageEnabled, + isCoverageReportUploadEnabled, + } = this.libraryConfig || {} this.testSessionSpan.setTag(TEST_STATUS, status) this.testModuleSpan.setTag(TEST_STATUS, status) @@ -394,6 +402,13 @@ class MochaPlugin extends CiPlugin { } ) + if (testSessionCoverageFiles?.length && isCoverageReportUploadEnabled) { + this.tracer._exporter.exportCoverage({ + sessionId: this.testSessionSpan.context()._traceId, + files: testSessionCoverageFiles, + }) + } + if (isEarlyFlakeDetectionEnabled) { this.testSessionSpan.setTag(TEST_EARLY_FLAKE_ENABLED, 'true') } diff --git a/packages/datadog-plugin-moleculer/test/index.spec.js b/packages/datadog-plugin-moleculer/test/index.spec.js index b2cc97abbf..7dbcc257ad 100644 --- a/packages/datadog-plugin-moleculer/test/index.spec.js +++ b/packages/datadog-plugin-moleculer/test/index.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert') const os = require('node:os') +const { inspect } = require('node:util') const { assertObjectContains } = require('../../../integration-tests/helpers') const agent = require('../../dd-trace/test/plugins/agent') @@ -76,7 +77,10 @@ describe('Plugin', () => { assert.strictEqual(spans[0].meta['span.kind'], 'server') assert.strictEqual(spans[0].meta['moleculer.context.action'], 'math.add') assert.strictEqual(spans[0].meta['moleculer.context.node_id'], `server-${process.pid}`) - assert.ok(Object.hasOwn(spans[0].meta, 'moleculer.context.request_id')) + assert.ok( + Object.hasOwn(spans[0].meta, 'moleculer.context.request_id'), + `Available keys: ${inspect(Object.keys(spans[0].meta))}` + ) assert.strictEqual(spans[0].meta['moleculer.context.service'], 'math') assert.strictEqual(spans[0].meta['moleculer.namespace'], 'multi') assert.strictEqual(spans[0].meta['moleculer.node_id'], `server-${process.pid}`) @@ -90,7 +94,10 @@ describe('Plugin', () => { assert.strictEqual(spans[1].meta['span.kind'], 'server') assert.strictEqual(spans[1].meta['moleculer.context.action'], 'math.numerify') assert.strictEqual(spans[1].meta['moleculer.context.node_id'], `server-${process.pid}`) - assert.ok(Object.hasOwn(spans[1].meta, 'moleculer.context.request_id')) + assert.ok( + Object.hasOwn(spans[1].meta, 'moleculer.context.request_id'), + `Available keys: ${inspect(Object.keys(spans[1].meta))}` + ) assert.strictEqual(spans[1].meta['moleculer.context.service'], 'math') assert.strictEqual(spans[1].meta['moleculer.namespace'], 'multi') assert.strictEqual(spans[1].meta['moleculer.node_id'], `server-${process.pid}`) diff --git a/packages/datadog-plugin-moleculer/test/integration-test/client.spec.js b/packages/datadog-plugin-moleculer/test/integration-test/client.spec.js index 56e3ae5865..94ec8ffab5 100644 --- a/packages/datadog-plugin-moleculer/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-moleculer/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -40,7 +41,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'moleculer.action'), true) }) diff --git a/packages/datadog-plugin-mongodb-core/src/index.js b/packages/datadog-plugin-mongodb-core/src/index.js index 31c22f2f89..f355a42182 100644 --- a/packages/datadog-plugin-mongodb-core/src/index.js +++ b/packages/datadog-plugin-mongodb-core/src/index.js @@ -1,5 +1,7 @@ 'use strict' +const { isMap, isRegExp } = require('node:util').types + const DatabasePlugin = require('../../dd-trace/src/plugins/database') class MongodbCorePlugin extends DatabasePlugin { @@ -138,62 +140,301 @@ function truncate (input) { return input.length > MAX_QUERY_LENGTH ? input.slice(0, MAX_QUERY_LENGTH) : input } -// Single-pass sanitisation. The replacer: -// - skips functions and coerces bigint to its decimal string, -// - collapses Buffer / BSON Binary / BSON types without toJSON (MinKey, MaxKey) to a sentinel, -// - lets JSON.stringify call toJSON on other BSON types (ObjectId, Long, Decimal128, Date, Timestamp, ...) -// so the result lands here as a primitive or plain object, -// - tracks depth via an ancestor stack so cycles and depth >= MAX_DEPTH collapse to the sentinel, -// - in `redact` mode, replaces every primitive leaf (including null) with '?', -// - in `types` mode, replaces every primitive leaf with the typeof of the *original* value (so a -// BSON Date that flattens to a string still reports as 'object'), and 'null' for null. -// Keys, operator names, and array / pipeline shape are preserved in both modes so the resulting -// JSON is still a usable query signature. +// Depth doubles as the cycle bound: a cycle pushes past MAX_DEPTH and bails, +// after which the slow path catches it via its ancestor stack. +/** @param {unknown} input */ +function canStringifyDirect (input) { + if (input === null || + typeof input !== 'object' || + ArrayBuffer.isView(input) || + input._bsontype !== undefined || + isRegExp(input) || + isMap(input) || + typeof input.toJSON === 'function') { + return false + } + return canStringifyDirectWalk(input, 1) +} + +/** + * @param {Record | unknown[]} value + * @param {number} depth + */ +function canStringifyDirectWalk (value, depth) { + if (depth > MAX_DEPTH) return false + const children = Array.isArray(value) ? value : Object.values(value) + for (const child of children) { + if (child === null || + typeof child === 'string' || + typeof child === 'number' || + typeof child === 'boolean') { + continue + } + if (typeof child !== 'object' || + ArrayBuffer.isView(child) || + child._bsontype !== undefined || + isRegExp(child) || + isMap(child) || + typeof child.toJSON === 'function') { + return false + } + if (!canStringifyDirectWalk(child, depth + 1)) return false + } + return true +} + /** * @param {Record | unknown[]} input * @param {'none' | 'types' | 'redact'} mode */ function sanitiseAndStringify (input, mode) { - const ancestors = [] - return JSON.stringify(input, function (key, value) { - if (typeof value === 'function') return - if (typeof value === 'bigint') { - if (mode === 'redact') return '?' - if (mode === 'types') return 'bigint' - return value.toString() - } + if (mode === 'none') { + if (canStringifyDirect(input)) return JSON.stringify(input) + return buildNone(input, []) + } + if (mode === 'redact') return buildRedact(input, []) + return buildTypes(input, []) +} - const original = key === '' ? value : this[key] - if (typeof original === 'object' && original !== null) { - const bsontype = original._bsontype - if (Buffer.isBuffer(original) || (bsontype !== undefined && (bsontype === 'Binary' || value === original))) { - return mode === 'types' ? 'object' : '?' - } +const REDACT_LEAF = '"?"' + +/** + * @param {RegExp} value + * @returns {string} + */ +function stringifyRegExp (value) { + return `{"$regex":${JSON.stringify(value.source)},"$options":${JSON.stringify(value.flags)}}` +} + +/** + * @param {Record | unknown[]} value + * @param {object[]} ancestors + * @returns {string | undefined} + */ +function buildNone (value, ancestors) { + // ArrayBuffer views (Buffer, every TypedArray, DataView) and Binary BSON + // wrappers redact at the leaf; the walker neither recurses into the bytes + // nor invokes any custom conversion. + const bsontype = value._bsontype + if (ArrayBuffer.isView(value) || bsontype === 'Binary' || + ancestors.length >= MAX_DEPTH || ancestors.includes(value)) { + return REDACT_LEAF + } + + if (isRegExp(value)) return stringifyRegExp(value) + + // Mirror JSON.stringify's contract: when `toJSON` is present, walk its + // result (wrappers like Timestamp / Decimal128 expand to a small object, + // ObjectId / Date flatten to a primitive). + if (typeof value.toJSON === 'function') { + const json = value.toJSON() + if (json === value) return REDACT_LEAF + // JSON.stringify keeps a null result as null (an invalid Date's toJSON + // returns null); only function / symbol / undefined results drop the key. + if (json === null) return 'null' + if (typeof json !== 'object') return classifyLeafForNone(json) + // A wrapper that exposes binary state through toJSON (Buffer-backed + // class with WeakMap state, etc.) returns a TypedArray here. Re-screen + // before the per-key walk would expand it element by element. + if (ArrayBuffer.isView(json) || json._bsontype === 'Binary') return REDACT_LEAF + value = json + } else if (bsontype !== undefined) { + return REDACT_LEAF + } + + // The driver serializes a Map via its entries; mirror that as a document so + // the tag matches the wire shape. + if (isMap(value)) value = Object.fromEntries(value) + + ancestors.push(value) + + let result + if (Array.isArray(value)) { + result = '[' + let sep = '' + for (let i = 0; i < value.length; i++) { + // JSON.stringify renders unsupported leaves (function, symbol, undefined) as null in arrays. + result += sep + (classifyForNone(value[i], ancestors) ?? 'null') + sep = ',' } + result += ']' + } else { + result = '{' + let sep = '' + for (const key of Object.keys(value)) { + const childResult = classifyForNone(value[key], ancestors) + if (childResult === undefined) continue + result += sep + JSON.stringify(key) + ':' + childResult + sep = ',' + } + result += '}' + } + ancestors.pop() + return result +} + +/** + * @param {unknown} child + * @param {object[]} ancestors + * @returns {string | undefined} + */ +function classifyForNone (child, ancestors) { + if (typeof child !== 'object') return classifyLeafForNone(child) + if (child === null) return 'null' + return buildNone(child, ancestors) +} + +/** + * @param {unknown} leaf + * @returns {string | undefined} + */ +function classifyLeafForNone (leaf) { + // Implicit `undefined` for function / symbol / undefined matches the + // contract callers rely on: JSON.stringify drops those property values + // inside objects and writes `null` in arrays. + switch (typeof leaf) { + case 'string': return JSON.stringify(leaf) + case 'number': return Number.isFinite(leaf) ? String(leaf) : 'null' + case 'boolean': return leaf ? 'true' : 'false' + case 'bigint': return `"${String(leaf)}"` + } +} + +/** + * @param {Record | unknown[]} value + * @param {object[]} ancestors + */ +function buildRedact (value, ancestors) { + const bsontype = value._bsontype + if (ArrayBuffer.isView(value) || bsontype === 'Binary' || isRegExp(value) || + ancestors.length >= MAX_DEPTH || ancestors.includes(value)) { + return REDACT_LEAF + } + + // Mirror JSON.stringify: when `toJSON` is present, walk its result (which + // wrappers like Timestamp / Decimal128 expand to `{$timestamp: "..."}` etc). + // A primitive, null, or self-reference collapses to the sentinel — master's + // `value === original` short-circuit. + if (typeof value.toJSON === 'function') { + const json = value.toJSON() + if (typeof json !== 'object' || json === null || json === value) return REDACT_LEAF + // Re-screen: toJSON can return a TypedArray or Binary BSON wrapper. + if (ArrayBuffer.isView(json) || json._bsontype === 'Binary') return REDACT_LEAF + value = json + } else if (bsontype !== undefined) { + return REDACT_LEAF + } + + if (isMap(value)) value = Object.fromEntries(value) + + ancestors.push(value) - if (value === null || typeof value !== 'object') { - if (key === '' || mode === 'none') return value - if (mode === 'redact') return '?' - return original === null ? 'null' : typeof original + let result + if (Array.isArray(value)) { + result = '[' + let sep = '' + for (let i = 0; i < value.length; i++) { + result += sep + classifyForRedact(value[i], ancestors) + sep = ',' } + result += ']' + } else { + result = '{' + let sep = '' + for (const key of Object.keys(value)) { + result += sep + JSON.stringify(key) + ':' + classifyForRedact(value[key], ancestors) + sep = ',' + } + result += '}' + } + ancestors.pop() + return result +} - while (ancestors.length > 0 && ancestors.at(-1) !== this) ancestors.pop() - if (ancestors.length >= MAX_DEPTH || ancestors.includes(value)) { - return mode === 'types' ? 'object' : '?' +/** + * @param {unknown} child + * @param {object[]} ancestors + */ +function classifyForRedact (child, ancestors) { + if (typeof child !== 'object' || child === null) return REDACT_LEAF + return buildRedact(child, ancestors) +} + +const TYPE_OBJECT = '"object"' +const TYPE_NULL = '"null"' +const TYPE_BY_TYPEOF = { + string: '"string"', + number: '"number"', + boolean: '"boolean"', + bigint: '"bigint"', + undefined: '"undefined"', +} + +/** + * @param {Record | unknown[]} value + * @param {object[]} ancestors + */ +function buildTypes (value, ancestors) { + const bsontype = value._bsontype + if (ArrayBuffer.isView(value) || bsontype === 'Binary' || isRegExp(value) || + ancestors.length >= MAX_DEPTH || ancestors.includes(value)) { + return TYPE_OBJECT + } + + if (typeof value.toJSON === 'function') { + const json = value.toJSON() + if (typeof json !== 'object' || + json === null || + json === value || + ArrayBuffer.isView(json) || + json._bsontype === 'Binary') { + return TYPE_OBJECT } - ancestors.push(value) + value = json + } else if (bsontype !== undefined) { + return TYPE_OBJECT + } + + if (isMap(value)) value = Object.fromEntries(value) - return value - }) + ancestors.push(value) + + let result + if (Array.isArray(value)) { + result = '[' + let sep = '' + for (let i = 0; i < value.length; i++) { + // JSON.stringify renders unsupported leaves (function, symbol) as null in arrays. + result += sep + (classifyForTypes(value[i], ancestors) ?? 'null') + sep = ',' + } + result += ']' + } else { + result = '{' + let sep = '' + for (const key of Object.keys(value)) { + const childResult = classifyForTypes(value[key], ancestors) + if (childResult === undefined) continue + result += sep + JSON.stringify(key) + ':' + childResult + sep = ',' + } + result += '}' + } + ancestors.pop() + return result } /** - * Coerce the plugin-config and env values for `obfuscateQuery` to one of the three canonical modes. - * Anything outside the enum — including `undefined` — falls back to `'none'`. - * - * @param {unknown} value - * @returns {'none' | 'types' | 'redact'} + * @param {unknown} child + * @param {object[]} ancestors */ +function classifyForTypes (child, ancestors) { + if (typeof child !== 'object') return TYPE_BY_TYPEOF[typeof child] + if (child === null) return TYPE_NULL + return buildTypes(child, ancestors) +} + +/** @param {unknown} value */ function normaliseObfuscateQuery (value) { if (value === 'types' || value === 'redact') return value return 'none' diff --git a/packages/datadog-plugin-mongodb-core/test/core.spec.js b/packages/datadog-plugin-mongodb-core/test/core.spec.js index 2560c88771..675cfdf663 100644 --- a/packages/datadog-plugin-mongodb-core/test/core.spec.js +++ b/packages/datadog-plugin-mongodb-core/test/core.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') const ddpv = require('mocha/package.json').version @@ -37,7 +38,7 @@ describe('Plugin', () => { let id let tracer let collection - let startSpy + let injectCommentSpy describe('mongodb-core (core)', () => { withTopologies(getServer => { @@ -437,19 +438,19 @@ describe('Plugin', () => { server.connect() - startSpy = sinon.spy(MongodbCorePlugin.prototype, 'start') + injectCommentSpy = sinon.spy(MongodbCorePlugin.prototype, 'injectDbmComment') }) afterEach(() => { - startSpy?.restore() + injectCommentSpy?.restore() }) it('DBM propagation should not inject comment', done => { agent .assertSomeTraces(traces => { - assert.strictEqual(startSpy.called, true) - const ops = startSpy.getCall(0).args[0].ops - assert.ok(!('comment' in ops)) + assert.strictEqual(injectCommentSpy.called, true) + assert.strictEqual(injectCommentSpy.getCall(0).args[1], undefined) + assert.strictEqual(injectCommentSpy.getCall(0).returnValue, undefined) }) .then(done) .catch(done) @@ -481,18 +482,18 @@ describe('Plugin', () => { server.connect() - startSpy = sinon.spy(MongodbCorePlugin.prototype, 'start') + injectCommentSpy = sinon.spy(MongodbCorePlugin.prototype, 'injectDbmComment') }) afterEach(() => { - startSpy?.restore() + injectCommentSpy?.restore() }) it('DBM propagation should not inject comment', done => { agent .assertSomeTraces(traces => { - assert.strictEqual(startSpy.called, true) - const { comment } = startSpy.getCall(0).args[0].ops + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue assert.strictEqual(comment, undefined) }) .then(done) @@ -504,8 +505,8 @@ describe('Plugin', () => { it('DBM propagation should not alter existing comment', done => { agent .assertSomeTraces(traces => { - assert.strictEqual(startSpy.called, true) - const { comment } = startSpy.getCall(0).args[0].ops + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue assert.strictEqual(comment, 'test comment') }) .then(done) @@ -550,11 +551,11 @@ describe('Plugin', () => { server.connect() - startSpy = sinon.spy(MongodbCorePlugin.prototype, 'start') + injectCommentSpy = sinon.spy(MongodbCorePlugin.prototype, 'injectDbmComment') }) afterEach(() => { - startSpy?.restore() + injectCommentSpy?.restore() }) it('DBM propagation should inject full mode comment with traceparent', done => { @@ -563,9 +564,9 @@ describe('Plugin', () => { const traceId = span.meta['_dd.p.tid'] + span.trace_id.toString(16).padStart(16, '0') const spanId = span.span_id.toString(16).padStart(16, '0') - assert.strictEqual(startSpy.called, true) - const { comment } = startSpy.getCall(0).args[0].ops - assert.ok(comment.includes(`traceparent='00-${traceId}-${spanId}-01'`)) + assert.strictEqual(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) @@ -598,11 +599,11 @@ describe('Plugin', () => { server.connect() - startSpy = sinon.spy(MongodbCorePlugin.prototype, 'start') + injectCommentSpy = sinon.spy(MongodbCorePlugin.prototype, 'injectDbmComment') }) afterEach(() => { - startSpy?.restore() + injectCommentSpy?.restore() }) it('DBM propagation should inject service mode as comment', done => { @@ -610,8 +611,8 @@ describe('Plugin', () => { .assertSomeTraces(traces => { const span = traces[0][0] - assert.strictEqual(startSpy.called, true) - const { comment } = startSpy.getCall(0).args[0].ops + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue assert.strictEqual(comment, `dddb='${encodeURIComponent(span.meta['db.name'])}',` + 'dddbs=\'test-mongodb\',' + @@ -633,8 +634,8 @@ describe('Plugin', () => { .assertSomeTraces(traces => { const span = traces[0][0] - assert.strictEqual(startSpy.called, true) - const { comment } = startSpy.getCall(0).args[0].ops + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue assert.strictEqual(comment, 'test comment,' + `dddb='${encodeURIComponent(span.meta['db.name'])}',` + @@ -663,8 +664,8 @@ describe('Plugin', () => { .assertSomeTraces(traces => { const span = traces[0][0] - assert.strictEqual(startSpy.called, true) - const { comment } = startSpy.getCall(0).args[0].ops + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue assert.deepStrictEqual(comment, [ 'test comment', `dddb='${encodeURIComponent(span.meta['db.name'])}',` + @@ -712,11 +713,11 @@ describe('Plugin', () => { server.connect() - startSpy = sinon.spy(MongodbCorePlugin.prototype, 'start') + injectCommentSpy = sinon.spy(MongodbCorePlugin.prototype, 'injectDbmComment') }) afterEach(() => { - startSpy?.restore() + injectCommentSpy?.restore() }) it('DBM propagation should inject full mode with traceparent as comment', done => { @@ -725,8 +726,8 @@ describe('Plugin', () => { const traceId = span.meta['_dd.p.tid'] + span.trace_id.toString(16).padStart(16, '0') const spanId = span.span_id.toString(16).padStart(16, '0') - assert.strictEqual(startSpy.called, true) - const { comment } = startSpy.getCall(0).args[0].ops + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue assert.strictEqual(comment, `dddb='${encodeURIComponent(span.meta['db.name'])}',` + 'dddbs=\'test-mongodb\',' + @@ -768,11 +769,11 @@ describe('Plugin', () => { server.connect() - startSpy = sinon.spy(MongodbCorePlugin.prototype, 'start') + injectCommentSpy = sinon.spy(MongodbCorePlugin.prototype, 'injectDbmComment') }) afterEach(() => { - startSpy?.restore() + injectCommentSpy?.restore() }) it( @@ -784,8 +785,8 @@ describe('Plugin', () => { const traceId = span.meta['_dd.p.tid'] + span.trace_id.toString(16).padStart(16, '0') const spanId = span.span_id.toString(16).padStart(16, '0') - assert.strictEqual(startSpy.called, true) - const { comment } = startSpy.getCall(0).args[0].ops + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue assert.match( comment, new RegExp(String.raw`traceparent='00-${traceId}-${spanId}-00'`) diff --git a/packages/datadog-plugin-mongodb-core/test/integration-test/client.spec.js b/packages/datadog-plugin-mongodb-core/test/integration-test/client.spec.js index c3c0d6105f..e2d535f67d 100644 --- a/packages/datadog-plugin-mongodb-core/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-mongodb-core/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -38,7 +39,7 @@ describe('esm', () => { it(`is instrumented loaded with ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'mongodb.query'), true) }) @@ -71,7 +72,7 @@ describe('esm', () => { it(`is instrumented loaded with ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'mongodb.query'), true) }) diff --git a/packages/datadog-plugin-mongodb-core/test/limit-depth.spec.js b/packages/datadog-plugin-mongodb-core/test/limit-depth.spec.js index 0c44bbc394..cfb473ef10 100644 --- a/packages/datadog-plugin-mongodb-core/test/limit-depth.spec.js +++ b/packages/datadog-plugin-mongodb-core/test/limit-depth.spec.js @@ -1,14 +1,15 @@ 'use strict' const assert = require('node:assert/strict') +const vm = require('node:vm') const { describe, it } = require('mocha') const sinon = require('sinon') const MongodbCorePlugin = require('../src/index') -// `limitDepth` is module-private; exercise it through `bindStart`, the only -// caller that observably surfaces its output (as `meta['mongodb.query']`). +// The sanitisation helpers are module-private; exercise them through `bindStart`, +// which surfaces their output as `meta['mongodb.query']`. function callBindStart (ctx, configOverride) { const startSpan = sinon.stub().returns({ finish () {} }) const self = { @@ -105,8 +106,13 @@ describe('mongodb-core query depth limiter', () => { ]) }) - it('renders Binary BSON values as "?"', () => { - const binary = { _bsontype: 'Binary', buffer: Buffer.from('payload') } + it('renders Binary BSON values as "?" even when toJSON flattens to a base64 string', () => { + // Mirrors bson@>=4 Binary.prototype.toJSON. + const binary = { + _bsontype: 'Binary', + buffer: Buffer.from('payload'), + toJSON () { return this.buffer.toString('base64') }, + } const query = callBindStart({ ns: 'db.coll', ops: { query: { blob: binary } }, @@ -139,6 +145,103 @@ describe('mongodb-core query depth limiter', () => { assert.deepStrictEqual(JSON.parse(query), { a: 1, self: '?' }) }) + it('preserves sibling objects under the slow none path', () => { + // The bigint disqualifies canStringifyDirect so the JSON.stringify replacer runs. + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { a: { b: 1 }, c: { d: 2 }, big: 9n } }, + name: 'find', + }) + + assert.deepStrictEqual(JSON.parse(query), { a: { b: 1 }, c: { d: 2 }, big: '9' }) + }) + + it('does not throw when a property getter returns a different value on the second read', () => { + // JSON.stringify snapshots the first read into `value` and passes the parent + // as `this` to the replacer; reading `this[key]` again can yield a different + // result for non-pure getters / Proxies. The replacer must not assume the + // second read is non-nullish. + let reads = 0 + const flaky = {} + Object.defineProperty(flaky, 'volatile', { + enumerable: true, + get () { + reads += 1 + return reads === 1 ? { nested: 'value' } : undefined + }, + }) + + const query = callBindStart({ + ns: 'db.coll', + // The leading bigint disqualifies canStringifyDirect on its first + // iteration so the slow path's JSON.stringify replacer sees the getter, + // not the precheck (which would consume the first read and mask the bug). + ops: { query: { big: 9n, outer: flaky } }, + name: 'find', + }) + + assert.deepStrictEqual(JSON.parse(query), { + big: '9', + outer: { volatile: { nested: 'value' } }, + }) + }) + + it('drops functions and renders non-Binary BSON values in the slow none path', () => { + // The bigint forces the slow none path; MinKey has no toJSON so the walker + // falls into the BSON sentinel branch. + const minKey = { _bsontype: 'MinKey' } + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { boundary: minKey, drop: () => {}, big: 9n } }, + name: 'find', + }) + + assert.deepStrictEqual(JSON.parse(query), { boundary: '?', big: '9' }) + }) + + it('renders toJSON-flattened BSON wrappers and primitive leaves in the slow none path', () => { + // The bigint forces the slow path past canStringifyDirect; the rest of the + // input exercises every leaf branch of the walker: + // - ObjectId / Date → toJSON returns a primitive string + // - Decimal128 / Timestamp → toJSON returns a small wrapper object that gets walked + // - flag → boolean leaf + // - bytes → number / NaN array elements + // - circular → self-referencing toJSON (collapses to "?") + const objectId = { _bsontype: 'ObjectId', toJSON: () => '5f47ac9e2c2f4a0001a1b2c3' } + const decimal = { _bsontype: 'Decimal128', toJSON: () => ({ $numberDecimal: '12.34' }) } + const timestamp = { _bsontype: 'Timestamp', toJSON: () => ({ $timestamp: '1' }) } + const cycle = {} + cycle.toJSON = () => cycle + + const query = callBindStart({ + ns: 'db.coll', + ops: { + query: { + _id: objectId, + createdAt: new Date('2020-01-01T00:00:00Z'), + price: decimal, + version: timestamp, + flag: true, + bytes: [1, 2, Number.NaN, Number.POSITIVE_INFINITY], + circular: cycle, + big: 9n, + }, + }, + name: 'find', + }) + + assert.deepStrictEqual(JSON.parse(query), { + _id: '5f47ac9e2c2f4a0001a1b2c3', + createdAt: '2020-01-01T00:00:00.000Z', + price: { $numberDecimal: '12.34' }, + version: { $timestamp: '1' }, + flag: true, + bytes: [1, 2, null, null], + circular: '?', + big: '9', + }) + }) + it('collapses depth past MAX_DEPTH to "?"', () => { let nested = { leaf: 'value' } for (let i = 0; i < 20; i++) { @@ -228,6 +331,37 @@ describe('mongodb-core query obfuscation (redact mode)', () => { assert.deepStrictEqual(JSON.parse(query), { blob: '?' }) }) + it('redacts every TypedArray view as "?"', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { + query: { + u8: new Uint8Array(4), + f32: new Float32Array(4), + bi64: new BigInt64Array(4), + dv: new DataView(new ArrayBuffer(8)), + }, + }, + name: 'find', + }, { obfuscateQuery: 'redact' }) + + assert.deepStrictEqual(JSON.parse(query), { u8: '?', f32: '?', bi64: '?', dv: '?' }) + }) + + it('redacts BSON internal types without toJSON as "?"', () => { + // MinKey, MaxKey, and Long don't implement Symbol.toPrimitive / toJSON, so + // JSON.stringify would call their default Object#toString or leave them as + // empty objects. Mirror master and collapse to the sentinel. + const minKey = { _bsontype: 'MinKey' } + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { boundary: minKey } }, + name: 'find', + }, { obfuscateQuery: 'redact' }) + + assert.deepStrictEqual(JSON.parse(query), { boundary: '?' }) + }) + it('preserves pipeline operator shapes while redacting leaves', () => { const query = callBindStart({ ns: 'db.coll', @@ -246,6 +380,54 @@ describe('mongodb-core query obfuscation (redact mode)', () => { ]) }) + it('redacts Date values via their toJSON marker', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { createdAt: new Date('2020-01-01') } }, + name: 'find', + }, { obfuscateQuery: 'redact' }) + + assert.deepStrictEqual(JSON.parse(query), { createdAt: '?' }) + }) + + it('preserves Timestamp / Decimal128 wrapper shapes while redacting leaves', () => { + // Mirrors bson@>=4 Timestamp.prototype.toJSON / Decimal128.prototype.toJSON, + // both of which return a single-key wrapper object. Master walked into that + // wrapper and redacted only the leaf; collapsing the whole value to "?" + // merges distinct query signatures. + const timestamp = { _bsontype: 'Timestamp', toJSON: () => ({ $timestamp: '0' }) } + const decimal = { _bsontype: 'Decimal128', toJSON: () => ({ $numberDecimal: '12.34' }) } + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { _time: timestamp, price: decimal } }, + name: 'find', + }, { obfuscateQuery: 'redact' }) + + assert.deepStrictEqual(JSON.parse(query), { + _time: { $timestamp: '?' }, + price: { $numberDecimal: '?' }, + }) + }) + + it('collapses depth past MAX_DEPTH in redact mode', () => { + let nested = { leaf: 'value' } + for (let i = 0; i < 20; i++) { + nested = { inner: nested } + } + + const query = callBindStart({ + ns: 'db.coll', + ops: { query: nested }, + name: 'find', + }, { obfuscateQuery: 'redact' }) + + let walk = JSON.parse(query) + while (typeof walk === 'object' && walk !== null && walk.inner !== '?') { + walk = walk.inner + } + assert.strictEqual(walk.inner, '?') + }) + it('redacts each q across multi-statement updates', () => { const query = callBindStart({ ns: 'db.coll', @@ -273,6 +455,55 @@ describe('mongodb-core query obfuscation (redact mode)', () => { assert.deepStrictEqual(JSON.parse(query), { user: 'alice', age: 30 }) }) + + it('redacts a top-level Buffer as "?"', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { filter: Buffer.alloc(64, 0x42) }, + name: 'find', + }, { obfuscateQuery: 'redact' }) + + assert.strictEqual(query, '"?"') + }) + + it('redacts a wrapper-class toJSON that returns a Buffer as "?"', () => { + // Pins the post-toJSON re-screen for redact mode: the wrapper has no own + // enumerable properties, so the walker would otherwise descend into the + // returned Buffer once toJSON resolves it. + const state = new WeakMap() + class PhotoQuery { + constructor (photo) { state.set(this, photo) } + toJSON () { return state.get(this) } + } + + const query = callBindStart({ + ns: 'db.coll', + ops: { filter: { photo: new PhotoQuery(Buffer.alloc(64, 0x42)) } }, + name: 'find', + }, { obfuscateQuery: 'redact' }) + + assert.deepStrictEqual(JSON.parse(query), { photo: '?' }) + }) + + it('redacts a RegExp value as "?"', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { name: /^a$/i } }, + name: 'find', + }, { obfuscateQuery: 'redact' }) + + assert.deepStrictEqual(JSON.parse(query), { name: '?' }) + }) + + it('redacts the leaves of a Map rendered as its entries', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { m: new Map([['a', 1], ['b', 2]]) } }, + name: 'find', + }, { obfuscateQuery: 'redact' }) + + assert.deepStrictEqual(JSON.parse(query), { m: { a: '?', b: '?' } }) + }) }) describe('mongodb-core query obfuscation (types mode)', () => { @@ -341,6 +572,34 @@ describe('mongodb-core query obfuscation (types mode)', () => { assert.deepStrictEqual(JSON.parse(query), { blob: 'object' }) }) + it('reports every TypedArray view as "object"', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { + query: { + u8: new Uint8Array(4), + f32: new Float32Array(4), + bi64: new BigInt64Array(4), + dv: new DataView(new ArrayBuffer(8)), + }, + }, + name: 'find', + }, { obfuscateQuery: 'types' }) + + assert.deepStrictEqual(JSON.parse(query), { u8: 'object', f32: 'object', bi64: 'object', dv: 'object' }) + }) + + it('reports BSON internal types without toJSON as "object"', () => { + const minKey = { _bsontype: 'MinKey' } + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { boundary: minKey } }, + name: 'find', + }, { obfuscateQuery: 'types' }) + + assert.deepStrictEqual(JSON.parse(query), { boundary: 'object' }) + }) + it('collapses cycles to "object"', () => { const circular = { a: 1 } circular.self = circular @@ -353,4 +612,404 @@ describe('mongodb-core query obfuscation (types mode)', () => { assert.deepStrictEqual(JSON.parse(query), { a: 'number', self: 'object' }) }) + + it('reports array elements of every primitive type', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { mixed: ['s', 1, true, null, 9n] } }, + name: 'find', + }, { obfuscateQuery: 'types' }) + + assert.deepStrictEqual(JSON.parse(query), { + mixed: ['string', 'number', 'boolean', 'null', 'bigint'], + }) + }) + + it('emits null for array elements JSON drops (undefined kept, function / symbol nulled)', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { items: ['ok', undefined, () => {}, Symbol('x'), 'tail'] } }, + name: 'find', + }, { obfuscateQuery: 'types' }) + + assert.deepStrictEqual(JSON.parse(query), { + items: ['string', 'undefined', null, null, 'string'], + }) + }) + + it('drops function- and symbol-valued object fields', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { keep: 1, drop: () => {}, sym: Symbol('x') } }, + name: 'find', + }, { obfuscateQuery: 'types' }) + + assert.deepStrictEqual(JSON.parse(query), { keep: 'number' }) + }) + + it('reports undefined object fields by their typeof name', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { u: undefined } }, + name: 'find', + }, { obfuscateQuery: 'types' }) + + assert.deepStrictEqual(JSON.parse(query), { u: 'undefined' }) + }) + + it('reports Date values as "object" via their toJSON marker', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { createdAt: new Date('2020-01-01') } }, + name: 'find', + }, { obfuscateQuery: 'types' }) + + assert.deepStrictEqual(JSON.parse(query), { createdAt: 'object' }) + }) + + it('preserves Timestamp / Decimal128 wrapper shapes while typing leaves', () => { + const timestamp = { _bsontype: 'Timestamp', toJSON: () => ({ $timestamp: '0' }) } + const decimal = { _bsontype: 'Decimal128', toJSON: () => ({ $numberDecimal: '12.34' }) } + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { _time: timestamp, price: decimal } }, + name: 'find', + }, { obfuscateQuery: 'types' }) + + assert.deepStrictEqual(JSON.parse(query), { + _time: { $timestamp: 'string' }, + price: { $numberDecimal: 'string' }, + }) + }) + + it('recurses into nested objects inside arrays', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { pipeline: [{ $match: { user: 'alice' } }, { $count: 'total' }] }, + name: 'aggregate', + }, { obfuscateQuery: 'types' }) + + assert.deepStrictEqual(JSON.parse(query), [ + { $match: { user: 'string' } }, + { $count: 'string' }, + ]) + }) + + it('reports a top-level Buffer as "object"', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { filter: Buffer.alloc(64, 0x42) }, + name: 'find', + }, { obfuscateQuery: 'types' }) + + assert.strictEqual(query, '"object"') + }) + + it('reports a wrapper-class toJSON that returns a Buffer as "object"', () => { + const state = new WeakMap() + class PhotoQuery { + constructor (photo) { state.set(this, photo) } + toJSON () { return state.get(this) } + } + + const query = callBindStart({ + ns: 'db.coll', + ops: { filter: { photo: new PhotoQuery(Buffer.alloc(64, 0x42)) } }, + name: 'find', + }, { obfuscateQuery: 'types' }) + + assert.deepStrictEqual(JSON.parse(query), { photo: 'object' }) + }) + + it('reports a RegExp value as "object"', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { name: /^a$/i } }, + name: 'find', + }, { obfuscateQuery: 'types' }) + + assert.deepStrictEqual(JSON.parse(query), { name: 'object' }) + }) + + it('reports the leaf types of a Map rendered as its entries', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { query: { m: new Map([['a', 1], ['b', 'two']]) } }, + name: 'find', + }, { obfuscateQuery: 'types' }) + + assert.deepStrictEqual(JSON.parse(query), { m: { a: 'number', b: 'string' } }) + }) +}) + +describe('mongodb-core query sanitization (none mode)', () => { + it('stringifies a top-level Buffer as "?" at every query extraction point', () => { + const buffer = () => Buffer.alloc(64, 0x42) + const cases = [ + { ops: { filter: buffer() }, name: 'find' }, + { ops: { pipeline: buffer() }, name: 'aggregate' }, + { ops: { deletes: [{ q: buffer(), limit: 1 }] }, name: 'delete' }, + { ops: { updates: [{ q: buffer(), u: { $set: { a: 1 } } }] }, name: 'update' }, + ] + + for (const { ops, name } of cases) { + assert.strictEqual(callBindStart({ ns: 'db.coll', ops, name }), '"?"', `${name} did not redact the Buffer`) + } + }) + + it('stringifies a nested Buffer as "?"', () => { + const actual = callBindStart({ ns: 'db.coll', ops: { filter: { hash: Buffer.alloc(64, 0x42) } }, name: 'find' }) + + assert.deepStrictEqual(JSON.parse(actual), { hash: '?' }) + }) + + it('stringifies a deeply nested Buffer as "?"', () => { + const actual = callBindStart({ + ns: 'db.coll', + ops: { filter: { user: { metadata: { fingerprint: Buffer.alloc(64, 0x42) } } } }, + name: 'find', + }) + + assert.deepStrictEqual(JSON.parse(actual), { user: { metadata: { fingerprint: '?' } } }) + }) + + it('stringifies buffers inside an array as "?"', () => { + const actual = callBindStart({ + ns: 'db.coll', + ops: { filter: { hashes: [Buffer.alloc(8, 0x41), Buffer.alloc(8, 0x42), Buffer.alloc(8, 0x43)] } }, + name: 'find', + }) + + assert.deepStrictEqual(JSON.parse(actual), { hashes: ['?', '?', '?'] }) + }) + + it('stringifies a nested Buffer as "?" even when a sibling bigint forces the slow path', () => { + // The bigint disqualifies canStringifyDirect, forcing the manual walker path that + // production traffic hits whenever a command mixes primitives and BSON wrappers. + const actual = callBindStart({ + ns: 'db.coll', + ops: { filter: { hash: Buffer.alloc(64, 0x42), big: 9n } }, + name: 'find', + }) + assert.deepStrictEqual(JSON.parse(actual), { hash: '?', big: '9' }) + }) + + it('stringifies a top-level Uint8Array as "?"', () => { + const actual = callBindStart({ + ns: 'db.coll', + ops: { filter: new Uint8Array(64).fill(0xAB) }, + name: 'find', + }) + + assert.strictEqual(actual, '"?"') + }) + + it('stringifies a nested Uint8Array as "?" through the fast path', () => { + const actual = callBindStart({ + ns: 'db.coll', + ops: { filter: { photo: new Uint8Array(64).fill(0xAB) } }, + name: 'find', + }) + + assert.deepStrictEqual(JSON.parse(actual), { photo: '?' }) + }) + + it('stringifies a nested Uint8Array as "?" through the slow path', () => { + // The sibling bigint forces the manual walker rather than the fast path. + const actual = callBindStart({ + ns: 'db.coll', + ops: { filter: { photo: new Uint8Array(64).fill(0xAB), big: 9n } }, + name: 'find', + }) + + assert.deepStrictEqual(JSON.parse(actual), { photo: '?', big: '9' }) + }) + + it('stringifies every TypedArray view shape as "?"', () => { + const actual = callBindStart({ + ns: 'db.coll', + ops: { + filter: { + u8: new Uint8Array(4), + u8c: new Uint8ClampedArray(4), + i8: new Int8Array(4), + u16: new Uint16Array(4), + i16: new Int16Array(4), + u32: new Uint32Array(4), + i32: new Int32Array(4), + f32: new Float32Array(4), + f64: new Float64Array(4), + dv: new DataView(new ArrayBuffer(8)), + }, + }, + name: 'find', + }) + + assert.deepStrictEqual(JSON.parse(actual), { + u8: '?', + u8c: '?', + i8: '?', + u16: '?', + i16: '?', + u32: '?', + i32: '?', + f32: '?', + f64: '?', + dv: '?', + }) + }) + + it('redacts a BigInt64Array without throwing inside JSON.stringify', () => { + const actual = callBindStart({ + ns: 'db.coll', + ops: { filter: { payload: new BigInt64Array(8).fill(1234567890123n) } }, + name: 'find', + }) + + assert.deepStrictEqual(JSON.parse(actual), { payload: '?' }) + }) + + it('stringifies a zero-length Uint8Array as "?"', () => { + const actual = callBindStart({ + ns: 'db.coll', + ops: { filter: { empty: new Uint8Array(0) } }, + name: 'find', + }) + + assert.deepStrictEqual(JSON.parse(actual), { empty: '?' }) + }) + + it('redacts a wrapper-class toJSON that exposes binary state at the top level and nested', () => { + const state = new WeakMap() + class BinaryQuery { + constructor (data) { state.set(this, data) } + toJSON () { return state.get(this) } + } + + const topLevel = callBindStart({ + ns: 'db.coll', + ops: { filter: new BinaryQuery(Buffer.alloc(64, 0x42)) }, + name: 'find', + }) + assert.strictEqual(topLevel, '"?"') + + const nested = callBindStart({ + ns: 'db.coll', + ops: { filter: { payload: new BinaryQuery(new Uint8Array(64).fill(0xAB)) } }, + name: 'find', + }) + assert.deepStrictEqual(JSON.parse(nested), { payload: '?' }) + }) + + it('does not invoke toJSON on a non-enumerable Buffer-wrapping property', () => { + const carrier = {} + Object.defineProperty(carrier, 'toJSON', { + enumerable: false, + value () { return Buffer.alloc(64, 0xAB) }, + }) + + const actual = callBindStart({ + ns: 'db.coll', + ops: { filter: carrier }, + name: 'find', + }) + + assert.strictEqual(actual, '"?"') + }) + + it('coerces a toJSON result of bigint to its decimal string', () => { + const longLike = { _bsontype: 'Long', toJSON: () => 123n } + const actual = callBindStart({ + ns: 'db.coll', + ops: { filter: { count: longLike, big: 9n } }, + name: 'find', + }) + + assert.deepStrictEqual(JSON.parse(actual), { count: '123', big: '9' }) + }) + + it('keeps a null toJSON result as null, matching JSON.stringify', () => { + const invalidDate = new Date(NaN) + assert.strictEqual(invalidDate.toJSON(), null) + + const object = callBindStart({ + ns: 'db.coll', + ops: { filter: { expiresAt: invalidDate, big: 9n } }, + name: 'find', + }) + assert.deepStrictEqual(JSON.parse(object), { expiresAt: null, big: '9' }) + + const topLevel = callBindStart({ ns: 'db.coll', ops: { filter: invalidDate }, name: 'find' }) + assert.strictEqual(topLevel, 'null') + + const inArray = callBindStart({ ns: 'db.coll', ops: { filter: { at: [invalidDate, 1] } }, name: 'find' }) + assert.deepStrictEqual(JSON.parse(inArray), { at: [null, 1] }) + }) + + it('renders a RegExp as its source and flags through the fast and slow paths', () => { + const fast = callBindStart({ ns: 'db.coll', ops: { filter: { name: /^a$/i } }, name: 'find' }) + assert.deepStrictEqual(JSON.parse(fast), { name: { $regex: '^a$', $options: 'i' } }) + + const slow = callBindStart({ ns: 'db.coll', ops: { filter: { name: /^a$/i, big: 9n } }, name: 'find' }) + assert.deepStrictEqual(JSON.parse(slow), { name: { $regex: '^a$', $options: 'i' }, big: '9' }) + }) + + it('renders a Map as a document of its entries, matching the driver wire shape', () => { + const fast = callBindStart({ + ns: 'db.coll', + ops: { filter: { m: new Map([['a', 1], ['nested', { x: 2 }]]) } }, + name: 'find', + }) + assert.deepStrictEqual(JSON.parse(fast), { m: { a: 1, nested: { x: 2 } } }) + + const slow = callBindStart({ + ns: 'db.coll', + ops: { filter: { m: new Map([['a', 1]]), big: 9n } }, + name: 'find', + }) + assert.deepStrictEqual(JSON.parse(slow), { m: { a: 1 }, big: '9' }) + }) + + it('renders an empty Map as an empty object', () => { + const actual = callBindStart({ ns: 'db.coll', ops: { filter: { m: new Map() } }, name: 'find' }) + + assert.deepStrictEqual(JSON.parse(actual), { m: {} }) + }) + + it('renders a cross-realm RegExp by its source and flags', () => { + const foreign = vm.runInNewContext('/^a$/i') + const actual = callBindStart({ ns: 'db.coll', ops: { filter: { name: foreign } }, name: 'find' }) + + assert.deepStrictEqual(JSON.parse(actual), { name: { $regex: '^a$', $options: 'i' } }) + }) + + it('treats a plain object with a size property as a document, not a Map', () => { + const actual = callBindStart({ ns: 'db.coll', ops: { filter: { size: 5, name: 'x' } }, name: 'find' }) + + assert.deepStrictEqual(JSON.parse(actual), { size: 5, name: 'x' }) + }) +}) + +describe('mongodb-core query obfuscation (array edge cases under redact)', () => { + it('redacts every leaf uniformly, including functions and symbols', () => { + const query = callBindStart({ + ns: 'db.coll', + ops: { + query: { + keep: 1, + drop: () => {}, + sym: Symbol('x'), + items: ['ok', () => {}, Symbol('x'), 'tail', null], + }, + }, + name: 'find', + }, { obfuscateQuery: 'redact' }) + + assert.deepStrictEqual(JSON.parse(query), { + keep: '?', + drop: '?', + sym: '?', + items: ['?', '?', '?', '?', '?'], + }) + }) }) diff --git a/packages/datadog-plugin-mongodb-core/test/mongodb.spec.js b/packages/datadog-plugin-mongodb-core/test/mongodb.spec.js index a5abc0cd0c..6211fde112 100644 --- a/packages/datadog-plugin-mongodb-core/test/mongodb.spec.js +++ b/packages/datadog-plugin-mongodb-core/test/mongodb.spec.js @@ -68,7 +68,6 @@ describe('Plugin', () => { let collection let db let BSON - let startSpy let injectCommentSpy let usesDelete @@ -663,11 +662,11 @@ describe('Plugin', () => { db = client.db('test') collection = db.collection(collectionName) - startSpy = sinon.spy(MongodbCorePlugin.prototype, 'start') + injectCommentSpy = sinon.spy(MongodbCorePlugin.prototype, 'injectDbmComment') }) afterEach(() => { - startSpy?.restore() + injectCommentSpy?.restore() }) it('DBM propagation should inject service mode as comment', done => { @@ -675,8 +674,8 @@ describe('Plugin', () => { .assertSomeTraces(traces => { const span = traces[0][0] - assert.strictEqual(startSpy.called, true) - const { comment } = startSpy.getCall(0).args[0].ops + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue assert.strictEqual(comment, `dddb='${encodeURIComponent(span.meta['db.name'])}',` + 'dddbs=\'test-mongodb\',' + @@ -710,12 +709,10 @@ describe('Plugin', () => { db = client.db('test') collection = db.collection(collectionName) - startSpy = sinon.spy(MongodbCorePlugin.prototype, 'start') injectCommentSpy = sinon.spy(MongodbCorePlugin.prototype, 'injectDbmComment') }) afterEach(() => { - startSpy?.restore() injectCommentSpy?.restore() }) @@ -725,9 +722,7 @@ describe('Plugin', () => { const traceId = span.meta['_dd.p.tid'] + span.trace_id.toString(16).padStart(16, '0') const spanId = span.span_id.toString(16).padStart(16, '0') - assert.strictEqual(startSpy.called, true) assert.strictEqual(injectCommentSpy.called, true) - const comment = injectCommentSpy.getCall(0).returnValue assert.strictEqual(comment, `dddb='${encodeURIComponent(span.meta['db.name'])}',` + @@ -763,11 +758,11 @@ describe('Plugin', () => { db = client.db('test') collection = db.collection(collectionName) - startSpy = sinon.spy(MongodbCorePlugin.prototype, 'start') + injectCommentSpy = sinon.spy(MongodbCorePlugin.prototype, 'injectDbmComment') }) afterEach(() => { - startSpy?.restore() + injectCommentSpy?.restore() }) it( @@ -779,8 +774,8 @@ describe('Plugin', () => { const traceId = span.meta['_dd.p.tid'] + span.trace_id.toString(16).padStart(16, '0') const spanId = span.span_id.toString(16).padStart(16, '0') - assert.strictEqual(startSpy.called, true) - const { comment } = startSpy.getCall(0).args[0].ops + assert.strictEqual(injectCommentSpy.called, true) + const comment = injectCommentSpy.getCall(0).returnValue assert.match( comment, new RegExp(String.raw`traceparent='00-${traceId}-${spanId}-00'`) @@ -858,7 +853,7 @@ describe('Plugin', () => { agent .assertSomeTraces(traces => { - assert.ok(traces[0].length >= 2) + assert.ok(traces[0].length >= 2, `Expected ${traces[0].length} >= 2`) const rootSpan = traces[0][0] assert.strictEqual(rootSpan.name, 'test.parent') @@ -961,7 +956,7 @@ describe('Plugin', () => { agent .assertSomeTraces(traces => { - assert.ok(traces[0].length >= 2) + assert.ok(traces[0].length >= 2, `Expected ${traces[0].length} >= 2`) const rootSpan = traces[0][0] assert.strictEqual(rootSpan.name, 'test.parent') diff --git a/packages/datadog-plugin-mongoose/test/integration-test/client.spec.js b/packages/datadog-plugin-mongoose/test/integration-test/client.spec.js index 79f6ce6873..410ba7569d 100644 --- a/packages/datadog-plugin-mongoose/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-mongoose/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -37,7 +38,7 @@ describe('esm', () => { it(`is instrumented loaded with ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'mongodb.query'), true) }) diff --git a/packages/datadog-plugin-mysql/test/integration-test/client.spec.js b/packages/datadog-plugin-mysql/test/integration-test/client.spec.js index 12060be9b6..33ac0e9679 100644 --- a/packages/datadog-plugin-mysql/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-mysql/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -39,7 +40,7 @@ describe('esm', () => { it(`is instrumented loaded with ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'mysql.query'), true) }) diff --git a/packages/datadog-plugin-mysql2/test/integration-test/client.spec.js b/packages/datadog-plugin-mysql2/test/integration-test/client.spec.js index 4106d24250..5b4a15e6cc 100644 --- a/packages/datadog-plugin-mysql2/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-mysql2/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -39,7 +40,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'mysql.query'), true) }) diff --git a/packages/datadog-plugin-nats/src/consumer.js b/packages/datadog-plugin-nats/src/consumer.js new file mode 100644 index 0000000000..16e507ccb5 --- /dev/null +++ b/packages/datadog-plugin-nats/src/consumer.js @@ -0,0 +1,43 @@ +'use strict' + +const { TEXT_MAP } = require('../../../ext/formats') +const ConsumerPlugin = require('../../dd-trace/src/plugins/consumer') +const { headersToTextMap } = require('./util') + +const MESSAGING_DESTINATION_KEY = 'messaging.destination.name' + +class NatsConsumerPlugin extends ConsumerPlugin { + static id = 'nats' + static operation = 'consume' + + bindStart (ctx) { + const { subject: filter, message } = ctx + // For wildcard subscriptions (e.g. `orders.*`), `filter` is the subscription + // pattern but `message.subject` is the actual delivered subject. Prefer the + // delivered one for resource/destination so spans aren't all collapsed under + // the wildcard pattern. Fall back to the filter if the message is missing it. + const subject = typeof message?.subject === 'string' ? message.subject : filter + const carrier = headersToTextMap(message?.headers) + const childOf = carrier ? this.tracer.extract(TEXT_MAP, carrier) : null + + const meta = { + component: 'nats', + 'nats.subject': subject, + [MESSAGING_DESTINATION_KEY]: subject, + } + if (filter && filter !== subject) { + meta['nats.subscription.subject'] = filter + } + + this.startSpan({ + childOf, + resource: subject, + type: 'worker', + meta, + }, ctx) + + return ctx.currentStore + } +} + +module.exports = NatsConsumerPlugin diff --git a/packages/datadog-plugin-nats/src/index.js b/packages/datadog-plugin-nats/src/index.js new file mode 100644 index 0000000000..fc484ade52 --- /dev/null +++ b/packages/datadog-plugin-nats/src/index.js @@ -0,0 +1,20 @@ +'use strict' + +const CompositePlugin = require('../../dd-trace/src/plugins/composite') +const ProducerPlugin = require('./producer') +const ConsumerPlugin = require('./consumer') + +class NatsPlugin extends CompositePlugin { + static id = 'nats' + // Disabled by default — users must opt in via DD_TRACE_NATS_ENABLED=true + // or `tracer.use('nats')`. Matches the feature parity dashboard policy. + static experimental = true + static get plugins () { + return { + producer: ProducerPlugin, + consumer: ConsumerPlugin, + } + } +} + +module.exports = NatsPlugin diff --git a/packages/datadog-plugin-nats/src/producer.js b/packages/datadog-plugin-nats/src/producer.js new file mode 100644 index 0000000000..9ca796f5e5 --- /dev/null +++ b/packages/datadog-plugin-nats/src/producer.js @@ -0,0 +1,62 @@ +'use strict' + +const { TEXT_MAP } = require('../../../ext/formats') +const { CLIENT_PORT_KEY } = require('../../dd-trace/src/constants') +const ProducerPlugin = require('../../dd-trace/src/plugins/producer') +const { getOperationName } = require('./util') + +const MESSAGING_DESTINATION_KEY = 'messaging.destination.name' + +class NatsProducerPlugin extends ProducerPlugin { + static id = 'nats' + static operation = 'publish' + static peerServicePrecursors = [MESSAGING_DESTINATION_KEY] + + bindStart (ctx) { + const { subject, options, connection, type, createHeaders } = ctx + const server = connection?.protocol?.servers?.getCurrent?.() ?? + connection?.protocol?.servers?.getCurrentServer?.() + const operation = getOperationName(type) + + const span = this.startSpan({ + resource: subject, + meta: { + component: 'nats', + 'nats.subject': subject, + 'nats.operation': operation, + [MESSAGING_DESTINATION_KEY]: subject, + 'out.host': server?.hostname, + }, + }, ctx) + + if (server?.port) { + span.setTag(CLIENT_PORT_KEY, server.port) + } + + if (this.serverSupportsHeaders(connection)) { + let headers = options.headers + if (!headers && typeof createHeaders === 'function') { + headers = createHeaders() + options.headers = headers + } + if (headers && typeof headers.set === 'function') { + const carrier = {} + this.tracer.inject(span, TEXT_MAP, carrier) + for (const key of Object.keys(carrier)) { + headers.set(key, carrier[key]) + } + } + } + + return ctx.currentStore + } + + serverSupportsHeaders (connection) { + const info = connection?.protocol?.info + // If info isn't available yet (e.g. publish before INFO), assume supported — modern NATS does. + if (!info) return true + return info.headers !== false + } +} + +module.exports = NatsProducerPlugin diff --git a/packages/datadog-plugin-nats/src/util.js b/packages/datadog-plugin-nats/src/util.js new file mode 100644 index 0000000000..b2d0e3d569 --- /dev/null +++ b/packages/datadog-plugin-nats/src/util.js @@ -0,0 +1,33 @@ +'use strict' + +function headersToTextMap (msgHdrs) { + if (!msgHdrs || typeof msgHdrs[Symbol.iterator] !== 'function') return null + const textMap = {} + for (const [key, values] of msgHdrs) { + if (!Array.isArray(values) || values.length === 0) continue + // Trace headers are single-valued (injected via `set`, not `append`), so + // the first element is always the authoritative value. + textMap[key] = values[0] + } + return textMap +} + +function getOperationName (type) { + switch (type) { + case 'publish': + return 'publish' + case 'request': + case 'requestMany': + return 'request' + default: + // Surface unrecognized operations explicitly rather than silently + // collapsing them into 'publish' — if NATS adds a new outbound API, + // this lets us see it in traces and fix the mapping deliberately. + return 'unknown' + } +} + +module.exports = { + headersToTextMap, + getOperationName, +} diff --git a/packages/datadog-plugin-nats/test/index.spec.js b/packages/datadog-plugin-nats/test/index.spec.js new file mode 100644 index 0000000000..8cde0068ba --- /dev/null +++ b/packages/datadog-plugin-nats/test/index.spec.js @@ -0,0 +1,461 @@ +'use strict' + +const assert = require('node:assert/strict') +const { setTimeout: setTimeoutPromise } = require('node:timers/promises') + +const { afterEach, beforeEach, describe, it } = require('mocha') + +const agent = require('../../dd-trace/test/plugins/agent') +const id = require('../../dd-trace/src/id') +const { ERROR_MESSAGE } = require('../../dd-trace/src/constants') +const { withNamingSchema, withPeerService, withVersions } = require('../../dd-trace/test/setup/mocha') +const { assertObjectContains } = require('../../../integration-tests/helpers') +const { expectedSchema, rawExpectedSchema } = require('./naming') + +// Pulls messages from an async iterator subscription, returning all received. +async function drainAll (sub) { + const messages = [] + for await (const msg of sub) { + messages.push(msg) + } + return messages +} + +describe('Plugin', () => { + let tracer + let connect + let connection + let subject + + describe('nats', () => { + withVersions('nats', '@nats-io/transport-node', version => { + beforeEach(() => { + tracer = require('../../dd-trace') + subject = `test-${id()}` + connect = require(`../../../versions/@nats-io/transport-node@${version}`).get().connect + }) + + afterEach(async () => { + if (connection && !connection.isClosed()) { + // close() may hang if a subscription callback threw (the library's drain + // path waits for inflight messages); race with a timer to keep tests fast. + // The AbortController cancels the timer once close() settles so the timer + // does not keep the process alive for the full 500ms window. + const ac = new AbortController() + await Promise.race([ + connection.close().catch(() => {}).finally(() => ac.abort()), + setTimeoutPromise(500, undefined, { signal: ac.signal }).catch(() => {}), + ]) + } + connection = null + }) + + describe('without configuration', () => { + beforeEach(async () => { + await agent.load('nats') + connection = await connect({ servers: '127.0.0.1:4222' }) + }) + + afterEach(() => agent.close({ ritmReset: false })) + + it('should run commands normally without a plugin loaded', async () => { + // Sanity: published message must round-trip even when only producer spans matter. + const received = new Promise(resolve => { + connection.subscribe(subject, { + max: 1, + callback: (_err, msg) => resolve(msg), + }) + }) + connection.publish(subject, 'hello') + const msg = await received + assert.ok(msg, `expected to receive a message on ${subject}`) + }) + + describe('publish', () => { + withPeerService( + () => tracer, + 'nats', + (done) => { + connection.publish(subject, 'hello') + done() + }, + () => subject, + 'messaging.destination.name' + ) + + it('creates a producer span for publish', () => { + const assertion = agent.assertSomeTraces(traces => { + const span = traces[0][0] + assertObjectContains(span, { + name: expectedSchema.send.opName, + service: expectedSchema.send.serviceName, + resource: subject, + meta: { + component: 'nats', + 'span.kind': 'producer', + 'nats.subject': subject, + 'nats.operation': 'publish', + 'messaging.destination.name': subject, + '_dd.integration': 'nats', + }, + }) + }) + + connection.publish(subject, 'hello') + return assertion + }) + + it('does not throw when options object is frozen', () => { + const frozenOpts = Object.freeze({}) + connection.publish(subject, 'hello', frozenOpts) + return agent.assertSomeTraces(traces => { + assert.ok(traces[0][0], 'expected a span') + }) + }) + + withNamingSchema( + () => connection.publish(subject, 'hello'), + rawExpectedSchema.send + ) + }) + + describe('request', () => { + it('creates a producer span for request', () => { + const responder = connection.subscribe(subject, { + max: 1, + callback: (_err, msg) => msg.respond('pong'), + }) + void responder + + const assertion = agent.assertSomeTraces(traces => { + const producer = traces[0].find(s => s.meta?.component === 'nats' && s.meta['span.kind'] === 'producer') + assert.ok(producer, 'expected producer span') + assertObjectContains(producer, { + name: expectedSchema.send.opName, + service: expectedSchema.send.serviceName, + resource: subject, + meta: { + component: 'nats', + 'nats.subject': subject, + 'nats.operation': 'request', + }, + }) + }) + + return Promise.all([ + connection.request(subject, 'ping', { timeout: 2000 }), + assertion, + ]) + }) + + it('does not throw when options object is frozen', async () => { + const responder = connection.subscribe(subject, { + max: 1, + callback: (_e, msg) => msg.respond('pong'), + }) + void responder + const frozenOpts = Object.freeze({ timeout: 2000 }) + await connection.request(subject, 'ping', frozenOpts) + }) + }) + + describe('subscribe (callback)', () => { + it('creates a consumer span for delivered messages', async () => { + const received = new Promise(resolve => { + connection.subscribe(subject, { + max: 1, + callback: (_err, msg) => resolve(msg), + }) + }) + + const assertion = agent.assertSomeTraces(traces => { + const consumer = traces[0].find(s => s.meta?.component === 'nats' && s.meta['span.kind'] === 'consumer') + assert.ok(consumer, 'expected consumer span') + assertObjectContains(consumer, { + name: expectedSchema.receive.opName, + service: expectedSchema.receive.serviceName, + resource: subject, + type: 'worker', + meta: { + component: 'nats', + 'nats.subject': subject, + 'messaging.destination.name': subject, + }, + }) + }) + + connection.publish(subject, 'hello') + return Promise.all([ + received, + assertion, + ]) + }) + + withNamingSchema( + () => new Promise(resolve => { + connection.subscribe(subject, { + max: 1, + callback: () => resolve(), + }) + connection.publish(subject, 'hello') + }), + rawExpectedSchema.receive + ) + }) + + describe('subscribe (iterator)', () => { + it('creates a consumer span per yielded message', async () => { + const sub = connection.subscribe(subject, { max: 1 }) + + const assertion = agent.assertSomeTraces(traces => { + const consumer = traces[0].find(s => s.meta?.component === 'nats' && s.meta['span.kind'] === 'consumer') + assert.ok(consumer, 'expected consumer span') + assertObjectContains(consumer, { + resource: subject, + meta: { component: 'nats', 'nats.subject': subject }, + }) + }) + + const receive = drainAll(sub) + + connection.publish(subject, 'hello') + return Promise.all([ + receive, + assertion, + ]) + }) + }) + + describe('request span deduplication', () => { + it('creates exactly one producer span per request (no nested publish span)', async () => { + // request() internally calls this.publish() which is also wrapped. + // Without suppression that would double-count every traced request. + const responder = connection.subscribe(subject, { + max: 1, + callback: (_e, msg) => msg.respond('pong'), + }) + void responder + + const assertion = agent.assertSomeTraces(traces => { + const producers = traces[0].filter( + s => s.meta?.component === 'nats' && s.meta['span.kind'] === 'producer' + ) + assert.strictEqual(producers.length, 1, + `expected exactly one producer span for the request, got ${producers.length}`) + assert.strictEqual(producers[0].meta['nats.operation'], 'request') + }) + + await connection.request(subject, 'ping', { timeout: 2000 }) + await assertion + }) + }) + + describe('publishMessage', () => { + it('creates a producer span via the wrapped prototype publish', () => { + const assertion = agent.assertSomeTraces(traces => { + const producer = traces[0].find( + s => s.meta?.component === 'nats' && s.meta['span.kind'] === 'producer' + ) + assert.ok(producer, 'expected producer span') + assertObjectContains(producer, { + resource: subject, + meta: { 'nats.subject': subject, 'nats.operation': 'publish' }, + }) + }) + + connection.publishMessage({ subject, data: 'hello' }) + return assertion + }) + }) + + describe('respondMessage', () => { + it('creates a producer span when replying to a Msg', async () => { + const replyInbox = `reply-${id()}` + const received = new Promise(resolve => { + connection.subscribe(replyInbox, { + max: 1, + callback: (_e, msg) => resolve(msg), + }) + }) + + const assertion = agent.assertSomeTraces(traces => { + const producer = traces[0].find( + s => + s.meta?.component === 'nats' && + s.meta['span.kind'] === 'producer' && + s.resource === replyInbox + ) + assert.ok(producer, 'expected producer span for the reply') + }) + + // respondMessage internally calls this.publish(msg.reply, ...) which + // hits the wrapped prototype method. + connection.respondMessage({ subject, reply: replyInbox, data: 'pong' }) + return Promise.all([ + received, + assertion, + ]) + }) + }) + + describe('wildcard subscriptions', () => { + it('uses the delivered subject, not the subscription filter', async () => { + const wildcard = `${subject}.*` + const concrete = `${subject}.created` + const received = new Promise(resolve => { + connection.subscribe(wildcard, { + max: 1, + callback: (_e, msg) => resolve(msg), + }) + }) + + const assertion = agent.assertSomeTraces(traces => { + const consumer = traces[0].find( + s => s.meta?.component === 'nats' && s.meta['span.kind'] === 'consumer' + ) + assert.ok(consumer, 'expected consumer span') + assertObjectContains(consumer, { + resource: concrete, + meta: { + 'nats.subject': concrete, + 'messaging.destination.name': concrete, + 'nats.subscription.subject': wildcard, + }, + }) + }) + + connection.publish(concrete, 'hello') + return Promise.all([ + received, + assertion, + ]) + }) + }) + + describe('distributed tracing', () => { + it('propagates trace context via headers', async () => { + const received = new Promise(resolve => { + connection.subscribe(subject, { + max: 1, + callback: (_err, msg) => resolve(msg), + }) + }) + + const assertion = agent.assertSomeTraces(traces => { + const consumer = traces[0].find( + s => s.meta?.component === 'nats' && s.meta['span.kind'] === 'consumer' + ) + assert.ok(consumer, 'expected consumer span') + const parentId = consumer.parent_id?.toString?.() + assert.ok(parentId && parentId !== '0', `expected non-zero parent_id, got ${parentId}`) + }) + + connection.publish(subject, 'hello') + return Promise.all([ + received, + assertion, + ]) + }) + }) + + describe('errors', () => { + it('records sync publish failures and rethrows', () => { + const assertion = agent.assertSomeTraces(traces => { + const producer = traces[0].find( + s => s.meta?.component === 'nats' && s.meta['span.kind'] === 'producer' + ) + assert.ok(producer, 'expected producer span') + assert.strictEqual(producer.error, 1) + assert.ok(producer.meta?.[ERROR_MESSAGE], 'expected an error message tag') + }) + + // Empty subject — nats-core's `_check()` throws synchronously, + // exercising the catch/publishErrorCh branch in `wrapSyncProducer`. + assert.throws(() => connection.publish('', 'hello')) + return assertion + }) + + it('records async request failures and rejects', async () => { + const assertion = agent.assertSomeTraces(traces => { + const producer = traces[0].find( + s => s.meta?.component === 'nats' && s.meta['span.kind'] === 'producer' + ) + assert.ok(producer, 'expected producer span') + assert.strictEqual(producer.error, 1) + }) + + // No responder is subscribed — the broker returns a 503 NoResponders + // status after the configured timeout, hitting the async rejection branch. + await assert.rejects(connection.request(subject, 'hello', { timeout: 200 })) + await assertion + }) + + it('records consumer callback errors and rethrows', async () => { + const fakeError = new Error('boom') + + const assertion = agent.assertSomeTraces(traces => { + const consumer = traces[0].find( + s => s.meta?.component === 'nats' && s.meta['span.kind'] === 'consumer' + ) + assert.ok(consumer, 'expected consumer span') + assert.strictEqual(consumer.error, 1) + assert.strictEqual(consumer.meta?.[ERROR_MESSAGE], fakeError.message) + }) + + connection.subscribe(subject, { + max: 1, + callback: () => { throw fakeError }, + }) + connection.publish(subject, 'hello') + await assertion + }) + + it('passes through null/error deliveries without creating a span', async () => { + // NATS calls the user's callback with `(err, {})` on subscription timeout — + // exercises the `!message || err` short-circuit before the runStores branch. + const ac = new AbortController() + const received = new Promise(resolve => { + connection.subscribe(subject, { + max: 1, + timeout: 50, + callback: (err) => resolve(err), + }) + }) + const err = await Promise.race([ + received.finally(() => ac.abort()), + setTimeoutPromise(500, undefined, { signal: ac.signal }).catch(() => null), + ]) + assert.ok(err) + }) + }) + }) + + describe('when the plugin is disabled', () => { + beforeEach(async () => { + await agent.load('nats', { enabled: false }) + connection = await connect({ servers: '127.0.0.1:4222' }) + }) + + afterEach(() => agent.close({ ritmReset: false })) + + it('skips the publish wrapper fast-path', () => { + // The wrap's `!hasSubscribers` branch returns the original immediately, + // covering the early-out line in `wrapSyncProducer`/`wrapAsyncProducer`/subscribe. + connection.publish(subject, 'hello') + }) + + it('skips the subscribe wrapper fast-path', () => { + const sub = connection.subscribe(subject, { max: 1 }) + assert.ok(sub, 'expected subscription object') + sub.unsubscribe() + }) + + it('skips the async-producer wrapper fast-path', async () => { + // No responder — `request` will reject with timeout; the fast-path returns + // the original promise without instrumenting it. + await assert.rejects(connection.request(subject, 'hi', { timeout: 50 })) + }) + }) + }) + }) +}) diff --git a/packages/datadog-plugin-nats/test/naming.js b/packages/datadog-plugin-nats/test/naming.js new file mode 100644 index 0000000000..77ddc1ace3 --- /dev/null +++ b/packages/datadog-plugin-nats/test/naming.js @@ -0,0 +1,31 @@ +'use strict' + +const { resolveNaming } = require('../../dd-trace/test/plugins/helpers') + +const rawExpectedSchema = { + send: { + v0: { + opName: 'nats.publish', + serviceName: 'test-nats', + }, + v1: { + opName: 'nats.send', + serviceName: 'test', + }, + }, + receive: { + v0: { + opName: 'nats.consume', + serviceName: 'test-nats', + }, + v1: { + opName: 'nats.process', + serviceName: 'test', + }, + }, +} + +module.exports = { + rawExpectedSchema, + expectedSchema: resolveNaming(rawExpectedSchema), +} diff --git a/packages/datadog-plugin-net/test/integration-test/client.spec.js b/packages/datadog-plugin-net/test/integration-test/client.spec.js index 06f4d63441..852decbe46 100644 --- a/packages/datadog-plugin-net/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-net/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -37,7 +38,7 @@ describe('esm', () => { it(`is instrumented loaded with ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'tcp.connect'), true) const metaContainsNet = payload.some((span) => span.some((nestedSpan) => nestedSpan.meta.component === 'net')) assert.strictEqual(metaContainsNet, true) diff --git a/packages/datadog-plugin-next/src/index.js b/packages/datadog-plugin-next/src/index.js index e6abe84887..46685bedca 100644 --- a/packages/datadog-plugin-next/src/index.js +++ b/packages/datadog-plugin-next/src/index.js @@ -33,11 +33,13 @@ class NextPlugin extends ServerPlugin { 'span.type': 'web', 'span.kind': 'server', 'http.method': req.method, - ...(serviceSource === undefined ? {} : { [SVC_SRC_KEY]: serviceSource }), + ...(serviceSource === undefined ? undefined : { [SVC_SRC_KEY]: serviceSource }), }, integrationName: this.constructor.id, }) + this.stampIntegrationService(span, serviceName) + analyticsSampler.sample(span, this.config.measured, true) return { ...store, span, req } @@ -60,7 +62,7 @@ class NextPlugin extends ServerPlugin { if (!store) return const span = store.span - const error = span.context()._tags.error + const error = span.context().getTag('error') const requestError = req.error || nextRequest.error if (requestError) { @@ -97,7 +99,7 @@ class NextPlugin extends ServerPlugin { if (!req) return // Only use error page names if there's not already a name - const current = span.context()._tags['next.page'] + const current = span.context().getTag('next.page') const isErrorPage = errorPages.has(page) if (current && isErrorPage) { diff --git a/packages/datadog-plugin-next/test/integration-test/client.spec.js b/packages/datadog-plugin-next/test/integration-test/client.spec.js index 1a55047f14..c1fcb4b5c3 100644 --- a/packages/datadog-plugin-next/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-next/test/integration-test/client.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const { execSync } = require('child_process') +const { inspect } = require('node:util') const { FakeAgent, curlAndAssertMessage, @@ -59,7 +60,7 @@ describe('esm', () => { }) return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assertObjectContains(headers, { host: `127.0.0.1:${agent.port}` }) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'next.request'), true) }, undefined, undefined, true) }).timeout(300 * 1000) diff --git a/packages/datadog-plugin-openai/src/tracing.js b/packages/datadog-plugin-openai/src/tracing.js index 24302eef09..af9647ed07 100644 --- a/packages/datadog-plugin-openai/src/tracing.js +++ b/packages/datadog-plugin-openai/src/tracing.js @@ -8,6 +8,7 @@ const Sampler = require('../../dd-trace/src/sampler') const { MEASURED } = require('../../../ext/tags') const { DD_MAJOR } = require('../../../version') +const { ERROR_TYPE } = require('../../dd-trace/src/constants') const { convertBuffersToObjects, constructCompletionResponseFromStreamedChunks, @@ -157,7 +158,7 @@ class OpenAiTracingPlugin extends TracingPlugin { const span = store?.span if (!span) return - const error = !!span.context()._tags.error + const error = !!span.context().getTag('error') let headers, body, method, path if (!error) { @@ -171,7 +172,7 @@ class OpenAiTracingPlugin extends TracingPlugin { headers = Object.fromEntries(headers) } - const resource = span._spanContext._tags['resource.name'] + const resource = span.context().getTag('resource.name') const normalizedMethodName = store.normalizedMethodName body = coerceResponseBody(body, normalizedMethodName) @@ -211,6 +212,18 @@ class OpenAiTracingPlugin extends TracingPlugin { this.sendMetrics(headers, body, endpoint, span._duration, error, tags) } + error (ctx) { + const span = ctx.currentStore?.span + if (!span) return + + super.error(ctx) // add normal error tag + + const errorType = ctx.error?.type + if (errorType) { + span.setTag(ERROR_TYPE, errorType) + } + } + sendMetrics (headers, body, endpoint, duration, error, spanTags) { const tags = [`error:${Number(!!error)}`] if (error) { diff --git a/packages/datadog-plugin-openai/test/index.spec.js b/packages/datadog-plugin-openai/test/index.spec.js index d60b078cf4..dc25ee6141 100644 --- a/packages/datadog-plugin-openai/test/index.spec.js +++ b/packages/datadog-plugin-openai/test/index.spec.js @@ -4,6 +4,7 @@ const { execFileSync } = require('child_process') const fs = require('fs') const assert = require('node:assert/strict') const Path = require('path') +const { inspect } = require('node:util') const semver = require('semver') const sinon = require('sinon') @@ -113,13 +114,6 @@ describe('Plugin', () => { }) it('should attach an error to the span', async () => { - const checkTraces = agent.assertFirstTraceSpan({ - error: 1, - meta: { - 'error.type': 'Error', - }, - }) - const params = { model: 'gpt-3.5-turbo', // incorrect model prompt: 'Hello, OpenAI!', @@ -129,17 +123,26 @@ describe('Plugin', () => { stream: false, } + let errorType = 'Error' + try { if (semver.satisfies(realVersion, '>=4.0.0')) { await openai.completions.create(params) } else { await openai.createCompletion(params) } - } catch { - // ignore, we expect an error + } catch (e) { + if (e.type) { // version 3 of openai does not return an error type + errorType = e.type + } } - await checkTraces + await agent.assertFirstTraceSpan({ + error: 1, + meta: { + 'error.type': errorType, + }, + }) clock.tick(10 * 1000) @@ -209,8 +212,11 @@ describe('Plugin', () => { }) for await (const part of stream) { - assert.ok(Object.hasOwn(part, 'choices')) - assert.ok(Object.hasOwn(part.choices[0], 'delta')) + assert.ok(Object.hasOwn(part, 'choices'), `Available keys: ${inspect(Object.keys(part))}`) + assert.ok( + Object.hasOwn(part.choices[0], 'delta'), + `Available keys: ${inspect(Object.keys(part.choices[0]))}` + ) } tracer.trace('child of outer', innerSpan => { @@ -242,7 +248,10 @@ describe('Plugin', () => { 'openai.request.model': 'gpt-3.5-turbo-instruct', }, }) - assert.ok(Object.hasOwn(traces[0][0].meta, 'openai.response.model')) + assert.ok( + Object.hasOwn(traces[0][0].meta, 'openai.response.model'), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) }) const params = { @@ -307,7 +316,10 @@ describe('Plugin', () => { it('makes a successful call', async () => { const checkTraces = agent .assertSomeTraces(traces => { - assert.ok(Object.hasOwn(traces[0][0].meta, 'openai.response.model')) + assert.ok( + Object.hasOwn(traces[0][0].meta, 'openai.response.model'), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) }) const params = { @@ -322,8 +334,11 @@ describe('Plugin', () => { const stream = await openai.completions.create(params) for await (const part of stream) { - assert.ok(Object.hasOwn(part, 'choices')) - assert.ok(Object.hasOwn(part.choices[0], 'text')) + assert.ok(Object.hasOwn(part, 'choices'), `Available keys: ${inspect(Object.keys(part))}`) + assert.ok( + Object.hasOwn(part.choices[0], 'text'), + `Available keys: ${inspect(Object.keys(part.choices[0]))}` + ) } await checkTraces @@ -332,7 +347,10 @@ describe('Plugin', () => { it('makes a successful call with usage included', async () => { const checkTraces = agent .assertSomeTraces(traces => { - assert.ok(Object.hasOwn(traces[0][0].meta, 'openai.response.model')) + assert.ok( + Object.hasOwn(traces[0][0].meta, 'openai.response.model'), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) }) const params = { @@ -350,9 +368,12 @@ describe('Plugin', () => { const stream = await openai.completions.create(params) for await (const part of stream) { - assert.ok(Object.hasOwn(part, 'choices')) + assert.ok(Object.hasOwn(part, 'choices'), `Available keys: ${inspect(Object.keys(part))}`) if (part.choices.length) { // last usage chunk will have no choices - assert.ok(Object.hasOwn(part.choices[0], 'text')) + assert.ok( + Object.hasOwn(part.choices[0], 'text'), + `Available keys: ${inspect(Object.keys(part.choices[0]))}` + ) } } @@ -378,8 +399,11 @@ describe('Plugin', () => { const stream = await openai.completions.create(params) for await (const part of stream) { - assert.ok(Object.hasOwn(part, 'choices')) - assert.ok(Object.hasOwn(part.choices[0], 'text')) + assert.ok(Object.hasOwn(part, 'choices'), `Available keys: ${inspect(Object.keys(part))}`) + assert.ok( + Object.hasOwn(part.choices[0], 'text'), + `Available keys: ${inspect(Object.keys(part.choices[0]))}` + ) } await checkTraces @@ -405,7 +429,10 @@ describe('Plugin', () => { 'openai.request.model': 'text-embedding-ada-002', }, }) - assert.ok(Object.hasOwn(traces[0][0].meta, 'openai.response.model')) + assert.ok( + Object.hasOwn(traces[0][0].meta, 'openai.response.model'), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) }) const params = { @@ -445,7 +472,10 @@ describe('Plugin', () => { }, }) - assert.ok(Object.hasOwn(traces[0][0].metrics, 'openai.response.count')) + assert.ok( + Object.hasOwn(traces[0][0].metrics, 'openai.response.count'), + `Available keys: ${inspect(Object.keys(traces[0][0].metrics))}` + ) }) if (semver.satisfies(realVersion, '>=4.0.0')) { @@ -552,7 +582,10 @@ describe('Plugin', () => { 'openai.request.method': 'GET', }, }) - assert.ok(Object.hasOwn(traces[0][0].metrics, 'openai.response.count')) + assert.ok( + Object.hasOwn(traces[0][0].metrics, 'openai.response.count'), + `Available keys: ${inspect(Object.keys(traces[0][0].metrics))}` + ) }) if (semver.satisfies(realVersion, '>=4.0.0')) { @@ -595,10 +628,19 @@ describe('Plugin', () => { 'openai.response.filename': 'fine-tune.jsonl', }, }) - assert.ok(Object.hasOwn(traces[0][0].meta, 'openai.response.status')) + assert.ok( + Object.hasOwn(traces[0][0].meta, 'openai.response.status'), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) assert.match(traces[0][0].meta['openai.response.id'], /^file-/) - assert.ok(Object.hasOwn(traces[0][0].metrics, 'openai.response.bytes')) - assert.ok(Object.hasOwn(traces[0][0].metrics, 'openai.response.created_at')) + assert.ok( + Object.hasOwn(traces[0][0].metrics, 'openai.response.bytes'), + `Available keys: ${inspect(Object.keys(traces[0][0].metrics))}` + ) + assert.ok( + Object.hasOwn(traces[0][0].metrics, 'openai.response.created_at'), + `Available keys: ${inspect(Object.keys(traces[0][0].metrics))}` + ) }) if (semver.satisfies(realVersion, '>=4.0.0')) { @@ -638,9 +680,18 @@ describe('Plugin', () => { 'openai.response.purpose': 'fine-tune', }, }) - assert.ok(Object.hasOwn(traces[0][0].meta, 'openai.response.status')) - assert.ok(Object.hasOwn(traces[0][0].metrics, 'openai.response.bytes')) - assert.ok(Object.hasOwn(traces[0][0].metrics, 'openai.response.created_at')) + assert.ok( + Object.hasOwn(traces[0][0].meta, 'openai.response.status'), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) + assert.ok( + Object.hasOwn(traces[0][0].metrics, 'openai.response.bytes'), + `Available keys: ${inspect(Object.keys(traces[0][0].metrics))}` + ) + assert.ok( + Object.hasOwn(traces[0][0].metrics, 'openai.response.created_at'), + `Available keys: ${inspect(Object.keys(traces[0][0].metrics))}` + ) }) if (semver.satisfies(realVersion, '>=4.0.0')) { @@ -713,7 +764,10 @@ describe('Plugin', () => { 'openai.response.id': 'file-RpTpuvRVtnKpdKZb7DDGto', }, }) - assert.ok(Object.hasOwn(traces[0][0].metrics, 'openai.response.deleted')) + assert.ok( + Object.hasOwn(traces[0][0].metrics, 'openai.response.deleted'), + `Available keys: ${inspect(Object.keys(traces[0][0].metrics))}` + ) }) if (semver.satisfies(realVersion, '>=4.0.0')) { @@ -756,7 +810,10 @@ describe('Plugin', () => { }, }) assert.match(traces[0][0].meta['openai.response.id'], /^ftjob-/) - assert.ok(Object.hasOwn(traces[0][0].metrics, 'openai.response.created_at')) + assert.ok( + Object.hasOwn(traces[0][0].metrics, 'openai.response.created_at'), + `Available keys: ${inspect(Object.keys(traces[0][0].metrics))}` + ) }) const params = { @@ -792,8 +849,14 @@ describe('Plugin', () => { 'openai.response.id': 'ftjob-q9CUUUsHJemGUVQ1Ecc01zcf', }, }) - assert.ok(Object.hasOwn(traces[0][0].meta, 'openai.response.model')) - assert.ok(Object.hasOwn(traces[0][0].metrics, 'openai.response.created_at')) + assert.ok( + Object.hasOwn(traces[0][0].meta, 'openai.response.model'), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) + assert.ok( + Object.hasOwn(traces[0][0].metrics, 'openai.response.created_at'), + `Available keys: ${inspect(Object.keys(traces[0][0].metrics))}` + ) }) const result = await openai.fineTuning.jobs.retrieve('ftjob-q9CUUUsHJemGUVQ1Ecc01zcf') @@ -825,7 +888,10 @@ describe('Plugin', () => { 'openai.response.id': 'ftjob-q9CUUUsHJemGUVQ1Ecc01zcf', }, }) - assert.ok(Object.hasOwn(traces[0][0].metrics, 'openai.response.created_at')) + assert.ok( + Object.hasOwn(traces[0][0].metrics, 'openai.response.created_at'), + `Available keys: ${inspect(Object.keys(traces[0][0].metrics))}` + ) }) const result = await openai.fineTuning.jobs.cancel('ftjob-q9CUUUsHJemGUVQ1Ecc01zcf') @@ -857,7 +923,10 @@ describe('Plugin', () => { }, }) - assert.ok(Object.hasOwn(traces[0][0].metrics, 'openai.response.count')) + assert.ok( + Object.hasOwn(traces[0][0].metrics, 'openai.response.count'), + `Available keys: ${inspect(Object.keys(traces[0][0].metrics))}` + ) }) const result = await openai.fineTuning.jobs.listEvents('ftjob-q9CUUUsHJemGUVQ1Ecc01zcf') @@ -889,7 +958,10 @@ describe('Plugin', () => { }, }) - assert.ok(Object.hasOwn(traces[0][0].metrics, 'openai.response.count')) + assert.ok( + Object.hasOwn(traces[0][0].metrics, 'openai.response.count'), + `Available keys: ${inspect(Object.keys(traces[0][0].metrics))}` + ) }) const result = await openai.fineTuning.jobs.list() @@ -921,7 +993,10 @@ describe('Plugin', () => { }) assert.match(traces[0][0].meta['openai.response.id'], /^modr-/) - assert.ok(Object.hasOwn(traces[0][0].meta, 'openai.response.model')) + assert.ok( + Object.hasOwn(traces[0][0].meta, 'openai.response.model'), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) }) if (semver.satisfies(realVersion, '>=4.0.0')) { @@ -1224,7 +1299,10 @@ describe('Plugin', () => { 'openai.request.model': 'gpt-3.5-turbo', }, }) - assert.ok(Object.hasOwn(traces[0][0].meta, 'openai.response.model')) + assert.ok( + Object.hasOwn(traces[0][0].meta, 'openai.response.model'), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) }) const params = { @@ -1248,7 +1326,10 @@ describe('Plugin', () => { if (semver.satisfies(realVersion, '>=4.0.0')) { const prom = openai.chat.completions.create(params) - assert.ok(!Object.hasOwn(prom, 'withResponse') && ('withResponse' in prom)) + assert.ok( + !Object.hasOwn(prom, 'withResponse') && ('withResponse' in prom), + `Expected 'withResponse' to be a non-own inherited property, got prom: ${inspect(prom)}` + ) const result = await prom @@ -1276,7 +1357,10 @@ describe('Plugin', () => { const checkTraces = agent .assertSomeTraces(traces => { assert.strictEqual(traces[0][0].name, 'openai.request') - assert.ok(Object.hasOwn(traces[0][0].meta, 'openai.response.model')) + assert.ok( + Object.hasOwn(traces[0][0].meta, 'openai.response.model'), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) }) const params = { @@ -1300,7 +1384,10 @@ describe('Plugin', () => { if (semver.satisfies(realVersion, '>=4.0.0')) { const prom = openai.chat.completions.create(params) - assert.ok(!Object.hasOwn(prom, 'withResponse') && ('withResponse' in prom)) + assert.ok( + !Object.hasOwn(prom, 'withResponse') && ('withResponse' in prom), + `Expected 'withResponse' to be a non-own inherited property, got prom: ${inspect(prom)}` + ) const result = await prom assert.strictEqual(result.choices.length, 3) @@ -1427,12 +1514,18 @@ describe('Plugin', () => { } const prom = openai.chat.completions.create(params, { /* request-specific options */ }) - assert.ok(!Object.hasOwn(prom, 'withResponse') && ('withResponse' in prom)) + assert.ok( + !Object.hasOwn(prom, 'withResponse') && ('withResponse' in prom), + `Expected 'withResponse' to be a non-own inherited property, got prom: ${inspect(prom)}` + ) const stream = await prom for await (const part of stream) { - assert.ok(Object.hasOwn(part, 'choices')) - assert.ok(Object.hasOwn(part.choices[0], 'delta')) + assert.ok(Object.hasOwn(part, 'choices'), `Available keys: ${inspect(Object.keys(part))}`) + assert.ok( + Object.hasOwn(part.choices[0], 'delta'), + `Available keys: ${inspect(Object.keys(part.choices[0]))}` + ) } await checkTraces @@ -1464,12 +1557,18 @@ describe('Plugin', () => { } const prom = openai.chat.completions.create(params, { /* request-specific options */ }) - assert.ok(!Object.hasOwn(prom, 'withResponse') && ('withResponse' in prom)) + assert.ok( + !Object.hasOwn(prom, 'withResponse') && ('withResponse' in prom), + `Expected 'withResponse' to be a non-own inherited property, got prom: ${inspect(prom)}` + ) const stream = await prom for await (const part of stream) { - assert.ok(Object.hasOwn(part, 'choices')) - assert.ok(Object.hasOwn(part.choices[0], 'delta')) + assert.ok(Object.hasOwn(part, 'choices'), `Available keys: ${inspect(Object.keys(part))}`) + assert.ok( + Object.hasOwn(part.choices[0], 'delta'), + `Available keys: ${inspect(Object.keys(part.choices[0]))}` + ) } await checkTraces @@ -1504,13 +1603,19 @@ describe('Plugin', () => { } const prom = openai.chat.completions.create(params, { /* request-specific options */ }) - assert.ok(!Object.hasOwn(prom, 'withResponse') && ('withResponse' in prom)) + assert.ok( + !Object.hasOwn(prom, 'withResponse') && ('withResponse' in prom), + `Expected 'withResponse' to be a non-own inherited property, got prom: ${inspect(prom)}` + ) const stream = await prom for await (const part of stream) { - assert.ok(Object.hasOwn(part, 'choices')) + assert.ok(Object.hasOwn(part, 'choices'), `Available keys: ${inspect(Object.keys(part))}`) if (part.choices.length) { // last usage chunk will have no choices - assert.ok(Object.hasOwn(part.choices[0], 'delta')) + assert.ok( + Object.hasOwn(part.choices[0], 'delta'), + `Available keys: ${inspect(Object.keys(part.choices[0]))}` + ) } } @@ -1543,12 +1648,18 @@ describe('Plugin', () => { } const prom = openai.chat.completions.create(params, { /* request-specific options */ }) - assert.ok(!Object.hasOwn(prom, 'withResponse') && ('withResponse' in prom)) + assert.ok( + !Object.hasOwn(prom, 'withResponse') && ('withResponse' in prom), + `Expected 'withResponse' to be a non-own inherited property, got prom: ${inspect(prom)}` + ) const stream = await prom for await (const part of stream) { - assert.ok(Object.hasOwn(part, 'choices')) - assert.ok(Object.hasOwn(part.choices[0], 'delta')) + assert.ok(Object.hasOwn(part, 'choices'), `Available keys: ${inspect(Object.keys(part))}`) + assert.ok( + Object.hasOwn(part.choices[0], 'delta'), + `Available keys: ${inspect(Object.keys(part.choices[0]))}` + ) } await checkTraces @@ -1584,8 +1695,11 @@ describe('Plugin', () => { const stream = await openai.chat.completions.create(params) for await (const part of stream) { - assert.ok(Object.hasOwn(part, 'choices')) - assert.ok(Object.hasOwn(part.choices[0], 'delta')) + assert.ok(Object.hasOwn(part, 'choices'), `Available keys: ${inspect(Object.keys(part))}`) + assert.ok( + Object.hasOwn(part.choices[0], 'delta'), + `Available keys: ${inspect(Object.keys(part.choices[0]))}` + ) } await checkTraces @@ -1623,8 +1737,11 @@ describe('Plugin', () => { const stream = await openai.chat.completions.create(params) for await (const part of stream) { - assert.ok(Object.hasOwn(part, 'choices')) - assert.ok(Object.hasOwn(part.choices[0], 'delta')) + assert.ok(Object.hasOwn(part, 'choices'), `Available keys: ${inspect(Object.keys(part))}`) + assert.ok( + Object.hasOwn(part.choices[0], 'delta'), + `Available keys: ${inspect(Object.keys(part.choices[0]))}` + ) } await checkTraces @@ -1660,7 +1777,10 @@ describe('Plugin', () => { user: 'dd-trace-test', }) - assert.ok(!Object.hasOwn(prom, 'withResponse') && ('withResponse' in prom)) + assert.ok( + !Object.hasOwn(prom, 'withResponse') && ('withResponse' in prom), + `Expected 'withResponse' to be a non-own inherited property, got prom: ${inspect(prom)}` + ) const response = await prom assert.ok(response.choices[0].message.content) diff --git a/packages/datadog-plugin-openai/test/integration-test/client.spec.js b/packages/datadog-plugin-openai/test/integration-test/client.spec.js index f5a53db1f6..a233439353 100644 --- a/packages/datadog-plugin-openai/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-openai/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -49,7 +50,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual( checkSpansForServiceName(payload, 'openai.request'), true diff --git a/packages/datadog-plugin-opensearch/test/index.spec.js b/packages/datadog-plugin-opensearch/test/index.spec.js index f06b70e3a1..02bd95309e 100644 --- a/packages/datadog-plugin-opensearch/test/index.spec.js +++ b/packages/datadog-plugin-opensearch/test/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') @@ -157,7 +158,10 @@ describe('Plugin', () => { it('should propagate context', done => { agent .assertSomeTraces(traces => { - assert.ok(Object.hasOwn(traces[0][0], 'parent_id')) + assert.ok( + Object.hasOwn(traces[0][0], 'parent_id'), + `Available keys: ${inspect(Object.keys(traces[0][0]))}` + ) assert.notStrictEqual(traces[0][0].parent_id, null) }) .then(done) diff --git a/packages/datadog-plugin-opensearch/test/integration-test/client.spec.js b/packages/datadog-plugin-opensearch/test/integration-test/client.spec.js index 55c2784104..cd53356525 100644 --- a/packages/datadog-plugin-opensearch/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-opensearch/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -38,7 +39,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'opensearch.query'), true) }) diff --git a/packages/datadog-plugin-oracledb/src/index.js b/packages/datadog-plugin-oracledb/src/index.js index 6843161232..d5c6106a80 100644 --- a/packages/datadog-plugin-oracledb/src/index.js +++ b/packages/datadog-plugin-oracledb/src/index.js @@ -24,19 +24,30 @@ class OracledbPlugin extends DatabasePlugin { dbInstance ??= dbInfo.dbInstance } - this.startSpan(this.operationName(), { + // oracledb >= 6.4 accepts `execute({ statement, values })` (sql-template-tag form) + // in addition to a plain SQL string. Extract the SQL text either way so we can tag + // the resource and inject DBM into the statement, then re-wrap if needed to keep + // the caller's binds. + const sql = query?.statement ?? query + + const span = this.startSpan(this.operationName(), { service, - resource: query, + resource: sql, type: 'sql', kind: 'client', meta: { 'db.user': this.config.user, 'db.instance': dbInstance, + 'db.name': dbInstance, 'db.hostname': hostname, + 'out.host': hostname, [CLIENT_PORT_KEY]: port, }, }, ctx) + const injected = this.injectDbmQuery(span, sql, service.name) + ctx.injected = query?.statement ? { ...query, statement: injected } : injected + return ctx.currentStore } } diff --git a/packages/datadog-plugin-oracledb/test/index.spec.js b/packages/datadog-plugin-oracledb/test/index.spec.js index 84f9665446..da20af9d57 100644 --- a/packages/datadog-plugin-oracledb/test/index.spec.js +++ b/packages/datadog-plugin-oracledb/test/index.spec.js @@ -2,11 +2,16 @@ const assert = require('node:assert') -const { after, before, describe, it } = require('mocha') +const dc = require('dc-polyfill') +const { after, before, beforeEach, describe, it } = require('mocha') +const semver = require('semver') +const ddpv = require('mocha/package.json').version +const { storage } = require('../../datadog-core') const { ERROR_MESSAGE, ERROR_TYPE, ERROR_STACK } = require('../../dd-trace/src/constants') const agent = require('../../dd-trace/test/plugins/agent') const { withNamingSchema, withPeerService, withVersions } = require('../../dd-trace/test/setup/mocha') +const { assertObjectContains } = require('../../../integration-tests/helpers') const { expectedSchema, rawExpectedSchema } = require('./naming') const hostname = 'localhost' // TODO: Use another port or db instance to differentiate it better from defaults @@ -98,7 +103,9 @@ describe('Plugin', () => { 'span.kind': 'client', component: 'oracledb', 'db.instance': dbInstance, + 'db.name': dbInstance, 'db.hostname': hostname, + 'out.host': hostname, 'network.destination.port': port, }, }) @@ -122,7 +129,9 @@ describe('Plugin', () => { 'span.kind': 'client', component: 'oracledb', 'db.instance': dbInstance, + 'db.name': dbInstance, 'db.hostname': hostname, + 'out.host': hostname, 'network.destination.port': port, }, }).then(done, done) @@ -166,7 +175,9 @@ describe('Plugin', () => { 'span.kind': 'client', component: 'oracledb', 'db.instance': dbInstance, + 'db.name': dbInstance, 'db.hostname': hostname, + 'out.host': hostname, 'network.destination.port': port, [ERROR_MESSAGE]: error.message, [ERROR_TYPE]: error.name, @@ -436,6 +447,356 @@ describe('Plugin', () => { }) }) }) + + // oracledb has no stable JS-side queue across v5 thick / v6 thin, so the DBM tests below capture + // the plugin-produced SQL via `apm:oracledb:query:start` instead of reading a driver-internal queue + // (the pattern pg / mysql / mysql2 tests use). + describe('with DBM propagation disabled (default)', () => { + let injected + const onStart = (ctx) => { injected = ctx.injected } + + before(async () => { + tracer = await agent.load('oracledb') + oracledb = require(`../../../versions/oracledb@${version}`).get() + dc.subscribe('apm:oracledb:query:start', onStart) + connection = await oracledb.getConnection(config) + }) + + after(async () => { + dc.unsubscribe('apm:oracledb:query:start', onStart) + await connection.close() + await agent.close() + }) + + beforeEach(() => { + injected = undefined + }) + + it('should not inject a comment when propagation is disabled', async () => { + await connection.execute(dbQuery) + assert.strictEqual(injected, dbQuery) + }) + }) + + // When the legacy store handle is marked `noop` (the suppression mechanism used by the + // agent's own request loop), the plugin's bindStart is skipped and ctx.injected stays + // undefined; the instrumentation must not overwrite the caller's SQL with it. + describe('with tracing suppressed via the noop legacy store handle', () => { + before(async () => { + tracer = await agent.load('oracledb') + oracledb = require(`../../../versions/oracledb@${version}`).get() + connection = await oracledb.getConnection(config) + }) + + after(async () => { + await connection.close() + await agent.close() + }) + + it('passes the caller SQL through unchanged when bindStart is skipped', async () => { + await storage('legacy').run({ noop: true }, () => connection.execute(dbQuery)) + }) + }) + + describe('with DBM propagation enabled with service using plugin configurations', () => { + let injected + const onStart = (ctx) => { injected = ctx.injected } + + before(async () => { + tracer = await agent.load('oracledb', { dbmPropagationMode: 'service', service: () => 'serviced' }) + oracledb = require(`../../../versions/oracledb@${version}`).get() + dc.subscribe('apm:oracledb:query:start', onStart) + connection = await oracledb.getConnection(config) + }) + + after(async () => { + dc.unsubscribe('apm:oracledb:query:start', onStart) + await connection.close() + await agent.close() + }) + + beforeEach(() => { + injected = undefined + }) + + it('should contain comment in query text', async () => { + await connection.execute(dbQuery) + assert.strictEqual( + injected, + `/*dddb='${dbInstance}',dddbs='serviced',dde='tester',ddh='${hostname}',ddps='test',` + + `ddpv='${ddpv}'*/ ${dbQuery}` + ) + }) + + it('should contain comment in query text for callback-form execute', done => { + connection.execute(dbQuery, err => { + if (err) return done(err) + try { + assert.strictEqual( + injected, + `/*dddb='${dbInstance}',dddbs='serviced',dde='tester',ddh='${hostname}',ddps='test',` + + `ddpv='${ddpv}'*/ ${dbQuery}` + ) + done() + } catch (e) { + done(e) + } + }) + }) + + it('trace query resource should not be changed when propagation is enabled', async () => { + await Promise.all([ + agent.assertSomeTraces(traces => { + assert.strictEqual(traces[0][0].resource, dbQuery) + }), + connection.execute(dbQuery), + ]) + }) + }) + + // oracledb 6.4 added object-form execute (`{ statement, values }`) to support + // sql-template-tag style usage. Earlier drivers reject the object outright at + // argument validation, so the test only runs on >= 6.4. + if (semver.intersects(version, '>=6.4.0')) { + describe('with DBM propagation enabled and object-form execute', () => { + let injected + const onStart = (ctx) => { injected = ctx.injected } + + before(async () => { + tracer = await agent.load('oracledb', { dbmPropagationMode: 'service', service: () => 'serviced' }) + oracledb = require(`../../../versions/oracledb@${version}`).get() + dc.subscribe('apm:oracledb:query:start', onStart) + connection = await oracledb.getConnection(config) + }) + + after(async () => { + dc.unsubscribe('apm:oracledb:query:start', onStart) + await connection.close() + await agent.close() + }) + + beforeEach(() => { + injected = undefined + }) + + it('should inject comment into statement and preserve binds', async () => { + await connection.execute({ statement: dbQuery, values: [] }) + assert.deepStrictEqual(injected, { + statement: + `/*dddb='${dbInstance}',dddbs='serviced',dde='tester',ddh='${hostname}',ddps='test',` + + `ddpv='${ddpv}'*/ ${dbQuery}`, + values: [], + }) + }) + + it('trace query resource should reflect the statement string', async () => { + await Promise.all([ + agent.assertSomeTraces(traces => { + assert.strictEqual(traces[0][0].resource, dbQuery) + }), + connection.execute({ statement: dbQuery, values: [] }), + ]) + }) + }) + + describe('with DBM propagation disabled and object-form execute', () => { + let injected + const onStart = (ctx) => { injected = ctx.injected } + + before(async () => { + tracer = await agent.load('oracledb') + oracledb = require(`../../../versions/oracledb@${version}`).get() + dc.subscribe('apm:oracledb:query:start', onStart) + connection = await oracledb.getConnection(config) + }) + + after(async () => { + dc.unsubscribe('apm:oracledb:query:start', onStart) + await connection.close() + await agent.close() + }) + + beforeEach(() => { + injected = undefined + }) + + it('should pass through the original statement and binds unchanged', async () => { + const query = { statement: dbQuery, values: [] } + await connection.execute(query) + assert.deepStrictEqual(injected, { statement: dbQuery, values: [] }) + }) + }) + } + + describe('DBM propagation should handle special characters', () => { + let injected + const onStart = (ctx) => { injected = ctx.injected } + + before(async () => { + tracer = await agent.load('oracledb', { dbmPropagationMode: 'service', service: '~!@#$%^&*()_+|??/<>' }) + oracledb = require(`../../../versions/oracledb@${version}`).get() + dc.subscribe('apm:oracledb:query:start', onStart) + connection = await oracledb.getConnection(config) + }) + + after(async () => { + dc.unsubscribe('apm:oracledb:query:start', onStart) + await connection.close() + await agent.close() + }) + + beforeEach(() => { + injected = undefined + }) + + it('DBM propagation should handle special characters', async () => { + await connection.execute(dbQuery) + assert.strictEqual( + injected, + `/*dddb='${dbInstance}',dddbs='~!%40%23%24%25%5E%26*()_%2B%7C%3F%3F%2F%3C%3E',dde='tester',` + + `ddh='${hostname}',ddps='test',ddpv='${ddpv}'*/ ${dbQuery}` + ) + }) + }) + + describe('with DBM propagation enabled with full using tracer configurations', () => { + let seenTraceParent + let seenTraceId + let seenSpanId + const onStart = (ctx) => { + const m = ctx.injected?.match(/traceparent='([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})'/) + if (m) { + seenTraceParent = true + seenTraceId = m[2] + seenSpanId = m[3] + } + } + + before(async () => { + tracer = await agent.load('oracledb') + oracledb = require(`../../../versions/oracledb@${version}`).get() + dc.subscribe('apm:oracledb:query:start', onStart) + connection = await oracledb.getConnection(config) + }) + + after(async () => { + dc.unsubscribe('apm:oracledb:query:start', onStart) + await connection.close() + await agent.close() + }) + + beforeEach(() => { + tracer.use('oracledb', { dbmPropagationMode: 'full' }) + seenTraceParent = undefined + seenTraceId = undefined + seenSpanId = undefined + }) + + it('query text should contain traceparent', async () => { + await Promise.all([ + agent.assertSomeTraces(traces => { + const expectedTimePrefix = traces[0][0].meta['_dd.p.tid'].toString(16).padStart(16, '0') + const traceId = expectedTimePrefix + traces[0][0].trace_id.toString(16).padStart(16, '0') + const spanId = traces[0][0].span_id.toString(16).padStart(16, '0') + assert.strictEqual(seenTraceParent, true) + assert.strictEqual(seenTraceId, traceId) + assert.strictEqual(seenSpanId, spanId) + }), + connection.execute(dbQuery), + ]) + }) + + it('query should inject _dd.dbm_trace_injected into span', async () => { + await Promise.all([ + agent.assertSomeTraces(traces => { + assertObjectContains(traces[0][0].meta, { + '_dd.dbm_trace_injected': 'true', + }) + }), + connection.execute(dbQuery), + ]) + }) + + it('service should default to tracer service name', async () => { + await Promise.all([ + agent.assertSomeTraces(traces => { + assert.strictEqual(traces[0][0].service, expectedSchema.outbound.serviceName) + }), + connection.execute(dbQuery), + ]) + }) + }) + + describe('with DBM propagation enabled with append comment configurations', () => { + let injected + const onStart = (ctx) => { injected = ctx.injected } + + before(async () => { + tracer = await agent.load('oracledb', { + appendComment: true, + dbmPropagationMode: 'service', + service: () => 'serviced', + }) + oracledb = require(`../../../versions/oracledb@${version}`).get() + dc.subscribe('apm:oracledb:query:start', onStart) + connection = await oracledb.getConnection(config) + }) + + after(async () => { + dc.unsubscribe('apm:oracledb:query:start', onStart) + await connection.close() + await agent.close() + }) + + beforeEach(() => { + injected = undefined + }) + + it('should append comment in query text', async () => { + await connection.execute(dbQuery) + assert.strictEqual( + injected, + `${dbQuery} /*dddb='${dbInstance}',dddbs='serviced',dde='tester',ddh='${hostname}',` + + `ddps='test',ddpv='${ddpv}'*/` + ) + }) + }) + }) + + describe('with DBM propagation enabled with append comment using tracer configuration', () => { + let injected + const onStart = (ctx) => { injected = ctx.injected } + + before(async () => { + tracer = await agent.load('oracledb', { + appendComment: true, + service: () => 'serviced', + }, { + dbmPropagationMode: 'service', + }) + oracledb = require('../../../versions/oracledb').get() + dc.subscribe('apm:oracledb:query:start', onStart) + connection = await oracledb.getConnection(config) + }) + + after(async () => { + dc.unsubscribe('apm:oracledb:query:start', onStart) + await connection.close() + await agent.close() + }) + + beforeEach(() => { + injected = undefined + }) + + it('should append service mode comment in query text', async () => { + await connection.execute(dbQuery) + assert.strictEqual( + injected, + `${dbQuery} /*dddb='${dbInstance}',dddbs='serviced',dde='tester',ddh='${hostname}',` + + `ddps='test',ddpv='${ddpv}'*/` + ) + }) }) }) }) diff --git a/packages/datadog-plugin-oracledb/test/integration-test/client.spec.js b/packages/datadog-plugin-oracledb/test/integration-test/client.spec.js index 300ba86f09..6230681f66 100644 --- a/packages/datadog-plugin-oracledb/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-oracledb/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -39,7 +40,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'oracle.query'), true) }) diff --git a/packages/datadog-plugin-pg/test/index.spec.js b/packages/datadog-plugin-pg/test/index.spec.js index ebbbeacb97..c05607eead 100644 --- a/packages/datadog-plugin-pg/test/index.spec.js +++ b/packages/datadog-plugin-pg/test/index.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert') const EventEmitter = require('node:events') const net = require('node:net') +const { inspect } = require('node:util') const semver = require('semver') @@ -87,7 +88,10 @@ describe('Plugin', () => { }) if (implementation !== 'pg.native') { - assert.ok(Object.hasOwn(traces[0][0].metrics, 'db.pid')) + assert.ok( + Object.hasOwn(traces[0][0].metrics, 'db.pid'), + `Available keys: ${inspect(Object.keys(traces[0][0].metrics))}` + ) } }, { spanResourceMatch: /^SELECT \$1::text as message$/ }) .then(done) @@ -140,7 +144,10 @@ describe('Plugin', () => { }) if (implementation !== 'pg.native') { - assert.ok(Object.hasOwn(traces[0][0].metrics, 'db.pid')) + assert.ok( + Object.hasOwn(traces[0][0].metrics, 'db.pid'), + `Available keys: ${inspect(Object.keys(traces[0][0].metrics))}` + ) } }) .then(done) @@ -314,7 +321,7 @@ describe('Plugin', () => { const readPromise = (async () => { for await (const row of stream) { - assert.ok(Object.hasOwn(row, 'num')) + assert.ok(Object.hasOwn(row, 'num'), `Available keys: ${inspect(Object.keys(row))}`) } })() @@ -350,7 +357,7 @@ describe('Plugin', () => { const rejectedRead = assert.rejects(async () => { // eslint-disable-next-line no-unreachable-loop for await (const row of stream) { - assert.ok(Object.hasOwn(row, 'num')) + assert.ok(Object.hasOwn(row, 'num'), `Available keys: ${inspect(Object.keys(row))}`) throw new Error('Test error') } }, { diff --git a/packages/datadog-plugin-pg/test/integration-test/client.spec.js b/packages/datadog-plugin-pg/test/integration-test/client.spec.js index 2bed625b18..74284b4256 100644 --- a/packages/datadog-plugin-pg/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-pg/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const semver = require('semver') const { @@ -48,7 +49,7 @@ describe('esm', () => { it(`is instrumented loaded with ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'pg.query'), true) }) diff --git a/packages/datadog-plugin-pino/src/index.js b/packages/datadog-plugin-pino/src/index.js index f17f45b181..fbab4bb8a9 100644 --- a/packages/datadog-plugin-pino/src/index.js +++ b/packages/datadog-plugin-pino/src/index.js @@ -1,9 +1,51 @@ 'use strict' +const { buildLogHolder, messageProxy } = require('../../dd-trace/src/plugins/log_injection') const LogPlugin = require('../../dd-trace/src/plugins/log_plugin') class PinoPlugin extends LogPlugin { static id = 'pino' + + constructor (...args) { + super(...args) + this.addSub('apm:pino:log:json', (payload) => this.handleJsonLine(payload)) + this.addSub('apm:pino:log', (arg) => this.handlePrettyMessage(arg)) + } + + /** + * Splice `,"dd":` into the JSON line pino has already produced. + * The caller-owned message object is never observed -- user Proxies and + * custom serialisers see nothing because there is no mutation to see. + * + * @param {{ line: string }} payload + */ + handleJsonLine (payload) { + const logHolder = buildLogHolder(this.tracer) + if (!logHolder) return + + const line = payload.line + const lastClose = line.lastIndexOf('}') + if (lastClose < 1) return + + const ddJson = JSON.stringify(logHolder.dd) + const sep = line.charCodeAt(lastClose - 1) === 0x7B ? '' : ',' + payload.line = line.slice(0, lastClose) + sep + '"dd":' + ddJson + line.slice(lastClose) + } + + /** + * `pino-pretty` (bundled with pino 5/7, separate package on >=8) reads + * the original message object rather than the JSON line, so the splice + * above is invisible to it. Wrap the message in a Proxy that exposes a + * virtual `dd` field for the prettifier to pick up. + * + * @param {{ message: object }} arg + */ + handlePrettyMessage (arg) { + const logHolder = buildLogHolder(this.tracer) + if (!logHolder) return + + arg.message = messageProxy(arg.message, logHolder) + } } module.exports = PinoPlugin diff --git a/packages/datadog-plugin-pino/test/index.spec.js b/packages/datadog-plugin-pino/test/index.spec.js index 5257debb55..6aeb8bfa6c 100644 --- a/packages/datadog-plugin-pino/test/index.spec.js +++ b/packages/datadog-plugin-pino/test/index.spec.js @@ -170,6 +170,18 @@ describe('Plugin', () => { }) }) + it('should not overwrite a caller-supplied dd field', () => { + tracer.scope().activate(span, () => { + logger.info({ dd: { custom: 'value' } }, 'message') + + sinon.assert.called(stream.write) + + const record = JSON.parse(stream.write.firstCall.args[0].toString()) + + assert.deepStrictEqual(record.dd, { custom: 'value' }) + }) + }) + it('should not inject trace_id or span_id without an active span', () => { logger.info('message') diff --git a/packages/datadog-plugin-pino/test/integration-test/client.spec.js b/packages/datadog-plugin-pino/test/integration-test/client.spec.js index e308064b2d..faf55e0b06 100644 --- a/packages/datadog-plugin-pino/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-pino/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, spawnPluginIntegrationTestProcAndExpectExit, @@ -43,7 +44,7 @@ describe('esm', () => { undefined, (data) => { const jsonObject = JSON.parse(data.toString()) - assert.ok(Object.hasOwn(jsonObject, 'dd')) + assert.ok(Object.hasOwn(jsonObject, 'dd'), `Available keys: ${inspect(Object.keys(jsonObject))}`) } ) }).timeout(20000) diff --git a/packages/datadog-plugin-pino/test/unit.spec.js b/packages/datadog-plugin-pino/test/unit.spec.js new file mode 100644 index 0000000000..4a8e300ef5 --- /dev/null +++ b/packages/datadog-plugin-pino/test/unit.spec.js @@ -0,0 +1,136 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it } = require('mocha') +const { channel } = require('dc-polyfill') +const { storage } = require('../../datadog-core') + +require('../../dd-trace/test/setup/core') +const PinoPlugin = require('../src') +const Tracer = require('../../dd-trace/src/tracer') +const getConfig = require('../../dd-trace/src/config') + +const jsonCh = channel('apm:pino:log:json') +const messageCh = channel('apm:pino:log') + +const tracer = new Tracer(getConfig({ + enabled: true, + logInjection: true, + env: 'my-env', + service: 'my-service', + version: '1.2.3', +})) + +const plugin = new PinoPlugin({ + _tracer: tracer, +}) +plugin.configure({ + logInjection: true, + enabled: true, +}) + +describe('PinoPlugin', () => { + it('splices trace correlation into pino JSON output', () => { + const data = { line: '{"level":30,"msg":"hello"}' } + jsonCh.publish(data) + const parsed = JSON.parse(data.line) + assert.strictEqual(parsed.level, 30) + assert.strictEqual(parsed.msg, 'hello') + assert.strictEqual(parsed.dd.service, 'my-service') + assert.strictEqual(parsed.dd.version, '1.2.3') + assert.strictEqual(parsed.dd.env, 'my-env') + }) + + it('handles a pino JSON line that ends with a newline', () => { + const data = { line: '{"level":30,"msg":"hi"}\n' } + jsonCh.publish(data) + // The splice happens before the closing `}`; the trailing newline stays. + assert.match(data.line, /\}\n$/) + const parsed = JSON.parse(data.line) + assert.strictEqual(parsed.dd.service, 'my-service') + }) + + it('produces valid JSON when the original line is empty `{}`', () => { + const data = { line: '{}' } + jsonCh.publish(data) + const parsed = JSON.parse(data.line) + assert.strictEqual(parsed.dd.service, 'my-service') + }) + + it('includes trace_id and span_id when a span is active', () => { + const span = tracer.startSpan('test') + + storage('legacy').run({ span }, () => { + const data = { line: '{"msg":"x"}' } + jsonCh.publish(data) + const parsed = JSON.parse(data.line) + assert.strictEqual(parsed.dd.trace_id, span.context().toTraceId(true)) + assert.strictEqual(parsed.dd.span_id, span.context().toSpanId()) + }) + }) + + it('does not splice when the line is unrecognised', () => { + const data = { line: 'malformed' } + jsonCh.publish(data) + assert.strictEqual(data.line, 'malformed') + }) + + it('leaves the line untouched when the propagator emits no dd', () => { + const originalInject = tracer.inject + tracer.inject = () => {} + try { + const data = { line: '{"level":30,"msg":"hello"}' } + jsonCh.publish(data) + assert.strictEqual(data.line, '{"level":30,"msg":"hello"}') + } finally { + tracer.inject = originalInject + } + }) + + describe('apm:pino:log (pino-pretty path)', () => { + it('exposes dd as a virtual field on the message proxy', () => { + const original = { level: 30, msg: 'hello' } + const data = { message: original } + messageCh.publish(data) + assert.notStrictEqual(data.message, original) + assert.deepStrictEqual(data.message.dd, { + service: 'my-service', + version: '1.2.3', + env: 'my-env', + }) + assert.strictEqual(data.message.msg, 'hello') + assert.strictEqual(Object.keys(data.message).includes('dd'), true) + }) + + it('includes trace_id and span_id when a span is active', () => { + const span = tracer.startSpan('test') + storage('legacy').run({ span }, () => { + const data = { message: { msg: 'hello' } } + messageCh.publish(data) + assert.strictEqual(data.message.dd.trace_id, span.context().toTraceId(true)) + assert.strictEqual(data.message.dd.span_id, span.context().toSpanId()) + }) + }) + + it('keeps the caller-set dd visible without overriding it', () => { + const original = { msg: 'hello', dd: { trace_id: 'user-supplied' } } + const data = { message: original } + messageCh.publish(data) + assert.strictEqual(data.message.dd.trace_id, 'user-supplied') + }) + + it('leaves the message untouched when the propagator emits no dd', () => { + const originalInject = tracer.inject + tracer.inject = () => {} + try { + const original = { msg: 'hello' } + const data = { message: original } + messageCh.publish(data) + assert.strictEqual(data.message, original) + } finally { + tracer.inject = originalInject + } + }) + }) +}) diff --git a/packages/datadog-plugin-playwright/src/index.js b/packages/datadog-plugin-playwright/src/index.js index 281e1b8283..a75e7ee26d 100644 --- a/packages/datadog-plugin-playwright/src/index.js +++ b/packages/datadog-plugin-playwright/src/index.js @@ -12,9 +12,9 @@ const { TEST_BROWSER_NAME, TEST_BROWSER_VERSION, TEST_CODE_OWNERS, - TEST_COMMAND, TEST_EARLY_FLAKE_ABORT_REASON, TEST_EARLY_FLAKE_ENABLED, + TEST_FRAMEWORK_VERSION, TEST_HAS_FAILED_ALL_RETRIES, TEST_IS_MODIFIED, TEST_IS_NEW, @@ -234,7 +234,7 @@ class PlaywrightPlugin extends CiPlugin { formattedSpan.meta[TEST_SESSION_ID] = this.testSessionSpan.context().toTraceId() formattedSpan.meta[TEST_MODULE_ID] = this.testModuleSpan.context().toSpanId() Object.assign(formattedSpan.meta, this.getSessionRequestErrorTags()) - formattedSpan.meta[TEST_COMMAND] = this.command + formattedSpan.meta[TEST_FRAMEWORK_VERSION] = this.frameworkVersion formattedSpan.meta[TEST_MODULE] = this.constructor.id // MISSING _trace.startTime and _trace.ticks - because by now the suite is already serialized const testSuite = this._testSuiteSpansByTestSuiteAbsolutePath.get( @@ -326,7 +326,7 @@ class PlaywrightPlugin extends CiPlugin { }) => { if (!span) return - const isRUMActive = span.context()._tags[TEST_IS_RUM_ACTIVE] + const isRUMActive = span.context().getTag(TEST_IS_RUM_ACTIVE) span.setTag(TEST_STATUS, testStatus) @@ -416,7 +416,7 @@ class PlaywrightPlugin extends CiPlugin { TELEMETRY_EVENT_FINISHED, 'test', { - hasCodeOwners: !!span.context()._tags[TEST_CODE_OWNERS], + hasCodeOwners: !!span.context().getTag(TEST_CODE_OWNERS), isNew, isRum: isRUMActive, browserDriver: 'playwright', diff --git a/packages/datadog-plugin-prisma/test/integration-test/client.spec.js b/packages/datadog-plugin-prisma/test/integration-test/client.spec.js index 2e15d955f8..224d50e7ec 100644 --- a/packages/datadog-plugin-prisma/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-prisma/test/integration-test/client.spec.js @@ -126,6 +126,8 @@ const prismaClientConfigs = [{ waitForService: waitForMongoReplicaSet, skipMigrateReset: true, variant: 'destructure', + // mongodb@7.2 dropped Node 18 (crypto.getRandomValues is not a global there). + skip: () => !semifies(semver.clean(process.version), '>=20.19.0'), dbSpan: { name: 'prisma.engine', meta: { diff --git a/packages/datadog-plugin-protobufjs/src/schema_iterator.js b/packages/datadog-plugin-protobufjs/src/schema_iterator.js index 8f595d5b80..c999bc8275 100644 --- a/packages/datadog-plugin-protobufjs/src/schema_iterator.js +++ b/packages/datadog-plugin-protobufjs/src/schema_iterator.js @@ -135,7 +135,7 @@ class SchemaExtractor { return } - if (span.context()._tags[SCHEMA_TYPE] && operation === 'serialization') { + if (span.context().getTag(SCHEMA_TYPE) && operation === 'serialization') { // we have already added a schema to this span, this call is an encode of nested schema types return } diff --git a/packages/datadog-plugin-protobufjs/test/index.spec.js b/packages/datadog-plugin-protobufjs/test/index.spec.js index 4a60aa68ce..1ef0ae85ba 100644 --- a/packages/datadog-plugin-protobufjs/test/index.spec.js +++ b/packages/datadog-plugin-protobufjs/test/index.spec.js @@ -28,7 +28,7 @@ const OTHER_MESSAGE_SCHEMA_ID = '2691489402935632768' const ALL_TYPES_MESSAGE_SCHEMA_ID = '15890948796193489151' function compareJson (expected, span) { - const actual = JSON.parse(span.context()._tags[SCHEMA_DEFINITION]) + const actual = JSON.parse(span.context().getTag(SCHEMA_DEFINITION)) return JSON.stringify(actual) === JSON.stringify(expected) } @@ -76,11 +76,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'other_message.serialize') assert.strictEqual(compareJson(OTHER_MESSAGE_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'protobuf') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'OtherMessage') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'serialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], OTHER_MESSAGE_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'protobuf') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'OtherMessage') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'serialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], OTHER_MESSAGE_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) }) }) @@ -92,11 +92,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'other_message.serialize') assert.strictEqual(compareJson(OTHER_MESSAGE_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'protobuf') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'OtherMessage') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'serialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], OTHER_MESSAGE_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'protobuf') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'OtherMessage') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'serialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], OTHER_MESSAGE_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) }) }) }) @@ -110,11 +110,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'message_pb2.serialize') assert.strictEqual(compareJson(MESSAGE_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'protobuf') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'MyMessage') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'serialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], MESSAGE_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'protobuf') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'MyMessage') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'serialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], MESSAGE_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) }) }) @@ -127,11 +127,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'all_types.serialize') assert.strictEqual(compareJson(ALL_TYPES_MESSAGE_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'protobuf') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'example.MainMessage') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'serialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], ALL_TYPES_MESSAGE_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'protobuf') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'example.MainMessage') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'serialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], ALL_TYPES_MESSAGE_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) }) }) @@ -146,11 +146,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'other_message.deserialize') assert.strictEqual(compareJson(OTHER_MESSAGE_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'protobuf') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'OtherMessage') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'deserialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], OTHER_MESSAGE_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'protobuf') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'OtherMessage') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'deserialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], OTHER_MESSAGE_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) }) }) @@ -165,11 +165,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'my_message.deserialize') assert.strictEqual(compareJson(MESSAGE_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'protobuf') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'MyMessage') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'deserialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], MESSAGE_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'protobuf') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'MyMessage') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'deserialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], MESSAGE_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) }) }) @@ -184,11 +184,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'all_types.deserialize') assert.strictEqual(compareJson(ALL_TYPES_MESSAGE_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'protobuf') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'example.MainMessage') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'deserialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], ALL_TYPES_MESSAGE_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'protobuf') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'example.MainMessage') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'deserialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], ALL_TYPES_MESSAGE_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) }) }) @@ -209,11 +209,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'other_message.deserialize') assert.strictEqual(compareJson(OTHER_MESSAGE_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'protobuf') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'OtherMessage') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'deserialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], OTHER_MESSAGE_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'protobuf') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'OtherMessage') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'deserialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], OTHER_MESSAGE_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) }) }) @@ -233,11 +233,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'other_message.deserialize') assert.strictEqual(compareJson(OTHER_MESSAGE_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'protobuf') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'OtherMessage') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'deserialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], OTHER_MESSAGE_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'protobuf') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'OtherMessage') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'deserialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], OTHER_MESSAGE_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) }) }) @@ -259,11 +259,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'other_message.deserialize') assert.strictEqual(compareJson(OTHER_MESSAGE_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'protobuf') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'OtherMessage') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'deserialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], OTHER_MESSAGE_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'protobuf') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'OtherMessage') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'deserialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], OTHER_MESSAGE_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) }) }) @@ -285,11 +285,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'other_message.deserialize') assert.strictEqual(compareJson(OTHER_MESSAGE_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'protobuf') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'OtherMessage') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'deserialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], OTHER_MESSAGE_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'protobuf') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'OtherMessage') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'deserialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], OTHER_MESSAGE_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) }) }) @@ -318,11 +318,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'message_pb2.serialize') assert.strictEqual(compareJson(MESSAGE_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'protobuf') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'MyMessage') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'serialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], MESSAGE_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'protobuf') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'MyMessage') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'serialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], MESSAGE_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) // we sampled 1 schema with 1 subschema, so the constructor should've only been called twice assert.strictEqual(cacheSetSpy.callCount, 2) @@ -335,11 +335,11 @@ describe('Plugin', () => { assert.strictEqual(span.context()._name, 'message_pb2.serialize') assert.strictEqual(compareJson(MESSAGE_SCHEMA_DEF, span), true) - assert.strictEqual(span.context()._tags[SCHEMA_TYPE], 'protobuf') - assert.strictEqual(span.context()._tags[SCHEMA_NAME], 'MyMessage') - assert.strictEqual(span.context()._tags[SCHEMA_OPERATION], 'serialization') - assert.strictEqual(span.context()._tags[SCHEMA_ID], MESSAGE_SCHEMA_ID) - assert.strictEqual(span.context()._tags[SCHEMA_WEIGHT], 1) + assert.strictEqual(span.context().getTags()[SCHEMA_TYPE], 'protobuf') + assert.strictEqual(span.context().getTags()[SCHEMA_NAME], 'MyMessage') + assert.strictEqual(span.context().getTags()[SCHEMA_OPERATION], 'serialization') + assert.strictEqual(span.context().getTags()[SCHEMA_ID], MESSAGE_SCHEMA_ID) + assert.strictEqual(span.context().getTags()[SCHEMA_WEIGHT], 1) // ensure schema was sampled and returned via the cache, so no extra cache set // calls were needed, only gets diff --git a/packages/datadog-plugin-redis/test/integration-test/client.spec.js b/packages/datadog-plugin-redis/test/integration-test/client.spec.js index 1372e95fe9..af870a447f 100644 --- a/packages/datadog-plugin-redis/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-redis/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -39,7 +40,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'redis.command'), true) }) diff --git a/packages/datadog-plugin-restify/test/integration-test/client.spec.js b/packages/datadog-plugin-restify/test/integration-test/client.spec.js index 7e1506752a..1a9207a2ca 100644 --- a/packages/datadog-plugin-restify/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-restify/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -43,7 +44,7 @@ describe('esm', () => { return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'restify.request'), true) }) }).timeout(20000) diff --git a/packages/datadog-plugin-rhea/src/producer.js b/packages/datadog-plugin-rhea/src/producer.js index 078234ee48..70ca0058d5 100644 --- a/packages/datadog-plugin-rhea/src/producer.js +++ b/packages/datadog-plugin-rhea/src/producer.js @@ -42,7 +42,7 @@ function addDeliveryAnnotations (msg, tracer, span) { tracer.inject(span, 'text_map', msg.delivery_annotations) if (tracer._config.dsmEnabled) { - const targetName = span.context()._tags['amqp.link.target.address'] + const targetName = span.context().getTag('amqp.link.target.address') const payloadSize = getAmqpMessageSize({ content: msg.body, headers: msg.delivery_annotations }) const dataStreamsContext = tracer .setCheckpoint(['direction:out', `exchange:${targetName}`, 'type:rabbitmq'], span, payloadSize) diff --git a/packages/datadog-plugin-rhea/test/index.spec.js b/packages/datadog-plugin-rhea/test/index.spec.js index 1210ff5aea..f7201ca185 100644 --- a/packages/datadog-plugin-rhea/test/index.spec.js +++ b/packages/datadog-plugin-rhea/test/index.spec.js @@ -125,7 +125,7 @@ describe('Plugin', () => { }) } }, { timeoutMs: 2000 }) - assert.ok(((statsPointsReceived) >= (1))) + assert.ok(statsPointsReceived >= 1, `Expected ${statsPointsReceived} >= 1`) assert.strictEqual(agent.dsmStatsExist(agent, expectedProducerHash), true) }).then(done, done) @@ -143,7 +143,7 @@ describe('Plugin', () => { }) } }) - assert.ok(((statsPointsReceived) >= (2))) + assert.ok(statsPointsReceived >= 2, `Expected ${statsPointsReceived} >= 2`) assert.strictEqual(agent.dsmStatsExist(agent, expectedConsumerHash), true) }, { timeoutMs: 2000 }).then(done, done) diff --git a/packages/datadog-plugin-rhea/test/integration-test/client.spec.js b/packages/datadog-plugin-rhea/test/integration-test/client.spec.js index 8a263a4dc6..667edadc70 100644 --- a/packages/datadog-plugin-rhea/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-rhea/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -38,7 +39,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'amqp.send'), true) }) diff --git a/packages/datadog-plugin-router/src/index.js b/packages/datadog-plugin-router/src/index.js index 6e37d4ab15..6291c531f4 100644 --- a/packages/datadog-plugin-router/src/index.js +++ b/packages/datadog-plugin-router/src/index.js @@ -9,31 +9,35 @@ const { COMPONENT } = require('../../dd-trace/src/constants') class RouterPlugin extends WebPlugin { static id = 'router' - #storeStacks = new WeakMap() #contexts = new WeakMap() constructor (...args) { super(...args) this.addSub(`apm:${this.constructor.id}:middleware:enter`, ({ req, name, route }) => { - const childOf = this.#getActive(req) || this.#getStoreSpan() - + // One ALS hop covers both the parent-span fallback (when no + // per-request context exists yet) and the `storeStack` push below. + // The previous shape paid an ALS read inside `#getStoreSpan` and a + // second one here for the saved-store push. + const store = storage('legacy').getStore() + let context = this.#contexts.get(req) + let childOf + if (context !== undefined) { + const middleware = context.middleware + childOf = middleware.length === 0 ? context.span : middleware[middleware.length - 1] + } else if (store) { + childOf = store.span + } if (!childOf) return const span = this.#getMiddlewareSpan(name, childOf) - const context = this.#createContext(req, route, childOf) + context = this.#updateContext(req, context, route, childOf) if (childOf !== span) { context.middleware.push(span) } - const store = storage('legacy').getStore() - let storeStack = this.#storeStacks.get(req) - if (!storeStack) { - storeStack = [] - this.#storeStacks.set(req, storeStack) - } - storeStack.push(store) + context.storeStack.push(store) this.enter(span, store) web.patch(req) @@ -57,11 +61,8 @@ class RouterPlugin extends WebPlugin { }) this.addSub(`apm:${this.constructor.id}:middleware:exit`, ({ req }) => { - const storeStack = this.#storeStacks.get(req) - const savedStore = storeStack && storeStack.pop() - if (storeStack && storeStack.length === 0) { - this.#storeStacks.delete(req) - } + const context = this.#contexts.get(req) + const savedStore = context && context.storeStack.pop() const span = savedStore && savedStore.span this.enter(span, savedStore) }) @@ -71,8 +72,10 @@ class RouterPlugin extends WebPlugin { if (!this.config.middleware) return - const span = this.#getActive(req) - + const context = this.#contexts.get(req) + if (!context) return + const middleware = context.middleware + const span = middleware.length === 0 ? context.span : middleware[middleware.length - 1] if (!span) return span.setTag('error', error) @@ -91,21 +94,6 @@ class RouterPlugin extends WebPlugin { }) } - #getActive (req) { - const context = this.#contexts.get(req) - - if (!context) return - if (context.middleware.length === 0) return context.span - - return context.middleware.at(-1) - } - - #getStoreSpan () { - const store = storage('legacy').getStore() - - return store && store.span - } - #getMiddlewareSpan (name, childOf) { if (this.config.middleware === false) { return childOf @@ -125,9 +113,7 @@ class RouterPlugin extends WebPlugin { return span } - #createContext (req, route, span) { - let context = this.#contexts.get(req) - + #updateContext (req, context, route, span) { if (!route || route === '/' || route === '*') { route = '' } @@ -141,17 +127,20 @@ class RouterPlugin extends WebPlugin { if (isMoreSpecificThan(route, context.route)) { context.route = route } - } else { - context = { - span, - stack: [route], - route, - middleware: [], - } + return context + } - this.#contexts.set(req, context) + // Five-property shape pinned at allocation so every request shares the + // same hidden class — no per-field transitions after construction. + context = { + span, + stack: [route], + route, + middleware: [], + storeStack: [], } + this.#contexts.set(req, context) return context } } diff --git a/packages/datadog-plugin-router/test/integration-test/client.spec.js b/packages/datadog-plugin-router/test/integration-test/client.spec.js index b221a4a471..70076a74d3 100644 --- a/packages/datadog-plugin-router/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-router/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -42,7 +43,7 @@ describe('esm', () => { return curlAndAssertMessage(agent, proc, ({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'router.middleware'), true) }) }).timeout(20000) diff --git a/packages/datadog-plugin-selenium/src/index.js b/packages/datadog-plugin-selenium/src/index.js index 801e5d1dfd..aad3d2cd04 100644 --- a/packages/datadog-plugin-selenium/src/index.js +++ b/packages/datadog-plugin-selenium/src/index.js @@ -14,7 +14,7 @@ const { const { SPAN_TYPE } = require('../../../ext/tags') function isTestSpan (span) { - return span.context()._tags[SPAN_TYPE] === 'test' + return span.context().getTag(SPAN_TYPE) === 'test' } function getTestSpanFromTrace (trace) { diff --git a/packages/datadog-plugin-sharedb/test/index.spec.js b/packages/datadog-plugin-sharedb/test/index.spec.js index 275dd4c968..702444205a 100644 --- a/packages/datadog-plugin-sharedb/test/index.spec.js +++ b/packages/datadog-plugin-sharedb/test/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') const sinon = require('sinon') @@ -263,7 +264,10 @@ describe('Plugin', () => { assert.strictEqual(traces[0][0].meta['sharedb.action'], 'fetch') assert.strictEqual(traces[0][0].meta[ERROR_TYPE], 'Error') assert.strictEqual(traces[0][0].meta[ERROR_MESSAGE], 'Snapshot Fetch Failure') - assert.ok(Object.hasOwn(traces[0][0].meta, ERROR_STACK)) + assert.ok( + Object.hasOwn(traces[0][0].meta, ERROR_STACK), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) assert.strictEqual(traces[0][0].meta.component, 'sharedb') }) .then(done) diff --git a/packages/datadog-plugin-sharedb/test/integration-test/client.spec.js b/packages/datadog-plugin-sharedb/test/integration-test/client.spec.js index 7f30753a8b..34c0b2bae4 100644 --- a/packages/datadog-plugin-sharedb/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-sharedb/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -38,7 +39,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'sharedb.request'), true) }) diff --git a/packages/datadog-plugin-tedious/test/integration-test/client.spec.js b/packages/datadog-plugin-tedious/test/integration-test/client.spec.js index 6b6c283cfd..5953aa72ef 100644 --- a/packages/datadog-plugin-tedious/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-tedious/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, @@ -46,7 +47,7 @@ describe('esm', () => { it(`is instrumented ${variant}`, async () => { const res = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(headers.host, `127.0.0.1:${agent.port}`) - assert.ok(Array.isArray(payload)) + assert.ok(Array.isArray(payload), `Expected array, got ${inspect(payload)}`) assert.strictEqual(checkSpansForServiceName(payload, 'tedious.request'), true) }) diff --git a/packages/datadog-plugin-vitest/src/index.js b/packages/datadog-plugin-vitest/src/index.js index 729c002b24..5ccd3e8ea0 100644 --- a/packages/datadog-plugin-vitest/src/index.js +++ b/packages/datadog-plugin-vitest/src/index.js @@ -15,7 +15,7 @@ const { TEST_IS_RETRY, TEST_CODE_COVERAGE_LINES_PCT, TEST_CODE_OWNERS, - TEST_LEVEL_EVENT_TYPES, + TEST_COMMAND, TEST_SESSION_NAME, TEST_SOURCE_START, TEST_IS_NEW, @@ -323,19 +323,11 @@ class VitestPlugin extends CiPlugin { const trimmedCommand = DD_MAJOR < 6 ? this.command : 'vitest run' // test suites run in a different process, so they also need to init the metadata dictionary const testSessionName = getTestSessionName(this.config, trimmedCommand, this.testEnvironmentMetadata) - const metadataTags = {} - for (const testLevel of TEST_LEVEL_EVENT_TYPES) { - metadataTags[testLevel] = { - [TEST_SESSION_NAME]: testSessionName, - } - } if (this.tracer._exporter.addMetadataTags) { - const libraryCapabilitiesTags = getLibraryCapabilitiesTags(this.constructor.id) - metadataTags.test = { - ...metadataTags.test, - ...libraryCapabilitiesTags, - } - this.tracer._exporter.addMetadataTags(metadataTags) + this.tracer._exporter.addMetadataTags({ + '*': { [TEST_COMMAND]: testCommand, [TEST_SESSION_NAME]: testSessionName }, + test: getLibraryCapabilitiesTags(this.constructor.id), + }) } const testSuite = getTestSuitePath(testSuiteAbsolutePath, this.repositoryRoot) diff --git a/packages/datadog-plugin-winston/src/index.js b/packages/datadog-plugin-winston/src/index.js index 209f187fe3..c5999cabe5 100644 --- a/packages/datadog-plugin-winston/src/index.js +++ b/packages/datadog-plugin-winston/src/index.js @@ -1,8 +1,38 @@ 'use strict' +const { buildLogHolder, messageProxy } = require('../../dd-trace/src/plugins/log_injection') const LogPlugin = require('../../dd-trace/src/plugins/log_plugin') class WinstonPlugin extends LogPlugin { static id = 'winston' + + constructor (...args) { + super(...args) + this.addSub('apm:winston:log', (arg) => this.handleLog(arg)) + } + + /** + * The prototype + extensibility check is load-bearing. The Proxy + * fallback keeps `dd` off caller-owned objects (Error, Set, Map, any + * user class) and out of non-extensible records, where a strict-mode + * write would throw and `Plugin.addSub` would react by disabling the + * plugin for the rest of the process. + * + * @param {{ message: unknown }} arg + */ + handleLog (arg) { + const info = arg.message + if (info === null || typeof info !== 'object' || Object.hasOwn(info, 'dd')) return + + const logHolder = buildLogHolder(this.tracer) + if (!logHolder) return + + if (Object.getPrototypeOf(info) === Object.prototype && Object.isExtensible(info)) { + info.dd = logHolder.dd + } else { + arg.message = messageProxy(info, logHolder) + } + } } + module.exports = WinstonPlugin diff --git a/packages/datadog-plugin-winston/test/integration-test/client.spec.js b/packages/datadog-plugin-winston/test/integration-test/client.spec.js index 70f98181f0..5c5b1e632d 100644 --- a/packages/datadog-plugin-winston/test/integration-test/client.spec.js +++ b/packages/datadog-plugin-winston/test/integration-test/client.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { FakeAgent, sandboxCwd, @@ -44,7 +45,7 @@ describe('esm', () => { undefined, (data) => { const jsonObject = JSON.parse(data.toString()) - assert.ok(Object.hasOwn(jsonObject, 'dd')) + assert.ok(Object.hasOwn(jsonObject, 'dd'), `Available keys: ${inspect(Object.keys(jsonObject))}`) } ) }).timeout(50000) diff --git a/packages/datadog-plugin-winston/test/unit.spec.js b/packages/datadog-plugin-winston/test/unit.spec.js new file mode 100644 index 0000000000..6d65871cf9 --- /dev/null +++ b/packages/datadog-plugin-winston/test/unit.spec.js @@ -0,0 +1,96 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it } = require('mocha') +const { channel } = require('dc-polyfill') +const { storage } = require('../../datadog-core') + +require('../../dd-trace/test/setup/core') +const WinstonPlugin = require('../src') +const Tracer = require('../../dd-trace/src/tracer') +const getConfig = require('../../dd-trace/src/config') + +const logCh = channel('apm:winston:log') + +const tracer = new Tracer(getConfig({ + enabled: true, + logInjection: true, + env: 'my-env', + service: 'my-service', + version: '1.2.3', +})) + +const plugin = new WinstonPlugin({ + _tracer: tracer, +}) +plugin.configure({ + logInjection: true, + enabled: true, +}) + +describe('WinstonPlugin', () => { + it('injects dd onto the info object winston passes through write', () => { + const info = { level: 'info', message: 'hello' } + logCh.publish({ message: info }) + assert.strictEqual(info.dd.service, 'my-service') + assert.strictEqual(info.dd.version, '1.2.3') + assert.strictEqual(info.dd.env, 'my-env') + }) + + it('preserves a caller-provided dd field', () => { + const info = { level: 'info', message: 'hello', dd: { custom: true } } + logCh.publish({ message: info }) + assert.deepStrictEqual(info.dd, { custom: true }) + }) + + it('adds trace_id and span_id when a span is active', () => { + const span = tracer.startSpan('test') + + storage('legacy').run({ span }, () => { + const info = { level: 'info', message: 'hello' } + logCh.publish({ message: info }) + assert.strictEqual(info.dd.trace_id, span.context().toTraceId(true)) + assert.strictEqual(info.dd.span_id, span.context().toSpanId()) + }) + }) + + it('does not run on non-object messages', () => { + const payload = { message: null } + logCh.publish(payload) + assert.strictEqual(payload.message, null) + }) + + it('wraps non-extensible messages in a proxy and leaves the original untouched', () => { + const info = Object.preventExtensions({ level: 'info', message: 'hello' }) + const payload = { message: info } + logCh.publish(payload) + assert.notStrictEqual(payload.message, info) + // `messageProxy` cannot expose `dd` on a non-extensible target -- the + // `ownKeys` and `get` traps both bail out -- but the original record + // stays unmutated. + assert.strictEqual(Object.hasOwn(info, 'dd'), false) + assert.strictEqual(payload.message.dd, undefined) + }) + + it('wraps Error instances in a proxy that exposes the dd field', () => { + const error = new Error('boom') + const payload = { message: error } + logCh.publish(payload) + assert.notStrictEqual(payload.message, error) + assert.strictEqual(Object.hasOwn(error, 'dd'), false) + assert.strictEqual(payload.message.dd.service, 'my-service') + }) + + it('leaves the message untouched when the propagator emits no dd', () => { + const originalInject = tracer.inject + tracer.inject = () => {} + try { + const info = { level: 'info', message: 'hello' } + logCh.publish({ message: info }) + assert.strictEqual(info.dd, undefined) + } finally { + tracer.inject = originalInject + } + }) +}) diff --git a/packages/datadog-plugin-ws/test/index.spec.js b/packages/datadog-plugin-ws/test/index.spec.js index 05825d7007..2521237406 100644 --- a/packages/datadog-plugin-ws/test/index.spec.js +++ b/packages/datadog-plugin-ws/test/index.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert') const { once } = require('node:events') +const { inspect } = require('node:util') const dc = require('dc-polyfill') const setSocketCh = dc.channel('tracing:ws:server:connect:setSocket') @@ -325,7 +326,7 @@ describe('Plugin', () => { } } } - assert.ok(sendCount > 0) + assert.ok(sendCount > 0, `Expected ${sendCount} > 0`) }) }) }) @@ -401,7 +402,7 @@ describe('Plugin', () => { const messageHandled = new Promise((resolve, reject) => { wsServer.on('connection', (ws) => { ws.on('message', (data) => { - assert.ok(Buffer.isBuffer(data)) + assert.ok(Buffer.isBuffer(data), `Expected Buffer, got ${inspect(data)}`) assert.strictEqual(data.toString(), payload.toString()) resolve() }) @@ -451,7 +452,7 @@ describe('Plugin', () => { } assert.strictEqual(receiveCount, 0) - assert.ok(sendCount > 0) + assert.ok(sendCount > 0, `Expected ${sendCount} > 0`) })) }) @@ -820,7 +821,7 @@ describe('Plugin', () => { didFindPointerLink = true const { attributes } = pointerLink - assert.ok(Object.hasOwn(attributes, 'ptr.hash')) + assert.ok(Object.hasOwn(attributes, 'ptr.hash'), `Available keys: ${inspect(Object.keys(attributes))}`) // Hash format: <32 hex trace id><16 hex span id><8 hex counter> assert.match(attributes['ptr.hash'], /^[SC][0-9a-f]{32}[0-9a-f]{16}[0-9a-f]{8}$/) assert.strictEqual(attributes['ptr.hash'].length, 57) @@ -866,7 +867,7 @@ describe('Plugin', () => { didFindPointerLink = true const { attributes } = pointerLink - assert.ok(Object.hasOwn(attributes, 'ptr.hash')) + assert.ok(Object.hasOwn(attributes, 'ptr.hash'), `Available keys: ${inspect(Object.keys(attributes))}`) // Hash format: <32 hex trace id><16 hex span id><8 hex counter> assert.match(attributes['ptr.hash'], /^[SC][0-9a-f]{32}[0-9a-f]{16}[0-9a-f]{8}$/) assert.strictEqual(attributes['ptr.hash'].length, 57) diff --git a/packages/datadog-shimmer/src/shimmer.js b/packages/datadog-shimmer/src/shimmer.js index 7ff320152e..3b410030a9 100644 --- a/packages/datadog-shimmer/src/shimmer.js +++ b/packages/datadog-shimmer/src/shimmer.js @@ -13,11 +13,16 @@ const skipMethodSize = skipMethods.size const nonConfigurableModuleExports = new WeakMap() +// Reused descriptor scratch space for the `name` and `length` slots that +// `copyProperties` and `wrapCallback` rewrite per wrap. `Object.defineProperty` +// reads the descriptor's slots synchronously and does not retain the object, +// so mutating `value` between calls is safe. +const lengthDescriptor = { value: 0, configurable: true } +const nameDescriptor = { value: '', configurable: true } + /** - * Copies properties from the original function to the wrapped function. - * - * @param {Function} original - The original function. - * @param {Function} wrapped - The wrapped function. + * @param {Function} original + * @param {Function} wrapped */ function copyProperties (original, wrapped) { if (original.constructor !== wrapped.constructor) { @@ -26,11 +31,15 @@ function copyProperties (original, wrapped) { } const ownKeys = Reflect.ownKeys(original) - if (original.length !== wrapped.length) { - Object.defineProperty(wrapped, 'length', { value: original.length, configurable: true }) + const originalLength = original.length + if (originalLength !== wrapped.length) { + lengthDescriptor.value = originalLength + Object.defineProperty(wrapped, 'length', lengthDescriptor) } - if (original.name !== wrapped.name) { - Object.defineProperty(wrapped, 'name', { value: original.name, configurable: true }) + const originalName = original.name + if (originalName !== wrapped.name) { + nameDescriptor.value = originalName + Object.defineProperty(wrapped, 'name', nameDescriptor) } if (ownKeys.length !== 2) { for (const key of ownKeys) { @@ -46,11 +55,9 @@ function copyProperties (original, wrapped) { } /** - * Copies properties from the original object to the wrapped object, skipping a specific key. - * - * @param {Record} original - The original object. - * @param {Record} wrapped - The wrapped object. - * @param {string | symbol} skipKey - The key to skip during copying. + * @param {Record} original + * @param {Record} wrapped + * @param {string | symbol} skipKey */ function copyObjectProperties (original, wrapped, skipKey) { const ownKeys = Reflect.ownKeys(original) @@ -66,11 +73,8 @@ function copyObjectProperties (original, wrapped, skipKey) { } /** - * Wraps a function with a wrapper function. - * - * @param {Function} original - The original function to wrap. - * @param {(original: Function) => Function} wrapper - The wrapper function. - * @returns {Function} The wrapped function. + * @param {Function} original + * @param {(original: Function) => Function} wrapper */ function wrapFunction (original, wrapper) { if (typeof original !== 'function') return original @@ -83,24 +87,14 @@ function wrapFunction (original, wrapper) { } /** - * Lean variant of `wrapFunction` for the case where the wrapped value is a - * user-supplied callback that the user cannot reasonably introspect beyond - * `name` and `length`, and the wrapper closure is fully controlled by us. - * - * Compared to `wrapFunction`, this skips the prototype copy, the - * `assertNotClass` guard, and the `Reflect.ownKeys` descriptor-copy loop. - * Only `name` and `length` are preserved, and only when the wrapper's - * autogenerated values differ -- a wrapper whose closure already has the - * right arity / name pays no overhead. - * - * Use `wrapFunction` instead when any of the following is true: the wrapped - * function needs to keep its prototype, has custom own properties the caller - * may read, or is `new`-ed. + * Lean variant of `wrapFunction` for tracer-owned closures wrapping a + * user-supplied callback. Preserves `name` and `length` only; skips the + * prototype copy, `assertNotClass`, and the `Reflect.ownKeys` descriptor + * walk. Use `wrapFunction` instead when the wrapped value needs its + * prototype, has own properties the caller may read, or is `new`-ed. * - * @param {Function} original - User-supplied callback being wrapped. - * @param {(original: Function) => Function} wrapper - Factory that receives - * `original` and returns the wrapper closure. - * @returns {Function} The wrapper closure with `name` and `length` preserved. + * @param {Function} original + * @param {(original: Function) => Function} wrapper */ function wrapCallback (original, wrapper) { if (typeof original !== 'function') { @@ -108,10 +102,12 @@ function wrapCallback (original, wrapper) { } const wrapped = wrapper(original) if (wrapped.name !== original.name) { - Object.defineProperty(wrapped, 'name', { value: original.name, configurable: true }) + nameDescriptor.value = original.name + Object.defineProperty(wrapped, 'name', nameDescriptor) } if (wrapped.length !== original.length) { - Object.defineProperty(wrapped, 'length', { value: original.length, configurable: true }) + lengthDescriptor.value = original.length + Object.defineProperty(wrapped, 'length', lengthDescriptor) } return wrapped } @@ -174,7 +170,6 @@ function wrap (target, name, wrapper, options) { copyProperties(original, wrapped) if (descriptor.writable) { - // Fast path for assigned properties. if (descriptor.configurable && descriptor.enumerable) { target[name] = wrapped return target @@ -209,8 +204,6 @@ function wrap (target, name, wrapper, options) { // with this code. That way it would be possible to directly pass through // the entries. - // In case more than a single property is not configurable and writable, - // Just reuse the already created object. let moduleExports = nonConfigurableModuleExports.get(target) if (!moduleExports) { if (typeof target === 'function') { diff --git a/packages/dd-trace/src/aiguard/index.js b/packages/dd-trace/src/aiguard/index.js index 3d92dc9c20..74840ba358 100644 --- a/packages/dd-trace/src/aiguard/index.js +++ b/packages/dd-trace/src/aiguard/index.js @@ -44,7 +44,7 @@ function disable () { /** * Handles channel messages with pre-converted messages. * - * @param {{messages: Array, resolve: Function, reject: Function}} ctx + * @param {{messages: Array, integration?: string, resolve: Function, reject: Function}} ctx */ function onEvaluate (ctx) { if (!ctx.messages?.length) { diff --git a/packages/dd-trace/src/aiguard/sdk.js b/packages/dd-trace/src/aiguard/sdk.js index f187944a87..3875b32e39 100644 --- a/packages/dd-trace/src/aiguard/sdk.js +++ b/packages/dd-trace/src/aiguard/sdk.js @@ -148,7 +148,7 @@ class AIGuard extends NoopAIGuard { #setRootSpanClientIpTags (rootSpan) { if (!rootSpan) return - const currentTags = rootSpan.context()._tags + const currentTags = rootSpan.context().getTags() const needsHttpClientIp = !Object.hasOwn(currentTags, HTTP_CLIENT_IP) const needsNetworkClientIp = !Object.hasOwn(currentTags, NETWORK_CLIENT_IP) diff --git a/packages/dd-trace/src/appsec/api_security_sampler.js b/packages/dd-trace/src/appsec/api_security_sampler.js index 0b602f0665..7ed4911200 100644 --- a/packages/dd-trace/src/appsec/api_security_sampler.js +++ b/packages/dd-trace/src/appsec/api_security_sampler.js @@ -79,7 +79,7 @@ function getRouteOrEndpoint (context, statusCode) { // If route is not available, fallback to http.endpoint if (statusCode !== 404) { - const endpoint = context?.span?.context()?._tags?.['http.endpoint'] + const endpoint = context?.span?.context()?.getTag('http.endpoint') if (endpoint) { return endpoint } diff --git a/packages/dd-trace/src/appsec/index.js b/packages/dd-trace/src/appsec/index.js index 20842b00b3..384dfeaeec 100644 --- a/packages/dd-trace/src/appsec/index.js +++ b/packages/dd-trace/src/appsec/index.js @@ -289,7 +289,7 @@ function onExpressSession ({ req, res, sessionId, abortController }) { return } - const isSdkCalled = rootSpan.context()._tags['usr.session_id'] + const isSdkCalled = rootSpan.context().getTag('usr.session_id') if (isSdkCalled) return const results = waf.run({ diff --git a/packages/dd-trace/src/appsec/reporter.js b/packages/dd-trace/src/appsec/reporter.js index 34291ee8f2..a72fd382b9 100644 --- a/packages/dd-trace/src/appsec/reporter.js +++ b/packages/dd-trace/src/appsec/reporter.js @@ -361,18 +361,18 @@ function reportAttack ({ events: attackData, actions }, req) { const rootSpan = web.root(req) if (!rootSpan) return - const currentTags = rootSpan.context()._tags + const spanContext = rootSpan.context() const newTags = { 'appsec.event': 'true', } // TODO: maybe add this to format.js later (to take decision as late as possible) - if (!currentTags['_dd.origin']) { + if (!spanContext.getTag('_dd.origin')) { newTags['_dd.origin'] = 'appsec' } - const currentJson = currentTags['_dd.appsec.json'] + const currentJson = spanContext.getTag('_dd.appsec.json') // merge JSON arrays without parsing them const attackDataStr = JSON.stringify(attackData) @@ -469,8 +469,7 @@ function reportRequestBody (rootSpan, requestBody, comesFromRaspAction = false) if (rootSpan.meta_struct['http.request.body']) { // If the rasp.exceed metric exists, set also the same for the new tag - const currentTags = rootSpan.context()._tags - const sizeExceedTagValue = currentTags['_dd.appsec.rasp.request_body_size.exceeded'] + const sizeExceedTagValue = rootSpan.context().getTag('_dd.appsec.rasp.request_body_size.exceeded') if (sizeExceedTagValue) { rootSpan.setTag('_dd.appsec.request_body_size.exceeded', sizeExceedTagValue) @@ -572,7 +571,7 @@ function finishRequest (req, res, storedResponseHeaders, requestBody) { incrementWafRequestsMetric(req) - const tags = rootSpan.context()._tags + const tags = rootSpan.context().getTags() const extendedDataCollection = extendedDataCollectionRequest.get(req) const newTags = getCollectedHeaders( diff --git a/packages/dd-trace/src/appsec/sdk/user_blocking.js b/packages/dd-trace/src/appsec/sdk/user_blocking.js index c1edacd992..7b2d9baaba 100644 --- a/packages/dd-trace/src/appsec/sdk/user_blocking.js +++ b/packages/dd-trace/src/appsec/sdk/user_blocking.js @@ -22,7 +22,7 @@ function checkUserAndSetUser (tracer, user) { const rootSpan = getRootSpan() if (rootSpan) { - if (!rootSpan.context()._tags['usr.id']) { + if (!rootSpan.context().getTag('usr.id')) { setUserTags(user, rootSpan) } } else { diff --git a/packages/dd-trace/src/appsec/sdk/utils.js b/packages/dd-trace/src/appsec/sdk/utils.js index 6fcf240241..ec74f41f84 100644 --- a/packages/dd-trace/src/appsec/sdk/utils.js +++ b/packages/dd-trace/src/appsec/sdk/utils.js @@ -18,7 +18,7 @@ function getRootSpan () { parentId = pContext._parentId - if (!pContext._tags?._inferred_span) { + if (!pContext.getTag('_inferred_span')) { span = parent } } diff --git a/packages/dd-trace/src/appsec/user_tracking.js b/packages/dd-trace/src/appsec/user_tracking.js index fe6a3702dd..1ae1765514 100644 --- a/packages/dd-trace/src/appsec/user_tracking.js +++ b/packages/dd-trace/src/appsec/user_tracking.js @@ -91,12 +91,13 @@ function trackLogin (framework, login, user, success, rootSpan) { [addresses.USER_LOGIN]: login, } - const currentTags = rootSpan.context()._tags - const isSdkCalled = currentTags[`_dd.appsec.events.users.login.${success ? 'success' : 'failure'}.sdk`] === 'true' + const spanContext = rootSpan.context() + const sdkTag = `_dd.appsec.events.users.login.${success ? 'success' : 'failure'}.sdk` + const isSdkCalled = spanContext.getTag(sdkTag) === 'true' // used to not overwrite tags set by SDK function shouldSetTag (tag) { - return !(isSdkCalled && currentTags[tag]) + return !(isSdkCalled && spanContext.getTag(tag)) } if (success) { @@ -167,7 +168,7 @@ function trackUser (user, rootSpan) { rootSpan.setTag('_dd.appsec.usr.id', userId) - const isSdkCalled = rootSpan.context()._tags['_dd.appsec.user.collection_mode'] === 'sdk' + const isSdkCalled = rootSpan.context().getTag('_dd.appsec.user.collection_mode') === 'sdk' // do not override SDK if (!isSdkCalled) { rootSpan.addTags({ diff --git a/packages/dd-trace/src/baggage.js b/packages/dd-trace/src/baggage.js index ecf6daf5d2..7cae1d2560 100644 --- a/packages/dd-trace/src/baggage.js +++ b/packages/dd-trace/src/baggage.js @@ -62,7 +62,13 @@ function removeBaggageItem (keyToRemove) { } function removeAllBaggageItems () { - baggageStorage.enterWith(EMPTY_STORE) + // Skip `enterWith` (a real ALS frame switch) when the store is already + // the empty sentinel. Entry-point services without active baggage hit this + // on every extract. + const store = baggageStorage.getStore() + if (store !== undefined && store !== EMPTY_STORE) { + baggageStorage.enterWith(EMPTY_STORE) + } return EMPTY_STORE } 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 e16273734e..e92e20890e 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 @@ -236,7 +236,6 @@ class CiVisibilityExporter extends BufferingExporter { testManagementAttemptToFixRetries ?? this._config.testManagementAttemptToFixRetries, isImpactedTestsEnabled: isImpactedTestsEnabled && this._config.isImpactedTestsEnabled, isCoverageReportUploadEnabled, - DD_TEST_TIA_KEEP_COV_CONFIG: this._config.DD_TEST_TIA_KEEP_COV_CONFIG, } } diff --git a/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js b/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js index 153fdfcbc3..011f9a92f8 100644 --- a/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js +++ b/packages/dd-trace/src/ci-visibility/intelligent-test-runner/get-skippable-suites.js @@ -31,10 +31,11 @@ function getSkippableSuites ({ runtimeVersion, custom, testLevel = 'suite', + isCoverageReportUploadEnabled = false, }, done) { const cacheKey = buildCacheKey('skippable', [ sha, service, env, repositoryUrl, osPlatform, osVersion, osArchitecture, - runtimeName, runtimeVersion, testLevel, custom, + runtimeName, runtimeVersion, testLevel, custom, isCoverageReportUploadEnabled, ]) withCache(cacheKey, (activeCacheKey, cb) => { @@ -54,11 +55,12 @@ function getSkippableSuites ({ runtimeVersion, custom, testLevel, + isCoverageReportUploadEnabled, cacheKey: activeCacheKey, }, cb) }, (err, data) => { if (err) return done(err) - done(null, data.skippableSuites, data.correlationId) + done(null, data.skippableSuites, data.correlationId, data.coverage) }) } @@ -81,6 +83,7 @@ function getSkippableSuites ({ * @param {string} params.runtimeVersion * @param {object} [params.custom] * @param {string} [params.testLevel] + * @param {boolean} [params.isCoverageReportUploadEnabled] * @param {string | null} params.cacheKey * @param {Function} done */ @@ -100,6 +103,7 @@ function fetchFromApi ({ runtimeVersion, custom, testLevel, + isCoverageReportUploadEnabled, cacheKey, }, done) { const options = { @@ -148,7 +152,6 @@ function fetchFromApi ({ }, }, }) - incrementCountMetric(TELEMETRY_ITR_SKIPPABLE_TESTS) const startTime = Date.now() @@ -161,27 +164,36 @@ function fetchFromApi ({ } else { try { const parsedResponse = JSON.parse(res) - const skippableSuites = parsedResponse + const coverage = parsedResponse.meta?.coverage || {} + + const skippableItems = parsedResponse .data .filter(({ type }) => type === testLevel) - .map(({ attributes: { suite, name } }) => { - if (testLevel === 'suite') { - return suite - } - return { suite, name } - }) - const { meta: { correlation_id: correlationId } } = parsedResponse + const skippableSuites = [] + for (const { + attributes: { + suite, + name, + _is_missing_line_code_coverage: isMissingLineCodeCoverage, + }, + } of skippableItems) { + // Only reject candidates without backend line coverage when we need that coverage to backfill reports. + if (isCoverageReportUploadEnabled && isMissingLineCodeCoverage) continue + + skippableSuites.push(testLevel === 'suite' ? suite : { suite, name }) + } + const correlationId = parsedResponse.meta?.correlation_id incrementCountMetric( testLevel === 'test' ? TELEMETRY_ITR_SKIPPABLE_TESTS_RESPONSE_TESTS : TELEMETRY_ITR_SKIPPABLE_TESTS_RESPONSE_SUITES, {}, - skippableSuites.length + skippableItems.length ) distributionMetric(TELEMETRY_ITR_SKIPPABLE_TESTS_RESPONSE_BYTES, {}, res.length) log.debug('Number of received skippable %ss:', testLevel, skippableSuites.length) - const result = { skippableSuites, correlationId } + const result = { skippableSuites, correlationId, coverage } writeToCache(cacheKey, result) done(null, result) diff --git a/packages/dd-trace/src/ci-visibility/test-optimization-cache.js b/packages/dd-trace/src/ci-visibility/test-optimization-cache.js index 88466dae99..4b9faff9e5 100644 --- a/packages/dd-trace/src/ci-visibility/test-optimization-cache.js +++ b/packages/dd-trace/src/ci-visibility/test-optimization-cache.js @@ -1,6 +1,6 @@ 'use strict' -const { writeFileSync } = require('node:fs') +const { existsSync, readFileSync, writeFileSync } = require('node:fs') const { tmpdir } = require('node:os') const { randomUUID } = require('node:crypto') const path = require('node:path') @@ -8,6 +8,9 @@ const path = require('node:path') const { getValueFromEnvSources } = require('../config/helper') const log = require('../log') +const COVERAGE_BACKFILL_KEY = '_ddCoverageBackfill' +const COVERAGE_BACKFILL_ROOT_DIR_KEY = '_ddCoverageBackfillRootDir' + /** * Gets the test optimization settings cache file path from the env var. * @returns {string|undefined} The cache file path, or undefined if not set. @@ -36,26 +39,87 @@ function setupSettingsCachePath () { } /** - * Writes the settings to the cache file specified by DD_EXPERIMENTAL_TEST_OPT_SETTINGS_CACHE. - * Does nothing if the env var is not set. - * @param {object} settings - The settings object to cache. + * Reads the shared test optimization cache file. + * @returns {object} Cached settings and metadata. */ -function writeSettingsToCache (settings) { +function readCacheFile () { + const settingsCachePath = getSettingsCachePath() + if (!settingsCachePath || !existsSync(settingsCachePath)) { + return {} + } + + try { + return JSON.parse(readFileSync(settingsCachePath, 'utf8')) + } catch (err) { + log.debug('Failed to read settings cache: %s', err.message) + return {} + } +} + +/** + * Writes the shared test optimization cache file. + * @param {object} cache - Cached settings and metadata. + */ +function writeCacheFile (cache) { const settingsCachePath = getSettingsCachePath() if (!settingsCachePath) { return } try { - writeFileSync(settingsCachePath, JSON.stringify(settings), 'utf8') + writeFileSync(settingsCachePath, JSON.stringify(cache), 'utf8') log.debug('Settings written to %s', settingsCachePath) } catch (err) { log.error('Failed to write settings to cache file', err) } } +/** + * Writes the settings to the cache file specified by DD_EXPERIMENTAL_TEST_OPT_SETTINGS_CACHE. + * Does nothing if the env var is not set. + * @param {object} settings - The settings object to cache. + */ +function writeSettingsToCache (settings) { + writeCacheFile({ + ...readCacheFile(), + ...settings, + }) +} + +/** + * Writes TIA coverage backfill to the shared nyc settings cache. + * @param {object} coverage - Repository-relative coverage bitmaps by filename. + * @param {string} [rootDir] - Root directory that coverage filenames are relative to. + */ +function writeCoverageBackfillToCache (coverage, rootDir) { + writeCacheFile({ + ...readCacheFile(), + [COVERAGE_BACKFILL_KEY]: coverage, + [COVERAGE_BACKFILL_ROOT_DIR_KEY]: rootDir, + }) +} + +/** + * Reads TIA coverage backfill from the shared nyc settings cache. + * @returns {object|undefined} Repository-relative coverage bitmaps by filename. + */ +function readCoverageBackfillFromCache () { + return readCacheFile()[COVERAGE_BACKFILL_KEY] +} + +/** + * Reads TIA coverage backfill root directory from the shared nyc settings cache. + * @returns {string|undefined} Root directory that cached coverage filenames are relative to. + */ +function readCoverageBackfillRootDirFromCache () { + return readCacheFile()[COVERAGE_BACKFILL_ROOT_DIR_KEY] +} + module.exports = { getSettingsCachePath, + readCoverageBackfillFromCache, + readCoverageBackfillRootDirFromCache, setupSettingsCachePath, + writeCoverageBackfillToCache, writeSettingsToCache, } 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 4ea5f60dae..8fcc25ba50 100644 --- a/packages/dd-trace/src/config/generated-config-types.d.ts +++ b/packages/dd-trace/src/config/generated-config-types.d.ts @@ -65,7 +65,7 @@ export interface GeneratedConfig { dbm: { injectSqlBaseHash: boolean; }; - dbmPropagationMode: string; + dbmPropagationMode: "disabled" | "service" | "full" | "dynamic_service"; DD_ACTION_EXECUTION_ID: string | undefined; DD_AGENTLESS_LOG_SUBMISSION_ENABLED: boolean; DD_AGENTLESS_LOG_SUBMISSION_URL: string | undefined; @@ -162,7 +162,6 @@ export interface GeneratedConfig { DD_TEST_FLEET_CONFIG_PATH: string | undefined; DD_TEST_LOCAL_CONFIG_PATH: string | undefined; DD_TEST_SESSION_NAME: string | undefined; - DD_TEST_TIA_KEEP_COV_CONFIG: boolean; DD_TRACE_AEROSPIKE_ENABLED: boolean; DD_TRACE_AI_ENABLED: boolean; DD_TRACE_AMQP10_ENABLED: boolean; @@ -211,6 +210,7 @@ export interface GeneratedConfig { DD_TRACE_AWS_SDK_STEPFUNCTIONS_BATCH_PROPAGATION_ENABLED: boolean; DD_TRACE_AWS_SDK_STEPFUNCTIONS_ENABLED: boolean; DD_TRACE_AXIOS_ENABLED: boolean; + DD_TRACE_AZURE_COSMOS_ENABLED: boolean; DD_TRACE_AZURE_DURABLE_FUNCTIONS_ENABLED: boolean; DD_TRACE_AZURE_EVENT_HUBS_ENABLED: boolean; DD_TRACE_AZURE_EVENTHUBS_BATCH_LINKS_ENABLED: boolean; @@ -331,6 +331,7 @@ export interface GeneratedConfig { DD_TRACE_MYSQL_ENABLED: boolean; DD_TRACE_MYSQL2_ENABLED: boolean; DD_TRACE_NATIVE_SPAN_EVENTS: boolean; + DD_TRACE_NATS_ENABLED: boolean; DD_TRACE_NET_ENABLED: boolean; DD_TRACE_NEXT_ENABLED: boolean; DD_TRACE_NODE_CHILD_PROCESS_ENABLED: boolean; @@ -428,6 +429,9 @@ export interface GeneratedConfig { flaggingProvider: { enabled: boolean; initializationTimeoutMs: number; + spanEnrichment: { + enabled: boolean; + }; }; }; flakyTestRetriesCount: number; diff --git a/packages/dd-trace/src/config/supported-configurations.json b/packages/dd-trace/src/config/supported-configurations.json index 9e33281aa4..15b337b203 100644 --- a/packages/dd-trace/src/config/supported-configurations.json +++ b/packages/dd-trace/src/config/supported-configurations.json @@ -640,7 +640,9 @@ "configurationNames": [ "dbmPropagationMode" ], - "default": "disabled" + "default": "disabled", + "allowed": "disabled|service|full|dynamic_service", + "transform": "toLowerCase" } ], "DD_DOGSTATSD_HOST": [ @@ -773,6 +775,16 @@ "default": "false" } ], + "DD_EXPERIMENTAL_FLAGGING_PROVIDER_SPAN_ENRICHMENT_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "configurationNames": [ + "experimental.flaggingProvider.spanEnrichment.enabled" + ], + "default": "false" + } + ], "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": [ { "implementation": "B", @@ -1773,13 +1785,6 @@ "internalPropertyName": "isTestManagementEnabled" } ], - "DD_TEST_TIA_KEEP_COV_CONFIG": [ - { - "implementation": "A", - "type": "boolean", - "default": "false" - } - ], "DD_TEST_SESSION_NAME": [ { "implementation": "A", @@ -2176,6 +2181,13 @@ "default": "true" } ], + "DD_TRACE_AZURE_COSMOS_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "default": "true" + } + ], "DD_TRACE_AZURE_DURABLE_FUNCTIONS_ENABLED": [ { "implementation": "B", @@ -3184,6 +3196,13 @@ "default": "false" } ], + "DD_TRACE_NATS_ENABLED": [ + { + "implementation": "A", + "type": "boolean", + "default": "false" + } + ], "DD_TRACE_NET_ENABLED": [ { "implementation": "A", diff --git a/packages/dd-trace/src/datastreams/writer.js b/packages/dd-trace/src/datastreams/writer.js index cbb87fe705..da6e475456 100644 --- a/packages/dd-trace/src/datastreams/writer.js +++ b/packages/dd-trace/src/datastreams/writer.js @@ -4,11 +4,9 @@ const zlib = require('zlib') const pkg = require('../../../../package.json') const log = require('../log') const request = require('../exporters/common/request') -const { MsgpackEncoder } = require('../msgpack') +const { encode: encodeMsgpack } = require('../msgpack') const { getAgentUrl } = require('../agent/url') -const msgpack = new MsgpackEncoder() - function makeRequest (data, url, cb) { const options = { path: '/v0.1/pipeline_stats', @@ -39,7 +37,7 @@ class DataStreamsWriter { log.debug('Maximum number of active requests reached. Payload discarded: %j', payload) return } - const encodedPayload = msgpack.encode(payload) + const encodedPayload = encodeMsgpack(payload) zlib.gzip(encodedPayload, { level: 1 }, (err, compressedData) => { if (err) { diff --git a/packages/dd-trace/src/debugger/devtools_client/condition.js b/packages/dd-trace/src/debugger/devtools_client/condition.js index 2267e8750a..fa574726e9 100644 --- a/packages/dd-trace/src/debugger/devtools_client/condition.js +++ b/packages/dd-trace/src/debugger/devtools_client/condition.js @@ -6,7 +6,7 @@ module.exports = { templateRequiresEvaluation, } -const identifierRegex = /^[@a-zA-Z_$][\w$]*$/ +const identifierRegex = /^(@[\w$]+|[a-zA-Z_$][\w$]*)$/ // The following identifiers have purposefully not been included in this list: // - The reserved words `this` and `super` as they can have valid use cases as `ref` values @@ -99,14 +99,11 @@ function compile (node) { ? `(typeof ${compile(value[0])} === '${value[1]}')` // TODO: Is parenthesizing necessary? : `Function.prototype[Symbol.hasInstance].call(${assertIdentifier(value[1])}, ${compile(value[0])})` } else if (type === 'ref') { - if (value === '@it') { - return '$dd_it' - } else if (value === '@key') { - return '$dd_key' - } else if (value === '@value') { - return '$dd_value' + const refValue = assertIdentifier(value) + if (refValue.startsWith('@')) { + return `$dd_${refValue.slice(1)}` } - return assertIdentifier(value) + return refValue } else if (Array.isArray(value)) { const args = value.map(compile) switch (type) { diff --git a/packages/dd-trace/src/encode/0.4.js b/packages/dd-trace/src/encode/0.4.js index 98e10a7f16..4093b1b605 100644 --- a/packages/dd-trace/src/encode/0.4.js +++ b/packages/dd-trace/src/encode/0.4.js @@ -1,11 +1,17 @@ 'use strict' const getConfig = require('../config') -const { MsgpackChunk, MsgpackEncoder } = require('../msgpack') +const { MsgpackChunk } = require('../msgpack') const log = require('../log') const { normalizeSpan } = require('./tags-processors') const SOFT_LIMIT = 8 * 1024 * 1024 // 8MB +// Values longer than this byte threshold skip the `_stringMap` lookup and +// emit through `bytes.write` directly. Hashing a multi-KiB string for +// `Map.get` costs more than the cache hit saves on the inputs that produce +// strings this long (events JSON, stack traces, large query bodies) — they +// are unique per span, so the cache hit rate stays near zero anyway. +const STRING_CACHE_BYPASS_LIMIT = 1024 // Pre-encoded static keys + value-prefix bytes; the hot encode loop emits // each via one Uint8Array.set instead of routing through the string cache. @@ -43,6 +49,17 @@ const KEY_SERVICE = buildKey('service') const KEY_ERROR = buildKey('error') const KEY_START = buildKey('start') const KEY_DURATION = buildKey('duration') + +// Fused `[KEY_ERROR, fixint]` payloads. `error` is `0` or `1` on nearly every +// span (the boolean-shaped tracer field collapsed onto a single byte). One +// `bytes.set` writes the key and the value together instead of routing the +// value through `writeIntOrFloat`'s reserve + branch table. +const KEY_ERROR_0 = Buffer.concat([KEY_ERROR, Buffer.from([0x00])]) +const KEY_ERROR_1 = Buffer.concat([KEY_ERROR, Buffer.from([0x01])]) +// `[KEY_START, 0xCF]` — `start` is always a nanosecond timestamp ≥ 2³², so +// the msgpack u64 type byte is statically known and fuses with the key. The +// 8-byte value is written inline right after. +const KEY_START_PREFIX = buildKeyWithPrefix('start', 0xCF) const KEY_SPAN_EVENTS = buildKey('span_events') const KEY_META_STRUCT = buildKey('meta_struct') const KEY_TRACE_ID_PREFIX = buildKeyWithPrefix('trace_id', 0xCF) @@ -97,6 +114,8 @@ const ATTR_PAYLOAD_BOOL_FALSE = Buffer.concat([ATTR_PREFIX_BOOL, Buffer.from([0x function formatSpanWithLegacyEvents (span) { span = normalizeSpan(span) if (span.span_events) { + // TODO: this is currently a main cost driver. By unifying it with the formatter + // it should be possible to improve performance significantly overall. span.meta.events = stringifySpanEvents(span.span_events) // `= undefined` over `delete` to keep the span's hidden class — `delete` // would push every event-bearing span into V8 dictionary mode. @@ -201,8 +220,12 @@ function escapeJsonString (value) { return '"' + value + '"' } +function lazyEncodedTraceBufferLogger (bytes, start, end) { + const hex = bytes.buffer.subarray(start, end).toString('hex').match(/../g).join(' ') + return `Adding encoded trace to buffer: ${hex}` +} + class AgentEncoder { - #msgpack = new MsgpackEncoder() #limit #writer #config @@ -239,11 +262,7 @@ class AgentEncoder { if (this.#debugEncoding) { const end = bytes.length - // eslint-disable-next-line eslint-rules/eslint-log-printf-style - log.debug(() => { - const hex = bytes.buffer.subarray(start, end).toString('hex').match(/../g).join(' ') - return `Adding encoded trace to buffer: ${hex}` - }) + log.debug(lazyEncodedTraceBufferLogger, bytes, start, end) } // Soft limit overshoot is fine — the agent caps at 50 MB. @@ -269,7 +288,7 @@ class AgentEncoder { } _encode (bytes, trace) { - this._encodeArrayPrefix(bytes, trace) + bytes.writeArrayPrefix(trace) const formatSpan = this.#formatSpan const stringMap = this._stringMap @@ -286,10 +305,10 @@ class AgentEncoder { if (span.span_events) mapSize++ // Pre-fetch the cached string entries up front and fuse the map prefix, - // optional `type`, three IDs, and `name` / `resource` / `service` + // optional `type`, three IDs, `name` / `resource` / `service`, and — + // in the common fixint-error case — the error/start/duration_key // emissions into a single `bytes.reserve` + sequential native writes. - // Replaces seven `bytes.reserve` calls per span (one each for the - // header, type, three IDs, three strings) with one. + // Replaces up to ten separate `bytes.reserve` calls per span with one. let typeEntry if (span.type) { typeEntry = stringMap[span.type] ?? this._cacheString(span.type) @@ -301,8 +320,17 @@ class AgentEncoder { const resourceLen = resourceEntry.length const serviceLen = serviceEntry.length - // 1 byte map prefix + 3 ID fields (10/9/11 bytes prefix + 8 bytes value - // each) + the three string fields. + // Almost every span carries `error: 0` or `error: 1` AND a nanosecond + // `start` timestamp ≥ 2³² (so `start` always encodes as a u64). When + // both hold, the block fuses error key+value, the start key + 0xCF + // type byte + 8-byte timestamp, and the duration key into the per-span + // reserve. The fallback path covers synthetic/test inputs with small + // starts and rare non-binary error flags by keeping per-field emits so + // each integer picks the shortest msgpack encoding. + const errorIsFixint = span.error === 0 || span.error === 1 + const startFitsU64 = span.start >= 0x1_00_00_00_00 + const fuseTail = errorIsFixint && startFitsU64 + let blockSize = 1 + KEY_TRACE_ID_PREFIX.length + 8 + KEY_SPAN_ID_PREFIX.length + 8 + @@ -311,6 +339,9 @@ class AgentEncoder { KEY_RESOURCE.length + resourceLen + KEY_SERVICE.length + serviceLen if (typeEntry) blockSize += KEY_TYPE.length + typeEntry.length + if (fuseTail) { + blockSize += KEY_ERROR_0.length + KEY_START_PREFIX.length + 8 + KEY_DURATION.length + } const blockOffset = bytes.length bytes.reserve(blockSize) @@ -343,13 +374,35 @@ class AgentEncoder { target.set(KEY_SERVICE, cursor) cursor += KEY_SERVICE.length target.set(serviceEntry, cursor) + cursor += serviceLen + + if (fuseTail) { + target.set(span.error === 0 ? KEY_ERROR_0 : KEY_ERROR_1, cursor) + cursor += KEY_ERROR_0.length - bytes.set(KEY_ERROR) - this._encodeIntOrFloat(bytes, span.error) - bytes.set(KEY_START) - this._encodeIntOrFloat(bytes, span.start) - bytes.set(KEY_DURATION) - this._encodeIntOrFloat(bytes, span.duration) + target.set(KEY_START_PREFIX, cursor) + cursor += KEY_START_PREFIX.length + // Inline u64 write so the 0xCF type byte and the 8 timestamp bytes + // share the same reserve as the keys. + target.writeUInt32BE((span.start / 0x1_00_00_00_00) >>> 0, cursor) + target.writeUInt32BE(span.start >>> 0, cursor + 4) + cursor += 8 + + target.set(KEY_DURATION, cursor) + } else { + if (span.error === 0) { + bytes.set(KEY_ERROR_0) + } else if (span.error === 1) { + bytes.set(KEY_ERROR_1) + } else { + bytes.set(KEY_ERROR) + bytes.writeIntOrFloat(span.error) + } + bytes.set(KEY_START) + bytes.writeIntOrFloat(span.start) + bytes.set(KEY_DURATION) + } + bytes.writeIntOrFloat(span.duration) this.#encodeMetaEntries(bytes, KEY_META_PREFIX, span.meta) this.#encodeMetaEntries(bytes, KEY_METRICS_PREFIX, span.metrics) @@ -390,34 +443,14 @@ class AgentEncoder { _reset () { this._traceCount = 0 - this._traceBytes.length = 0 + this._traceBytes.reset() this._stringCount = 0 - this._stringBytes.length = 0 + this._stringBytes.reset() this._stringMap = Object.create(null) this._cacheString('') } - _encodeBuffer (bytes, buffer) { - this.#msgpack.encodeBin(bytes, buffer) - } - - _encodeBool (bytes, value) { - this.#msgpack.encodeBoolean(bytes, value) - } - - _encodeArrayPrefix (bytes, value) { - this.#msgpack.encodeArrayPrefix(bytes, value) - } - - _encodeMapPrefix (bytes, keysLength) { - this.#msgpack.encodeMapPrefix(bytes, keysLength) - } - - _encodeByte (bytes, value) { - this.#msgpack.encodeByte(bytes, value) - } - // TODO: Use BigInt instead. _encodeId (bytes, identifier) { const idBuffer = identifier.toBuffer() @@ -438,18 +471,6 @@ class AgentEncoder { target[offset + 8] = idBuffer[start + 7] } - _encodeNumber (bytes, value) { - this.#msgpack.encodeNumber(bytes, value) - } - - _encodeInteger (bytes, value) { - this.#msgpack.encodeInteger(bytes, value) - } - - _encodeLong (bytes, value) { - this.#msgpack.encodeLong(bytes, value) - } - // Single pass: reserve the count slot, encode entries while counting, patch the count. // Subclasses (0.5, CI visibility encoders) inherit this; the wire stays on float64 // for numeric values to keep their established trace / events intake unchanged. @@ -467,7 +488,7 @@ class AgentEncoder { count++ } else if (typeof entryValue === 'number') { this._encodeString(bytes, key) - this.#encodeFloat(bytes, entryValue) + bytes.writeFloat(entryValue) count++ } } @@ -480,6 +501,10 @@ class AgentEncoder { } _encodeString (bytes, value = '') { + if (value.length > STRING_CACHE_BYPASS_LIMIT) { + bytes.write(value) + return + } const entry = this._stringMap[value] ?? this._cacheString(value) const length = entry.length const offset = bytes.length @@ -540,6 +565,17 @@ class AgentEncoder { const writeOffset = bytes.length if (typeof entryValue === 'string') { + if (entryValue.length > STRING_CACHE_BYPASS_LIMIT) { + // Long values (events JSON, stack traces, large query bodies) are + // unique per span; hashing them for the cache lookup costs more + // than the lookup ever recovers. Emit the key from the cache and + // stream the value directly. + bytes.reserve(keyEntryLen) + bytes.buffer.set(keyEntry, writeOffset) + bytes.write(entryValue) + count++ + continue + } const valueEntry = stringMap[entryValue] ?? this._cacheString(entryValue) const valueEntryLen = valueEntry.length bytes.reserve(keyEntryLen + valueEntryLen) @@ -547,9 +583,22 @@ class AgentEncoder { target.set(keyEntry, writeOffset) target.set(valueEntry, writeOffset + keyEntryLen) } else { - bytes.reserve(keyEntryLen) - bytes.buffer.set(keyEntry, writeOffset) - this._encodeIntOrFloat(bytes, entryValue) + // Speculate that `entryValue` is a positive fixint (0..127): one + // reserve covers both the key and the value. The metrics map (sample + // rate, priority, `_dd.measured`, attribute counts) is mostly small + // unsigned integers, so the speculation wins on every entry that + // doesn't go through the slow `writeIntOrFloat` dispatch chain. + bytes.reserve(keyEntryLen + 1) + const target = bytes.buffer + target.set(keyEntry, writeOffset) + if (entryValue === (entryValue & 0x7F)) { + target[writeOffset + keyEntryLen] = entryValue + } else { + // Speculation missed; rewind the speculative byte and route the + // value through the full encoder so it picks the right type. + bytes.length = writeOffset + keyEntryLen + bytes.writeIntOrFloat(entryValue) + } } count++ } @@ -589,41 +638,6 @@ class AgentEncoder { return offset + 8 } - /** - * Emit `value` as the smallest valid msgpack number encoding: compact - * unsigned/signed int when integer, float64 otherwise. Unlike - * `MsgpackEncoder#encodeNumber`, NaN keeps its float64 bits instead of - * coercing to fixint 0. - * - * Underscore-protected so the 0.5 subclass can call it from its own - * `_encode` / `_encodeMap` overrides. - * - * @param {MsgpackChunk} bytes - * @param {number} value - */ - _encodeIntOrFloat (bytes, value) { - // Fast path: positive fixint (0..127). `value === (value & 0x7F)` is true - // iff `value` is an exact integer in that range — covers `error: 0/1`, - // priority flags, attribute counts, HTTP status codes mapped to numbers, - // and most small metrics. NaN, ±Infinity, negatives, and any non-integer - // float fall through. - if (value === (value & 0x7F)) { - const offset = bytes.length - bytes.reserve(1) - bytes.buffer[offset] = value - return - } - if (Number.isInteger(value)) { - if (value >= 0) { - this.#msgpack.encodeUnsigned(bytes, value) - } else { - this.#msgpack.encodeSigned(bytes, value) - } - } else { - this.#encodeFloat(bytes, value) - } - } - /** * @param {MsgpackChunk} bytes * @param {string | number | boolean} value @@ -634,21 +648,17 @@ class AgentEncoder { this._encodeString(bytes, value) break case 'number': - this.#encodeFloat(bytes, value) + bytes.writeFloat(value) break case 'boolean': - this._encodeBool(bytes, value) + bytes.writeBoolean(value) break } } - #encodeFloat (bytes, value) { - this.#msgpack.encodeFloat(bytes, value) - } - #encodeMetaStruct (bytes, value) { if (Array.isArray(value)) { - this._encodeMapPrefix(bytes, 0) + bytes.writeMapPrefix(0) return } @@ -774,7 +784,7 @@ class AgentEncoder { bytes.set(KEY_NAME) this._encodeString(bytes, event.name) bytes.set(KEY_EVENT_TIME) - this.#encodeFloat(bytes, event.time_unix_nano) + bytes.writeFloat(event.time_unix_nano) const attributes = event.attributes if (attributes !== null && typeof attributes === 'object') { @@ -844,7 +854,7 @@ class AgentEncoder { if (typeof value === 'number') { this._encodeString(bytes, key) bytes.set(Number.isInteger(value) ? ATTR_PREFIX_INT : ATTR_PREFIX_DOUBLE) - this._encodeIntOrFloat(bytes, value) + bytes.writeIntOrFloat(value) return true } if (typeof value === 'boolean') { @@ -855,8 +865,11 @@ class AgentEncoder { if (Array.isArray(value)) { return this.#emitArrayAttribute(bytes, key, value) } - memoizedLogDebug(key, 'Encountered unsupported data type for span event v0.4 encoding, key: ' + - `${key}: with value: ${typeof value}. Skipping encoding of pair.` + memoizedLogDebug( + key, + 'Encountered unsupported data type for span event v0.4 encoding, key: ' + + '%s: with value: %s. Skipping encoding of pair.', + value ) return false } @@ -914,7 +927,7 @@ class AgentEncoder { } if (typeof value === 'number') { bytes.set(Number.isInteger(value) ? ATTR_PREFIX_INT : ATTR_PREFIX_DOUBLE) - this._encodeIntOrFloat(bytes, value) + bytes.writeIntOrFloat(value) return true } if (typeof value === 'boolean') { @@ -922,8 +935,11 @@ class AgentEncoder { return true } if (Array.isArray(value)) { - memoizedLogDebug(key, 'Encountered nested array data type for span event v0.4 encoding. ' + - `Skipping encoding key: ${key}: with value: ${typeof value}.` + memoizedLogDebug( + key, + 'Encountered nested array data type for span event v0.4 encoding. ' + + 'Skipping encoding key: %s: with value: %s.', + value ) } return false @@ -931,10 +947,10 @@ class AgentEncoder { } const seenKeys = new Set() -function memoizedLogDebug (key, message) { +function memoizedLogDebug (key, message, value) { if (!seenKeys.has(key)) { seenKeys.add(key) - log.debug(message) + log.debug(message, key, typeof value) } } diff --git a/packages/dd-trace/src/encode/0.5.js b/packages/dd-trace/src/encode/0.5.js index ffa926946d..a97e883d25 100644 --- a/packages/dd-trace/src/encode/0.5.js +++ b/packages/dd-trace/src/encode/0.5.js @@ -6,10 +6,18 @@ const { AgentEncoder: BaseEncoder, stringifySpanEvents } = require('./0.4') const ARRAY_OF_TWO = 0x92 const ARRAY_OF_TWELVE = 0x9C +// Per-span fused head: `[0x9C, service-idx, name-idx, resource-idx, +// trace-id, span-id, parent-id]` — three uint32 indexes (5 bytes each) + +// three uint64 IDs (9 bytes each) + the array marker. Replaces seven +// separate reserves (`writeByte` + 3 × `writeInteger` + 3 × `_encodeId`) +// with one block-sized reserve per span. +const HEAD_BLOCK_SIZE = 1 + 5 * 3 + 9 * 3 + function formatSpan (span) { span = normalizeSpan(span) // v0.5 has no native span_events slot; always serialize as a meta tag. if (span.span_events) { + // TODO: this is a costly operation. Consolidate this with the formatter span.meta.events = stringifySpanEvents(span.span_events) // `= undefined` over `delete` to keep the span's hidden class. span.span_events = undefined @@ -35,20 +43,36 @@ class AgentEncoder extends BaseEncoder { } _encode (bytes, trace) { - this._encodeArrayPrefix(bytes, trace) + bytes.writeArrayPrefix(trace) + + const stringMap = this._stringMap for (let span of trace) { span = formatSpan(span) - this._encodeByte(bytes, ARRAY_OF_TWELVE) - this._encodeString(bytes, span.service) - this._encodeString(bytes, span.name) - this._encodeString(bytes, span.resource) - this._encodeId(bytes, span.trace_id) - this._encodeId(bytes, span.span_id) - this._encodeId(bytes, span.parent_id) - this._encodeIntOrFloat(bytes, span.start || 0) - this._encodeIntOrFloat(bytes, span.duration || 0) - this._encodeIntOrFloat(bytes, span.error) + + // Resolve the three head string indices up front. `_cacheString` + // writes into `_stringBytes`, an independent chunk, so the side + // effect is safe to interleave with the `_traceBytes` reserve + // below. + const serviceIndex = stringMap[span.service] ?? this._cacheString(span.service) + const nameIndex = stringMap[span.name] ?? this._cacheString(span.name) + const resourceIndex = stringMap[span.resource] ?? this._cacheString(span.resource) + + const blockOffset = bytes.length + bytes.reserve(HEAD_BLOCK_SIZE) + const target = bytes.buffer + + target[blockOffset] = ARRAY_OF_TWELVE + let cursor = this.#writeIndexAt(target, blockOffset + 1, serviceIndex) + cursor = this.#writeIndexAt(target, cursor, nameIndex) + cursor = this.#writeIndexAt(target, cursor, resourceIndex) + cursor = this.#writeIdAt(target, cursor, span.trace_id) + cursor = this.#writeIdAt(target, cursor, span.span_id) + this.#writeIdAt(target, cursor, span.parent_id) + + bytes.writeIntOrFloat(span.start || 0) + bytes.writeIntOrFloat(span.duration || 0) + bytes.writeIntOrFloat(span.error) this._encodeMap(bytes, span.meta || {}) this._encodeMap(bytes, span.metrics || {}) this._encodeString(bytes, span.type) @@ -65,18 +89,41 @@ class AgentEncoder extends BaseEncoder { bytes.reserve(5) bytes.buffer[offset] = 0xDF + const stringMap = this._stringMap let count = 0 for (const key of Object.keys(value)) { const entryValue = value[key] + if (typeof entryValue !== 'string' && typeof entryValue !== 'number') continue + + const keyIndex = stringMap[key] ?? this._cacheString(key) + const writeOffset = bytes.length + if (typeof entryValue === 'string') { - this._encodeString(bytes, key) - this._encodeString(bytes, entryValue) - count++ - } else if (typeof entryValue === 'number') { - this._encodeString(bytes, key) - this._encodeIntOrFloat(bytes, entryValue) - count++ + // Both halves are uint32 indices on the v0.5 wire — known + // size, so the key and value pair fuses into one reserve. + const valueIndex = stringMap[entryValue] ?? this._cacheString(entryValue) + bytes.reserve(10) + const target = bytes.buffer + this.#writeIndexAt(target, writeOffset, keyIndex) + this.#writeIndexAt(target, writeOffset + 5, valueIndex) + } else { + // Speculate that the value is a positive fixint (0..127). The + // metrics map is mostly small unsigned integers (sample priority, + // `_dd.measured`, attribute counts), so one reserve covers the + // key (5 bytes) and the value (1 byte). Misses rewind the + // speculative value byte and route the value through the full + // encoder so the wire still picks the shortest valid encoding. + bytes.reserve(6) + const target = bytes.buffer + this.#writeIndexAt(target, writeOffset, keyIndex) + if (entryValue === (entryValue & 0x7F)) { + target[writeOffset + 5] = entryValue + } else { + bytes.length = writeOffset + 5 + bytes.writeIntOrFloat(entryValue) + } } + count++ } const target = bytes.buffer @@ -87,20 +134,18 @@ class AgentEncoder extends BaseEncoder { } _encodeString (bytes, value = '') { + const index = this._stringMap[value] ?? this._cacheString(value) + bytes.writeInteger(index) + } + + _cacheString (value) { let index = this._stringMap[value] if (index === undefined) { index = this._stringCount++ this._stringMap[value] = index this._stringBytes.write(value) } - this._encodeInteger(bytes, index) - } - - _cacheString (value) { - if (this._stringMap[value] === undefined) { - this._stringMap[value] = this._stringCount++ - this._stringBytes.write(value) - } + return index } _writeStrings (buffer, offset) { @@ -109,6 +154,49 @@ class AgentEncoder extends BaseEncoder { return offset } + + /** + * Write `[0xCE, uint32(index)]` into `target` at `offset` and return the + * new cursor. Caller is responsible for having reserved enough room. + * + * @param {Uint8Array} target + * @param {number} offset + * @param {number} index + * @returns {number} + */ + #writeIndexAt (target, offset, index) { + target[offset] = 0xCE + target[offset + 1] = index >> 24 + target[offset + 2] = index >> 16 + target[offset + 3] = index >> 8 + target[offset + 4] = index + return offset + 5 + } + + /** + * Write `[0xCF, uint64(id)]` into `target` at `offset` and return the + * new cursor. The id is truncated to the low 8 bytes, matching the + * inherited `_encodeId` behavior. + * + * @param {Uint8Array} target + * @param {number} offset + * @param {{ toBuffer: () => Uint8Array | number[] }} identifier + * @returns {number} + */ + #writeIdAt (target, offset, identifier) { + target[offset] = 0xCF + const idBuffer = identifier.toBuffer() + const start = idBuffer.length - 8 + target[offset + 1] = idBuffer[start] + target[offset + 2] = idBuffer[start + 1] + target[offset + 3] = idBuffer[start + 2] + target[offset + 4] = idBuffer[start + 3] + target[offset + 5] = idBuffer[start + 4] + target[offset + 6] = idBuffer[start + 5] + target[offset + 7] = idBuffer[start + 6] + target[offset + 8] = idBuffer[start + 7] + return offset + 9 + } } module.exports = { AgentEncoder } diff --git a/packages/dd-trace/src/encode/agentless-ci-visibility.js b/packages/dd-trace/src/encode/agentless-ci-visibility.js index 911c4ee2c0..036913740c 100644 --- a/packages/dd-trace/src/encode/agentless-ci-visibility.js +++ b/packages/dd-trace/src/encode/agentless-ci-visibility.js @@ -9,7 +9,7 @@ const { } = require('../ci-visibility/telemetry') const { MsgpackChunk } = require('../msgpack') const { AgentEncoder } = require('./0.4') -const { truncateSpan, normalizeSpan } = require('./tags-processors') +const { truncateSpanTestOpt, normalizeSpan } = require('./tags-processors') const ENCODING_VERSION = 1 const ALLOWED_CONTENT_TYPES = new Set(['test_session_end', 'test_module_end', 'test_suite_end', 'test']) @@ -32,7 +32,7 @@ function formatSpan (span) { return { type: ALLOWED_CONTENT_TYPES.has(span.type) ? span.type : 'span', version: encodingVersion, - content: normalizeSpan(truncateSpan(span)), + content: normalizeSpan(truncateSpanTestOpt(span)), } } @@ -48,11 +48,18 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { this._eventCount = 0 this.metadataTags = {} + this.wildcardMetadataTags = {} this.reset() } addMetadataTags (tags) { + if (tags['*']) { + this.wildcardMetadataTags = { + ...this.wildcardMetadataTags, + ...tags['*'], + } + } for (const type of ALLOWED_CONTENT_TYPES) { if (tags[type]) { this.metadataTags[type] = { @@ -70,7 +77,7 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { keysLength++ } - this._encodeMapPrefix(bytes, keysLength) + bytes.writeMapPrefix(keysLength) this._encodeString(bytes, 'type') this._encodeString(bytes, content.type) @@ -90,7 +97,7 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { } this._encodeString(bytes, 'error') - this._encodeNumber(bytes, content.error) + bytes.writeNumber(content.error) this._encodeString(bytes, 'name') this._encodeString(bytes, content.name) this._encodeString(bytes, 'service') @@ -98,9 +105,9 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { this._encodeString(bytes, 'resource') this._encodeString(bytes, content.resource) this._encodeString(bytes, 'start') - this._encodeNumber(bytes, content.start) + bytes.writeNumber(content.start) this._encodeString(bytes, 'duration') - this._encodeNumber(bytes, content.duration) + bytes.writeNumber(content.duration) this._encodeString(bytes, 'meta') this._encodeMap(bytes, content.meta) this._encodeString(bytes, 'metrics') @@ -108,7 +115,7 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { } _encodeTestModule (bytes, content) { - this._encodeMapPrefix(bytes, TEST_MODULE_KEYS_LENGTH) + bytes.writeMapPrefix(TEST_MODULE_KEYS_LENGTH) this._encodeString(bytes, 'type') this._encodeString(bytes, content.type) @@ -119,7 +126,7 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { this._encodeId(bytes, content.span_id) this._encodeString(bytes, 'error') - this._encodeNumber(bytes, content.error) + bytes.writeNumber(content.error) this._encodeString(bytes, 'name') this._encodeString(bytes, content.name) this._encodeString(bytes, 'service') @@ -127,9 +134,9 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { this._encodeString(bytes, 'resource') this._encodeString(bytes, content.resource) this._encodeString(bytes, 'start') - this._encodeNumber(bytes, content.start) + bytes.writeNumber(content.start) this._encodeString(bytes, 'duration') - this._encodeNumber(bytes, content.duration) + bytes.writeNumber(content.duration) this._encodeString(bytes, 'meta') this._encodeMap(bytes, content.meta) this._encodeString(bytes, 'metrics') @@ -137,7 +144,7 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { } _encodeTestSession (bytes, content) { - this._encodeMapPrefix(bytes, TEST_SESSION_KEYS_LENGTH) + bytes.writeMapPrefix(TEST_SESSION_KEYS_LENGTH) this._encodeString(bytes, 'type') this._encodeString(bytes, content.type) @@ -145,7 +152,7 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { this._encodeId(bytes, content.trace_id) this._encodeString(bytes, 'error') - this._encodeNumber(bytes, content.error) + bytes.writeNumber(content.error) this._encodeString(bytes, 'name') this._encodeString(bytes, content.name) this._encodeString(bytes, 'service') @@ -153,9 +160,9 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { this._encodeString(bytes, 'resource') this._encodeString(bytes, content.resource) this._encodeString(bytes, 'start') - this._encodeNumber(bytes, content.start) + bytes.writeNumber(content.start) this._encodeString(bytes, 'duration') - this._encodeNumber(bytes, content.duration) + bytes.writeNumber(content.duration) this._encodeString(bytes, 'meta') this._encodeMap(bytes, content.meta) this._encodeString(bytes, 'metrics') @@ -180,7 +187,7 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { if (content.type) { totalKeysLength += 1 } - this._encodeMapPrefix(bytes, totalKeysLength) + bytes.writeMapPrefix(totalKeysLength) if (content.type) { this._encodeString(bytes, 'type') this._encodeString(bytes, content.type) @@ -198,11 +205,11 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { this._encodeString(bytes, 'service') this._encodeString(bytes, content.service) this._encodeString(bytes, 'error') - this._encodeNumber(bytes, content.error) + bytes.writeNumber(content.error) this._encodeString(bytes, 'start') - this._encodeNumber(bytes, content.start) + bytes.writeNumber(content.start) this._encodeString(bytes, 'duration') - this._encodeNumber(bytes, content.duration) + bytes.writeNumber(content.duration) /** * We include `test_session_id` and `test_suite_id` * in the root of the event by passing them via the `meta` dict. @@ -243,12 +250,12 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { } _encodeEvent (bytes, event) { - this._encodeMapPrefix(bytes, Object.keys(event).length) + bytes.writeMapPrefix(Object.keys(event).length) this._encodeString(bytes, 'type') this._encodeString(bytes, event.type) this._encodeString(bytes, 'version') - this._encodeNumber(bytes, event.version) + bytes.writeNumber(event.version) this._encodeString(bytes, 'content') if (event.type === 'span' || event.type === 'test') { @@ -318,6 +325,7 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { '*': { language: 'javascript', library_version: ddTraceVersion, + ...this.wildcardMetadataTags, }, ...this.metadataTags, }, @@ -331,11 +339,11 @@ class AgentlessCiVisibilityEncoder extends AgentEncoder { payload.metadata['*']['runtime-id'] = this.runtimeId } - this._encodeMapPrefix(bytes, Object.keys(payload).length) + bytes.writeMapPrefix(Object.keys(payload).length) this._encodeString(bytes, 'version') - this._encodeNumber(bytes, payload.version) + bytes.writeNumber(payload.version) this._encodeString(bytes, 'metadata') - this._encodeMapPrefix(bytes, Object.keys(payload.metadata).length) + bytes.writeMapPrefix(Object.keys(payload.metadata).length) this._encodeString(bytes, '*') this._encodeMap(bytes, payload.metadata['*']) if (payload.metadata.test) { diff --git a/packages/dd-trace/src/encode/agentless-json.js b/packages/dd-trace/src/encode/agentless-json.js index 8e3634c0b0..7fa1a734dd 100644 --- a/packages/dd-trace/src/encode/agentless-json.js +++ b/packages/dd-trace/src/encode/agentless-json.js @@ -86,10 +86,12 @@ class AgentlessJSONEncoder { /** * @param {object} writer - Writer instance with a flush() method, called when the buffer exceeds the soft limit * @param {object} [metadata] - Shared metadata spread into each trace object (hostname, env, tracerVersion, etc.) + * @param {number} [softLimit] - Estimated payload-size threshold that triggers an early flush. Defaults to 8 MiB. */ - constructor (writer, metadata = {}) { + constructor (writer, metadata = {}, softLimit = SOFT_LIMIT) { this._writer = writer this._metadata = metadata + this._softLimit = softLimit this._reset() } @@ -134,7 +136,7 @@ class AgentlessJSONEncoder { log.error('All %d span(s) in trace failed to encode. Entire trace dropped.', trace.length) } - if (this._estimatedSize > SOFT_LIMIT) { + if (this._estimatedSize > this._softLimit) { log.debug('Buffer went over soft limit, flushing') try { this._writer.flush() diff --git a/packages/dd-trace/src/encode/coverage-ci-visibility.js b/packages/dd-trace/src/encode/coverage-ci-visibility.js index 21b6047834..0e02cf6503 100644 --- a/packages/dd-trace/src/encode/coverage-ci-visibility.js +++ b/packages/dd-trace/src/encode/coverage-ci-visibility.js @@ -12,6 +12,17 @@ const { AgentEncoder } = require('./0.4') const COVERAGE_PAYLOAD_VERSION = 2 const COVERAGE_KEYS_LENGTH = 2 +function getBitmapBuffer (bitmap) { + if (!bitmap) return + if (Buffer.isBuffer(bitmap)) return bitmap + if (ArrayBuffer.isView(bitmap)) { + return Buffer.from(bitmap.buffer, bitmap.byteOffset, bitmap.byteLength) + } + if (bitmap.type === 'Buffer' && Array.isArray(bitmap.data)) { + return Buffer.from(bitmap.data) + } +} + class CoverageCIVisibilityEncoder extends AgentEncoder { constructor () { super(...arguments) @@ -39,32 +50,40 @@ class CoverageCIVisibilityEncoder extends AgentEncoder { } encodeCodeCoverage (bytes, coverage) { - if (coverage.testId) { - this._encodeMapPrefix(bytes, 4) - } else { - this._encodeMapPrefix(bytes, 3) - } + let keysLength = 2 + if (coverage.suiteId) keysLength++ + if (coverage.testId) keysLength++ + + bytes.writeMapPrefix(keysLength) this._encodeString(bytes, 'test_session_id') this._encodeId(bytes, coverage.sessionId) - this._encodeString(bytes, 'test_suite_id') - this._encodeId(bytes, coverage.suiteId) + if (coverage.suiteId) { + this._encodeString(bytes, 'test_suite_id') + this._encodeId(bytes, coverage.suiteId) + } if (coverage.testId) { this._encodeString(bytes, 'span_id') this._encodeId(bytes, coverage.testId) } this._encodeString(bytes, 'files') - this._encodeArrayPrefix(bytes, coverage.files) - for (const filename of coverage.files) { - this._encodeMapPrefix(bytes, 1) + bytes.writeArrayPrefix(coverage.files) + for (const file of coverage.files) { + const filename = typeof file === 'string' ? file : file.filename + const bitmap = getBitmapBuffer(file.bitmap) + bytes.writeMapPrefix(bitmap ? 2 : 1) this._encodeString(bytes, 'filename') this._encodeString(bytes, filename) + if (bitmap) { + this._encodeString(bytes, 'bitmap') + bytes.writeBin(bitmap) + } } } reset () { this._reset() if (this._coverageBytes) { - this._coverageBytes.length = 0 + this._coverageBytes.reset() } this._coveragesCount = 0 this._encodePayloadStart(this._coverageBytes) @@ -75,9 +94,9 @@ class CoverageCIVisibilityEncoder extends AgentEncoder { version: COVERAGE_PAYLOAD_VERSION, coverages: [], } - this._encodeMapPrefix(bytes, COVERAGE_KEYS_LENGTH) + bytes.writeMapPrefix(COVERAGE_KEYS_LENGTH) this._encodeString(bytes, 'version') - this._encodeInteger(bytes, payload.version) + bytes.writeInteger(payload.version) this._encodeString(bytes, 'coverages') // Get offset of the coverages list to update the length of the array when calling `makePayload` this._coveragesOffset = bytes.length diff --git a/packages/dd-trace/src/encode/span-stats.js b/packages/dd-trace/src/encode/span-stats.js index f738ee9ff3..2db7f17bd5 100644 --- a/packages/dd-trace/src/encode/span-stats.js +++ b/packages/dd-trace/src/encode/span-stats.js @@ -31,7 +31,7 @@ class SpanStatsEncoder extends AgentEncoder { } _encodeStat (bytes, stat) { - this._encodeMapPrefix(bytes, 15) + bytes.writeMapPrefix(15) this._encodeString(bytes, 'Service') const service = stat.Service || DEFAULT_SERVICE_NAME @@ -45,31 +45,31 @@ class SpanStatsEncoder extends AgentEncoder { this._encodeString(bytes, truncate(stat.Resource, MAX_RESOURCE_NAME_LENGTH, '...')) this._encodeString(bytes, 'HTTPStatusCode') - this._encodeInteger(bytes, stat.HTTPStatusCode) + bytes.writeInteger(stat.HTTPStatusCode) this._encodeString(bytes, 'Type') this._encodeString(bytes, truncate(stat.Type, MAX_TYPE_LENGTH)) this._encodeString(bytes, 'Hits') - this._encodeLong(bytes, stat.Hits) + bytes.writeLong(stat.Hits) this._encodeString(bytes, 'Errors') - this._encodeLong(bytes, stat.Errors) + bytes.writeLong(stat.Errors) this._encodeString(bytes, 'Duration') - this._encodeLong(bytes, stat.Duration) + bytes.writeLong(stat.Duration) this._encodeString(bytes, 'OkSummary') - this._encodeBuffer(bytes, stat.OkSummary) + bytes.writeBin(stat.OkSummary) this._encodeString(bytes, 'ErrorSummary') - this._encodeBuffer(bytes, stat.ErrorSummary) + bytes.writeBin(stat.ErrorSummary) this._encodeString(bytes, 'Synthetics') - this._encodeBool(bytes, stat.Synthetics) + bytes.writeBoolean(stat.Synthetics) this._encodeString(bytes, 'TopLevelHits') - this._encodeLong(bytes, stat.TopLevelHits) + bytes.writeLong(stat.TopLevelHits) this._encodeString(bytes, 'HTTPMethod') this._encodeString(bytes, stat.HTTPMethod) @@ -82,23 +82,23 @@ class SpanStatsEncoder extends AgentEncoder { } _encodeBucket (bytes, bucket) { - this._encodeMapPrefix(bytes, 3) + bytes.writeMapPrefix(3) this._encodeString(bytes, 'Start') - this._encodeLong(bytes, bucket.Start) + bytes.writeLong(bucket.Start) this._encodeString(bytes, 'Duration') - this._encodeLong(bytes, bucket.Duration) + bytes.writeLong(bucket.Duration) this._encodeString(bytes, 'Stats') - this._encodeArrayPrefix(bytes, bucket.Stats) + bytes.writeArrayPrefix(bucket.Stats) for (const stat of bucket.Stats) { this._encodeStat(bytes, stat) } } _encode (bytes, stats) { - this._encodeMapPrefix(bytes, stats.ProcessTags ? 9 : 8) + bytes.writeMapPrefix(stats.ProcessTags ? 9 : 8) this._encodeString(bytes, 'Hostname') this._encodeString(bytes, stats.Hostname) @@ -110,7 +110,7 @@ class SpanStatsEncoder extends AgentEncoder { this._encodeString(bytes, stats.Version) this._encodeString(bytes, 'Stats') - this._encodeArrayPrefix(bytes, stats.Stats) + bytes.writeArrayPrefix(stats.Stats) for (const bucket of stats.Stats) { this._encodeBucket(bytes, bucket) } @@ -125,7 +125,7 @@ class SpanStatsEncoder extends AgentEncoder { this._encodeString(bytes, stats.RuntimeID) this._encodeString(bytes, 'Sequence') - this._encodeLong(bytes, stats.Sequence) + bytes.writeLong(stats.Sequence) if (stats.ProcessTags) { this._encodeString(bytes, 'ProcessTags') diff --git a/packages/dd-trace/src/encode/tags-processors.js b/packages/dd-trace/src/encode/tags-processors.js index a13b880f7a..4a553fc6e5 100644 --- a/packages/dd-trace/src/encode/tags-processors.js +++ b/packages/dd-trace/src/encode/tags-processors.js @@ -9,6 +9,8 @@ const MAX_RESOURCE_NAME_LENGTH = 5000 const MAX_META_KEY_LENGTH = 200 // MAX_META_VALUE_LENGTH the maximum length of metadata value const MAX_META_VALUE_LENGTH = 25_000 +// MAX_META_VALUE_LENGTH_TEST_OPTIMIZATION the maximum length of metadata value for Test Optimization +const MAX_META_VALUE_LENGTH_TEST_OPTIMIZATION = 5000 // MAX_METRIC_KEY_LENGTH the maximum length of a metric name key const MAX_METRIC_KEY_LENGTH = MAX_META_KEY_LENGTH @@ -32,6 +34,18 @@ function truncateSpan (span) { return span } +function truncateSpanTestOpt (span) { + truncateSpan(span) + if (span.meta) { + for (const key of Object.keys(span.meta)) { + if (span.meta[key].length > MAX_META_VALUE_LENGTH_TEST_OPTIMIZATION) { + span.meta[key] = `${span.meta[key].slice(0, MAX_META_VALUE_LENGTH_TEST_OPTIMIZATION)}...` + } + } + } + return span +} + function normalizeSpan (span) { span.service = span.service || DEFAULT_SERVICE_NAME if (span.service.length > MAX_SERVICE_LENGTH) { @@ -53,9 +67,11 @@ function normalizeSpan (span) { module.exports = { truncateSpan, + truncateSpanTestOpt, normalizeSpan, MAX_META_KEY_LENGTH, MAX_META_VALUE_LENGTH, + MAX_META_VALUE_LENGTH_TEST_OPTIMIZATION, MAX_METRIC_KEY_LENGTH, MAX_NAME_LENGTH, MAX_SERVICE_LENGTH, diff --git a/packages/dd-trace/src/llmobs/plugins/ai/util.js b/packages/dd-trace/src/llmobs/plugins/ai/util.js index 5cd803d86c..debf8cbc7b 100644 --- a/packages/dd-trace/src/llmobs/plugins/ai/util.js +++ b/packages/dd-trace/src/llmobs/plugins/ai/util.js @@ -41,7 +41,7 @@ const VERCEL_AI_GENERATION_METADATA_PREFIX = 'ai.settings.' */ function getSpanTags (ctx) { const span = ctx.currentStore?.span - return /** @type {SpanTags} */ (ctx.attributes ?? span?.context()._tags ?? {}) + return /** @type {SpanTags} */ (ctx.attributes ?? span?.context().getTags() ?? {}) } /** diff --git a/packages/dd-trace/src/llmobs/plugins/genai/index.js b/packages/dd-trace/src/llmobs/plugins/genai/index.js index b5df5be271..e37183ad01 100644 --- a/packages/dd-trace/src/llmobs/plugins/genai/index.js +++ b/packages/dd-trace/src/llmobs/plugins/genai/index.js @@ -55,7 +55,7 @@ class GenAiLLMObsPlugin extends LLMObsPlugin { const inputs = args[0] const response = ctx.result - const error = !!span.context()._tags.error + const error = !!span.context().getTag('error') const operation = getOperation(methodName) diff --git a/packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js b/packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js index d936cc7ae6..2438e9ed8e 100644 --- a/packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js +++ b/packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js @@ -7,7 +7,7 @@ class LangChainLLMObsHandler { } getName ({ span }) { - return span?.context()._tags?.['resource.name'] + return span?.context()?.getTag('resource.name') } setMetaTags () {} diff --git a/packages/dd-trace/src/llmobs/plugins/langchain/index.js b/packages/dd-trace/src/llmobs/plugins/langchain/index.js index d9c484f353..e2b50cbb00 100644 --- a/packages/dd-trace/src/llmobs/plugins/langchain/index.js +++ b/packages/dd-trace/src/llmobs/plugins/langchain/index.js @@ -44,7 +44,8 @@ class BaseLangChainLLMObsPlugin extends LLMObsPlugin { getLLMObsSpanRegisterOptions (ctx) { const span = ctx.currentStore?.span - const tags = span?.context()._tags || {} + const spanContext = span?.context() + const tags = spanContext?.getTags() || {} const modelProvider = tags['langchain.request.provider'] // could be undefined const modelName = tags['langchain.request.model'] // could be undefined @@ -76,7 +77,7 @@ class BaseLangChainLLMObsPlugin extends LLMObsPlugin { return } - const provider = span?.context()._tags['langchain.request.provider'] + const provider = span?.context()?.getTag('langchain.request.provider') const integrationName = this.getIntegrationName(type, provider) this.setMetadata(span, provider) @@ -93,14 +94,15 @@ class BaseLangChainLLMObsPlugin extends LLMObsPlugin { const metadata = {} // these fields won't be set for non model-based operations + const spanContext = span?.context() const temperature = - span?.context()._tags[`langchain.request.${provider}.parameters.temperature`] || - span?.context()._tags[`langchain.request.${provider}.parameters.model_kwargs.temperature`] + spanContext?.getTag(`langchain.request.${provider}.parameters.temperature`) || + spanContext?.getTag(`langchain.request.${provider}.parameters.model_kwargs.temperature`) const maxTokens = - span?.context()._tags[`langchain.request.${provider}.parameters.max_tokens`] || - span?.context()._tags[`langchain.request.${provider}.parameters.maxTokens`] || - span?.context()._tags[`langchain.request.${provider}.parameters.model_kwargs.max_tokens`] + spanContext?.getTag(`langchain.request.${provider}.parameters.max_tokens`) || + spanContext?.getTag(`langchain.request.${provider}.parameters.maxTokens`) || + spanContext?.getTag(`langchain.request.${provider}.parameters.model_kwargs.max_tokens`) if (temperature) { metadata.temperature = Number.parseFloat(temperature) diff --git a/packages/dd-trace/src/llmobs/plugins/langgraph/index.js b/packages/dd-trace/src/llmobs/plugins/langgraph/index.js index 9eaa961045..4cc08be8e2 100644 --- a/packages/dd-trace/src/llmobs/plugins/langgraph/index.js +++ b/packages/dd-trace/src/llmobs/plugins/langgraph/index.js @@ -35,7 +35,7 @@ class PregelStreamLLMObsPlugin extends LLMObsPlugin { class NextStreamLLMObsPlugin extends LLMObsPlugin { static id = 'llmobs_langgraph_next_stream' - static prefix = 'tracing:orchestrion:@langchain/langgraph:Pregel_stream_next' + static prefix = 'tracing:orchestrion:@langchain/langgraph:Pregel_stream:next' start () {} // no-op: span was already registered by PregelStreamLLMObsPlugin diff --git a/packages/dd-trace/src/llmobs/plugins/openai/index.js b/packages/dd-trace/src/llmobs/plugins/openai/index.js index ec1831b48b..4ad77798b7 100644 --- a/packages/dd-trace/src/llmobs/plugins/openai/index.js +++ b/packages/dd-trace/src/llmobs/plugins/openai/index.js @@ -63,7 +63,7 @@ class OpenAiLLMObsPlugin extends LLMObsPlugin { const inputs = ctx.args[0] // completion, chat completion, and embeddings take one argument const response = ctx.result?.data // no result if error - const error = !!span.context()._tags.error + const error = !!span.context().getTag('error') const operation = getOperation(methodName) diff --git a/packages/dd-trace/src/llmobs/sdk.js b/packages/dd-trace/src/llmobs/sdk.js index 2ce427334f..98550b4635 100644 --- a/packages/dd-trace/src/llmobs/sdk.js +++ b/packages/dd-trace/src/llmobs/sdk.js @@ -11,8 +11,6 @@ const { SPAN_KIND, OUTPUT_VALUE, INPUT_VALUE, - LLMOBS_TRACE_ID_BRIDGE_KEY, - LLMOBS_PARENT_ID_BRIDGE_KEY, } = require('./constants/tags') const { getFunctionArguments, @@ -553,20 +551,6 @@ class LLMObs extends NoopLLMObs { ...options, parent: parentStore?.span, }) - - // Bridge tags read by the dd-go LLMObs trace-indexer to correlate OTel - // gen_ai.* spans with SDK LLMObs spans. Written once per local trace, - // on the first successful SDK LLMObs span registration. The shared - // _trace.tags bag is serialized to the first span in every flushed - // chunk's meta, so partial flush is covered automatically without a - // separate flush-time processor. Writing only after registerLLMObsSpan - // succeeds avoids poisoning _trace.tags with bridge tags pointing at a - // span that will never produce an LLMObs event. - const traceTags = span?.context?.()._trace?.tags - if (this.enabled && traceTags && !traceTags[LLMOBS_TRACE_ID_BRIDGE_KEY]) { - traceTags[LLMOBS_TRACE_ID_BRIDGE_KEY] = span.context().toTraceId(true) - traceTags[LLMOBS_PARENT_ID_BRIDGE_KEY] = span.context().toSpanId() - } } try { diff --git a/packages/dd-trace/src/llmobs/span_processor.js b/packages/dd-trace/src/llmobs/span_processor.js index 3dd22e50f8..22964771c9 100644 --- a/packages/dd-trace/src/llmobs/span_processor.js +++ b/packages/dd-trace/src/llmobs/span_processor.js @@ -107,7 +107,7 @@ class LLMObsSpanProcessor { // those cases avoids dd-go reparenting OTel children under a span that // has no corresponding LLMObs event. if (enqueued) { - span.context()._tags[LLMOBS_SUBMITTED_TAG_KEY] = '1' + span.context().setTag(LLMOBS_SUBMITTED_TAG_KEY, '1') } } catch (e) { // this should be a rare case @@ -123,7 +123,7 @@ class LLMObsSpanProcessor { format (span) { let inputType, outputType - const spanTags = span.context()._tags + const spanTags = span.context().getTags() const mlObsTags = LLMObsTagger.tagMap.get(span) const spanKind = mlObsTags[SPAN_KIND] @@ -318,7 +318,7 @@ class LLMObsSpanProcessor { language: 'javascript', } - const errType = span.context()._tags[ERROR_TYPE] || error?.name + const errType = span.context().getTag(ERROR_TYPE) || error?.name if (errType) tags.error_type = errType if (sessionId) tags.session_id = sessionId diff --git a/packages/dd-trace/src/llmobs/tagger.js b/packages/dd-trace/src/llmobs/tagger.js index a2d3be4a7e..a9135702c6 100644 --- a/packages/dd-trace/src/llmobs/tagger.js +++ b/packages/dd-trace/src/llmobs/tagger.js @@ -43,7 +43,7 @@ const { INSTRUMENTATION_METHOD_ANNOTATED, } = require('./constants/tags') const { storage } = require('./storage') -const { validateCostTags } = require('./util') +const { findGenAIAncestorSpanId, validateCostTags, writeBridgeTags } = require('./util') // global registry of LLMObs spans // maps LLMObs spans to their annotations @@ -97,6 +97,13 @@ class LLMObsTagger { this._register(span) + // When the registering span sits below an OTel `gen_ai.*` ancestor, use + // that ancestor as the parent_id fallback and suppress the bridge + // parent_id tag so the indexer doesn't invert the trace. + const genAIAncestorSpanId = findGenAIAncestorSpanId(span) + + writeBridgeTags(span, { includeParentId: genAIAncestorSpanId === null }) + this._setTag(span, ML_APP, spanMlApp) if (name) this._setTag(span, NAME, name) @@ -113,6 +120,7 @@ class LLMObsTagger { const parentId = parent?.context().toSpanId() ?? span.context()._trace.tags[PROPAGATED_PARENT_ID_KEY] ?? + genAIAncestorSpanId ?? ROOT_PARENT_ID this._setTag(span, PARENT_ID_KEY, parentId) diff --git a/packages/dd-trace/src/llmobs/telemetry.js b/packages/dd-trace/src/llmobs/telemetry.js index 711a1de89d..db720f4f72 100644 --- a/packages/dd-trace/src/llmobs/telemetry.js +++ b/packages/dd-trace/src/llmobs/telemetry.js @@ -45,7 +45,7 @@ function incrementLLMObsSpanStartCount (tags, value = 1) { function incrementLLMObsSpanFinishedCount (span, value = 1) { const mlObsTags = LLMObsTagger.tagMap.get(span) - const spanTags = span.context()._tags + const spanTags = span.context().getTags() const isRootSpan = mlObsTags[PARENT_ID_KEY] === ROOT_PARENT_ID const hasSessionId = mlObsTags[SESSION_ID] != null diff --git a/packages/dd-trace/src/llmobs/util.js b/packages/dd-trace/src/llmobs/util.js index 205f6ce8c8..d20dce7ea4 100644 --- a/packages/dd-trace/src/llmobs/util.js +++ b/packages/dd-trace/src/llmobs/util.js @@ -1,7 +1,11 @@ 'use strict' const log = require('../log') -const { SPAN_KINDS } = require('./constants/tags') +const { + LLMOBS_PARENT_ID_BRIDGE_KEY, + LLMOBS_TRACE_ID_BRIDGE_KEY, + SPAN_KINDS, +} = require('./constants/tags') // LLM I/O is overwhelmingly ASCII (English prompts and code). Walk once // looking for the first non-ASCII char; if there is none, hand the input @@ -233,8 +237,8 @@ function getFunctionArguments (fn, args = []) { } function spanHasError (span) { - const tags = span.context()._tags - return !!(tags.error || tags['error.type']) + const spanContext = span.context() + return !!(spanContext.getTag('error') || spanContext.getTag('error.type')) } // LLM SDKs stream tool-call argument JSON across SSE chunks; a malformed @@ -248,11 +252,70 @@ function safeJsonParse (value, fallback) { } } +// Bridge tags read by the trace-indexer to pull OTel `gen_ai.*` spans into +// the same LLMObs trace. Written once per local trace (first-writer wins on +// `_trace.tags`). Pass `includeParentId: false` when the span sits below an +// OTel `gen_ai.*` ancestor — without it the indexer treats this span as the +// LLMObs root and hoists the gen_ai ancestors under it, inverting the trace. +/** + * @param {import('../opentracing/span')} span + * @param {{ includeParentId?: boolean }} [opts] + */ +function writeBridgeTags (span, { includeParentId = true } = {}) { + const traceTags = span?.context?.()._trace?.tags + if (!traceTags || traceTags[LLMOBS_TRACE_ID_BRIDGE_KEY]) return + traceTags[LLMOBS_TRACE_ID_BRIDGE_KEY] = span.context().toTraceId(true) + if (includeParentId) { + traceTags[LLMOBS_PARENT_ID_BRIDGE_KEY] = span.context().toSpanId() + } +} + +// Walks the APM parent chain for the nearest ancestor with any `gen_ai.*` +// tag. Lets an auto-instrumented LLMObs span nested under a manual OTel +// workflow point its `parent_id` at the OTel parent so the SDK-emitted +// event renders under it instead of as a parallel root. +/** + * @param {import('../opentracing/span')} span + * @returns {string | null} + */ +function findGenAIAncestorSpanId (span) { + const ctx = span?.context?.() + let parentId = ctx?._parentId?.toString(10) + if (!parentId || parentId === '0') return null + + const started = ctx._trace?.started + if (!started || started.length === 0) return null + + // Linear scan per hop — parent chains are short, avoids a per-call Map. + while (parentId && parentId !== '0') { + let parent = null + for (const s of started) { + if (s.context()._spanId.toString(10) === parentId) { + parent = s + break + } + } + if (!parent) return null + + const tags = parent.context().getTags() + if (tags) { + for (const key of Object.keys(tags)) { + if (key.startsWith('gen_ai.')) return parentId + } + } + + parentId = parent.context()._parentId?.toString(10) + } + return null +} + module.exports = { encodeUnicode, + findGenAIAncestorSpanId, validateCostTags, validateKind, getFunctionArguments, safeJsonParse, spanHasError, + writeBridgeTags, } diff --git a/packages/dd-trace/src/log/index.js b/packages/dd-trace/src/log/index.js index fc1c211e4d..7489a99f0a 100644 --- a/packages/dd-trace/src/log/index.js +++ b/packages/dd-trace/src/log/index.js @@ -107,7 +107,7 @@ function publishFormatted (ch, formatter, ...args) { function getErrorLog (err) { if (typeof err?.delegate === 'function') { - const result = err.delegate() + const result = err.delegate(...err.args) return Array.isArray(result) ? Log.parse(...result) : Log.parse(result) } return err diff --git a/packages/dd-trace/src/msgpack/chunk.js b/packages/dd-trace/src/msgpack/chunk.js index 5ad7c308ea..c45d339519 100644 --- a/packages/dd-trace/src/msgpack/chunk.js +++ b/packages/dd-trace/src/msgpack/chunk.js @@ -1,22 +1,47 @@ 'use strict' -const DEFAULT_MIN_SIZE = 2 * 1024 * 1024 // 2MB +const DEFAULT_MIN_SIZE = 1024 * 1024 // 1 MiB +// Number of consecutive `reset()` calls whose peak usage stayed under +// `SHRINK_USAGE_RATIO * buffer.length` before the buffer halves. Picked high +// enough that a one-off burst keeps the grown buffer warm. +const SHRINK_AFTER_FLUSHES = 32 +// Peak fraction of the current buffer the next flush must beat to keep the +// shrink streak from advancing. 1/4 — a quarter — matches the doubling growth +// shape: after a halving step the post-shrink fill is the prior peak doubled, +// still under 50 %. +const SHRINK_USAGE_RATIO = 4 /** - * Represents a chunk of a Msgpack payload. Exposes a subset of Array and Buffer - * interfaces so that it can be used seamlessly by any encoder code that expects - * either. + * Resizable msgpack write buffer. Owns the byte-layout primitives the encoder + * layer dispatches into; callers reach the underlying `Buffer` only when they + * need to assemble a fused write (pre-computed prefixes, span-id payloads). + * + * Growth doubles the capacity per `reserve`; shrink halves it after + * `SHRINK_AFTER_FLUSHES` consecutive `reset()` calls left the buffer barely + * filled. Both stop at `minSize` so callers can pin a floor (CI Visibility's + * payload prefix chunk uses ~2 KiB). */ class MsgpackChunk { #minSize + #lowUsageStreak = 0 constructor (minSize = DEFAULT_MIN_SIZE) { this.buffer = Buffer.allocUnsafe(minSize) - this.view = new DataView(this.buffer.buffer) + // `Buffer.allocUnsafe` pools small allocations, so `buffer.buffer` may be a + // shared slab; pass `byteOffset` / `byteLength` so the view spans only our slice. + this.view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength) this.length = 0 this.#minSize = minSize } + /** + * Emit `value` as a msgpack string (fixstr for < 32 bytes, str32 otherwise). + * Returns the number of bytes written so callers can subarray the underlying + * buffer at the resulting position. + * + * @param {string} value + * @returns {number} + */ write (value) { const length = Buffer.byteLength(value) const offset = this.length @@ -38,10 +63,24 @@ class MsgpackChunk { return this.length - offset } + /** + * Copy this chunk's used bytes into `target` starting at `target[0]`. Used + * by `AgentEncoder.makePayload` to assemble the final wire buffer. + * + * @param {Buffer} target + * @param {number} sourceStart + * @param {number} sourceEnd + */ copy (target, sourceStart, sourceEnd) { - target.set(new Uint8Array(this.buffer.buffer, sourceStart, sourceEnd - sourceStart)) + this.buffer.copy(target, 0, sourceStart, sourceEnd) } + /** + * Append a raw byte sequence to the chunk. Caller-supplied bytes are + * trusted; this is the fused-prefix path. + * + * @param {Uint8Array | Buffer} array + */ set (array) { const length = this.length @@ -50,20 +89,365 @@ class MsgpackChunk { this.buffer.set(array, length) } + /** + * Reserve `size` more bytes after the current cursor, growing the backing + * buffer if needed. The cursor advances unconditionally so subsequent + * writes can assume the room is available. + * + * @param {number} size + */ reserve (size) { - if (this.length + size > this.buffer.length) { - const minSize = this.#minSize - this.#resize(minSize * Math.ceil((this.length + size) / minSize)) + const needed = this.length + size + + if (needed > this.buffer.length) { + let newSize = this.buffer.length + // `*= 2` instead of `<<= 1`: `1073741824 << 1` is negative as int32, + // and msgpack values can legitimately reach the multi-GiB range. + while (newSize < needed) newSize *= 2 + this.#resize(newSize) } this.length += size } + /** + * Mark the buffer as flushed: zero the cursor and, when the previous flush + * barely filled the buffer for `SHRINK_AFTER_FLUSHES` consecutive resets, + * halve the backing buffer. A single high-watermark flush resets the + * streak. Long-lived encoders can therefore grow under bursts and give the + * memory back during quiet periods without the user having to recreate the + * chunk. + */ + reset () { + const peak = this.length + + this.length = 0 + + if (this.buffer.length > this.#minSize && peak * SHRINK_USAGE_RATIO < this.buffer.length) { + if (++this.#lowUsageStreak >= SHRINK_AFTER_FLUSHES) { + const newSize = Math.max(this.#minSize, this.buffer.length >>> 1) + this.buffer = Buffer.allocUnsafe(newSize) + this.view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength) + this.#lowUsageStreak = 0 + } + } else { + this.#lowUsageStreak = 0 + } + } + + writeNull () { + const offset = this.length + + this.reserve(1) + this.buffer[offset] = 0xC0 + } + + /** + * @param {boolean} value + */ + writeBoolean (value) { + const offset = this.length + + this.reserve(1) + this.buffer[offset] = value ? 0xC3 : 0xC2 + } + + /** + * @param {number} size 0..15. + */ + writeFixArray (size) { + const offset = this.length + + this.reserve(1) + this.buffer[offset] = 0x90 + size + } + + /** + * Reserve a 5-byte array32 header with `value.length` slots. Used when the + * length is not known to fit in fixarray. + * + * @param {{ length: number }} value + */ + writeArrayPrefix (value) { + const length = value.length + const offset = this.length + + this.reserve(5) + this.buffer[offset] = 0xDD + this.buffer[offset + 1] = length >> 24 + this.buffer[offset + 2] = length >> 16 + this.buffer[offset + 3] = length >> 8 + this.buffer[offset + 4] = length + } + + /** + * Reserve a 5-byte map32 header with `keysLength` entries. + * + * @param {number} keysLength + */ + writeMapPrefix (keysLength) { + const offset = this.length + + this.reserve(5) + this.buffer[offset] = 0xDF + this.buffer[offset + 1] = keysLength >> 24 + this.buffer[offset + 2] = keysLength >> 16 + this.buffer[offset + 3] = keysLength >> 8 + this.buffer[offset + 4] = keysLength + } + + /** + * Write a single raw byte. Used by `0.5.js` for the fixarray-of-twelve span + * marker. + * + * @param {number} value + */ + writeByte (value) { + this.reserve(1) + this.buffer[this.length - 1] = value + } + + /** + * @param {Buffer | Uint8Array} value + */ + writeBin (value) { + const offset = this.length + + if (value.byteLength < 256) { + this.reserve(2) + this.buffer[offset] = 0xC4 + this.buffer[offset + 1] = value.byteLength + } else if (value.byteLength < 65_536) { + this.reserve(3) + this.buffer[offset] = 0xC5 + this.buffer[offset + 1] = value.byteLength >> 8 + this.buffer[offset + 2] = value.byteLength + } else { + this.reserve(5) + this.buffer[offset] = 0xC6 + this.buffer[offset + 1] = value.byteLength >> 24 + this.buffer[offset + 2] = value.byteLength >> 16 + this.buffer[offset + 3] = value.byteLength >> 8 + this.buffer[offset + 4] = value.byteLength + } + + this.set(value) + } + + /** + * Write `value` as msgpack uint32 (`0xCE` + 4 bytes), regardless of + * magnitude. Callers that want the shortest encoding should use `writeUint`. + * + * @param {number} value + */ + writeInteger (value) { + const offset = this.length + + this.reserve(5) + this.buffer[offset] = 0xCE + this.buffer[offset + 1] = value >> 24 + this.buffer[offset + 2] = value >> 16 + this.buffer[offset + 3] = value >> 8 + this.buffer[offset + 4] = value + } + + /** + * Write `value` as msgpack uint64 (`0xCF` + 8 bytes). + * + * @param {number} value + */ + writeLong (value) { + const offset = this.length + const hi = (value / 2 ** 32) >> 0 + const lo = value >>> 0 + + this.reserve(9) + this.buffer[offset] = 0xCF + this.buffer[offset + 1] = hi >> 24 + this.buffer[offset + 2] = hi >> 16 + this.buffer[offset + 3] = hi >> 8 + this.buffer[offset + 4] = hi + this.buffer[offset + 5] = lo >> 24 + this.buffer[offset + 6] = lo >> 16 + this.buffer[offset + 7] = lo >> 8 + this.buffer[offset + 8] = lo + } + + /** + * Pick the shortest valid msgpack uint encoding for a non-negative integer. + * + * @param {number} value + */ + writeUnsigned (value) { + const offset = this.length + + if (value <= 0x7F) { + this.reserve(1) + this.buffer[offset] = value + } else if (value <= 0xFF) { + this.reserve(2) + this.buffer[offset] = 0xCC + this.buffer[offset + 1] = value + } else if (value <= 0xFF_FF) { + this.reserve(3) + this.buffer[offset] = 0xCD + this.buffer[offset + 1] = value >> 8 + this.buffer[offset + 2] = value + } else if (value <= 0xFF_FF_FF_FF) { + this.reserve(5) + this.buffer[offset] = 0xCE + this.buffer[offset + 1] = value >> 24 + this.buffer[offset + 2] = value >> 16 + this.buffer[offset + 3] = value >> 8 + this.buffer[offset + 4] = value + } else { + const hi = (value / 2 ** 32) >> 0 + const lo = value >>> 0 + + this.reserve(9) + this.buffer[offset] = 0xCF + this.buffer[offset + 1] = hi >> 24 + this.buffer[offset + 2] = hi >> 16 + this.buffer[offset + 3] = hi >> 8 + this.buffer[offset + 4] = hi + this.buffer[offset + 5] = lo >> 24 + this.buffer[offset + 6] = lo >> 16 + this.buffer[offset + 7] = lo >> 8 + this.buffer[offset + 8] = lo + } + } + + /** + * Pick the shortest valid msgpack int encoding for a negative integer. + * + * @param {number} value + */ + writeSigned (value) { + const offset = this.length + + if (value >= -0x20) { + this.reserve(1) + this.buffer[offset] = value + } else if (value >= -0x80) { + this.reserve(2) + this.buffer[offset] = 0xD0 + this.buffer[offset + 1] = value + } else if (value >= -0x80_00) { + this.reserve(3) + this.buffer[offset] = 0xD1 + this.buffer[offset + 1] = value >> 8 + this.buffer[offset + 2] = value + } else if (value >= -0x80_00_00_00) { + this.reserve(5) + this.buffer[offset] = 0xD2 + this.buffer[offset + 1] = value >> 24 + this.buffer[offset + 2] = value >> 16 + this.buffer[offset + 3] = value >> 8 + this.buffer[offset + 4] = value + } else { + const hi = Math.floor(value / 2 ** 32) + const lo = value >>> 0 + + this.reserve(9) + this.buffer[offset] = 0xD3 + this.buffer[offset + 1] = hi >> 24 + this.buffer[offset + 2] = hi >> 16 + this.buffer[offset + 3] = hi >> 8 + this.buffer[offset + 4] = hi + this.buffer[offset + 5] = lo >> 24 + this.buffer[offset + 6] = lo >> 16 + this.buffer[offset + 7] = lo >> 8 + this.buffer[offset + 8] = lo + } + } + + // TODO: Support BigInt larger than 64bit. + /** + * @param {bigint} value + */ + writeBigInt (value) { + const offset = this.length + + this.reserve(9) + + if (value >= 0n) { + this.buffer[offset] = 0xCF + this.view.setBigUint64(offset + 1, value) + } else { + this.buffer[offset] = 0xD3 + this.view.setBigInt64(offset + 1, value) + } + } + + /** + * @param {number} value + */ + writeFloat (value) { + const offset = this.length + + this.reserve(9) + this.buffer[offset] = 0xCB + this.view.setFloat64(offset + 1, value) + } + + /** + * Pick the shortest valid msgpack number encoding for `value`. `NaN` + * collapses to fixint 0 — callers that need to preserve `NaN` (the tracer's + * span numeric path) should use `writeIntOrFloat` instead. + * + * @param {number} value + */ + writeNumber (value) { + if (Number.isNaN(value)) { + value = 0 + } + if (Number.isInteger(value)) { + if (value >= 0) { + this.writeUnsigned(value) + } else { + this.writeSigned(value) + } + } else { + this.writeFloat(value) + } + } + + /** + * Emit `value` as the smallest valid msgpack number encoding: compact + * unsigned/signed int when integer, float64 otherwise. Unlike `writeNumber`, + * NaN keeps its float64 bits instead of coercing to fixint 0. Used on the + * tracer hot path so the agent sees the value the application produced. + * + * @param {number} value + */ + writeIntOrFloat (value) { + // Fast path: positive fixint (0..127). `value === (value & 0x7F)` is true + // iff `value` is an exact integer in that range — covers `error: 0/1`, + // priority flags, attribute counts, HTTP status codes mapped to numbers, + // and most small metrics. NaN, ±Infinity, negatives, and any non-integer + // float fall through. + if (value === (value & 0x7F)) { + const offset = this.length + this.reserve(1) + this.buffer[offset] = value + return + } + if (Number.isInteger(value)) { + if (value >= 0) { + this.writeUnsigned(value) + } else { + this.writeSigned(value) + } + } else { + this.writeFloat(value) + } + } + #resize (size) { const oldBuffer = this.buffer this.buffer = Buffer.allocUnsafe(size) - this.view = new DataView(this.buffer.buffer) + this.view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength) oldBuffer.copy(this.buffer, 0, 0, this.length) } diff --git a/packages/dd-trace/src/msgpack/encoder.js b/packages/dd-trace/src/msgpack/encoder.js deleted file mode 100644 index 7a789cc28e..0000000000 --- a/packages/dd-trace/src/msgpack/encoder.js +++ /dev/null @@ -1,308 +0,0 @@ -'use strict' - -const MsgpackChunk = require('./chunk') - -class MsgpackEncoder { - encode (value) { - const bytes = new MsgpackChunk() - this.encodeValue(bytes, value) - - return bytes.buffer.subarray(0, bytes.length) - } - - encodeValue (bytes, value) { - switch (typeof value) { - case 'bigint': - this.encodeBigInt(bytes, value) - break - case 'boolean': - this.encodeBoolean(bytes, value) - break - case 'number': - this.encodeNumber(bytes, value) - break - case 'object': - if (value === null) { - this.encodeNull(bytes, value) - } else if (Array.isArray(value)) { - this.encodeArray(bytes, value) - } else if (Buffer.isBuffer(value) || ArrayBuffer.isView(value)) { - this.encodeBin(bytes, value) - } else { - this.encodeMap(bytes, value) - } - break - case 'string': - this.encodeString(bytes, value) - break - case 'symbol': - this.encodeString(bytes, value.toString()) - break - default: // function, symbol, undefined - this.encodeNull(bytes, value) - break - } - } - - encodeNull (bytes) { - const offset = bytes.length - - bytes.reserve(1) - bytes.buffer[offset] = 0xC0 - } - - encodeBoolean (bytes, value) { - const offset = bytes.length - - bytes.reserve(1) - bytes.buffer[offset] = value ? 0xC3 : 0xC2 - } - - encodeString (bytes, value) { - bytes.write(value) - } - - encodeFixArray (bytes, size = 0) { - const offset = bytes.length - - bytes.reserve(1) - bytes.buffer[offset] = 0x90 + size - } - - encodeArrayPrefix (bytes, value) { - const length = value.length - const offset = bytes.length - - bytes.reserve(5) - bytes.buffer[offset] = 0xDD - bytes.buffer[offset + 1] = length >> 24 - bytes.buffer[offset + 2] = length >> 16 - bytes.buffer[offset + 3] = length >> 8 - bytes.buffer[offset + 4] = length - } - - encodeArray (bytes, value) { - if (value.length < 16) { - this.encodeFixArray(bytes, value.length) - } else { - this.encodeArrayPrefix(bytes, value) - } - - for (const item of value) { - this.encodeValue(bytes, item) - } - } - - encodeFixMap (bytes, size = 0) { - const offset = bytes.length - - bytes.reserve(1) - bytes.buffer[offset] = 0x80 + size - } - - encodeMapPrefix (bytes, keysLength) { - const offset = bytes.length - - bytes.reserve(5) - bytes.buffer[offset] = 0xDF - bytes.buffer[offset + 1] = keysLength >> 24 - bytes.buffer[offset + 2] = keysLength >> 16 - bytes.buffer[offset + 3] = keysLength >> 8 - bytes.buffer[offset + 4] = keysLength - } - - encodeByte (bytes, value) { - bytes.reserve(1) - bytes.buffer[bytes.length - 1] = value - } - - encodeBin (bytes, value) { - const offset = bytes.length - - if (value.byteLength < 256) { - bytes.reserve(2) - bytes.buffer[offset] = 0xC4 - bytes.buffer[offset + 1] = value.byteLength - } else if (value.byteLength < 65_536) { - bytes.reserve(3) - bytes.buffer[offset] = 0xC5 - bytes.buffer[offset + 1] = value.byteLength >> 8 - bytes.buffer[offset + 2] = value.byteLength - } else { - bytes.reserve(5) - bytes.buffer[offset] = 0xC6 - bytes.buffer[offset + 1] = value.byteLength >> 24 - bytes.buffer[offset + 2] = value.byteLength >> 16 - bytes.buffer[offset + 3] = value.byteLength >> 8 - bytes.buffer[offset + 4] = value.byteLength - } - - bytes.set(value) - } - - encodeInteger (bytes, value) { - const offset = bytes.length - - bytes.reserve(5) - bytes.buffer[offset] = 0xCE - bytes.buffer[offset + 1] = value >> 24 - bytes.buffer[offset + 2] = value >> 16 - bytes.buffer[offset + 3] = value >> 8 - bytes.buffer[offset + 4] = value - } - - encodeShort (bytes, value) { - const offset = bytes.length - - bytes.reserve(3) - bytes.buffer[offset] = 0xCD - bytes.buffer[offset + 1] = value >> 8 - bytes.buffer[offset + 2] = value - } - - encodeLong (bytes, value) { - const offset = bytes.length - const hi = (value / 2 ** 32) >> 0 - const lo = value >>> 0 - - bytes.reserve(9) - bytes.buffer[offset] = 0xCF - bytes.buffer[offset + 1] = hi >> 24 - bytes.buffer[offset + 2] = hi >> 16 - bytes.buffer[offset + 3] = hi >> 8 - bytes.buffer[offset + 4] = hi - bytes.buffer[offset + 5] = lo >> 24 - bytes.buffer[offset + 6] = lo >> 16 - bytes.buffer[offset + 7] = lo >> 8 - bytes.buffer[offset + 8] = lo - } - - encodeNumber (bytes, value) { - if (Number.isNaN(value)) { - value = 0 - } - if (Number.isInteger(value)) { - if (value >= 0) { - this.encodeUnsigned(bytes, value) - } else { - this.encodeSigned(bytes, value) - } - } else { - this.encodeFloat(bytes, value) - } - } - - encodeSigned (bytes, value) { - const offset = bytes.length - - if (value >= -0x20) { - bytes.reserve(1) - bytes.buffer[offset] = value - } else if (value >= -0x80) { - bytes.reserve(2) - bytes.buffer[offset] = 0xD0 - bytes.buffer[offset + 1] = value - } else if (value >= -0x80_00) { - bytes.reserve(3) - bytes.buffer[offset] = 0xD1 - bytes.buffer[offset + 1] = value >> 8 - bytes.buffer[offset + 2] = value - } else if (value >= -0x80_00_00_00) { - bytes.reserve(5) - bytes.buffer[offset] = 0xD2 - bytes.buffer[offset + 1] = value >> 24 - bytes.buffer[offset + 2] = value >> 16 - bytes.buffer[offset + 3] = value >> 8 - bytes.buffer[offset + 4] = value - } else { - const hi = Math.floor(value / 2 ** 32) - const lo = value >>> 0 - - bytes.reserve(9) - bytes.buffer[offset] = 0xD3 - bytes.buffer[offset + 1] = hi >> 24 - bytes.buffer[offset + 2] = hi >> 16 - bytes.buffer[offset + 3] = hi >> 8 - bytes.buffer[offset + 4] = hi - bytes.buffer[offset + 5] = lo >> 24 - bytes.buffer[offset + 6] = lo >> 16 - bytes.buffer[offset + 7] = lo >> 8 - bytes.buffer[offset + 8] = lo - } - } - - encodeUnsigned (bytes, value) { - const offset = bytes.length - - if (value <= 0x7F) { - bytes.reserve(1) - bytes.buffer[offset] = value - } else if (value <= 0xFF) { - bytes.reserve(2) - bytes.buffer[offset] = 0xCC - bytes.buffer[offset + 1] = value - } else if (value <= 0xFF_FF) { - bytes.reserve(3) - bytes.buffer[offset] = 0xCD - bytes.buffer[offset + 1] = value >> 8 - bytes.buffer[offset + 2] = value - } else if (value <= 0xFF_FF_FF_FF) { - bytes.reserve(5) - bytes.buffer[offset] = 0xCE - bytes.buffer[offset + 1] = value >> 24 - bytes.buffer[offset + 2] = value >> 16 - bytes.buffer[offset + 3] = value >> 8 - bytes.buffer[offset + 4] = value - } else { - const hi = (value / 2 ** 32) >> 0 - const lo = value >>> 0 - - bytes.reserve(9) - bytes.buffer[offset] = 0xCF - bytes.buffer[offset + 1] = hi >> 24 - bytes.buffer[offset + 2] = hi >> 16 - bytes.buffer[offset + 3] = hi >> 8 - bytes.buffer[offset + 4] = hi - bytes.buffer[offset + 5] = lo >> 24 - bytes.buffer[offset + 6] = lo >> 16 - bytes.buffer[offset + 7] = lo >> 8 - bytes.buffer[offset + 8] = lo - } - } - - // TODO: Support BigInt larger than 64bit. - encodeBigInt (bytes, value) { - const offset = bytes.length - - bytes.reserve(9) - - if (value >= 0n) { - bytes.buffer[offset] = 0xCF - bytes.view.setBigUint64(offset + 1, value) - } else { - bytes.buffer[offset] = 0xD3 - bytes.view.setBigInt64(offset + 1, value) - } - } - - encodeMap (bytes, value) { - const keys = Object.keys(value) - - this.encodeMapPrefix(bytes, keys.length) - - for (const key of keys) { - this.encodeValue(bytes, key) - this.encodeValue(bytes, value[key]) - } - } - - encodeFloat (bytes, value) { - const offset = bytes.length - - bytes.reserve(9) - bytes.buffer[offset] = 0xCB - bytes.view.setFloat64(offset + 1, value) - } -} - -module.exports = { MsgpackEncoder } diff --git a/packages/dd-trace/src/msgpack/index.js b/packages/dd-trace/src/msgpack/index.js index f5c62b41ab..d03c16aaad 100644 --- a/packages/dd-trace/src/msgpack/index.js +++ b/packages/dd-trace/src/msgpack/index.js @@ -1,6 +1,100 @@ 'use strict' const MsgpackChunk = require('./chunk') -const { MsgpackEncoder } = require('./encoder') -module.exports = { MsgpackChunk, MsgpackEncoder } +/** + * Encode an arbitrary JS value as a standalone msgpack buffer. Used by + * `DataStreamsWriter` (pipeline stats) where the payload shape is decided at + * runtime; encoder code that owns a `MsgpackChunk` should call + * `chunk.writeX(...)` directly instead. + * + * @param {unknown} value + * @returns {Buffer} + */ +function encode (value) { + const bytes = new MsgpackChunk() + writeValue(bytes, value) + + return bytes.buffer.subarray(0, bytes.length) +} + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isPlainObject (value) { + return typeof value === 'object' && value !== null +} + +/** + * @param {MsgpackChunk} bytes + * @param {unknown} value + */ +function writeValue (bytes, value) { + switch (typeof value) { + case 'string': + bytes.write(value) + break + case 'number': + bytes.writeNumber(value) + break + case 'object': + if (value === null) { + bytes.writeNull() + } else if (Array.isArray(value)) { + writeArray(bytes, value) + } else if (Buffer.isBuffer(value)) { + bytes.writeBin(value) + } else if (ArrayBuffer.isView(value)) { + bytes.writeBin(/** @type {Uint8Array} */ (value)) + } else if (isPlainObject(value)) { + writeMap(bytes, value) + } + break + case 'boolean': + bytes.writeBoolean(value) + break + case 'bigint': + bytes.writeBigInt(value) + break + case 'symbol': + bytes.write(value.toString()) + break + default: // function, undefined + bytes.writeNull() + break + } +} + +/** + * @param {MsgpackChunk} bytes + * @param {unknown[]} value + */ +function writeArray (bytes, value) { + if (value.length < 16) { + bytes.writeFixArray(value.length) + } else { + bytes.writeArrayPrefix(value) + } + + for (const item of value) { + writeValue(bytes, item) + } +} + +/** + * @param {MsgpackChunk} bytes + * @param {Record} value + */ +function writeMap (bytes, value) { + const keys = Object.keys(value) + + bytes.writeMapPrefix(keys.length) + + for (const key of keys) { + bytes.write(key) + writeValue(bytes, value[key]) + } +} + +module.exports = { MsgpackChunk, encode } diff --git a/packages/dd-trace/src/openfeature/encoding.js b/packages/dd-trace/src/openfeature/encoding.js new file mode 100644 index 0000000000..05dcd2feec --- /dev/null +++ b/packages/dd-trace/src/openfeature/encoding.js @@ -0,0 +1,70 @@ +'use strict' + +const crypto = require('node:crypto') + +/** + * Encode a single value as a ULEB128 varint (variable-length integer). + * Uses 7 bits per byte, with MSB as continuation flag. + * + * @param {number} value - Non-negative integer to encode + * @returns {number[]} Array of bytes representing the varint + */ +function encodeVarint (value) { + const bytes = [] + while (value > 0x7F) { + bytes.push((value & 0x7F) | 0x80) // Set continuation bit + value >>>= 7 + } + bytes.push(value & 0x7F) // Final byte without continuation bit + return bytes +} + +/** + * Encode a set of serial IDs using delta-varint encoding. + * + * Algorithm: + * 1. Sort serial IDs in ascending order + * 2. Compute deltas from previous value (first delta = first value) + * 3. Encode each delta as varint + * 4. Base64 encode the result + * + * @param {Set} serialIds - Set of serial IDs to encode + * @returns {string} Base64-encoded delta-varint string + */ +function encodeDeltaVarint (serialIds) { + if (!serialIds || serialIds.size === 0) { + return '' + } + + // Sort IDs in ascending order + const sorted = [...serialIds].sort((a, b) => a - b) + + // Compute deltas and encode as varints + const bytes = [] + let prev = 0 + + for (const id of sorted) { + const delta = id - prev + bytes.push(...encodeVarint(delta)) + prev = id + } + + // Base64 encode the byte array + return Buffer.from(bytes).toString('base64') +} + +/** + * Hash a targeting key using SHA256. + * + * @param {string} targetingKey - The targeting key to hash + * @returns {string} Lowercase hex digest of the SHA256 hash + */ +function hashTargetingKey (targetingKey) { + return crypto.createHash('sha256').update(targetingKey).digest('hex') +} + +module.exports = { + encodeVarint, + encodeDeltaVarint, + hashTargetingKey, +} diff --git a/packages/dd-trace/src/openfeature/flagging_provider.js b/packages/dd-trace/src/openfeature/flagging_provider.js index 5eea6c0578..39b58e2354 100644 --- a/packages/dd-trace/src/openfeature/flagging_provider.js +++ b/packages/dd-trace/src/openfeature/flagging_provider.js @@ -5,12 +5,16 @@ const { channel } = require('dc-polyfill') const log = require('../log') const { EXPOSURE_CHANNEL } = require('./constants/constants') const EvalMetricsHook = require('./eval-metrics-hook') +const SpanEnrichmentHook = require('./span-enrichment-hook') /** * OpenFeature provider that integrates with Datadog's feature flagging system. * Extends DatadogNodeServerProvider to add tracer integration and configuration management. */ class FlaggingProvider extends DatadogNodeServerProvider { + /** @type {SpanEnrichmentHook?} */ + #spanEnrichmentHook + /** * @param {import('../tracer')} tracer - Datadog tracer instance * @param {import('../config')} config - Tracer configuration object @@ -27,10 +31,26 @@ class FlaggingProvider extends DatadogNodeServerProvider { this.hooks.push(new EvalMetricsHook(config)) + if (config.experimental.flaggingProvider.spanEnrichment?.enabled) { + this.#spanEnrichmentHook = new SpanEnrichmentHook(tracer) + this.hooks.push(this.#spanEnrichmentHook) + log.info('%s span enrichment enabled', this.constructor.name) + } else { + log.info('%s span enrichment disabled', this.constructor.name) + } + log.debug('%s created with timeout: %dms', this.constructor.name, config.experimental.flaggingProvider.initializationTimeoutMs) } + /** + * Called when the provider is shut down. + * Cleans up resources including channel subscriptions. + */ + onClose () { + this.#spanEnrichmentHook?.destroy() + } + /** * Internal method to update flag configuration from Remote Config. * This method is called automatically when Remote Config delivers UFC updates. diff --git a/packages/dd-trace/src/openfeature/span-enrichment-hook.js b/packages/dd-trace/src/openfeature/span-enrichment-hook.js new file mode 100644 index 0000000000..eae06e2039 --- /dev/null +++ b/packages/dd-trace/src/openfeature/span-enrichment-hook.js @@ -0,0 +1,143 @@ +'use strict' + +const { channel } = require('dc-polyfill') +const log = require('../log') +const { SpanEnrichmentState } = require('./span-enrichment') + +const finishCh = channel('dd-trace:span:finish') + +/** + * OpenFeature hook that enriches APM spans with feature flag evaluation data. + * + * Implements the OpenFeature `finally` hook interface to capture flag evaluations + * and add span tags for observability. Tags are accumulated during the span's + * lifetime and applied when the root span finishes. + * + * Span tags added: + * - `ffe_flags_enc`: Base64 delta-varint encoded serial IDs + * - `ffe_subjects_enc`: JSON dict of SHA256(targeting_key) → encoded serial IDs + * - `ffe_runtime_defaults`: JSON dict of flag_key → default value string + */ +class SpanEnrichmentHook { + #tracer + /** @type {WeakMap} */ + #spanStates = new WeakMap() + + /** + * Handler for span finish channel. Applies accumulated tags to the span. + * Arrow function to preserve `this` binding for channel subscription. + * + * @param {object} span - The span that is finishing + */ + #onSpanFinish = (span) => { + const state = this.#spanStates.get(span) + if (!state || !state.hasData()) return + + try { + const tags = state.toSpanTags() + + for (const [key, value] of Object.entries(tags)) { + if (value) { + span.setTag(key, value) + } + } + } catch (err) { + log.warn('SpanEnrichmentHook: error applying span tags: %s', err.message) + } finally { + this.#spanStates.delete(span) + } + } + + /** + * @param {import('../tracer')} tracer - Datadog tracer instance + */ + constructor (tracer) { + this.#tracer = tracer + finishCh.subscribe(this.#onSpanFinish) + } + + /** + * Called by the OpenFeature SDK after every flag evaluation (success or error). + * + * @param {object} hookContext - Hook context containing the flag key and evaluation context + * @param {string} hookContext.flagKey - The flag key being evaluated + * @param {object} [hookContext.context] - Evaluation context + * @param {string} [hookContext.context.targetingKey] - Targeting key + * @param {object} evaluationDetails - Full evaluation details including flag metadata + * @param {object} [evaluationDetails.flagMetadata] - Metadata from the provider + * @param {number} [evaluationDetails.flagMetadata.__dd_split_serial_id] - Serial ID from UFC split + * @param {boolean} [evaluationDetails.flagMetadata.__dd_do_log] - Whether to log subject + * @param {string} [evaluationDetails.variant] - Variant key if flag was found in UFC + * @param {boolean|string|number|object} [evaluationDetails.value] - Evaluated value + * @returns {void} + */ + finally (hookContext, evaluationDetails) { + try { + const rootSpan = this._getRootSpan() + if (!rootSpan) return + + const state = this._getOrCreateState(rootSpan) + const { flagKey, context } = hookContext || {} + const { flagMetadata, variant, value } = evaluationDetails || {} + + const serialId = flagMetadata?.__dd_split_serial_id + const doLog = flagMetadata?.__dd_do_log ?? false + const targetingKey = context?.targetingKey + + if (serialId != null) { + state.addSerialId(serialId) + + if (doLog && targetingKey) { + state.addSubject(targetingKey, serialId) + } + } else if (variant === undefined) { + state.addDefault(flagKey, value) + } + } catch (err) { + log.warn('SpanEnrichmentHook: error in finally hook: %s', err.message) + } + } + + /** + * Get the root span for the current trace context. + * The root span is always the first span in trace.started since spans + * are added in creation order and the root is created first. + * + * @returns {object|null} The root span, or null if no active span + * @private + */ + _getRootSpan () { + const span = this.#tracer.scope().active() + if (!span) return null + + const trace = span.context()._trace + + return trace?.started?.[0] ?? span + } + + /** + * Get or create enrichment state for a span. + * + * @param {object} span - The span to get state for + * @returns {SpanEnrichmentState} The enrichment state + * @private + */ + _getOrCreateState (span) { + let state = this.#spanStates.get(span) + if (!state) { + state = new SpanEnrichmentState() + this.#spanStates.set(span, state) + } + return state + } + + /** + * Cleanup method to unsubscribe from channels. + * Should be called when the provider is shut down. + */ + destroy () { + finishCh.unsubscribe(this.#onSpanFinish) + } +} + +module.exports = SpanEnrichmentHook diff --git a/packages/dd-trace/src/openfeature/span-enrichment.js b/packages/dd-trace/src/openfeature/span-enrichment.js new file mode 100644 index 0000000000..e80a738548 --- /dev/null +++ b/packages/dd-trace/src/openfeature/span-enrichment.js @@ -0,0 +1,149 @@ +'use strict' + +const log = require('../log') + +const { encodeDeltaVarint, hashTargetingKey } = require('./encoding') + +const MAX_SERIAL_IDS = 200 +const MAX_SUBJECTS = 10 +const MAX_EXPERIMENTS_PER_SUBJECT = 20 +const MAX_DEFAULTS = 5 +const MAX_DEFAULT_VALUE_LENGTH = 64 + +/** + * Manages feature flag enrichment state for a single root span. + * Accumulates serial IDs, subjects, and defaults throughout the span's lifetime. + */ +class SpanEnrichmentState { + constructor () { + /** @type {Set} */ + this._serialIds = new Set() + + /** @type {Map>} hashed targeting key -> serial IDs */ + this._subjects = new Map() + + /** @type {Map} flag key -> runtime default value */ + this._defaults = new Map() + } + + /** + * Add a serial ID from a flag evaluation. + * + * @param {number} serialId - The serial ID to add + * @returns {boolean} True if added, false if limit reached + */ + addSerialId (serialId) { + if (this._serialIds.size >= MAX_SERIAL_IDS) { + log.debug('SpanEnrichment: MAX_SERIAL_IDS limit (%d) reached, dropping serialId %d', MAX_SERIAL_IDS, serialId) + return false + } + this._serialIds.add(serialId) + return true + } + + /** + * Add a subject (targeting key) with its associated serial ID. + * Only called when doLog=true. + * + * @param {string} targetingKey - The targeting key (will be hashed) + * @param {number} serialId - The serial ID associated with this evaluation + * @returns {boolean} True if added, false if limit reached + */ + addSubject (targetingKey, serialId) { + const hashedKey = hashTargetingKey(targetingKey) + + if (this._subjects.has(hashedKey)) { + const subjectIds = this._subjects.get(hashedKey) + if (subjectIds.size >= MAX_EXPERIMENTS_PER_SUBJECT) { + log.debug('SpanEnrichment: MAX_EXPERIMENTS_PER_SUBJECT limit (%d) reached for subject', + MAX_EXPERIMENTS_PER_SUBJECT) + return false + } + subjectIds.add(serialId) + return true + } + + if (this._subjects.size >= MAX_SUBJECTS) { + log.debug('SpanEnrichment: MAX_SUBJECTS limit (%d) reached, dropping subject', MAX_SUBJECTS) + return false + } + + this._subjects.set(hashedKey, new Set([serialId])) + return true + } + + /** + * Add a default fallback for a flag not found in UFC. + * + * @param {string} flagKey - The flag key + * @param {boolean|string|number|object} defaultValue - The default value used + * @returns {boolean} True if added, false if limit reached + */ + addDefault (flagKey, defaultValue) { + if (this._defaults.has(flagKey)) { + return true + } + + if (this._defaults.size >= MAX_DEFAULTS) { + log.debug('SpanEnrichment: MAX_DEFAULTS limit (%d) reached, dropping flag %s', MAX_DEFAULTS, flagKey) + return false + } + + let valueStr = typeof defaultValue === 'object' && defaultValue !== null + ? JSON.stringify(defaultValue) + : String(defaultValue) + + if (valueStr.length > MAX_DEFAULT_VALUE_LENGTH) { + valueStr = valueStr.slice(0, MAX_DEFAULT_VALUE_LENGTH) + } + + this._defaults.set(flagKey, valueStr) + return true + } + + /** + * Check if there is any enrichment data to add to the span. + * Note: _subjects is not checked because addSubject() is never called without first + * calling addSerialId(), so _subjects having data necessitates _serialIds having data. + * + * @returns {boolean} True if there is data to add + */ + hasData () { + return this._serialIds.size > 0 || this._defaults.size > 0 + } + + /** + * Convert accumulated state to span tags. + * + * @returns {object} Object with ffe_flags_enc, ffe_subjects_enc, and ffe_runtime_defaults tags + */ + toSpanTags () { + const tags = {} + + if (this._serialIds.size > 0) { + tags.ffe_flags_enc = encodeDeltaVarint(this._serialIds) + } + + if (this._subjects.size > 0) { + const subjectsObj = Object.fromEntries( + [...this._subjects].map(([key, ids]) => [key, encodeDeltaVarint(ids)]) + ) + tags.ffe_subjects_enc = JSON.stringify(subjectsObj) + } + + if (this._defaults.size > 0) { + tags.ffe_runtime_defaults = JSON.stringify(Object.fromEntries(this._defaults)) + } + + return tags + } +} + +module.exports = { + SpanEnrichmentState, + MAX_SERIAL_IDS, + MAX_SUBJECTS, + MAX_EXPERIMENTS_PER_SUBJECT, + MAX_DEFAULTS, + MAX_DEFAULT_VALUE_LENGTH, +} diff --git a/packages/dd-trace/src/opentelemetry/span-helpers.js b/packages/dd-trace/src/opentelemetry/span-helpers.js index 42e4c87fef..bcfe621ffd 100644 --- a/packages/dd-trace/src/opentelemetry/span-helpers.js +++ b/packages/dd-trace/src/opentelemetry/span-helpers.js @@ -7,6 +7,7 @@ const { timeInputToHrTime } = require('../../../../vendor/dist/@opentelemetry/co const { ERROR_MESSAGE, ERROR_STACK, ERROR_TYPE, IGNORE_OTEL_ERROR } = require('../constants') const DatadogSpanContext = require('../opentracing/span_context') const TraceState = require('../opentracing/propagation/tracestate') +const { DD_MAJOR } = require('../../../../version') const id = require('../id') @@ -176,8 +177,8 @@ function setOtelAttributes (ddSpan, attributes) { function addOtelLink (ddSpan, link, attrs) { if (!isWritable(ddSpan) || !link) return - // TODO: Drop the (context, attrs) form in v6.0.0. - const { context, attributes } = isOtelLink(link) + // v5 still accepts the legacy `addLink(context, attrs)` shape; v6 only takes `addLink(otel.Link)`. + const { context, attributes } = isOtelLink(link) || DD_MAJOR >= 6 ? link : { context: link, attributes: attrs ?? {} } @@ -230,7 +231,7 @@ function recordException (ddSpan, exception, timeInput) { [ERROR_TYPE]: exception.name, [ERROR_MESSAGE]: exception.message, [ERROR_STACK]: exception.stack, - [IGNORE_OTEL_ERROR]: ddSpan.context()._tags[IGNORE_OTEL_ERROR] ?? true, + [IGNORE_OTEL_ERROR]: ddSpan.context().getTag(IGNORE_OTEL_ERROR) ?? true, }) const attributes = {} diff --git a/packages/dd-trace/src/opentelemetry/span.js b/packages/dd-trace/src/opentelemetry/span.js index e3fcfda9a3..8ff5854e68 100644 --- a/packages/dd-trace/src/opentelemetry/span.js +++ b/packages/dd-trace/src/opentelemetry/span.js @@ -216,7 +216,7 @@ class Span extends BridgeSpanBase { 'ptr.hash': ptrHash, 'link.kind': 'span-pointer', } - return this.addLink(zeroContext, attributes) + return this.addLink({ context: zeroContext, attributes }) } /** diff --git a/packages/dd-trace/src/opentracing/propagation/log.js b/packages/dd-trace/src/opentracing/propagation/log.js index 6f72021661..f82b34bb8c 100644 --- a/packages/dd-trace/src/opentracing/propagation/log.js +++ b/packages/dd-trace/src/opentracing/propagation/log.js @@ -11,20 +11,31 @@ class LogPropagator { inject (spanContext, carrier) { if (!carrier) return - carrier.dd = {} + const dd = {} + let hasField = false if (spanContext) { - carrier.dd.trace_id = this._config.traceId128BitGenerationEnabled && + dd.trace_id = this._config.traceId128BitGenerationEnabled && this._config.traceId128BitLoggingEnabled && spanContext._trace.tags['_dd.p.tid'] ? spanContext.toTraceId(true) : spanContext.toTraceId() - - carrier.dd.span_id = spanContext.toSpanId() + dd.span_id = spanContext.toSpanId() + hasField = true + } + if (this._config.service) { + dd.service = this._config.service + hasField = true + } + if (this._config.version) { + dd.version = this._config.version + hasField = true + } + if (this._config.env) { + dd.env = this._config.env + hasField = true } - if (this._config.service) carrier.dd.service = this._config.service - if (this._config.version) carrier.dd.version = this._config.version - if (this._config.env) carrier.dd.env = this._config.env + if (hasField) carrier.dd = dd } extract (carrier) { diff --git a/packages/dd-trace/src/opentracing/propagation/text_map.js b/packages/dd-trace/src/opentracing/propagation/text_map.js index f3c453de1b..3a23e5839e 100644 --- a/packages/dd-trace/src/opentracing/propagation/text_map.js +++ b/packages/dd-trace/src/opentracing/propagation/text_map.js @@ -35,7 +35,6 @@ const b3FlagsKey = 'x-b3-flags' const b3HeaderKey = 'b3' const sqsdHeaderHey = 'x-aws-sqsd-attr-_datadog' const b3HeaderExpr = /^(([0-9a-f]{16}){1,2}-[0-9a-f]{16}(-[01d](-[0-9a-f]{16})?)?|[01d])$/i -const baggageExpr = new RegExp(`^${baggagePrefix}(.+)$`) // W3C Baggage key grammar: key = token (RFC 7230). // Spec (up-to-date): "Propagation format for distributed context: Baggage" §3.3.1 // https://www.w3.org/TR/baggage/#header-content @@ -56,6 +55,15 @@ const ddKeys = [traceKey, spanKey, samplingKey, originKey] const b3Keys = [b3TraceKey, b3SpanKey, b3ParentKey, b3SampledKey, b3FlagsKey, b3HeaderKey] const w3cKeys = [traceparentKey, tracestateKey] const logKeys = [...ddKeys, ...b3Keys, ...w3cKeys] +// 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 '_' @@ -68,27 +76,29 @@ const hex16 = /^[0-9A-Fa-f]{16}$/ const percentByte = /%([0-9A-Fa-f]{2})/g class TextMapPropagator { - #extractB3Context - /** @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 + constructor (config) { this._config = config - // v6: `'b3'` is always single-header. v5: env-name decides — OTEL_PROPAGATORS callers expect - // single, the legacy `DD_TRACE_PROPAGATION_STYLE` callers expect multi. + // 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 */ if (DD_MAJOR >= 6) { - this.#extractB3Context = this._extractB3SingleContext + this.#b3MethodName = '_extractB3SingleContext' } else { const envName = getConfiguredEnvName('DD_TRACE_PROPAGATION_STYLE') // eslint-disable-next-line eslint-rules/eslint-env-aliases - this.#extractB3Context = envName === 'OTEL_PROPAGATORS' - ? this._extractB3SingleContext - : this._extractB3MultiContext + this.#b3MethodName = envName === 'OTEL_PROPAGATORS' + ? '_extractB3SingleContext' + : '_extractB3MultiContext' } } @@ -129,8 +139,7 @@ class TextMapPropagator { extract (carrier) { const spanContext = this._extractSpanContext(carrier) - - if (!spanContext) return spanContext + if (spanContext === undefined) return null if (extractCh.hasSubscribers) { extractCh.publish({ spanContext, carrier }) @@ -292,7 +301,7 @@ class TextMapPropagator { // 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 null + if (!hasB3SingleHeader) return const traceId = this._getB3TraceId(spanContext) const spanId = spanContext._spanId.toString(16) @@ -361,7 +370,7 @@ class TextMapPropagator { } _hasTraceIdConflict (w3cSpanContext, firstSpanContext) { - return w3cSpanContext !== null && + return w3cSpanContext !== undefined && firstSpanContext.toTraceId(true) === w3cSpanContext.toTraceId(true) && firstSpanContext.toSpanId() !== w3cSpanContext.toSpanId() } @@ -372,7 +381,7 @@ class TextMapPropagator { _updateParentIdFromDdHeaders (carrier, firstSpanContext) { const ddCtx = this._extractDatadogContext(carrier) - if (ddCtx !== null) { + if (ddCtx !== undefined) { firstSpanContext._trace.tags[tags.DD_PARENT_ID] = ddCtx._spanId.toString().padStart(16, '0') } } @@ -394,35 +403,20 @@ class TextMapPropagator { } _extractSpanContext (carrier) { - let context = null + let context let style = '' for (const extractor of this._config.tracePropagationStyle.extract) { - let extractedContext = null - switch (extractor) { - case 'datadog': - extractedContext = this._extractDatadogContext(carrier) - break - case 'tracecontext': - extractedContext = this._extractTraceparentContext(carrier) - break - case 'b3 single header': - extractedContext = this._extractB3SingleContext(carrier) - break - case 'b3': - extractedContext = this.#extractB3Context(carrier) - break - case 'b3multi': - extractedContext = this._extractB3MultiContext(carrier) - break - default: - if (extractor !== 'baggage') log.warn('Unknown propagation style:', extractor) + const method = extractor === 'b3' ? this.#b3MethodName : EXTRACT_STYLE_METHODS.get(extractor) + if (method === undefined) { + if (extractor !== 'baggage') log.warn('Unknown propagation style:', extractor) + continue } - - if (extractedContext === null) { // If the current extractor was invalid, continue to the next extractor + const extractedContext = this[method](carrier) + if (extractedContext === undefined) { continue } - if (context === null) { + if (context === undefined) { context = extractedContext style = extractor if (this._config.DD_TRACE_PROPAGATION_EXTRACT_FIRST) { @@ -446,9 +440,7 @@ class TextMapPropagator { } if (this._config.DD_TRACE_PROPAGATION_BEHAVIOR_EXTRACT === 'ignore') { - // `context` is null when no extractor matched; the fallback below picks up - // the SQSD context if present, otherwise the request runs untraced. - if (context) context._links = [] + if (context !== undefined) context._links = [] } else { if (this._config.DD_TRACE_PROPAGATION_BEHAVIOR_EXTRACT === 'restart' && context) { context._links = [] @@ -490,14 +482,17 @@ class TextMapPropagator { _extractB3MultiContext (carrier) { const b3 = this._extractB3MultipleHeaders(carrier) - if (!b3) return null + if (b3 === undefined) return return this._extractB3Context(b3) } _extractB3SingleContext (carrier) { - if (!b3HeaderExpr.test(carrier[b3HeaderKey])) return null + // `typeof === 'string'` first; otherwise the regex coerces `undefined` to + // `'undefined'` and runs on every header-less request. + const header = carrier[b3HeaderKey] + if (typeof header !== 'string' || !b3HeaderExpr.test(header)) return const b3 = this._extractB3SingleHeader(carrier) - if (!b3) return null + if (b3 === undefined) return return this._extractB3Context(b3) } @@ -527,23 +522,19 @@ class TextMapPropagator { _extractSqsdContext (carrier) { const headerValue = carrier[sqsdHeaderHey] - if (!headerValue) { - return null - } + if (!headerValue) return let parsed try { parsed = JSON.parse(headerValue) } catch { - return null + return } return this._extractDatadogContext(parsed) } _extractTraceparentContext (carrier) { const headerValue = carrier[traceparentKey] - if (typeof headerValue !== 'string') { - return null - } + if (typeof headerValue !== 'string') return const matches = headerValue.trim().match(traceparentExpr) if (matches !== null) { const [, version, traceId, spanId, flags, tail] = matches @@ -554,14 +545,14 @@ class TextMapPropagator { ? carrier.tracestate.filter(item => typeof item === 'string').join(',') : carrier.tracestate const tracestate = TraceState.fromString(rawTracestate) - if (invalidSegment.test(traceId)) return null - if (invalidSegment.test(spanId)) return null + if (invalidSegment.test(traceId)) return + if (invalidSegment.test(spanId)) return // Version ff is considered invalid - if (version === 'ff') return null + if (version === 'ff') return // Version 00 should have no tail, but future versions may - if (tail && version === '00') return null + if (tail && version === '00') return const spanContext = new DatadogSpanContext({ traceId: id(traceId, 16), @@ -624,12 +615,11 @@ class TextMapPropagator { this._extractLegacyBaggageItems(carrier, spanContext) return spanContext } - return null } _extractGenericContext (carrier, traceKey, spanKey, radix) { if (carrier && carrier[traceKey] && carrier[spanKey]) { - if (invalidSegment.test(carrier[traceKey])) return null + if (invalidSegment.test(carrier[traceKey])) return return new DatadogSpanContext({ traceId: id(carrier[traceKey], radix), @@ -637,11 +627,17 @@ class TextMapPropagator { isRemote: true, }) } - - return null } _extractB3MultipleHeaders (carrier) { + // `b3ParentKey` is intentionally absent: this method never consults it, + // so a parent-id-only carrier should bail with the rest. + if (carrier[b3TraceKey] === undefined && + carrier[b3SampledKey] === undefined && + carrier[b3FlagsKey] === undefined) { + return + } + let empty = true const b3 = {} @@ -661,12 +657,12 @@ class TextMapPropagator { empty = false } - return empty ? null : b3 + return empty ? undefined : b3 } _extractB3SingleHeader (carrier) { const header = carrier[b3HeaderKey] - if (!header) return null + if (!header) return const parts = header.split('-') @@ -705,13 +701,12 @@ class TextMapPropagator { } _extractLegacyBaggageItems (carrier, spanContext) { - if (this._config.legacyBaggageEnabled) { - for (const key of Object.keys(carrier)) { - const match = key.match(baggageExpr) - - if (match) { - spanContext._baggageItems[match[1]] = carrier[key] - } + if (!this._config.legacyBaggageEnabled) return + for (const key of Object.keys(carrier)) { + if (!key.startsWith(baggagePrefix)) continue + const baggageKey = key.slice(baggagePrefix.length) + if (baggageKey) { + spanContext._baggageItems[baggageKey] = carrier[key] } } } diff --git a/packages/dd-trace/src/opentracing/span.js b/packages/dd-trace/src/opentracing/span.js index a32e75dd42..db581007d2 100644 --- a/packages/dd-trace/src/opentracing/span.js +++ b/packages/dd-trace/src/opentracing/span.js @@ -10,7 +10,10 @@ const tagger = require('../tagger') const runtimeMetrics = require('../runtime_metrics') const log = require('../log') const { storage } = require('../../../datadog-core') +const { resolveServiceSource } = require('../service-naming/source-resolver') const telemetryMetrics = require('../telemetry/metrics') +const { MANUAL_DROP, MANUAL_KEEP, SAMPLING_PRIORITY } = require('../../../../ext/tags') +const { DD_MAJOR } = require('../../../../version') const SpanContext = require('./span_context') const dateNow = Date.now @@ -103,7 +106,7 @@ class DatadogSpan { this._spanContext = this._createContext(parent, fields) this._spanContext._name = operationName - this._spanContext._tags = tags + Object.assign(this._spanContext.getTags(), tags) this._spanContext._hostname = hostname this._spanContext._trace.started.push(this) @@ -145,7 +148,7 @@ class DatadogSpan { toString () { const spanContext = this.context() - const resourceName = spanContext._tags['resource.name'] || '' + const resourceName = spanContext.getTag('resource.name') || '' const resource = resourceName.length > 100 ? `${resourceName.slice(0, 97)}...` : resourceName @@ -153,7 +156,7 @@ class DatadogSpan { traceId: spanContext._traceId, spanId: spanContext._spanId, parentId: spanContext._parentId, - service: spanContext._tags['service.name'], + service: spanContext.getTag('service.name'), name: spanContext._name, resource, }) @@ -199,12 +202,52 @@ class DatadogSpan { } setTag (key, value) { - this._addTags({ [key]: value }) + this._spanContext.setTag(key, value) + + if (isSamplingPriorityTag(key) && this._spanContext._sampling.priority === undefined) { + this._prioritySampler.sample(this, false) + } + + if (tagsUpdateCh.hasSubscribers) { + tagsUpdateCh.publish(this) + } + return this } addTags (keyValueMap) { - this._addTags(keyValueMap) + // v6 hot path: `Object.assign` straight onto the live tag map. The + // string and array shapes never appeared in the public TypeScript + // surface, and no internal v6 caller passes one (see MIGRATING.md). + // v5 still accepts both via `tagger.add` for `config.tags` / + // `options.tags` callers that pass `'key:val,key:val'` strings. + const tags = this._spanContext.getTags() + let mayChangeSamplingPriority + + if (keyValueMap !== null && typeof keyValueMap === 'object' && !Array.isArray(keyValueMap)) { + Object.assign(tags, keyValueMap) + mayChangeSamplingPriority = + MANUAL_KEEP in keyValueMap || + MANUAL_DROP in keyValueMap || + SAMPLING_PRIORITY in keyValueMap + } else { + /* istanbul ignore if: v5 fallback, master ships 6.0.0-pre */ + if (DD_MAJOR < 6 && (typeof keyValueMap === 'string' || Array.isArray(keyValueMap))) { + tagger.add(tags, keyValueMap) + mayChangeSamplingPriority = true + } else { + return this + } + } + + if (mayChangeSamplingPriority && this._spanContext._sampling.priority === undefined) { + this._prioritySampler.sample(this, false) + } + + if (tagsUpdateCh.hasSubscribers) { + tagsUpdateCh.publish(this) + } + return this } @@ -215,8 +258,9 @@ class DatadogSpan { logEvent () {} addLink (link, attrs) { - // TODO: Remove this once we remove addLink(context, attrs) in v6.0.0 - if (link instanceof SpanContext) { + // v5 still accepts the legacy `addLink(spanContext, attrs)` shape; v6 only takes + // `addLink({ context, attributes })`. + if (DD_MAJOR < 6 && link instanceof SpanContext) { link = { context: link, attributes: attrs ?? {} } } @@ -267,12 +311,14 @@ class DatadogSpan { return } - if (this.#parentTracer._config.DD_TRACE_EXPERIMENTAL_STATE_TRACKING && !this._spanContext._tags['service.name']) { + if (this.#parentTracer._config.DD_TRACE_EXPERIMENTAL_STATE_TRACKING && !this._spanContext.getTag('service.name')) { log.error('Finishing invalid span: %s', this) } getIntegrationCounter('spans_finished', this._integrationName).inc() - this._spanContext._tags['_dd.integration'] = this._integrationName + this._spanContext.setTag('_dd.integration', this._integrationName) + + resolveServiceSource(this, this.#parentTracer._service) if (this.#parentTracer._config.DD_TRACE_EXPERIMENTAL_SPAN_COUNTS && finishedRegistry) { runtimeMetrics.decrement('runtime.node.spans.unfinished') @@ -404,16 +450,6 @@ class DatadogSpan { return startTime + now() - ticks } - - _addTags (keyValuePairs) { - tagger.add(this._spanContext._tags, keyValuePairs) - - this._prioritySampler.sample(this, false) - - if (tagsUpdateCh.hasSubscribers) { - tagsUpdateCh.publish(this) - } - } } function createRegistry (type) { @@ -423,4 +459,8 @@ function createRegistry (type) { }) } +function isSamplingPriorityTag (key) { + return key === MANUAL_KEEP || key === MANUAL_DROP || key === SAMPLING_PRIORITY +} + module.exports = DatadogSpan diff --git a/packages/dd-trace/src/opentracing/span_context.js b/packages/dd-trace/src/opentracing/span_context.js index 6543bc7dd8..16df5e3232 100644 --- a/packages/dd-trace/src/opentracing/span_context.js +++ b/packages/dd-trace/src/opentracing/span_context.js @@ -71,6 +71,55 @@ class DatadogSpanContext { const version = (this._traceparent && this._traceparent.version) || '00' return `${version}-${traceId}-${spanId}-${flags}` } + + /** + * Set a tag value. + * @param {string} key - Tag key + * @param {unknown} value - Tag value + */ + setTag (key, value) { + this._tags[key] = value + } + + /** + * Get a tag value. + * @param {string} key - Tag key + * @returns {unknown} Tag value or undefined + */ + getTag (key) { + return this._tags[key] + } + + /** + * Check if a tag exists. + * @param {string} key - Tag key + * @returns {boolean} + */ + hasTag (key) { return Object.hasOwn(this._tags, key) } + + /** + * Delete a tag. + * @param {string} key - Tag key + */ + deleteTag (key) { delete this._tags[key] } + + /** + * Get the live internal tags map. The returned reference is mutable; + * callers may assign or delete keys directly (e.g. + * `Object.assign(getTags(), tags)` in span.js). Subclasses may have + * additional sync side effects on the individual `setTag` / `deleteTag` + * setters; mutating the returned map bypasses those. + * + * @returns {object} + */ + getTags () { + return this._tags + } + + /** + * Clear all tags. + */ + clearTags () { this._tags = Object.create(null) } } module.exports = DatadogSpanContext diff --git a/packages/dd-trace/src/plugins/ci_plugin.js b/packages/dd-trace/src/plugins/ci_plugin.js index 2cdcc56c65..c98c222657 100644 --- a/packages/dd-trace/src/plugins/ci_plugin.js +++ b/packages/dd-trace/src/plugins/ci_plugin.js @@ -52,7 +52,6 @@ const { TEST_ITR_SKIPPING_ENABLED, ITR_CORRELATION_ID, TEST_SOURCE_FILE, - TEST_LEVEL_EVENT_TYPES, TEST_SUITE, getFileAndLineNumberFromError, DI_ERROR_DEBUG_INFO_CAPTURED, @@ -128,7 +127,6 @@ function getTestSuiteLevelVisibilityTags (testSuiteSpan, testFramework) { const suiteTags = { [TEST_SUITE_ID]: testSuiteSpanContext.toSpanId(), [TEST_SESSION_ID]: testSuiteSpanContext.toTraceId(), - [TEST_COMMAND]: testSuiteSpanContext._tags[TEST_COMMAND], [TEST_MODULE]: testFramework, } @@ -174,7 +172,7 @@ module.exports = class CiPlugin extends Plugin { }, } this.tracer._exporter.addMetadataTags(metadataTags) - onDone({ err, libraryConfig, requestErrorTags }) + onDone({ err, libraryConfig, repositoryRoot: this.repositoryRoot, requestErrorTags }) }) }) @@ -186,15 +184,22 @@ module.exports = class CiPlugin extends Plugin { if (!this.tracer._exporter?.getSkippableSuites) { return onDone({ err: new Error('Test optimization was not initialized correctly') }) } - this.tracer._exporter.getSkippableSuites(this.testConfiguration, (err, skippableSuites, itrCorrelationId) => { - if (err) { - log.error('Skippable suites could not be fetched. %s', err.message) - this._addRequestErrorTag(DD_CI_LIBRARY_CONFIGURATION_ERROR_SKIPPABLE_TESTS, err) - } else { - this.itrCorrelationId = itrCorrelationId + this.tracer._exporter.getSkippableSuites( + { + ...this.testConfiguration, + isCoverageReportUploadEnabled: this.libraryConfig?.isCoverageReportUploadEnabled, + }, + (err, skippableSuites, itrCorrelationId, skippableSuitesCoverage) => { + if (err) { + log.error('Skippable suites could not be fetched. %s', err.message) + this._addRequestErrorTag(DD_CI_LIBRARY_CONFIGURATION_ERROR_SKIPPABLE_TESTS, err) + } else { + this.itrCorrelationId = itrCorrelationId + this.skippableSuitesCoverage = skippableSuitesCoverage + } + onDone({ err, skippableSuites, itrCorrelationId, skippableSuitesCoverage }) } - onDone({ err, skippableSuites, itrCorrelationId }) - }) + ) }) this.addSub(`ci:${this.constructor.id}:session:start`, ({ command, frameworkVersion, rootDir }) => { @@ -213,12 +218,7 @@ module.exports = class CiPlugin extends Plugin { this.testEnvironmentMetadata ) - const metadataTags = {} - for (const testLevel of TEST_LEVEL_EVENT_TYPES) { - metadataTags[testLevel] = { - [TEST_SESSION_NAME]: testSessionName, - } - } + const metadataTags = { '*': { [TEST_COMMAND]: command, [TEST_SESSION_NAME]: testSessionName } } // tracer might not be initialized correctly if (this.tracer._exporter.addMetadataTags) { this.tracer._exporter.addMetadataTags(metadataTags) @@ -255,7 +255,7 @@ module.exports = class CiPlugin extends Plugin { }) this.addSub(`ci:${this.constructor.id}:itr:skipped-suites`, ({ skippedSuites, frameworkVersion }) => { - const testCommand = this.testSessionSpan.context()._tags[TEST_COMMAND] + const testCommand = this.command for (const testSuite of skippedSuites) { const testSuiteMetadata = { ...getTestSuiteCommonTags(testCommand, frameworkVersion, testSuite, this.constructor.id), @@ -615,7 +615,7 @@ module.exports = class CiPlugin extends Plugin { const suiteTags = { [TEST_SUITE_ID]: testSuiteSpan.context().toSpanId(), [TEST_SESSION_ID]: testSuiteSpan.context().toTraceId(), - [TEST_COMMAND]: testSuiteSpan.context()._tags[TEST_COMMAND], + [TEST_COMMAND]: testSuiteSpan.context().getTag(TEST_COMMAND), [TEST_MODULE]: this.constructor.id, ...getSessionRequestErrorTags(this.testSessionSpan), } @@ -808,7 +808,7 @@ module.exports = class CiPlugin extends Plugin { } getTestTelemetryTags (testSpan) { - const activeSpanTags = testSpan.context()._tags + const activeSpanTags = testSpan.context().getTags() return { hasCodeOwners: !!activeSpanTags[TEST_CODE_OWNERS] || undefined, isNew: activeSpanTags[TEST_IS_NEW] === 'true' || undefined, diff --git a/packages/dd-trace/src/plugins/database.js b/packages/dd-trace/src/plugins/database.js index 3ee4555d10..e0e44aeb26 100644 --- a/packages/dd-trace/src/plugins/database.js +++ b/packages/dd-trace/src/plugins/database.js @@ -49,7 +49,7 @@ class DatabasePlugin extends StoragePlugin { * @returns {string} */ #createDBMPropagationCommentService (serviceName, span, peerData) { - const spanTags = span.context()._tags + const spanTags = span.context().getTags() const dddb = spanTags['db.name'] const ddh = spanTags['out.host'] const cacheKey = `${dddb ?? ''}\0${ddh ?? ''}\0${serviceName ?? ''}` @@ -91,23 +91,24 @@ class DatabasePlugin extends StoragePlugin { return null } - const peerData = this.getPeerService(span.context()._tags) + const peerData = this.getPeerService(span.context().getTags()) const dbmService = this.#getDbmServiceName(serviceName, peerData) const servicePropagation = this.#createDBMPropagationCommentService(dbmService, span, peerData) let dbmComment = servicePropagation - // Add propagation hash if both process tags and SQL base hash injection are enabled - if (propagationHash.isEnabled() && this.config['dbm.injectSqlBaseHash']) { + // Add propagation hash if process tags are enabled and either SQL base hash injection is enabled + // or dynamic_service mode implicitly enables it + if (propagationHash.isEnabled() && (this.config['dbm.injectSqlBaseHash'] || mode === 'dynamic_service')) { const hashBase64 = propagationHash.getHashBase64() if (hashBase64) { dbmComment += `,ddsh='${hashBase64}'` // Add hash to span meta as a tag - span.setTag('_dd.dbm.propagation_hash', hashBase64) + span.setTag('_dd.propagated_hash', hashBase64) } } - if (disableFullMode || mode === 'service') { + if (disableFullMode || mode === 'service' || mode === 'dynamic_service') { return dbmComment } else if (mode === 'full') { span.setTag('_dd.dbm_trace_injected', 'true') diff --git a/packages/dd-trace/src/plugins/index.js b/packages/dd-trace/src/plugins/index.js index 919ddb583c..02877d1452 100644 --- a/packages/dd-trace/src/plugins/index.js +++ b/packages/dd-trace/src/plugins/index.js @@ -4,6 +4,7 @@ const plugins = { get '@anthropic-ai/sdk' () { return require('../../../datadog-plugin-anthropic/src') }, get '@apollo/gateway' () { return require('../../../datadog-plugin-apollo/src') }, get '@aws-sdk/smithy-client' () { return require('../../../datadog-plugin-aws-sdk/src') }, + get '@azure/cosmos' () { return require('../../../datadog-plugin-azure-cosmos/src') }, get '@azure/event-hubs' () { return require('../../../datadog-plugin-azure-event-hubs/src') }, get '@azure/functions' () { return require('../../../datadog-plugin-azure-functions/src') }, get '@modelcontextprotocol/sdk' () { return require('../../../datadog-plugin-modelcontextprotocol-sdk/src') }, @@ -30,6 +31,7 @@ const plugins = { get '@prisma/client' () { return require('../../../datadog-plugin-prisma/src') }, get './runtime/library.js' () { return require('../../../datadog-plugin-prisma/src') }, get '@redis/client' () { return require('../../../datadog-plugin-redis/src') }, + get '@smithy/core' () { return require('../../../datadog-plugin-aws-sdk/src') }, get '@smithy/smithy-client' () { return require('../../../datadog-plugin-aws-sdk/src') }, get '@vitest/runner' () { return require('../../../datadog-plugin-vitest/src') }, get '@langchain/langgraph' () { return require('../../../datadog-plugin-langgraph/src') }, @@ -90,6 +92,8 @@ const plugins = { get mongoose () { return require('../../../datadog-plugin-mongoose/src') }, get mysql () { return require('../../../datadog-plugin-mysql/src') }, get mysql2 () { return require('../../../datadog-plugin-mysql2/src') }, + get '@nats-io/nats-core' () { return require('../../../datadog-plugin-nats/src') }, + get '@nats-io/transport-node' () { return require('../../../datadog-plugin-nats/src') }, get net () { return require('../../../datadog-plugin-net/src') }, get next () { return require('../../../datadog-plugin-next/src') }, get 'node:dns' () { return require('../../../datadog-plugin-dns/src') }, diff --git a/packages/dd-trace/src/plugins/log_injection.js b/packages/dd-trace/src/plugins/log_injection.js new file mode 100644 index 0000000000..b4a0c1604b --- /dev/null +++ b/packages/dd-trace/src/plugins/log_injection.js @@ -0,0 +1,56 @@ +'use strict' + +const { LOG } = require('../../../../ext/formats') +const { storage } = require('../../../datadog-core') + +const legacyStorage = storage('legacy') + +/** + * Runs the tracer's log injector and returns the populated log holder, or + * `undefined` when the propagator emitted no `dd` field (no span, no + * service / version / env). Hot-path callers gate on the return. + * + * @param {object} tracer + * @returns {{ dd: object } | undefined} + */ +function buildLogHolder (tracer) { + const logHolder = {} + tracer.inject(legacyStorage.getStore()?.span, LOG, logHolder) + return logHolder.dd ? logHolder : undefined +} + +/** + * @param {object} message Caller-owned log record; never mutated. + * @param {{ dd: object }} logHolder Holds the dd fields injected by the tracer. + */ +function messageProxy (message, logHolder) { + return new Proxy(message, { + get (target, key) { + if (shouldOverride(target, key)) return logHolder.dd + return target[key] + }, + set (target, key, value) { + return Reflect.set(target, key, value) + }, + ownKeys (target) { + const ownKeys = Reflect.ownKeys(target) + if (!Object.hasOwn(target, 'dd') && Reflect.isExtensible(target)) { + ownKeys.push('dd') + } + return ownKeys + }, + getOwnPropertyDescriptor (target, p) { + return Reflect.getOwnPropertyDescriptor(shouldOverride(target, p) ? logHolder : target, p) + }, + }) +} + +/** + * @param {object} target + * @param {string | symbol} p + */ +function shouldOverride (target, p) { + return p === 'dd' && !Object.hasOwn(target, p) && Reflect.isExtensible(target) +} + +module.exports = { buildLogHolder, messageProxy } diff --git a/packages/dd-trace/src/plugins/log_plugin.js b/packages/dd-trace/src/plugins/log_plugin.js index 38778ee20e..61bff5a499 100644 --- a/packages/dd-trace/src/plugins/log_plugin.js +++ b/packages/dd-trace/src/plugins/log_plugin.js @@ -1,55 +1,8 @@ 'use strict' -const { LOG } = require('../../../../ext/formats') -const { storage } = require('../../../datadog-core') const Plugin = require('./plugin') -const legacyStorage = storage('legacy') - -function messageProxy (message, holder) { - return new Proxy(message, { - get (target, key) { - if (shouldOverride(target, key)) { - return holder.dd - } - - return target[key] - }, - set (target, key, value) { - return Reflect.set(target, key, value) - }, - ownKeys (target) { - const ownKeys = Reflect.ownKeys(target) - if (!Object.hasOwn(target, 'dd') && Reflect.isExtensible(target)) { - ownKeys.push('dd') - } - return ownKeys - }, - getOwnPropertyDescriptor (target, p) { - return Reflect.getOwnPropertyDescriptor(shouldOverride(target, p) ? holder : target, p) - }, - }) -} - -function shouldOverride (target, p) { - return p === 'dd' && !Object.hasOwn(target, p) && Reflect.isExtensible(target) -} - -module.exports = class LogPlugin extends Plugin { - constructor (...args) { - super(...args) - - this.addSub(`apm:${this.constructor.id}:log`, (arg) => { - const span = legacyStorage.getStore()?.span - - // NOTE: This needs to run whether or not there is a span - // so service, version, and env will always get injected. - const holder = {} - this.tracer.inject(span, LOG, holder) - arg.message = messageProxy(arg.message, holder) - }) - } - +class LogPlugin extends Plugin { configure (config) { return super.configure({ ...config, @@ -57,3 +10,5 @@ module.exports = class LogPlugin extends Plugin { }) } } + +module.exports = LogPlugin diff --git a/packages/dd-trace/src/plugins/outbound.js b/packages/dd-trace/src/plugins/outbound.js index 458d21a579..05ae4bcae3 100644 --- a/packages/dd-trace/src/plugins/outbound.js +++ b/packages/dd-trace/src/plugins/outbound.js @@ -125,7 +125,7 @@ class OutboundPlugin extends TracingPlugin { */ tagPeerService (span) { if (this._tracerConfig.spanComputePeerService) { - const peerData = this.getPeerService(span.context()._tags) + const peerData = this.getPeerService(span.context().getTags()) if (peerData !== undefined) { span.addTags(this.getPeerServiceRemap(peerData)) } diff --git a/packages/dd-trace/src/plugins/plugin.js b/packages/dd-trace/src/plugins/plugin.js index 1495429b33..6a117dde41 100644 --- a/packages/dd-trace/src/plugins/plugin.js +++ b/packages/dd-trace/src/plugins/plugin.js @@ -6,6 +6,8 @@ const dc = require('dc-polyfill') const logger = require('../log') const { storage } = require('../../../datadog-core') +const legacyStorage = storage('legacy') + /** * Base class for all Datadog plugins. * @@ -28,8 +30,7 @@ class Subscription { constructor (event, handler) { this._channel = dc.channel(event) this._handler = (message, name) => { - const store = storage('legacy').getStore() - if (!store || !store.noop) { + if (!legacyStorage.getHandle()?.noop) { handler(message, name) } } @@ -50,20 +51,20 @@ class StoreBinding { constructor (event, transform) { this._channel = dc.channel(event) this._transform = data => { - const store = storage('legacy').getStore() + const handle = legacyStorage.getHandle() - return !store || !store.noop || (data && Object.hasOwn(data, 'currentStore')) + return !handle?.noop || (data && Object.hasOwn(data, 'currentStore')) ? transform(data) - : store + : legacyStorage.getStore() } } enable () { - this._channel.bindStore(storage('legacy'), this._transform) + this._channel.bindStore(legacyStorage, this._transform) } disable () { - this._channel.unbindStore(storage('legacy')) + this._channel.unbindStore(legacyStorage) } } @@ -102,24 +103,21 @@ module.exports = class Plugin { * @returns {void} */ enter (span, store) { - store = store || storage('legacy').getStore() - storage('legacy').enterWith({ ...store, span }) + store = store || legacyStorage.getStore() + legacyStorage.enterWith({ ...store, span }) } /** * Subscribe to a diagnostic channel with automatic error handling and enable/disable lifecycle. * * @param {string} channelName Diagnostic channel name. - * @param {(...args: unknown[]) => unknown} handler Handler invoked on messages. + * @param {(message: unknown, name: string) => unknown} handler Handler invoked on messages. * @returns {void} */ addSub (channelName, handler) { - /** - * @type {typeof handler} - */ - const wrappedHandler = (...args) => { + const wrappedHandler = (message, name) => { try { - return handler.apply(this, args) + return handler.call(this, message, name) } catch (error) { logger.error('Error in plugin handler:', error) logger.info('Disabling plugin: %s', this.constructor.name) @@ -147,12 +145,12 @@ module.exports = class Plugin { * @returns {void} */ addError (error) { - const store = storage('legacy').getStore() + const store = legacyStorage.getStore() if (!store || !store.span) return const span = /** @type {import('../opentracing/span')} */ (store.span) - if (!span._spanContext._tags.error) { + if (!span.context().getTag('error')) { span.setTag('error', error || 1) } } diff --git a/packages/dd-trace/src/plugins/structured_log_plugin.js b/packages/dd-trace/src/plugins/structured_log_plugin.js deleted file mode 100644 index 868ed395ea..0000000000 --- a/packages/dd-trace/src/plugins/structured_log_plugin.js +++ /dev/null @@ -1,9 +0,0 @@ -'use strict' - -const LogPlugin = require('./log_plugin') - -module.exports = class StructuredLogPlugin extends LogPlugin { - _isEnabled (config) { - return super._isEnabled(config) || (config.enabled && config.logInjection === 'structured') - } -} diff --git a/packages/dd-trace/src/plugins/tracing.js b/packages/dd-trace/src/plugins/tracing.js index 8d2457dd5f..0f4e2dd349 100644 --- a/packages/dd-trace/src/plugins/tracing.js +++ b/packages/dd-trace/src/plugins/tracing.js @@ -3,6 +3,7 @@ const { storage } = require('../../../datadog-core') const analyticsSampler = require('../analytics_sampler') const { COMPONENT, SVC_SRC_KEY } = require('../constants') +const { INTEGRATION_SERVICE } = require('../service-naming/source-resolver') const Plugin = require('./plugin') const legacyStorage = storage('legacy') @@ -99,13 +100,11 @@ class TracingPlugin extends Plugin { const bindName = `bind${event.charAt(0).toUpperCase()}${event.slice(1)}` if (this[event]) { - this.addTraceSub(event, message => { - this[event](message) - }) + this.addTraceSub(event, this[event].bind(this)) } if (this[bindName]) { - this.addTraceBind(event, message => this[bindName](message)) + this.addTraceBind(event, this[bindName].bind(this)) } } } @@ -128,12 +127,49 @@ class TracingPlugin extends Plugin { this.addBind(`${prefix}:${eventName}`, transform) } + /** + * Record the integration's intended `service.name` on a span without writing the tag. + * + * Use this when the plugin has already set `service.name` directly on the span (e.g. via + * the `tracer.startSpan` tags object) and only needs to stamp the marker so + * `Span#finish` can later detect user overrides and re-attribute the source. + * + * Prefer {@link TracingPlugin#setServiceName} when the tag itself also needs to be written. + * + * No-op when there is nothing meaningful to record + * + * @param {import('../opentracing/span')} span Internal DatadogSpan instance. + * @param {string|undefined} name Service name the integration is claiming. + */ + stampIntegrationService (span, name) { + if (name === undefined) return + span[INTEGRATION_SERVICE] = name + } + + /** + * Set `service.name` on a span on behalf of this integration and stamp the marker. + * + * Use this for late-binding cases where the service is not known at startSpan time + * (e.g. web framework config applied after the span is already open). + * + * For spans started via {@link TracingPlugin#startSpan}, pass `service` as an option + * instead — it sets the tag and stamps the marker in one step. + * + * @param {import('../opentracing/span')} span Internal DatadogSpan instance. + * @param {string} name Service name the integration is claiming. + */ + setServiceName (span, name) { + // eslint-disable-next-line eslint-rules/eslint-prefer-set-service-name -- this is the implementation + span._spanContext.setTag('service.name', name) + this.stampIntegrationService(span, name) + } + /** * @param {unknown} error * @param {import('../../../..').Span} [span] */ addError (error, span = this.activeSpan) { - if (span && !span._spanContext._tags.error) { + if (span && !span.context().getTag('error')) { // Errors may be wrapped in a context. span.setTag('error', error?.error || error || 1) } @@ -224,6 +260,8 @@ class TracingPlugin extends Plugin { links: childOf?._links, }) + this.stampIntegrationService(span, serviceName) + analyticsSampler.sample(span, config.measured) // TODO: Remove this after migration to TracingChannel is done. diff --git a/packages/dd-trace/src/plugins/util/test.js b/packages/dd-trace/src/plugins/util/test.js index a827c8f537..4a5985ca7b 100644 --- a/packages/dd-trace/src/plugins/util/test.js +++ b/packages/dd-trace/src/plugins/util/test.js @@ -229,11 +229,11 @@ const BASE_LIKE_BRANCH_FILTER = /^(main|master|preprod|prod|dev|development|trun /** * Returns request error tags from a test session span for propagation to child events. - * @param {{ context: () => { _tags?: Record } } | undefined} sessionSpan + * @param {{ context: () => { getTag?: (key: string) => string } } | undefined} sessionSpan * @returns {Record} */ function getSessionRequestErrorTags (sessionSpan) { - const tags = sessionSpan?.context()._tags + const tags = sessionSpan?.context()?.getTags?.() const sessionRequestErrorTags = {} if (!tags || typeof tags !== 'object') return {} if (tags[DD_CI_LIBRARY_CONFIGURATION_ERROR_SETTINGS] === 'true') { @@ -253,11 +253,11 @@ function getSessionRequestErrorTags (sessionSpan) { /** * Returns ITR skipping-enabled tags from a test session span for propagation to child events. - * @param {{ context: () => { _tags?: Record } } | undefined} sessionSpan + * @param {{ context: () => { getTags?: () => Record } } | undefined} sessionSpan * @returns {Record} */ function getSessionItrSkippingEnabledTags (sessionSpan) { - const tags = sessionSpan?.context()._tags + const tags = sessionSpan?.context()?.getTags?.() if (!tags || typeof tags !== 'object') return {} if (tags[TEST_ITR_SKIPPING_ENABLED] !== undefined) { return { @@ -418,6 +418,12 @@ module.exports = { ITR_CORRELATION_ID, addIntelligentTestRunnerSpanTags, getCoveredFilenamesFromCoverage, + getCoveredFilesFromCoverage, + getExecutableFilesFromCoverage, + getRelativeCoverageFiles, + getLineCoverageBitmap, + applySkippedCoverageToCoverage, + getTestCoverageLinesPercentage, resetCoverage, mergeCoverage, fromCoverageMapToCoverage, @@ -952,7 +958,6 @@ function getTestLevelCommonTags (command, testFrameworkVersion, testFramework) { return { [TEST_FRAMEWORK_VERSION]: testFrameworkVersion, [LIBRARY_VERSION]: ddTraceVersion, - [TEST_COMMAND]: command, [TEST_TYPE]: getTestTypeFromFramework(testFramework), } } @@ -1030,15 +1035,233 @@ function addIntelligentTestRunnerSpanTags ( } function getCoveredFilenamesFromCoverage (coverage) { - const coverageMap = istanbul.createCoverageMap(coverage) + return getCoveredFilesFromCoverage(coverage).map(({ filename }) => filename) +} - return coverageMap - .files() - .filter(filename => { - const fileCoverage = coverageMap.fileCoverageFor(filename) - const lineCoverage = fileCoverage.getLineCoverage() - return Object.entries(lineCoverage).some(([, numExecutions]) => !!numExecutions) - }) +function getCoverageMap (coverage) { + if (coverage?.files && coverage?.fileCoverageFor) { + return coverage + } + return istanbul.createCoverageMap(coverage) +} + +function getCoveredFilesFromCoverage (coverage) { + const coverageMap = getCoverageMap(coverage) + const coverageFiles = [] + + for (const filename of coverageMap.files()) { + const fileCoverage = coverageMap.fileCoverageFor(filename) + const bitmap = getLineCoverageBitmap(fileCoverage.getLineCoverage(), true) + if (bitmap) { + coverageFiles.push({ filename, bitmap }) + } + } + + return coverageFiles +} + +function getExecutableFilesFromCoverage (coverage) { + const coverageMap = getCoverageMap(coverage) + const coverageFiles = [] + + for (const filename of coverageMap.files()) { + const fileCoverage = coverageMap.fileCoverageFor(filename) + const bitmap = getLineCoverageBitmap(fileCoverage.getLineCoverage()) + if (bitmap) { + coverageFiles.push({ filename, bitmap }) + } + } + + return coverageFiles +} + +function getRelativeCoverageFiles (coverageFiles, rootDir) { + return coverageFiles.map(({ filename, bitmap }) => ({ + filename: getTestSuitePath(filename, rootDir), + bitmap, + })) +} + +function getLineCoverageBitmap (lineCoverage, onlyCoveredLines = false) { + let maxLine = 0 + const lines = [] + + for (const [line, hits] of Object.entries(lineCoverage)) { + if (onlyCoveredLines && !hits) continue + + const lineNumber = Number(line) + if (!Number.isSafeInteger(lineNumber) || lineNumber <= 0) continue + + lines.push(lineNumber) + if (lineNumber > maxLine) { + maxLine = lineNumber + } + } + + if (maxLine === 0) return + + const bitmap = Buffer.alloc(Math.ceil((maxLine + 1) / 8)) + for (const lineNumber of lines) { + bitmap[lineNumber >> 3] |= 1 << (lineNumber % 8) + } + + return bitmap +} + +function mergeCoverageBitmaps (targetBitmap, bitmap) { + if (!targetBitmap) { + return Buffer.from(bitmap) + } + + if (targetBitmap.length < bitmap.length) { + const biggerBitmap = Buffer.alloc(bitmap.length) + targetBitmap.copy(biggerBitmap) + targetBitmap = biggerBitmap + } + + for (let i = 0; i < bitmap.length; i++) { + targetBitmap[i] |= bitmap[i] + } + + return targetBitmap +} + +function countBitmapBits (bitmap) { + let count = 0 + + for (const byte of bitmap) { + let value = byte + while (value) { + value &= value - 1 + count++ + } + } + + return count +} + +function countCoveredExecutableBits (coveredBitmap, executableBitmap) { + if (!coveredBitmap) return 0 + + let count = 0 + const length = Math.min(coveredBitmap.length, executableBitmap.length) + + for (let i = 0; i < length; i++) { + let value = coveredBitmap[i] & executableBitmap[i] + while (value) { + value &= value - 1 + count++ + } + } + + return count +} + +function getCoverageFileBitmap (bitmap) { + if (!bitmap) return + if (Buffer.isBuffer(bitmap)) return bitmap + if (ArrayBuffer.isView(bitmap)) { + return Buffer.from(bitmap.buffer, bitmap.byteOffset, bitmap.byteLength) + } + if (typeof bitmap === 'string') { + return Buffer.from(bitmap, 'base64') + } +} + +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 + + for (const [filename, bitmap] of Object.entries(skippedCoverage)) { + const coverageBitmap = getCoverageFileBitmap(bitmap) + if (!coverageBitmap) continue + targetMap.set(filename, mergeCoverageBitmaps(targetMap.get(filename), coverageBitmap)) + } +} + +function hasSkippedCoverage (skippedCoverage) { + return skippedCoverage && typeof skippedCoverage === 'object' && Object.keys(skippedCoverage).length > 0 +} + +function getTestCoverageLinesPercentage (coverage, skippedCoverage, rootDir) { + const executableLinesByFile = new Map() + const coveredLinesByFile = new Map() + + addCoverageFilesToMap(getExecutableFilesFromCoverage(coverage), executableLinesByFile, rootDir) + addCoverageFilesToMap(getCoveredFilesFromCoverage(coverage), coveredLinesByFile, rootDir) + addSkippedCoverageToMap(skippedCoverage, coveredLinesByFile) + + let totalExecutableLines = 0 + let totalCoveredLines = 0 + + for (const [filename, executableLines] of executableLinesByFile) { + totalExecutableLines += countBitmapBits(executableLines) + totalCoveredLines += countCoveredExecutableBits(coveredLinesByFile.get(filename), executableLines) + } + + return totalExecutableLines === 0 ? 0 : Math.floor((totalCoveredLines / totalExecutableLines) * 10_000) / 100 +} + +function isLineCoveredByBitmap (bitmap, line) { + if (!Number.isSafeInteger(line) || line <= 0) return false + + const byteIndex = line >> 3 + return byteIndex < bitmap.length && !!(bitmap[byteIndex] & (1 << (line % 8))) +} + +function getSkippedCoverageByFilename (skippedCoverage) { + const skippedCoverageByFilename = new Map() + addSkippedCoverageToMap(skippedCoverage, skippedCoverageByFilename) + return skippedCoverageByFilename +} + +function applySkippedCoverageToFileCoverage (fileCoverage, skippedBitmap) { + let updated = false + for (const [statementId, statementLocation] of Object.entries(fileCoverage.data.statementMap)) { + const startLine = statementLocation?.start?.line + if (!isLineCoveredByBitmap(skippedBitmap, startLine)) continue + if (fileCoverage.data.s[statementId] > 0) continue + + fileCoverage.data.s[statementId] = 1 + updated = true + } + return updated +} + +/** + * Applies backend skipped-suite coverage to an Istanbul coverage map. + * @param {object} coverage + * @param {object} skippedCoverage + * @param {string} [rootDir] + * @returns {boolean} + */ +function applySkippedCoverageToCoverage (coverage, skippedCoverage, rootDir) { + if (!hasSkippedCoverage(skippedCoverage)) return false + + const coverageMap = getCoverageMap(coverage) + const skippedCoverageByFilename = getSkippedCoverageByFilename(skippedCoverage) + let matched = false + + for (const filename of coverageMap.files()) { + const relativeFilename = rootDir ? getTestSuitePath(filename, rootDir) : filename + const skippedBitmap = skippedCoverageByFilename.get(relativeFilename) + if (!skippedBitmap) continue + + const fileCoverage = coverageMap.fileCoverageFor(filename) + applySkippedCoverageToFileCoverage(fileCoverage, skippedBitmap) + matched = true + } + + return matched } function resetCoverage (coverage) { diff --git a/packages/dd-trace/src/plugins/util/web.js b/packages/dd-trace/src/plugins/util/web.js index dc2b4109f1..80871af8da 100644 --- a/packages/dd-trace/src/plugins/util/web.js +++ b/packages/dd-trace/src/plugins/util/web.js @@ -10,16 +10,14 @@ const kinds = require('../../../../../ext/kinds') const { ERROR_MESSAGE } = require('../../constants') const TracingPlugin = require('../tracing') const { storage } = require('../../../../datadog-core') +const legacyStorage = storage('legacy') const urlFilter = require('./urlfilter') const { createInferredProxySpan, finishInferredProxySpan } = require('./inferred_proxy') const { extractURL, obfuscateQs, calculateHttpEndpoint } = require('./url') -let extractIp - const WEB = types.WEB const SERVER = kinds.SERVER const RESOURCE_NAME = tags.RESOURCE_NAME -const SERVICE_NAME = tags.SERVICE_NAME const SPAN_TYPE = tags.SPAN_TYPE const SPAN_KIND = tags.SPAN_KIND const ERROR = tags.ERROR @@ -35,7 +33,6 @@ const HTTP_CLIENT_IP = tags.HTTP_CLIENT_IP const MANUAL_DROP = tags.MANUAL_DROP const contexts = new WeakMap() -const ends = new WeakMap() // TODO: change this to no longer rely on creating a dummy plugin to be able to access startSpan function createWebPlugin (tracer, config = {}) { @@ -67,7 +64,9 @@ const web = { const middleware = getMiddlewareSetting(config) const queryStringObfuscation = getQsObfuscator(config) - extractIp = config.clientIpEnabled && require('./ip_extractor').extractIp + const extractIp = config.clientIpEnabled + ? require('./ip_extractor').extractIp + : undefined return { ...config, @@ -77,6 +76,7 @@ const web = { filter, middleware, queryStringObfuscation, + extractIp, } }, @@ -87,7 +87,7 @@ const web = { if (!span) return span.context()._name = `${name}.request` - span.context()._tags.component = name + span.context().setTag('component', name) span._integrationName = name web.setConfig(req, config) @@ -105,7 +105,7 @@ const web = { } if (config.service) { - span.setTag(SERVICE_NAME, config.service) + web.plugin.setServiceName(span, config.service) } analyticsSampler.sample(span, config.measured, true) @@ -126,7 +126,6 @@ const web = { context.tracer = tracer context.span = span context.res = res - context.store = storage('legacy').getStore() this.setConfig(req, config) addRequestTags(context, this.TYPE) @@ -204,7 +203,7 @@ const web = { startServerlessSpanWithInferredProxy (tracer, config, name, req, traceCtx) { const headers = req.headers const reqCtx = contexts.get(req) - const store = storage('legacy').getStore() + const store = legacyStorage.getStore() const pubsubSpan = store?.span?._name === 'pubsub.push.receive' ? store.span : null let childOf = pubsubSpan || tracer.extract(FORMAT_HTTP_HEADERS, headers) @@ -225,9 +224,11 @@ const web = { const context = contexts.get(req) const { span, inferredProxySpan, error } = context - const spanHasExistingError = span.context()._tags.error || span.context()._tags[ERROR_MESSAGE] + const spanContext = span.context() + const spanHasExistingError = spanContext.getTag('error') || spanContext.getTag(ERROR_MESSAGE) const inferredSpanContext = inferredProxySpan?.context() - const inferredSpanHasExistingError = inferredSpanContext?._tags.error || inferredSpanContext?._tags[ERROR_MESSAGE] + const inferredSpanHasExistingError = inferredSpanContext?.getTag('error') || + inferredSpanContext?.getTag(ERROR_MESSAGE) const isValidStatusCode = context.config.validateStatus(statusCode) @@ -266,7 +267,16 @@ const web = { if (context.finished && !req.stream) return + // `addRequestTags` is idempotent: in the normal HTTP path it ran during + // `web.startSpan`. Serverless callers (e.g. Azure Functions) skip + // `web.startSpan` and rely on this call to do the request-side work. addRequestTags(context, spanType) + // Configured-header tagging runs at finish time. Framework plugins + // (connect, express, ...) install their own config via `setFramework` + // after `web.startSpan` has already locked the http-plugin config in; + // tagging earlier would use the http-plugin's `headers` list and drop + // the framework's. + addRequestHeaders(context) addResponseTags(context) context.config.hooks.request(context.span, req, res) @@ -293,11 +303,18 @@ const web = { const writeHead = res.writeHead return function (statusCode, statusMessage, headers) { - headers = typeof statusMessage === 'string' ? headers : statusMessage - headers = { ...res.getHeaders(), ...headers } - - if (req.method.toLowerCase() === 'options' && isOriginAllowed(req, headers)) { - addAllowHeaders(req, res, headers) + // CORS preflight tagging only matters for OPTIONS requests. Skip the + // getHeaders() spread + isOriginAllowed work entirely for the common + // GET / POST / etc. case. Node's http module passes `req.method` + // through unchanged, so all standard methods are uppercase; the + // `toLowerCase` fallback covers any non-standard caller. + if (req.method === 'OPTIONS' || req.method.toLowerCase() === 'options') { + headers = typeof statusMessage === 'string' ? headers : statusMessage + headers = { ...res.getHeaders(), ...headers } + + if (isOriginAllowed(req, headers)) { + addAllowHeaders(req, res, headers) + } } return writeHead.apply(this, arguments) @@ -306,34 +323,6 @@ const web = { getContext (req) { return contexts.get(req) }, - wrapRes (context, req, res, end) { - return function (...args) { - web.finishAll(context) - - return end.apply(res, args) - } - }, - wrapEnd (context) { - const req = context.req - const res = context.res - const end = res.end - - res.writeHead = web.wrapWriteHead(context) - - ends.set(res, this.wrapRes(context, req, res, end)) - - Object.defineProperty(res, 'end', { - configurable: true, - get () { - return ends.get(this) - }, - set (value) { - ends.set(this, function (...args) { - return storage('legacy').run(context.store, value, ...args) - }) - }, - }) - }, setRouteOrEndpointTag (req) { const context = contexts.get(req) @@ -379,6 +368,16 @@ function splitHeader (str) { function addRequestTags (context, spanType) { const { req, span, inferredProxySpan, config } = context + const spanContext = span.context() + + // Idempotency guard. `addRequestTags` runs in `web.startSpan` for the + // normal HTTP path and again in `web.finishSpan`; without this guard the + // second call would re-extract the URL, re-obfuscate the query string, + // and re-publish five `tagsUpdateCh` events with the same values. The + // serverless path skips `startSpan` and lands here first, in which case + // HTTP_URL is unset and the work runs normally. + if (spanContext.hasTag(HTTP_URL)) return + const url = extractURL(req) const type = spanType ?? WEB @@ -391,8 +390,8 @@ function addRequestTags (context, spanType) { }) // if client ip has already been set by appsec, no need to run it again - if (extractIp && !span.context()._tags.hasOwnProperty(HTTP_CLIENT_IP)) { - const clientIp = extractIp(config, req) + if (config.extractIp && !spanContext.hasTag(HTTP_CLIENT_IP)) { + const clientIp = config.extractIp(config, req) if (clientIp) { span.setTag(HTTP_CLIENT_IP, clientIp) @@ -410,8 +409,6 @@ function addRequestTags (context, spanType) { if (securityTest !== undefined) { span.setTag(`${HTTP_REQUEST_HEADERS}.x-datadog-security-test`, securityTest) } - - addHeaders(context) } function addResponseTags (context) { @@ -426,14 +423,26 @@ function addResponseTags (context) { [HTTP_STATUS_CODE]: res.statusCode, }) + addResponseHeaders(context) + web.addStatusError(req, res.statusCode) } function applyRouteOrEndpointTag (context) { const { paths, span, config } = context if (!span) return - const tags = span.context()._tags - const route = paths.join('') + const spanContext = span.context() + + // AppSec calls `web.setRouteOrEndpointTag` from a pre-finish hook so the + // route/endpoint tags are available for API Security sampling, and the + // normal finish-time path runs this again. Either tag being present + // means the work has already been done; paths are stable between the + // two calls, so the second pass has nothing to add. + if (spanContext.hasTag(HTTP_ROUTE) || spanContext.hasTag(HTTP_ENDPOINT)) return + + // Skip the `Array.prototype.join` builtin in the empty / single-segment + // cases; `paths[0]` covers both (`undefined` is falsy for the empty case). + const route = paths.length > 1 ? paths.join('') : paths[0] if (route) { // Use http.route from trusted framework instrumentation. @@ -441,44 +450,49 @@ function applyRouteOrEndpointTag (context) { return } - if (!config.resourceRenamingEnabled || tags[HTTP_ENDPOINT]) { - return - } + if (!config.resourceRenamingEnabled) return // Route is unavailable, compute http.endpoint once. - const url = tags[HTTP_URL] + const url = spanContext.getTag(HTTP_URL) const endpoint = url ? calculateHttpEndpoint(url) : '/' span.setTag(HTTP_ENDPOINT, endpoint) } function addResourceTag (context) { const { req, span } = context - const tags = span.context()._tags + const spanContext = span.context() - if (tags[RESOURCE_NAME]) return + if (spanContext.getTag(RESOURCE_NAME)) return - const resource = [req.method, tags[HTTP_ROUTE]] + const resource = [req.method, spanContext.getTag(HTTP_ROUTE)] .filter(Boolean) .join(' ') span.setTag(RESOURCE_NAME, resource) } -function addHeaders (context) { - const { req, res, config, span, inferredProxySpan } = context +function addRequestHeaders (context) { + const { req, config, span, inferredProxySpan } = context for (const [key, tag] of config.headers) { const reqHeader = req.headers[key] - const resHeader = res.getHeader(key) - if (reqHeader) { - span.setTag(tag || `${HTTP_REQUEST_HEADERS}.${key}`, reqHeader) - inferredProxySpan?.setTag(tag || `${HTTP_REQUEST_HEADERS}.${key}`, reqHeader) + const tagName = tag || `${HTTP_REQUEST_HEADERS}.${key}` + span.setTag(tagName, reqHeader) + inferredProxySpan?.setTag(tagName, reqHeader) } + } +} + +function addResponseHeaders (context) { + const { res, config, span, inferredProxySpan } = context + for (const [key, tag] of config.headers) { + const resHeader = res.getHeader(key) if (resHeader) { - span.setTag(tag || `${HTTP_RESPONSE_HEADERS}.${key}`, resHeader) - inferredProxySpan?.setTag(tag || `${HTTP_RESPONSE_HEADERS}.${key}`, resHeader) + const tagName = tag || `${HTTP_RESPONSE_HEADERS}.${key}` + span.setTag(tagName, resHeader) + inferredProxySpan?.setTag(tagName, resHeader) } } } diff --git a/packages/dd-trace/src/priority_sampler.js b/packages/dd-trace/src/priority_sampler.js index 983be7376a..c55fcda449 100644 --- a/packages/dd-trace/src/priority_sampler.js +++ b/packages/dd-trace/src/priority_sampler.js @@ -125,7 +125,7 @@ class PrioritySampler { log.trace(span, auto) - const tag = this._getPriorityFromTags(context._tags, context) + const tag = this._getPriorityFromTags(context.getTags(), context) if (this.validate(tag)) { context._sampling.priority = tag @@ -300,7 +300,7 @@ class PrioritySampler { * @returns {SamplingPriority} */ #getPriorityByAgent (context) { - const key = `service:${context._tags[SERVICE_NAME]},env:${this._env}` + const key = `service:${context.getTag(SERVICE_NAME)},env:${this._env}` // TODO: Change underscored properties to private ones. const sampler = this._samplers[key] || this._samplers[DEFAULT_KEY] diff --git a/packages/dd-trace/src/profiling/profiler.js b/packages/dd-trace/src/profiling/profiler.js index 64509eff70..b37788d715 100644 --- a/packages/dd-trace/src/profiling/profiler.js +++ b/packages/dd-trace/src/profiling/profiler.js @@ -17,7 +17,7 @@ function findWebSpan (startedSpans, spanId) { const ispan = startedSpans[i] const context = ispan.context() if (context._spanId === spanId) { - if (isWebServerSpan(context._tags)) { + if (isWebServerSpan(context.getTags())) { return true } spanId = context._parentId @@ -268,7 +268,7 @@ class Profiler extends EventEmitter { #onSpanFinish (span) { const context = span.context() - const tags = context._tags + const tags = context.getTags() if (!isWebServerSpan(tags)) return const endpointName = endpointNameFromTags(tags) diff --git a/packages/dd-trace/src/profiling/profilers/wall.js b/packages/dd-trace/src/profiling/profilers/wall.js index e66e7df0cd..e8704f4950 100644 --- a/packages/dd-trace/src/profiling/profilers/wall.js +++ b/packages/dd-trace/src/profiling/profilers/wall.js @@ -247,6 +247,7 @@ class NativeWallProfiler { // context -- we simply can't tell which one it might've been across all // possible async context frames. if (this.#asyncContextFrameEnabled) { + const current = this.#pprof.time.getContext() if (this.#customLabelsActive) { // Custom labels may be active in this async context. The current CPED // context could be a 2-element array [profilingContext, customLabels]. @@ -254,7 +255,6 @@ class NativeWallProfiler { // This flag is monotonic (once set, stays true) because async // continuations from runWithLabels can fire at any time after the // synchronous runWithLabels call has returned. - const current = this.#pprof.time.getContext() if (Array.isArray(current)) { if (current[0] !== sampleContext) { this.#pprof.time.setContext([sampleContext, current[1]]) @@ -262,7 +262,13 @@ class NativeWallProfiler { } else if (current !== sampleContext) { this.#pprof.time.setContext(sampleContext) } - } else { + // 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) { this.#pprof.time.setContext(sampleContext) } } else { @@ -294,7 +300,7 @@ class NativeWallProfiler { let webTags if (this.#endpointCollectionEnabled) { - const tags = context._tags + const tags = context.getTags() if (isWebServerSpan(tags)) { webTags = tags } else { @@ -333,7 +339,7 @@ class NativeWallProfiler { if (!this.#started) return const profilingContext = span[ProfilingContext] if (profilingContext === undefined || profilingContext.webTags !== undefined) return - const tags = span.context()._tags + const tags = span.context().getTags() if (isWebServerSpan(tags)) { profilingContext.webTags = tags } diff --git a/packages/dd-trace/src/sampling_rule.js b/packages/dd-trace/src/sampling_rule.js index d76b1d9ad6..0478705703 100644 --- a/packages/dd-trace/src/sampling_rule.js +++ b/packages/dd-trace/src/sampling_rule.js @@ -109,7 +109,7 @@ function matcher (pattern, locator) { * @returns {Locator} */ function makeTagLocator (tag) { - return (span) => span.context()._tags[tag] + return (span) => span.context().getTag(tag) } /** @@ -129,9 +129,9 @@ function nameLocator (span) { * @returns {string|undefined} */ function serviceLocator (span) { - const { _tags: tags } = span.context() - return tags.service || - tags['service.name'] || + const context = span.context() + return context.getTag('service') || + context.getTag('service.name') || span.tracer()._service } @@ -142,9 +142,9 @@ function serviceLocator (span) { * @returns {string|undefined} */ function resourceLocator (span) { - const { _tags: tags } = span.context() - return tags.resource || - tags['resource.name'] + const context = span.context() + return context.getTag('resource') || + context.getTag('resource.name') } /** diff --git a/packages/dd-trace/src/service-naming/schemas/v0/messaging.js b/packages/dd-trace/src/service-naming/schemas/v0/messaging.js index 3dd5eaa643..786c4bb213 100644 --- a/packages/dd-trace/src/service-naming/schemas/v0/messaging.js +++ b/packages/dd-trace/src/service-naming/schemas/v0/messaging.js @@ -55,6 +55,11 @@ const messaging = { serviceName: ({ tracerService }) => `${tracerService}-kafka`, serviceSource: integrationSource('kafka'), }, + nats: { + opName: () => 'nats.publish', + serviceName: ({ tracerService }) => `${tracerService}-nats`, + serviceSource: integrationSource('nats'), + }, rhea: { opName: () => 'amqp.send', serviceName: ({ tracerService }) => `${tracerService}-amqp-producer`, @@ -119,6 +124,11 @@ const messaging = { serviceName: ({ tracerService }) => `${tracerService}-kafka`, serviceSource: integrationSource('kafka'), }, + nats: { + opName: () => 'nats.consume', + serviceName: ({ tracerService }) => `${tracerService}-nats`, + serviceSource: integrationSource('nats'), + }, rhea: { opName: () => 'amqp.receive', serviceName: identityService, diff --git a/packages/dd-trace/src/service-naming/schemas/v1/messaging.js b/packages/dd-trace/src/service-naming/schemas/v1/messaging.js index 0e303e87ce..0561cbcc2d 100644 --- a/packages/dd-trace/src/service-naming/schemas/v1/messaging.js +++ b/packages/dd-trace/src/service-naming/schemas/v1/messaging.js @@ -44,6 +44,10 @@ const messaging = { opName: () => 'kafka.send', serviceName: identityService, }, + nats: { + opName: () => 'nats.send', + serviceName: identityService, + }, rhea: amqpOutbound, sqs: { opName: () => 'aws.sqs.send', @@ -89,6 +93,10 @@ const messaging = { opName: () => 'kafka.process', serviceName: identityService, }, + nats: { + opName: () => 'nats.process', + serviceName: identityService, + }, rhea: amqpInbound, sqs: { opName: () => 'aws.sqs.process', diff --git a/packages/dd-trace/src/service-naming/source-resolver.js b/packages/dd-trace/src/service-naming/source-resolver.js new file mode 100644 index 0000000000..44d84747c0 --- /dev/null +++ b/packages/dd-trace/src/service-naming/source-resolver.js @@ -0,0 +1,46 @@ +'use strict' + +const { SVC_SRC_KEY } = require('../constants') + +const INTEGRATION_SERVICE = Symbol('dd.integrationService') +const MANUAL = 'm' + +/** + * Reconcile `_dd.svc_src` against the span's final `service.name`. Called from + * `Span#finish` once all writes are in. + * + * Rules: + * - service.name equals the tracer default → clear any svc_src + * - integration marker exists and equals current service.name → integration + * owns the value; leave the source label the integration set + * - otherwise → user wrote (no marker) or overrode the integration value; + * stamp 'm' + * + * @param {object} span Internal DatadogSpan instance. + * @param {string|undefined} tracerService The tracer's configured default service. + */ +function resolveServiceSource (span, tracerService) { + const spanContext = span._spanContext + const currentService = spanContext.getTag('service.name') + const existingSource = spanContext.getTag(SVC_SRC_KEY) + + if (currentService === tracerService) { + if (existingSource === undefined) return + spanContext.deleteTag(SVC_SRC_KEY) + return + } + + const marker = span[INTEGRATION_SERVICE] + + if (marker === currentService) { + return + } + + spanContext.setTag(SVC_SRC_KEY, MANUAL) +} + +module.exports = { + INTEGRATION_SERVICE, + MANUAL, + resolveServiceSource, +} diff --git a/packages/dd-trace/src/span_format.js b/packages/dd-trace/src/span_format.js index d668c4a36c..895c876c58 100644 --- a/packages/dd-trace/src/span_format.js +++ b/packages/dd-trace/src/span_format.js @@ -30,14 +30,6 @@ const ERROR_STACK = constants.ERROR_STACK const ERROR_TYPE = constants.ERROR_TYPE const { IGNORE_OTEL_ERROR } = constants -// TODO(BridgeAR)[31.03.2025]: Should these land in the constants file? -const map = { - 'operation.name': 'name', - 'service.name': 'service', - 'span.type': 'type', - 'resource.name': 'resource', -} - /** * @typedef {object} FormattedSpan * @property {import('./id').Identifier} trace_id @@ -45,6 +37,8 @@ const map = { * @property {import('./id').Identifier} parent_id * @property {string} name * @property {string} resource + * @property {string | undefined} service + * @property {string | undefined} type * @property {number} error * @property {Record} meta * @property {Record} metrics @@ -52,7 +46,12 @@ const map = { * @property {number} start * @property {number} duration * @property {Array} links - * @property {Array<{ name: string, time_unix_nano: number, attributes?: Record }>} [span_events] + * @property {Array | undefined} span_events + * + * @typedef {object} SpanEvent + * @property {string} name + * @property {number} time_unix_nano + * @property {Record} [attributes] */ function format (span, isFirstSpanInChunk = false, tagForFirstSpanInChunk = false) { @@ -61,7 +60,9 @@ function format (span, isFirstSpanInChunk = false, tagForFirstSpanInChunk = fals extractSpanLinks(formatted, span) extractSpanEvents(formatted, span) extractRootTags(formatted, span) - extractChunkTags(formatted, span, isFirstSpanInChunk, tagForFirstSpanInChunk) + if (isFirstSpanInChunk) { + extractChunkTags(formatted, span, tagForFirstSpanInChunk) + } extractTags(formatted, span) return formatted @@ -69,13 +70,18 @@ function format (span, isFirstSpanInChunk = false, tagForFirstSpanInChunk = fals function formatSpan (span) { const spanContext = span.context() - + // Pre-initialise the `service`, `type`, and `span_events` slots so every + // formatted span shares one V8 hidden class regardless of which optional + // tags fire later. Downstream encoders gate on truthy values for each, + // so `undefined` stays byte-identical on the msgpack wire. return { trace_id: spanContext._traceId, span_id: spanContext._spanId, parent_id: spanContext._parentId || id('0'), name: String(spanContext._name), resource: String(spanContext._name), + service: undefined, + type: undefined, error: 0, meta: {}, meta_struct: span.meta_struct, @@ -83,14 +89,22 @@ function formatSpan (span) { start: Math.round(span._startTime * 1e6), duration: Math.round(span._duration * 1e6), links: [], + span_events: undefined, } } -function setSingleSpanIngestionTags (span, options) { +function setSingleSpanIngestionTags (formattedSpan, options) { if (!options) return - addTag({}, span.metrics, SPAN_SAMPLING_MECHANISM, SAMPLING_MECHANISM_SPAN) - addTag({}, span.metrics, SPAN_SAMPLING_RULE_RATE, options.sampleRate) - addTag({}, span.metrics, SPAN_SAMPLING_MAX_PER_SECOND, options.maxPerSecond) + const metrics = formattedSpan.metrics + metrics[SPAN_SAMPLING_MECHANISM] = SAMPLING_MECHANISM_SPAN + const sampleRate = options.sampleRate + if (typeof sampleRate === 'number') { + metrics[SPAN_SAMPLING_RULE_RATE] = sampleRate + } + const maxPerSecond = options.maxPerSecond + if (typeof maxPerSecond === 'number') { + metrics[SPAN_SAMPLING_MAX_PER_SECOND] = maxPerSecond + } } /** @@ -144,12 +158,14 @@ function extractTags (formattedSpan, span) { const origin = context._trace.origin // TODO(BridgeAR)[31.03.2025]: Look into changing the way we store tags. Using // a map is likely faster short term. - const tags = context._tags + const tags = context.getTags() const hostname = context._hostname const priority = context._sampling.priority + const meta = formattedSpan.meta + const metrics = formattedSpan.metrics if (tags['span.kind'] && tags['span.kind'] !== 'internal') { - addTag({}, formattedSpan.metrics, MEASURED, 1) + metrics[MEASURED] = 1 } const tracerService = span.tracer()._service.toLowerCase() @@ -159,27 +175,51 @@ function extractTags (formattedSpan, span) { registerExtraService(tags['service.name']) } - for (const [tag, value] of Object.entries(tags)) { - // TODO(BridgeAR)[31.03.2025]: Check how many tags are defined in average. - // In case there are more than 2 tags in average, check for all special - // cases up front and loop over the tags afterwards, skipping the already - // visited property names by checking a map with these keys. + for (const tag of Object.keys(tags)) { + const value = tags[tag] + // The typed-helper bodies are inlined per case: V8 was not inlining + // `addStringTag` / `addNumberTag` / `addMixedTag` here at the call rate + // this loop runs in HTTP-server traces (10+ tags × 1M spans/sec), so each + // one paid an extra call frame the helper body was small enough to + // expand inline. switch (tag) { case 'service.name': + if (typeof value === 'string') { + formattedSpan.service = value.length > MAX_META_VALUE_LENGTH + ? `${value.slice(0, MAX_META_VALUE_LENGTH)}...` + : value + } + break case 'span.type': + if (typeof value === 'string') { + formattedSpan.type = value.length > MAX_META_VALUE_LENGTH + ? `${value.slice(0, MAX_META_VALUE_LENGTH)}...` + : value + } + break case 'resource.name': - addTag(formattedSpan, {}, map[tag], value) + if (typeof value === 'string') { + formattedSpan.resource = value.length > MAX_META_VALUE_LENGTH + ? `${value.slice(0, MAX_META_VALUE_LENGTH)}...` + : value + } break // HACK: remove when Datadog supports numeric status code - case 'http.status_code': - addTag(formattedSpan.meta, {}, tag, value && String(value)) + case 'http.status_code': { + const stringValue = value && String(value) + if (typeof stringValue === 'string') { + meta[tag] = stringValue.length > MAX_META_VALUE_LENGTH + ? `${stringValue.slice(0, MAX_META_VALUE_LENGTH)}...` + : stringValue + } break + } case 'analytics.event': - addTag({}, formattedSpan.metrics, ANALYTICS, value === undefined || value ? 1 : 0) + metrics[ANALYTICS] = value === undefined || value ? 1 : 0 break case HOSTNAME_KEY: case MEASURED: - addTag({}, formattedSpan.metrics, tag, value === undefined || value ? 1 : 0) + metrics[tag] = value === undefined || value ? 1 : 0 break // TODO(BridgeAR)[31.03.2025]: How come we use two different ways to pass // through errors? Can we just unify the behavior to always use one way? @@ -190,52 +230,115 @@ function extractTags (formattedSpan, span) { break case ERROR_TYPE: case ERROR_MESSAGE: - case ERROR_STACK: + case ERROR_STACK: { // HACK: remove when implemented in the backend - if (context._name === 'fs.operation') { - break - } + if (context._name === 'fs.operation') break // otel.recordException should not influence trace.error if (!tags[IGNORE_OTEL_ERROR]) { formattedSpan.error = 1 } - default: // eslint-disable-line no-fallthrough - addTag(formattedSpan.meta, formattedSpan.metrics, tag, value) + if (value != null) writeErrorMeta(meta, tag, value) + break + } + default: { + const valueType = typeof value + if (valueType === 'string') { + let writeKey = tag + if (writeKey.length > MAX_META_KEY_LENGTH) { + writeKey = `${writeKey.slice(0, MAX_META_KEY_LENGTH)}...` + } + meta[writeKey] = value.length > MAX_META_VALUE_LENGTH + ? `${value.slice(0, MAX_META_VALUE_LENGTH)}...` + : value + } else if (valueType === 'number') { + if (!Number.isNaN(value)) { + let writeKey = tag + if (writeKey.length > MAX_METRIC_KEY_LENGTH) { + writeKey = `${writeKey.slice(0, MAX_METRIC_KEY_LENGTH)}...` + } + metrics[writeKey] = value + } + } else if (valueType === 'boolean') { + let writeKey = tag + if (writeKey.length > MAX_METRIC_KEY_LENGTH) { + writeKey = `${writeKey.slice(0, MAX_METRIC_KEY_LENGTH)}...` + } + metrics[writeKey] = value ? 1 : 0 + } else { + addMixedTag(meta, metrics, tag, value) + } + } } } setSingleSpanIngestionTags(formattedSpan, context._spanSampling) - addTag(formattedSpan.meta, formattedSpan.metrics, 'language', 'javascript') - addTag(formattedSpan.meta, formattedSpan.metrics, PROCESS_ID, process.pid) - addTag(formattedSpan.meta, formattedSpan.metrics, SAMPLING_PRIORITY_KEY, priority) - addTag(formattedSpan.meta, formattedSpan.metrics, ORIGIN_KEY, origin) - addTag(formattedSpan.meta, formattedSpan.metrics, HOSTNAME_KEY, hostname) + meta.language = 'javascript' + metrics[PROCESS_ID] = process.pid + if (typeof priority === 'number') { + metrics[SAMPLING_PRIORITY_KEY] = priority + } + if (typeof origin === 'string') { + meta[ORIGIN_KEY] = origin.length > MAX_META_VALUE_LENGTH + ? `${origin.slice(0, MAX_META_VALUE_LENGTH)}...` + : origin + } + if (typeof hostname === 'string') { + meta[HOSTNAME_KEY] = hostname.length > MAX_META_VALUE_LENGTH + ? `${hostname.slice(0, MAX_META_VALUE_LENGTH)}...` + : hostname + } } function extractRootTags (formattedSpan, span) { const context = span.context() - const isLocalRoot = span === context._trace.started[0] const parentId = context._parentId - if (!isLocalRoot || (parentId && parentId.toString(10) !== '0')) return + if (span !== context._trace.started[0] || (parentId && parentId.toString(10) !== '0')) return - addTag({}, formattedSpan.metrics, SAMPLING_RULE_DECISION, context._trace[SAMPLING_RULE_DECISION]) - addTag({}, formattedSpan.metrics, SAMPLING_LIMIT_DECISION, context._trace[SAMPLING_LIMIT_DECISION]) - addTag({}, formattedSpan.metrics, SAMPLING_AGENT_DECISION, context._trace[SAMPLING_AGENT_DECISION]) - addTag({}, formattedSpan.metrics, TOP_LEVEL_KEY, 1) + const trace = context._trace + const metrics = formattedSpan.metrics + const ruleDecision = trace[SAMPLING_RULE_DECISION] + if (typeof ruleDecision === 'number') { + metrics[SAMPLING_RULE_DECISION] = ruleDecision + } + const limitDecision = trace[SAMPLING_LIMIT_DECISION] + if (typeof limitDecision === 'number') { + metrics[SAMPLING_LIMIT_DECISION] = limitDecision + } + const agentDecision = trace[SAMPLING_AGENT_DECISION] + if (typeof agentDecision === 'number') { + metrics[SAMPLING_AGENT_DECISION] = agentDecision + } + metrics[TOP_LEVEL_KEY] = 1 } -function extractChunkTags (formattedSpan, span, isFirstSpanInChunk, tagForFirstSpanInChunk) { - const context = span.context() - - if (!isFirstSpanInChunk) return - - if (tagForFirstSpanInChunk) { - addTag(formattedSpan.meta, formattedSpan.metrics, TRACING_FIELD_NAME, tagForFirstSpanInChunk) +function extractChunkTags (formattedSpan, span, tagForFirstSpanInChunk) { + const meta = formattedSpan.meta + if (typeof tagForFirstSpanInChunk === 'string') { + meta[TRACING_FIELD_NAME] = tagForFirstSpanInChunk.length > MAX_META_VALUE_LENGTH + ? `${tagForFirstSpanInChunk.slice(0, MAX_META_VALUE_LENGTH)}...` + : tagForFirstSpanInChunk } - for (const [key, value] of Object.entries(context._trace.tags)) { - addTag(formattedSpan.meta, formattedSpan.metrics, key, value) + // Chunk tags are always strings in production (`_dd.p.dm`, `_dd.p.tid`, + // `_dd.p.ts`, `baggage.*`). Inline only the string branch; non-string + // values fall through to `addMixedTag` so we don't carry duplicate + // truncation logic for branches no real chunk tag ever takes. + const metrics = formattedSpan.metrics + const traceTags = span.context()._trace.tags + for (const key of Object.keys(traceTags)) { + const value = traceTags[key] + if (typeof value === 'string') { + let writeKey = key + if (writeKey.length > MAX_META_KEY_LENGTH) { + writeKey = `${writeKey.slice(0, MAX_META_KEY_LENGTH)}...` + } + meta[writeKey] = value.length > MAX_META_VALUE_LENGTH + ? `${value.slice(0, MAX_META_VALUE_LENGTH)}...` + : value + } else { + addMixedTag(meta, metrics, key, value) + } } } @@ -248,13 +351,42 @@ function extractError (formattedSpan, error) { // AggregateError only has a code and no message. // TODO(BridgeAR)[31.03.2025]: An AggregateError can have a message. Should // the code just generally be added, if available? - addTag(formattedSpan.meta, formattedSpan.metrics, ERROR_MESSAGE, error.message || error.code) - addTag(formattedSpan.meta, formattedSpan.metrics, ERROR_TYPE, error.name) - addTag(formattedSpan.meta, formattedSpan.metrics, ERROR_STACK, error.stack) + const meta = formattedSpan.meta + const message = error.message || error.code + if (message != null) writeErrorMeta(meta, ERROR_MESSAGE, message) + if (error.name != null) writeErrorMeta(meta, ERROR_TYPE, error.name) + if (error.stack != null) writeErrorMeta(meta, ERROR_STACK, error.stack) } } -function addTag (meta, metrics, key, value, nested) { +/** + * Coerces `value` to string and truncates at `MAX_META_VALUE_LENGTH` before + * writing it to one of the three error meta fields. + * + * @param {Record} meta + * @param {string} key + * @param {unknown} value + */ +function writeErrorMeta (meta, key, value) { + const stringValue = typeof value === 'string' ? value : String(value) + meta[key] = stringValue.length > MAX_META_VALUE_LENGTH + ? `${stringValue.slice(0, MAX_META_VALUE_LENGTH)}...` + : stringValue +} + +/** + * Mixed-type dispatch retained for `extractError` and the slow-path fallback + * inside the inlined per-tag loops in `extractTags` / `extractChunkTags`. + * The scalar branches are kept here so a single `addMixedTag` call covers + * recursion (nested object values) without re-entering the inlined paths. + * + * @param {Record} meta + * @param {Record} metrics + * @param {string} key + * @param {unknown} value + * @param {boolean} [nested] + */ +function addMixedTag (meta, metrics, key, value, nested) { switch (typeof value) { case 'string': if (key.length > MAX_META_KEY_LENGTH) { @@ -290,7 +422,7 @@ function addTag (meta, metrics, key, value, nested) { metrics[key] = value.toString() } else if (!Array.isArray(value) && !nested) { for (const [prop, val] of Object.entries(value)) { - addTag(meta, metrics, `${key}.${prop}`, val, true) + addMixedTag(meta, metrics, `${key}.${prop}`, val, true) } } } diff --git a/packages/dd-trace/src/spanleak.js b/packages/dd-trace/src/spanleak.js index 38def9db09..9d4a49fb71 100644 --- a/packages/dd-trace/src/spanleak.js +++ b/packages/dd-trace/src/spanleak.js @@ -66,7 +66,7 @@ module.exports.startScrubber = function () { if (!gc) continue // everything after this point is related to manual GC // TODO: what else can we do to alleviate memory usage - span.context()._tags = Object.create(null) + span.context().clearTags() } console.log('expired spans:' + diff --git a/packages/dd-trace/src/standalone/index.js b/packages/dd-trace/src/standalone/index.js index 699e48c220..80de7d6dff 100644 --- a/packages/dd-trace/src/standalone/index.js +++ b/packages/dd-trace/src/standalone/index.js @@ -29,12 +29,12 @@ function configure (config) { } function onSpanStart ({ span, fields }) { - const tags = span.context?.()?._tags - if (!tags) return + const context = span.context?.() + if (!context) return const { parent } = fields if (!parent || parent._isRemote) { - tags[APM_TRACING_ENABLED_KEY] = 0 + context.setTag(APM_TRACING_ENABLED_KEY, 0) } } diff --git a/packages/dd-trace/src/tagger.js b/packages/dd-trace/src/tagger.js index dce0b91a14..3230dd647f 100644 --- a/packages/dd-trace/src/tagger.js +++ b/packages/dd-trace/src/tagger.js @@ -9,8 +9,6 @@ function addNonEmpty (carrier, key, value) { } function add (carrier, keyValuePairs, valueSeparator = ':') { - if (!carrier) return - if (typeof keyValuePairs === 'string') { let valueStart = 0 let keyStart = 0 diff --git a/packages/dd-trace/test/agent/url.spec.js b/packages/dd-trace/test/agent/url.spec.js index 08c518a58c..91b7307019 100644 --- a/packages/dd-trace/test/agent/url.spec.js +++ b/packages/dd-trace/test/agent/url.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { URL } = require('url') +const { inspect } = require('node:util') const { describe, it } = require('mocha') @@ -93,7 +94,7 @@ describe('agent/url', () => { // IPv6 addresses get wrapped in brackets by URL constructor assert.strictEqual(result.hostname, '[::1]') assert.strictEqual(result.port, '8126') - assert.ok(result.href.includes('[::1]:8126')) + assert.ok(result.href.includes('[::1]:8126'), `Got: ${inspect(result.href)}`) }) }) }) diff --git a/packages/dd-trace/test/aiguard/index.spec.js b/packages/dd-trace/test/aiguard/index.spec.js index 8de47daa52..b58bada366 100644 --- a/packages/dd-trace/test/aiguard/index.spec.js +++ b/packages/dd-trace/test/aiguard/index.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { rejects } = require('node:assert/strict') +const { inspect } = require('node:util') const msgpack = require('@msgpack/msgpack') const { afterEach, beforeEach, describe, it } = require('mocha') @@ -492,7 +493,7 @@ describe('AIGuard SDK', () => { if (span.name === 'root') { assert.strictEqual(span.meta[EVENT_TAG_KEY], 'true') } else { - assert.ok(!Object.hasOwn(span.meta, EVENT_TAG_KEY)) + assert.ok(!Object.hasOwn(span.meta, EVENT_TAG_KEY), `Available keys: ${inspect(Object.keys(span.meta))}`) } } }) diff --git a/packages/dd-trace/test/appsec/api_security_sampler.spec.js b/packages/dd-trace/test/appsec/api_security_sampler.spec.js index 95ac3a97e3..69f0d9ecb8 100644 --- a/packages/dd-trace/test/appsec/api_security_sampler.spec.js +++ b/packages/dd-trace/test/appsec/api_security_sampler.spec.js @@ -248,13 +248,21 @@ describe('API Security Sampler', () => { apiSecuritySampler.configure({ appsec: { apiSecurity: { enabled: true, sampleDelay: 30 } } }) }) - it('should use http.endpoint when http.route is not available', () => { - const spanWithEndpoint = { + function makeSpan (tags) { + return { context: sinon.stub().returns({ _sampling: { priority: AUTO_KEEP }, - _tags: { 'http.endpoint': '/api/users' }, + _tags: tags, + getTag: (key) => tags[key], + getTags: () => tags, + setTag: (key, value) => { tags[key] = value }, + hasTag: (key) => key in tags, }), } + } + + it('should use http.endpoint when http.route is not available', () => { + const spanWithEndpoint = makeSpan({ 'http.endpoint': '/api/users' }) webStub.root.returns(spanWithEndpoint) webStub.getContext.returns({ paths: [], span: spanWithEndpoint }) @@ -264,12 +272,7 @@ describe('API Security Sampler', () => { it('should not use http.endpoint for 404 status codes', () => { const res404 = { statusCode: 404 } - const spanWithEndpoint = { - context: sinon.stub().returns({ - _sampling: { priority: AUTO_KEEP }, - _tags: { 'http.endpoint': '/api/users' }, - }), - } + const spanWithEndpoint = makeSpan({ 'http.endpoint': '/api/users' }) webStub.root.returns(spanWithEndpoint) webStub.getContext.returns({ paths: [], span: spanWithEndpoint }) @@ -278,12 +281,7 @@ describe('API Security Sampler', () => { }) it('should prefer http.route over http.endpoint when both are available', () => { - const spanWithBoth = { - context: sinon.stub().returns({ - _sampling: { priority: AUTO_KEEP }, - _tags: { 'http.endpoint': '/api/users' }, - }), - } + const spanWithBoth = makeSpan({ 'http.endpoint': '/api/users' }) webStub.root.returns(spanWithBoth) webStub.getContext.returns({ paths: ['/users/:id'], span: spanWithBoth }) @@ -292,12 +290,7 @@ describe('API Security Sampler', () => { }) it('should handle missing http.endpoint gracefully', () => { - const spanWithoutEndpoint = { - context: sinon.stub().returns({ - _sampling: { priority: AUTO_KEEP }, - _tags: {}, - }), - } + const spanWithoutEndpoint = makeSpan({}) webStub.root.returns(spanWithoutEndpoint) webStub.getContext.returns({ paths: [], span: spanWithoutEndpoint }) @@ -313,18 +306,8 @@ describe('API Security Sampler', () => { }) it('should sample different endpoints separately', () => { - const span1 = { - context: sinon.stub().returns({ - _sampling: { priority: AUTO_KEEP }, - _tags: { 'http.endpoint': '/api/users' }, - }), - } - const span2 = { - context: sinon.stub().returns({ - _sampling: { priority: AUTO_KEEP }, - _tags: { 'http.endpoint': '/api/products' }, - }), - } + const span1 = makeSpan({ 'http.endpoint': '/api/users' }) + const span2 = makeSpan({ 'http.endpoint': '/api/products' }) webStub.root.returns(span1) webStub.getContext.returns({ paths: [], span: span1 }) diff --git a/packages/dd-trace/test/appsec/api_security_sampling.integration.spec.js b/packages/dd-trace/test/appsec/api_security_sampling.integration.spec.js index 1979067d3a..2abdb079be 100644 --- a/packages/dd-trace/test/appsec/api_security_sampling.integration.spec.js +++ b/packages/dd-trace/test/appsec/api_security_sampling.integration.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const Axios = require('axios') const { sandboxCwd, useSandbox, FakeAgent, spawnProc, stopProc } = require('../../../../integration-tests/helpers') @@ -66,7 +67,10 @@ describe('API Security sampling integration', () => { it('samples first express route request only', async () => { const firstMessage = agent.assertMessageReceived(({ payload }) => { const span = findSpanBy(payload, span => span.meta?.['http.route'] === '/api_security_sampling/:i') - assert.ok(Object.hasOwn(span.meta, '_dd.appsec.s.req.body')) + assert.ok( + Object.hasOwn(span.meta, '_dd.appsec.s.req.body'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) }, 10_000) await axios.post('/api_security_sampling/1', { key: 'value' }) @@ -74,7 +78,10 @@ describe('API Security sampling integration', () => { const secondMessage = agent.assertMessageReceived(({ payload }) => { const span = findSpanBy(payload, span => span.meta?.['http.route'] === '/api_security_sampling/:i') - assert.ok(!Object.hasOwn(span.meta, '_dd.appsec.s.req.body')) + assert.ok( + !Object.hasOwn(span.meta, '_dd.appsec.s.req.body'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) }, 10_000) await axios.post('/api_security_sampling/2', { key: 'value' }) @@ -86,7 +93,10 @@ describe('API Security sampling integration', () => { const firstMessage = agent.assertMessageReceived(({ payload }) => { const span = findSpanBy(payload, span => span.meta?.['http.endpoint'] === expectedEndpoint) - assert.ok(Object.hasOwn(span.meta, '_dd.appsec.s.req.body')) + assert.ok( + Object.hasOwn(span.meta, '_dd.appsec.s.req.body'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) }, 10_000) await axios.post('/api_security_sampling_resource_renaming/101', { key: 'value' }) @@ -94,7 +104,10 @@ describe('API Security sampling integration', () => { const secondMessage = agent.assertMessageReceived(({ payload }) => { const span = findSpanBy(payload, span => span.meta?.['http.endpoint'] === expectedEndpoint) - assert.ok(!Object.hasOwn(span.meta, '_dd.appsec.s.req.body')) + assert.ok( + !Object.hasOwn(span.meta, '_dd.appsec.s.req.body'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) }, 10_000) await axios.post('/api_security_sampling_resource_renaming/202', { key: 'value' }) 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 5e5b95ad7e..5d18bae990 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 @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('node:path') +const { inspect } = require('node:util') const axios = require('axios') const agent = require('../plugins/agent') @@ -72,11 +73,20 @@ withVersions('express', 'express', expressVersion => { await agent.assertSomeTraces((traces) => { const span = traces[0][0] - assert.ok(Object.hasOwn(span.meta, '_dd.appsec.fp.http.header')) + assert.ok( + Object.hasOwn(span.meta, '_dd.appsec.fp.http.header'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) assert.strictEqual(span.meta['_dd.appsec.fp.http.header'], 'hdr-0110000110-74c2908f-5-55682ec1') - assert.ok(Object.hasOwn(span.meta, '_dd.appsec.fp.http.network')) + assert.ok( + Object.hasOwn(span.meta, '_dd.appsec.fp.http.network'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) assert.strictEqual(span.meta['_dd.appsec.fp.http.network'], 'net-1-0100000000') - assert.ok(Object.hasOwn(span.meta, '_dd.appsec.fp.http.endpoint')) + assert.ok( + Object.hasOwn(span.meta, '_dd.appsec.fp.http.endpoint'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) assert.strictEqual(span.meta['_dd.appsec.fp.http.endpoint'], 'http-post-8a5edab2-2c70e12b-be31090f') }) }) 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 adfa6a4bbb..98b2513035 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 @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('node:path') +const { inspect } = require('node:util') const Axios = require('axios') const agent = require('../plugins/agent') @@ -71,11 +72,20 @@ withVersions('fastify', 'fastify', fastifyVersion => { await agent.assertSomeTraces((traces) => { const span = traces[0][0] - assert.ok(Object.hasOwn(span.meta, '_dd.appsec.fp.http.header')) + assert.ok( + Object.hasOwn(span.meta, '_dd.appsec.fp.http.header'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) assert.strictEqual(span.meta['_dd.appsec.fp.http.header'], 'hdr-0110000110-74c2908f-5-55682ec1') - assert.ok(Object.hasOwn(span.meta, '_dd.appsec.fp.http.network')) + assert.ok( + Object.hasOwn(span.meta, '_dd.appsec.fp.http.network'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) assert.strictEqual(span.meta['_dd.appsec.fp.http.network'], 'net-1-0100000000') - assert.ok(Object.hasOwn(span.meta, '_dd.appsec.fp.http.endpoint')) + assert.ok( + Object.hasOwn(span.meta, '_dd.appsec.fp.http.endpoint'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) assert.strictEqual(span.meta['_dd.appsec.fp.http.endpoint'], 'http-post-8a5edab2-2c70e12b-be31090f') }) }) diff --git a/packages/dd-trace/test/appsec/attacker-fingerprinting.passport-http.plugin.spec.js b/packages/dd-trace/test/appsec/attacker-fingerprinting.passport-http.plugin.spec.js index 746b7f9b58..1d01c2df9a 100644 --- a/packages/dd-trace/test/appsec/attacker-fingerprinting.passport-http.plugin.spec.js +++ b/packages/dd-trace/test/appsec/attacker-fingerprinting.passport-http.plugin.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const Axios = require('axios') const agent = require('../plugins/agent') @@ -10,11 +11,17 @@ const { withVersions } = require('../setup/mocha') function assertFingerprintInTraces (traces) { const span = traces[0][0] - assert.ok(Object.hasOwn(span.meta, '_dd.appsec.fp.http.header')) + assert.ok(Object.hasOwn(span.meta, '_dd.appsec.fp.http.header'), `Available keys: ${inspect(Object.keys(span.meta))}`) assert.strictEqual(span.meta['_dd.appsec.fp.http.header'], 'hdr-0110000110-74c2908f-5-e58aa9dd') - assert.ok(Object.hasOwn(span.meta, '_dd.appsec.fp.http.network')) + assert.ok( + Object.hasOwn(span.meta, '_dd.appsec.fp.http.network'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) assert.strictEqual(span.meta['_dd.appsec.fp.http.network'], 'net-0-0000000000') - assert.ok(Object.hasOwn(span.meta, '_dd.appsec.fp.http.endpoint')) + assert.ok( + Object.hasOwn(span.meta, '_dd.appsec.fp.http.endpoint'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) assert.strictEqual(span.meta['_dd.appsec.fp.http.endpoint'], 'http-post-7e93fba0--') } diff --git a/packages/dd-trace/test/appsec/attacker-fingerprinting.passport-local.plugin.spec.js b/packages/dd-trace/test/appsec/attacker-fingerprinting.passport-local.plugin.spec.js index cd667ac38d..9cbf87a935 100644 --- a/packages/dd-trace/test/appsec/attacker-fingerprinting.passport-local.plugin.spec.js +++ b/packages/dd-trace/test/appsec/attacker-fingerprinting.passport-local.plugin.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const Axios = require('axios') const agent = require('../plugins/agent') @@ -10,11 +11,17 @@ const { withVersions } = require('../setup/mocha') function assertFingerprintInTraces (traces) { const span = traces[0][0] - assert.ok(Object.hasOwn(span.meta, '_dd.appsec.fp.http.header')) + assert.ok(Object.hasOwn(span.meta, '_dd.appsec.fp.http.header'), `Available keys: ${inspect(Object.keys(span.meta))}`) assert.strictEqual(span.meta['_dd.appsec.fp.http.header'], 'hdr-0110000110-74c2908f-4-c348f529') - assert.ok(Object.hasOwn(span.meta, '_dd.appsec.fp.http.network')) + assert.ok( + Object.hasOwn(span.meta, '_dd.appsec.fp.http.network'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) assert.strictEqual(span.meta['_dd.appsec.fp.http.network'], 'net-0-0000000000') - assert.ok(Object.hasOwn(span.meta, '_dd.appsec.fp.http.endpoint')) + assert.ok( + Object.hasOwn(span.meta, '_dd.appsec.fp.http.endpoint'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) assert.strictEqual(span.meta['_dd.appsec.fp.http.endpoint'], 'http-post-7e93fba0--f29f6224') } diff --git a/packages/dd-trace/test/appsec/attacker-fingerprinting.spec.js b/packages/dd-trace/test/appsec/attacker-fingerprinting.spec.js index 999dcc3c3d..6f6a13654d 100644 --- a/packages/dd-trace/test/appsec/attacker-fingerprinting.spec.js +++ b/packages/dd-trace/test/appsec/attacker-fingerprinting.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const axios = require('axios') const agent = require('../plugins/agent') @@ -56,9 +57,15 @@ describe('Attacker fingerprinting', () => { } agent.assertSomeTraces(traces => { - assert.ok(Object.hasOwn(traces[0][0].meta, '_dd.appsec.fp.http.header')) + assert.ok( + Object.hasOwn(traces[0][0].meta, '_dd.appsec.fp.http.header'), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) assert.strictEqual(traces[0][0].meta['_dd.appsec.fp.http.header'], 'hdr-0110000010-74c2908f-3-98425651') - assert.ok(Object.hasOwn(traces[0][0].meta, '_dd.appsec.fp.http.network')) + assert.ok( + Object.hasOwn(traces[0][0].meta, '_dd.appsec.fp.http.network'), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) assert.strictEqual(traces[0][0].meta['_dd.appsec.fp.http.network'], 'net-0-0000000000') }).then(done).catch(done) @@ -76,9 +83,15 @@ describe('Attacker fingerprinting', () => { } agent.assertSomeTraces(traces => { - assert.ok(Object.hasOwn(traces[0][0].meta, '_dd.appsec.fp.http.header')) + assert.ok( + Object.hasOwn(traces[0][0].meta, '_dd.appsec.fp.http.header'), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) assert.strictEqual(traces[0][0].meta['_dd.appsec.fp.http.header'], 'hdr-0110000010-74c2908f-3-98425651') - assert.ok(Object.hasOwn(traces[0][0].meta, '_dd.appsec.fp.http.network')) + assert.ok( + Object.hasOwn(traces[0][0].meta, '_dd.appsec.fp.http.network'), + `Available keys: ${inspect(Object.keys(traces[0][0].meta))}` + ) assert.strictEqual(traces[0][0].meta['_dd.appsec.fp.http.network'], 'net-0-0000000000') }).then(done).catch(done) diff --git a/packages/dd-trace/test/appsec/downstream_requests.spec.js b/packages/dd-trace/test/appsec/downstream_requests.spec.js index 82a8cc5efc..c908de04c8 100644 --- a/packages/dd-trace/test/appsec/downstream_requests.spec.js +++ b/packages/dd-trace/test/appsec/downstream_requests.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const sinon = require('sinon') const downstream = require('../../src/appsec/downstream_requests') @@ -206,7 +207,10 @@ describe('appsec downstream_requests', () => { const addressesMap = downstream.extractRequestData(ctx, true) - assert.ok(!Object.hasOwn(addressesMap, addresses.HTTP_OUTGOING_HEADERS)) + assert.ok( + !Object.hasOwn(addressesMap, addresses.HTTP_OUTGOING_HEADERS), + `Available keys: ${inspect(Object.keys(addressesMap))}` + ) }) }) @@ -243,7 +247,10 @@ describe('appsec downstream_requests', () => { it('omits body when not provided', () => { const addressesMap = downstream.extractResponseData(res) - assert.ok(!Object.hasOwn(addressesMap, addresses.HTTP_OUTGOING_RESPONSE_BODY)) + assert.ok( + !Object.hasOwn(addressesMap, addresses.HTTP_OUTGOING_RESPONSE_BODY), + `Available keys: ${inspect(Object.keys(addressesMap))}` + ) }) }) @@ -452,8 +459,8 @@ describe('appsec downstream_requests', () => { const trueCount = results.filter(r => r).length const falseCount = results.filter(r => !r).length - assert.ok(trueCount > 0) - assert.ok(falseCount > 0) + assert.ok(trueCount > 0, `Expected ${trueCount} > 0`) + assert.ok(falseCount > 0, `Expected ${falseCount} > 0`) }) it('tracks per-request body analysis count independently', () => { diff --git a/packages/dd-trace/test/appsec/extended-data-collection.express.plugin.spec.js b/packages/dd-trace/test/appsec/extended-data-collection.express.plugin.spec.js index ed1e85ee3f..18ea7c50d8 100644 --- a/packages/dd-trace/test/appsec/extended-data-collection.express.plugin.spec.js +++ b/packages/dd-trace/test/appsec/extended-data-collection.express.plugin.spec.js @@ -201,8 +201,14 @@ describe('extended data collection', () => { assert.strictEqual(collectedRequestHeaders, 8) assert.strictEqual(collectedResponseHeaders, 8) - assert.ok(span.metrics['_dd.appsec.request.header_collection.discarded'] > 2) - assert.ok(span.metrics['_dd.appsec.response.header_collection.discarded'] > 2) + assert.ok( + span.metrics['_dd.appsec.request.header_collection.discarded'] > 2, + `Expected ${span.metrics['_dd.appsec.request.header_collection.discarded']} > 2` + ) + assert.ok( + span.metrics['_dd.appsec.response.header_collection.discarded'] > 2, + `Expected ${span.metrics['_dd.appsec.response.header_collection.discarded']} > 2` + ) const metaStructBody = msgpack.decode(span.meta_struct['http.request.body']) assert.deepEqual(metaStructBody, requestBody) diff --git a/packages/dd-trace/test/appsec/extended-data-collection.fastify.plugin.spec.js b/packages/dd-trace/test/appsec/extended-data-collection.fastify.plugin.spec.js index fca84c150c..b0a3f4246f 100644 --- a/packages/dd-trace/test/appsec/extended-data-collection.fastify.plugin.spec.js +++ b/packages/dd-trace/test/appsec/extended-data-collection.fastify.plugin.spec.js @@ -203,8 +203,14 @@ describe('extended data collection', () => { assert.strictEqual(collectedRequestHeaders, 8) assert.strictEqual(collectedResponseHeaders, 8) - assert.ok(span.metrics['_dd.appsec.request.header_collection.discarded'] > 2) - assert.ok(span.metrics['_dd.appsec.response.header_collection.discarded'] > 2) + assert.ok( + span.metrics['_dd.appsec.request.header_collection.discarded'] > 2, + `Expected ${span.metrics['_dd.appsec.request.header_collection.discarded']} > 2` + ) + assert.ok( + span.metrics['_dd.appsec.response.header_collection.discarded'] > 2, + `Expected ${span.metrics['_dd.appsec.response.header_collection.discarded']} > 2` + ) const metaStructBody = msgpack.decode(span.meta_struct['http.request.body']) assert.deepEqual(metaStructBody, requestBody) diff --git a/packages/dd-trace/test/appsec/extended-data-collection.next.plugin.spec.js b/packages/dd-trace/test/appsec/extended-data-collection.next.plugin.spec.js index 6efc486b82..3008a84292 100644 --- a/packages/dd-trace/test/appsec/extended-data-collection.next.plugin.spec.js +++ b/packages/dd-trace/test/appsec/extended-data-collection.next.plugin.spec.js @@ -22,6 +22,10 @@ describe('extended data collection', () => { return } + if (satisfies(realVersion, '>=12 <13') && NODE_MAJOR >= 26) { + return // next 12.x fails to build on Node.js 26 + } + if (satisfies(realVersion, '>=16') && NODE_MAJOR < 20) { return } @@ -167,8 +171,14 @@ describe('extended data collection', () => { assert.strictEqual(collectedRequestHeaders, 8) assert.strictEqual(collectedResponseHeaders, 8) - assert.ok(span.metrics['_dd.appsec.request.header_collection.discarded'] >= 2) - assert.ok(span.metrics['_dd.appsec.response.header_collection.discarded'] >= 2) + assert.ok( + span.metrics['_dd.appsec.request.header_collection.discarded'] >= 2, + `Expected ${span.metrics['_dd.appsec.request.header_collection.discarded']} >= 2` + ) + assert.ok( + span.metrics['_dd.appsec.response.header_collection.discarded'] >= 2, + `Expected ${span.metrics['_dd.appsec.response.header_collection.discarded']} >= 2` + ) const metaStructBody = msgpack.decode(span.meta_struct['http.request.body']) assert.deepEqual(metaStructBody, requestBody) 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 c5754e08bc..c6aca3e971 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 @@ -8,9 +8,14 @@ const semver = require('semver') const { prepareTestServerForIastInExpress } = require('../utils') const agent = require('../../../plugins/agent') const { withVersions } = require('../../../setup/mocha') +const { NODE_MAJOR } = require('../../../../../../version') describe('nosql injection detection in mongodb - whole feature', () => { - withVersions('mongoose', 'express', expressVersion => { + withVersions('mongoose', 'express', (expressVersion, _moduleName, resolvedExpressVersion) => { + // Node 20 + Express 5 loses IAST taint on the per-request `req.query` getter; + // passes on Node 18 and Node 24. See APPSEC-66705. + if (NODE_MAJOR === 20 && semver.major(resolvedExpressVersion) >= 5) return + withVersions('mongoose', 'mongoose', '>4.0.0', mongooseVersion => { const specificMongooseVersion = require(`../../../../../../versions/mongoose@${mongooseVersion}`).version() diff --git a/packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.spec.js b/packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.spec.js index ed8123b11a..d35c2944e1 100644 --- a/packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.spec.js +++ b/packages/dd-trace/test/appsec/iast/analyzers/path-traversal-analyzer.spec.js @@ -5,6 +5,7 @@ const os = require('os') const path = require('path') const fs = require('fs') +const { inspect } = require('node:util') const sinon = require('sinon') const proxyquire = require('proxyquire') const { storage } = require('../../../../../datadog-core') @@ -93,7 +94,7 @@ describe('path-traversal-analyzer', () => { it('If no context it should return evidence with an undefined ranges array', () => { const evidence = pathTraversalAnalyzer._getEvidence('', null) assert.strictEqual(evidence.value, '') - assert.ok(Array.isArray(evidence.ranges)) + assert.ok(Array.isArray(evidence.ranges), `Expected array, got ${inspect(evidence.ranges)}`) assert.strictEqual(evidence.ranges.length, 0) }) diff --git a/packages/dd-trace/test/appsec/iast/code_injection.integration.spec.js b/packages/dd-trace/test/appsec/iast/code_injection.integration.spec.js index e86ff52621..bcc83cb9e8 100644 --- a/packages/dd-trace/test/appsec/iast/code_injection.integration.spec.js +++ b/packages/dd-trace/test/appsec/iast/code_injection.integration.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const Axios = require('axios') const { sandboxCwd, useSandbox, FakeAgent, spawnProc, stopProc } = require('../../../../../integration-tests/helpers') @@ -53,7 +54,10 @@ describe('IAST - code_injection - integration', () => { const checkMessages = agent.assertMessageReceived(({ headers, payload }) => { assert.strictEqual(payload[0][0].metrics['_dd.iast.enabled'], 1) - assert.ok(Object.hasOwn(payload[0][0].meta, '_dd.iast.json')) + assert.ok( + Object.hasOwn(payload[0][0].meta, '_dd.iast.json'), + `Available keys: ${inspect(Object.keys(payload[0][0].meta))}` + ) const vulnerabilitiesTrace = JSON.parse(payload[0][0].meta['_dd.iast.json']) assert.notStrictEqual(vulnerabilitiesTrace, null) const vulnerabilities = new Set() diff --git a/packages/dd-trace/test/appsec/iast/overhead-controller.integration.spec.js b/packages/dd-trace/test/appsec/iast/overhead-controller.integration.spec.js index 3e1ab4c4fb..c343d12a8f 100644 --- a/packages/dd-trace/test/appsec/iast/overhead-controller.integration.spec.js +++ b/packages/dd-trace/test/appsec/iast/overhead-controller.integration.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const Axios = require('axios') const { sandboxCwd, useSandbox, FakeAgent, spawnProc, stopProc } = require('../../../../../integration-tests/helpers') @@ -49,7 +50,10 @@ describe('IAST - overhead-controller - integration', () => { const assertPromise = agent.assertMessageReceived(({ payload }) => { assert.strictEqual(payload[0][0].type, 'web') assert.strictEqual(payload[0][0].metrics['_dd.iast.enabled'], 1) - assert.ok(Object.hasOwn(payload[0][0].meta, '_dd.iast.json')) + assert.ok( + Object.hasOwn(payload[0][0].meta, '_dd.iast.json'), + `Available keys: ${inspect(Object.keys(payload[0][0].meta))}` + ) const vulnerabilitiesTrace = JSON.parse(payload[0][0].meta['_dd.iast.json']) assert.notStrictEqual(vulnerabilitiesTrace, null) diff --git a/packages/dd-trace/test/appsec/iast/path-line.spec.js b/packages/dd-trace/test/appsec/iast/path-line.spec.js index 31c35abf7b..c9c00e85e8 100644 --- a/packages/dd-trace/test/appsec/iast/path-line.spec.js +++ b/packages/dd-trace/test/appsec/iast/path-line.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const os = require('os') const path = require('path') +const { inspect } = require('node:util') const proxyquire = require('proxyquire') @@ -114,7 +115,7 @@ describe('path-line', function () { const results = pathLine.getCallSiteFramesForLocation(callsites) assert.strictEqual(results.length, 3) - assert.ok(results.every(r => r.path && typeof r.isInternal === 'boolean')) + assert.ok(results.every(r => r.path && typeof r.isInternal === 'boolean'), `Got: ${inspect(results)}`) }) EXCLUDED_TEST_PATHS.forEach((dcPath) => { diff --git a/packages/dd-trace/test/appsec/iast/taint-tracking/sources/graphql.sources.test-utils.js b/packages/dd-trace/test/appsec/iast/taint-tracking/sources/graphql.sources.test-utils.js index 5159be5ca7..87fd5c7de6 100644 --- a/packages/dd-trace/test/appsec/iast/taint-tracking/sources/graphql.sources.test-utils.js +++ b/packages/dd-trace/test/appsec/iast/taint-tracking/sources/graphql.sources.test-utils.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const axios = require('axios') const { afterEach, beforeEach, describe, it } = require('mocha') @@ -86,7 +87,10 @@ function graphqlCommonTests (config) { it('Should detect COMMAND_INJECTION vulnerability with hardcoded query', (done) => { agent.assertSomeTraces(payload => { - assert.ok(Object.hasOwn(payload[0][0].meta, '_dd.iast.json')) + assert.ok( + Object.hasOwn(payload[0][0].meta, '_dd.iast.json'), + `Available keys: ${inspect(Object.keys(payload[0][0].meta))}` + ) const iastJson = JSON.parse(payload[0][0].meta['_dd.iast.json']) assert.strictEqual(iastJson.vulnerabilities[0].type, 'COMMAND_INJECTION') @@ -98,7 +102,10 @@ function graphqlCommonTests (config) { it('Should detect COMMAND_INJECTION vulnerability with query and variables', (done) => { agent.assertSomeTraces(payload => { - assert.ok(Object.hasOwn(payload[0][0].meta, '_dd.iast.json')) + assert.ok( + Object.hasOwn(payload[0][0].meta, '_dd.iast.json'), + `Available keys: ${inspect(Object.keys(payload[0][0].meta))}` + ) const iastJson = JSON.parse(payload[0][0].meta['_dd.iast.json']) assert.strictEqual(iastJson.vulnerabilities[0].type, 'COMMAND_INJECTION') diff --git a/packages/dd-trace/test/appsec/iast/taint-tracking/taint-tracking-operations.spec.js b/packages/dd-trace/test/appsec/iast/taint-tracking/taint-tracking-operations.spec.js index 7200051e5c..e9abbc5720 100644 --- a/packages/dd-trace/test/appsec/iast/taint-tracking/taint-tracking-operations.spec.js +++ b/packages/dd-trace/test/appsec/iast/taint-tracking/taint-tracking-operations.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { afterEach, beforeEach, describe, it } = require('mocha') const proxyquire = require('proxyquire') @@ -468,7 +469,7 @@ describe('IAST TaintTracking Operations', () => { it('Given null iastContext should return empty array', () => { const result = taintTrackingOperations.getRanges(null) - assert.ok(Array.isArray(result)) + assert.ok(Array.isArray(result), `Expected array, got ${inspect(result)}`) assert.strictEqual(result.length, 0) }) }) diff --git a/packages/dd-trace/test/appsec/iast/telemetry/namespaces.spec.js b/packages/dd-trace/test/appsec/iast/telemetry/namespaces.spec.js index e88e6027ee..592c60d2c8 100644 --- a/packages/dd-trace/test/appsec/iast/telemetry/namespaces.spec.js +++ b/packages/dd-trace/test/appsec/iast/telemetry/namespaces.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { afterEach, beforeEach, describe, it } = require('mocha') const sinon = require('sinon') @@ -50,7 +51,7 @@ describe('IAST metric namespaces', () => { sinon.assert.called(rootSpan.addTags) const tag = rootSpan.addTags.getCalls()[0].args[0] - assert.ok(`${TAG_PREFIX}.${REQUEST_TAINTED}` in tag) + assert.ok(`${TAG_PREFIX}.${REQUEST_TAINTED}` in tag, `Got: ${inspect(tag)}`) assert.strictEqual(tag[`${TAG_PREFIX}.${REQUEST_TAINTED}`], 10) assert.strictEqual(context[DD_IAST_METRICS_NAMESPACE], undefined) @@ -67,11 +68,11 @@ describe('IAST metric namespaces', () => { const calls = rootSpan.addTags.getCalls() const reqTaintedTag = calls[0].args[0] - assert.ok(`${TAG_PREFIX}.${REQUEST_TAINTED}` in reqTaintedTag) + assert.ok(`${TAG_PREFIX}.${REQUEST_TAINTED}` in reqTaintedTag, `Got: ${inspect(reqTaintedTag)}`) assert.strictEqual(reqTaintedTag[`${TAG_PREFIX}.${REQUEST_TAINTED}`], 15) const execSinkTag = calls[1].args[0] - assert.ok(`${TAG_PREFIX}.${EXECUTED_SINK}` in execSinkTag) + assert.ok(`${TAG_PREFIX}.${EXECUTED_SINK}` in execSinkTag, `Got: ${inspect(execSinkTag)}`) assert.strictEqual(execSinkTag[`${TAG_PREFIX}.${EXECUTED_SINK}`], 1) }) diff --git a/packages/dd-trace/test/appsec/iast/utils.js b/packages/dd-trace/test/appsec/iast/utils.js index 8325104681..d948348f22 100644 --- a/packages/dd-trace/test/appsec/iast/utils.js +++ b/packages/dd-trace/test/appsec/iast/utils.js @@ -4,6 +4,7 @@ const assert = require('node:assert/strict') const fs = require('node:fs') const os = require('node:os') const path = require('node:path') +const { inspect } = require('node:util') const msgpack = require('@msgpack/msgpack') const axios = require('axios') @@ -162,7 +163,7 @@ function checkNoVulnerabilityInRequest (vulnerability, config, done, makeRequest if (traces[0][0].type !== 'web') throw new Error('Not a web span') // iastJson == undefiend is valid const iastJson = traces[0][0].meta['_dd.iast.json'] || '' - assert.ok(!(iastJson).includes(`"${vulnerability}"`)) + assert.ok(!(iastJson).includes(`"${vulnerability}"`), `Got: ${inspect(iastJson)}`) }) .then(done) .catch(done) @@ -193,7 +194,10 @@ function checkVulnerabilityInRequest ( const span = getWebSpan(traces) assert.strictEqual(span.metrics['_dd.iast.enabled'], 1) assert.ok('_dd.iast.json' in span.meta) - assert.ok(Object.hasOwn(span.meta_struct, '_dd.stack')) + assert.ok( + Object.hasOwn(span.meta_struct, '_dd.stack'), + `Available keys: ${inspect(Object.keys(span.meta_struct))}` + ) const vulnerabilitiesTrace = JSON.parse(span.meta['_dd.iast.json']) assert.notStrictEqual(vulnerabilitiesTrace, null) @@ -203,9 +207,10 @@ function checkVulnerabilityInRequest ( vulnerabilitiesCount.set(v.type, count) }) - assert.ok(((vulnerabilitiesCount.get(vulnerability)) > (0))) + const occurrencesFound = vulnerabilitiesCount.get(vulnerability) + assert.ok(occurrencesFound > 0, `Expected ${occurrencesFound} > 0`) if (occurrences) { - assert.strictEqual(vulnerabilitiesCount.get(vulnerability), occurrences) + assert.strictEqual(occurrencesFound, occurrences) } if (location) { diff --git a/packages/dd-trace/test/appsec/iast/vulnerability-reporter.spec.js b/packages/dd-trace/test/appsec/iast/vulnerability-reporter.spec.js index a0de830d0c..370c4d1141 100644 --- a/packages/dd-trace/test/appsec/iast/vulnerability-reporter.spec.js +++ b/packages/dd-trace/test/appsec/iast/vulnerability-reporter.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const sinon = require('sinon') const { addVulnerability, sendVulnerabilities, clearCache, start, stop } = @@ -52,8 +53,11 @@ describe('vulnerability-reporter', () => { it('should create vulnerability array if it does not exist', () => { addVulnerability(iastContext, vulnerabilityAnalyzer._createVulnerability('INSECURE_HASHING', { value: 'sha1' }, 888), []) - assert.ok(Object.hasOwn(iastContext, 'vulnerabilities')) - assert.ok(Array.isArray(iastContext.vulnerabilities)) + assert.ok(Object.hasOwn(iastContext, 'vulnerabilities'), `Available keys: ${inspect(Object.keys(iastContext))}`) + assert.ok( + Array.isArray(iastContext.vulnerabilities), + `Expected array, got ${inspect(iastContext.vulnerabilities)}` + ) }) it('should deduplicate same vulnerabilities', () => { diff --git a/packages/dd-trace/test/appsec/index.express.plugin.spec.js b/packages/dd-trace/test/appsec/index.express.plugin.spec.js index e8220388e0..d81cd6f200 100644 --- a/packages/dd-trace/test/appsec/index.express.plugin.spec.js +++ b/packages/dd-trace/test/appsec/index.express.plugin.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('node:path') const zlib = require('node:zlib') +const { inspect } = require('node:util') const Axios = require('axios') const semver = require('semver') const sinon = require('sinon') @@ -333,7 +334,10 @@ withVersions('express', 'express', version => { await agent.assertSomeTraces((traces) => { const span = traces[0][0] - assert.ok(Object.hasOwn(span.meta, '_dd.appsec.s.req.body')) + assert.ok( + Object.hasOwn(span.meta, '_dd.appsec.s.req.body'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) assert.ok(!('_dd.appsec.s.res.body' in span.meta)) assert.equal(span.meta['_dd.appsec.s.req.body'], expectedRequestBodySchema) }) @@ -391,8 +395,8 @@ withVersions('express', 'express', version => { await agent.assertSomeTraces((traces) => { const span = traces[0][0] - assert(!Object.hasOwn(span.meta, '_dd.appsec.s.req.body')) - assert(!Object.hasOwn(span.meta, '_dd.appsec.s.res.body')) + assert(!Object.hasOwn(span.meta, '_dd.appsec.s.req.body'), `Available keys: ${inspect(Object.keys(span.meta))}`) + assert(!Object.hasOwn(span.meta, '_dd.appsec.s.res.body'), `Available keys: ${inspect(Object.keys(span.meta))}`) }) assert.equal(res.status, 200) 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 86be2c5fd7..81f4e91d9a 100644 --- a/packages/dd-trace/test/appsec/index.next.plugin.spec.js +++ b/packages/dd-trace/test/appsec/index.next.plugin.spec.js @@ -17,6 +17,10 @@ describe('test suite', () => { return // next 12.x fails on node 24.0.0, but 24.0.1 works } + if (satisfies(realVersion, '>=12 <13') && NODE_MAJOR >= 26) { + return // next 12.x fails to build on Node.js 26 + } + if (satisfies(realVersion, '>=16') && NODE_MAJOR < 20) { return } diff --git a/packages/dd-trace/test/appsec/index.spec.js b/packages/dd-trace/test/appsec/index.spec.js index 29554c3811..c09330f350 100644 --- a/packages/dd-trace/test/appsec/index.spec.js +++ b/packages/dd-trace/test/appsec/index.spec.js @@ -946,10 +946,17 @@ describe('AppSec Index', function () { beforeEach(() => { sinon.stub(waf, 'run') + const rootSpanTags = {} rootSpan = { setTag: sinon.stub(), - _tags: {}, - context: () => ({ _tags: rootSpan._tags }), + _tags: rootSpanTags, + context: () => ({ + _tags: rootSpanTags, + getTags () { return rootSpanTags }, + getTag (key) { return rootSpanTags[key] }, + setTag (key, value) { rootSpanTags[key] = value }, + hasTag (key) { return key in rootSpanTags }, + }), } web.root.returns(rootSpan) diff --git a/packages/dd-trace/test/appsec/payment_events.stripe.plugin.spec.js b/packages/dd-trace/test/appsec/payment_events.stripe.plugin.spec.js index 3730b669ec..ada645fed0 100644 --- a/packages/dd-trace/test/appsec/payment_events.stripe.plugin.spec.js +++ b/packages/dd-trace/test/appsec/payment_events.stripe.plugin.spec.js @@ -5,6 +5,7 @@ const assert = require('node:assert/strict') const crypto = require('node:crypto') const path = require('node:path') +const { inspect } = require('node:util') const Axios = require('axios') const { describe, it, before, after } = require('mocha') @@ -295,16 +296,46 @@ withVersions('stripe', 'stripe', version => { const span = traces[0][0] assert.equal(span.metrics._sampling_priority_v1, 1) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.integration')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.creation.id')) - assert(!Object.hasOwn(span.metrics, 'appsec.events.payments.creation.amount_total')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.creation.client_reference_id')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.creation.currency')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.creation.discounts.coupon')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.creation.discounts.promotion_code')) - assert(!Object.hasOwn(span.metrics, 'appsec.events.payments.creation.livemode')) - assert(!Object.hasOwn(span.metrics, 'appsec.events.payments.creation.total_details.amount_discount')) - assert(!Object.hasOwn(span.metrics, 'appsec.events.payments.creation.total_details.amount_shipping')) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.integration'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.creation.id'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.metrics, 'appsec.events.payments.creation.amount_total'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.creation.client_reference_id'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.creation.currency'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.creation.discounts.coupon'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.creation.discounts.promotion_code'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.metrics, 'appsec.events.payments.creation.livemode'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) + assert( + !Object.hasOwn(span.metrics, 'appsec.events.payments.creation.total_details.amount_discount'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) + assert( + !Object.hasOwn(span.metrics, 'appsec.events.payments.creation.total_details.amount_shipping'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) }) assert.equal(res.status, 200) @@ -360,16 +391,46 @@ withVersions('stripe', 'stripe', version => { const span = traces[0][0] assert.equal(span.metrics._sampling_priority_v1, 1) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.integration')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.creation.id')) - assert(!Object.hasOwn(span.metrics, 'appsec.events.payments.creation.amount_total')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.creation.client_reference_id')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.creation.currency')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.creation.discounts.coupon')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.creation.discounts.promotion_code')) - assert(!Object.hasOwn(span.metrics, 'appsec.events.payments.creation.livemode')) - assert(!Object.hasOwn(span.metrics, 'appsec.events.payments.creation.total_details.amount_discount')) - assert(!Object.hasOwn(span.metrics, 'appsec.events.payments.creation.total_details.amount_shipping')) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.integration'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.creation.id'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.metrics, 'appsec.events.payments.creation.amount_total'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.creation.client_reference_id'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.creation.currency'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.creation.discounts.coupon'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.creation.discounts.promotion_code'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.metrics, 'appsec.events.payments.creation.livemode'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) + assert( + !Object.hasOwn(span.metrics, 'appsec.events.payments.creation.total_details.amount_discount'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) + assert( + !Object.hasOwn(span.metrics, 'appsec.events.payments.creation.total_details.amount_shipping'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) }) assert.equal(res.status, 500) @@ -415,12 +476,30 @@ withVersions('stripe', 'stripe', version => { const span = traces[0][0] assert.equal(span.metrics._sampling_priority_v1, 1) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.creation.id')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.integration')) - assert(!Object.hasOwn(span.metrics, 'appsec.events.payments.creation.amount')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.creation.currency')) - assert(!Object.hasOwn(span.metrics, 'appsec.events.payments.creation.livemode')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.creation.payment_method')) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.creation.id'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.integration'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.metrics, 'appsec.events.payments.creation.amount'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.creation.currency'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.metrics, 'appsec.events.payments.creation.livemode'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.creation.payment_method'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) }) assert.equal(res.status, 500) @@ -567,12 +646,30 @@ withVersions('stripe', 'stripe', version => { const span = traces[0][0] assert.equal(span.metrics._sampling_priority_v1, 1) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.integration')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.success.id')) - assert(!Object.hasOwn(span.metrics, 'appsec.events.payments.success.amount')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.success.currency')) - assert(!Object.hasOwn(span.metrics, 'appsec.events.payments.success.livemode')) - assert(!Object.hasOwn(span.metrics, 'appsec.events.payments.success.payment_method')) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.integration'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.success.id'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.metrics, 'appsec.events.payments.success.amount'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.success.currency'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.metrics, 'appsec.events.payments.success.livemode'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) + assert( + !Object.hasOwn(span.metrics, 'appsec.events.payments.success.payment_method'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) }) assert.equal(res.status, 403) @@ -597,7 +694,10 @@ withVersions('stripe', 'stripe', version => { const span = traces[0][0] assert.equal(span.metrics._sampling_priority_v1, 1) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.integration')) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.integration'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) }) assert.equal(res.status, 200) @@ -663,12 +763,30 @@ withVersions('stripe', 'stripe', version => { const span = traces[0][0] assert.equal(span.metrics._sampling_priority_v1, 1) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.integration')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.success.id')) - assert(!Object.hasOwn(span.metrics, 'appsec.events.payments.success.amount')) - assert(!Object.hasOwn(span.meta, 'appsec.events.payments.success.currency')) - assert(!Object.hasOwn(span.metrics, 'appsec.events.payments.success.livemode')) - assert(!Object.hasOwn(span.metrics, 'appsec.events.payments.success.payment_method')) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.integration'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.success.id'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.metrics, 'appsec.events.payments.success.amount'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) + assert( + !Object.hasOwn(span.meta, 'appsec.events.payments.success.currency'), + `Available keys: ${inspect(Object.keys(span.meta))}` + ) + assert( + !Object.hasOwn(span.metrics, 'appsec.events.payments.success.livemode'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) + assert( + !Object.hasOwn(span.metrics, 'appsec.events.payments.success.payment_method'), + `Available keys: ${inspect(Object.keys(span.metrics))}` + ) }) assert.equal(res.status, 403) diff --git a/packages/dd-trace/test/appsec/rasp/command_injection.integration.spec.js b/packages/dd-trace/test/appsec/rasp/command_injection.integration.spec.js index b17110d48a..22cd956c87 100644 --- a/packages/dd-trace/test/appsec/rasp/command_injection.integration.spec.js +++ b/packages/dd-trace/test/appsec/rasp/command_injection.integration.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('node:path') +const { inspect } = require('node:util') const Axios = require('axios') const { describe, it, before, beforeEach, afterEach } = require('mocha') @@ -62,7 +63,10 @@ describe('RASP - command_injection - integration', () => { let appsecTelemetryReceived = false const checkMessages = agent.assertMessageReceived(({ headers, payload }) => { - assert.ok(Object.hasOwn(payload[0][0].meta, '_dd.appsec.json')) + assert.ok( + Object.hasOwn(payload[0][0].meta, '_dd.appsec.json'), + `Available keys: ${inspect(Object.keys(payload[0][0].meta))}` + ) assert.match(payload[0][0].meta['_dd.appsec.json'], new RegExp(`"rasp-command_injection-rule-id-${ruleId}"`)) }, 4_000) @@ -78,13 +82,13 @@ describe('RASP - command_injection - integration', () => { const matchSerie = series.find(s => s.metric === 'rasp.rule.match') assert.ok(evalSerie) - assert.ok(evalSerie.tags.includes('rule_type:command_injection')) - assert.ok(evalSerie.tags.includes(`rule_variant:${variant}`)) + assert.ok(evalSerie.tags.includes('rule_type:command_injection'), `Got: ${inspect(evalSerie.tags)}`) + assert.ok(evalSerie.tags.includes(`rule_variant:${variant}`), `Got: ${inspect(evalSerie.tags)}`) assert.strictEqual(evalSerie.type, 'count') assert.ok(matchSerie) - assert.ok(matchSerie.tags.includes('rule_type:command_injection')) - assert.ok(matchSerie.tags.includes(`rule_variant:${variant}`)) + assert.ok(matchSerie.tags.includes('rule_type:command_injection'), `Got: ${inspect(matchSerie.tags)}`) + assert.ok(matchSerie.tags.includes(`rule_variant:${variant}`), `Got: ${inspect(matchSerie.tags)}`) assert.strictEqual(matchSerie.type, 'count') } else { assert.fail('namespace should be appsec') diff --git a/packages/dd-trace/test/appsec/rasp/fs-plugin.spec.js b/packages/dd-trace/test/appsec/rasp/fs-plugin.spec.js index 62cfa74933..f7e0f041a8 100644 --- a/packages/dd-trace/test/appsec/rasp/fs-plugin.spec.js +++ b/packages/dd-trace/test/appsec/rasp/fs-plugin.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const path = require('node:path') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') const proxyquire = require('proxyquire') @@ -104,7 +105,7 @@ describe('AppsecFsPlugin', () => { let store = appsecFsPlugin._onFsOperationStart() - assert.ok(Object.hasOwn(store, 'fs')) + assert.ok(Object.hasOwn(store, 'fs'), `Available keys: ${inspect(Object.keys(store))}`) assert.strictEqual(store.fs.parentStore, origStore) assert.strictEqual(store.fs.root, true) @@ -120,7 +121,7 @@ describe('AppsecFsPlugin', () => { const rootStore = appsecFsPlugin._onFsOperationStart() - assert.ok(Object.hasOwn(rootStore, 'fs')) + assert.ok(Object.hasOwn(rootStore, 'fs'), `Available keys: ${inspect(Object.keys(rootStore))}`) assert.strictEqual(rootStore.fs.parentStore, origStore) assert.strictEqual(rootStore.fs.root, true) @@ -158,7 +159,7 @@ describe('AppsecFsPlugin', () => { let store = appsecFsPlugin._onResponseRenderStart() - assert.ok(Object.hasOwn(store, 'fs')) + assert.ok(Object.hasOwn(store, 'fs'), `Available keys: ${inspect(Object.keys(store))}`) assert.strictEqual(store.fs.parentStore, origStore) assert.strictEqual(store.fs.opExcluded, true) diff --git a/packages/dd-trace/test/appsec/rasp/lfi.express.plugin.spec.js b/packages/dd-trace/test/appsec/rasp/lfi.express.plugin.spec.js index 640b8c70e3..c2991cf9a5 100644 --- a/packages/dd-trace/test/appsec/rasp/lfi.express.plugin.spec.js +++ b/packages/dd-trace/test/appsec/rasp/lfi.express.plugin.spec.js @@ -5,6 +5,7 @@ const assert = require('node:assert/strict') const os = require('node:os') const fs = require('node:fs') const path = require('node:path') +const { inspect } = require('node:util') const Axios = require('axios') const semver = require('semver') @@ -125,7 +126,7 @@ describe('RASP - lfi', () => { const file = args[vulnerableIndex] return testBlockingRequest(`/?file=${file}`, undefined, ruleEvalCount) .then(span => { - assert(span.meta['_dd.appsec.json'].includes(file)) + assert(span.meta['_dd.appsec.json'].includes(file), `Got: ${inspect(span.meta['_dd.appsec.json'])}`) }) }) diff --git a/packages/dd-trace/test/appsec/rasp/lfi.integration.express.plugin.spec.js b/packages/dd-trace/test/appsec/rasp/lfi.integration.express.plugin.spec.js index 8d431f8e69..8d37e02d4f 100644 --- a/packages/dd-trace/test/appsec/rasp/lfi.integration.express.plugin.spec.js +++ b/packages/dd-trace/test/appsec/rasp/lfi.integration.express.plugin.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const Axios = require('axios') const { sandboxCwd, useSandbox, FakeAgent, spawnProc, stopProc } = require('../../../../../integration-tests/helpers') describe('RASP - lfi - integration - sync', () => { @@ -47,7 +48,10 @@ describe('RASP - lfi - integration - sync', () => { assert.strictEqual(e.response.status, 403) return await agent.assertMessageReceived(({ headers, payload }) => { - assert.ok(Object.hasOwn(payload[0][0].meta, '_dd.appsec.json')) + assert.ok( + Object.hasOwn(payload[0][0].meta, '_dd.appsec.json'), + `Available keys: ${inspect(Object.keys(payload[0][0].meta))}` + ) assert.match(payload[0][0].meta['_dd.appsec.json'], /"rasp-lfi-rule-id-1"/) }) } diff --git a/packages/dd-trace/test/appsec/rasp/rasp-metrics.integration.spec.js b/packages/dd-trace/test/appsec/rasp/rasp-metrics.integration.spec.js index 1a26658dc3..117c03ac83 100644 --- a/packages/dd-trace/test/appsec/rasp/rasp-metrics.integration.spec.js +++ b/packages/dd-trace/test/appsec/rasp/rasp-metrics.integration.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const Axios = require('axios') const { sandboxCwd, useSandbox, FakeAgent, spawnProc, stopProc } = require('../../../../../integration-tests/helpers') describe('RASP metrics', () => { @@ -65,7 +66,7 @@ describe('RASP metrics', () => { const errorSerie = series.find(s => s.metric === 'rasp.error') assert.ok(errorSerie) - assert.ok(errorSerie.tags.includes('waf_error:-127')) + assert.ok(errorSerie.tags.includes('waf_error:-127'), `Got: ${inspect(errorSerie.tags)}`) assert.strictEqual(errorSerie.type, 'count') } }, @@ -120,8 +121,8 @@ describe('RASP metrics', () => { const timeoutSerie = series.find(s => s.metric === 'rasp.timeout') assert.ok(timeoutSerie) - assert.ok(timeoutSerie.tags.includes('rule_type:command_injection')) - assert.ok(timeoutSerie.tags.includes('rule_variant:shell')) + assert.ok(timeoutSerie.tags.includes('rule_type:command_injection'), `Got: ${inspect(timeoutSerie.tags)}`) + assert.ok(timeoutSerie.tags.includes('rule_variant:shell'), `Got: ${inspect(timeoutSerie.tags)}`) assert.strictEqual(timeoutSerie.type, 'count') } }, diff --git a/packages/dd-trace/test/appsec/rasp/rasp_blocking.fastify.plugin.spec.js b/packages/dd-trace/test/appsec/rasp/rasp_blocking.fastify.plugin.spec.js index 693f2b795a..098ddeddeb 100644 --- a/packages/dd-trace/test/appsec/rasp/rasp_blocking.fastify.plugin.spec.js +++ b/packages/dd-trace/test/appsec/rasp/rasp_blocking.fastify.plugin.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('node:path') +const { inspect } = require('node:util') const Axios = require('axios') const { describe, it, afterEach, before, after } = require('mocha') const sinon = require('sinon') @@ -111,7 +112,7 @@ describe('RASP - fastify blocking', () => { sinon.assert.calledOnce(hooks.onError) assert.strictEqual(res.status, 500) assert.notStrictEqual(res.data, blockedJson) - assert(res.data.includes('loul')) + assert(res.data.includes('loul'), `Got: ${inspect(res.data)}`) await checkRaspExecutedAndNotThreat(agent, false) }) diff --git a/packages/dd-trace/test/appsec/rasp/sql_injection.integration.pg.plugin.spec.js b/packages/dd-trace/test/appsec/rasp/sql_injection.integration.pg.plugin.spec.js index bfa904d136..a85f093629 100644 --- a/packages/dd-trace/test/appsec/rasp/sql_injection.integration.pg.plugin.spec.js +++ b/packages/dd-trace/test/appsec/rasp/sql_injection.integration.pg.plugin.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const Axios = require('axios') const { sandboxCwd, useSandbox, FakeAgent, spawnProc, stopProc } = require('../../../../../integration-tests/helpers') // These test are here and not in the integration tests @@ -49,7 +50,10 @@ describe('RASP - sql_injection - integration', () => { assert.strictEqual(e.response.status, 403) return await agent.assertMessageReceived(({ headers, payload }) => { - assert.ok(Object.hasOwn(payload[0][0].meta, '_dd.appsec.json')) + assert.ok( + Object.hasOwn(payload[0][0].meta, '_dd.appsec.json'), + `Available keys: ${inspect(Object.keys(payload[0][0].meta))}` + ) assert.match(payload[0][0].meta['_dd.appsec.json'], /"rasp-sqli-rule-id-2"/) }) } @@ -67,7 +71,10 @@ describe('RASP - sql_injection - integration', () => { assert.strictEqual(e.response.status, 403) return await agent.assertMessageReceived(({ headers, payload }) => { - assert.ok(Object.hasOwn(payload[0][0].meta, '_dd.appsec.json')) + assert.ok( + Object.hasOwn(payload[0][0].meta, '_dd.appsec.json'), + `Available keys: ${inspect(Object.keys(payload[0][0].meta))}` + ) assert.match(payload[0][0].meta['_dd.appsec.json'], /"rasp-sqli-rule-id-2"/) }) } @@ -85,7 +92,10 @@ describe('RASP - sql_injection - integration', () => { assert.strictEqual(e.response.status, 403) return await agent.assertMessageReceived(({ headers, payload }) => { - assert.ok(Object.hasOwn(payload[0][0].meta, '_dd.appsec.json')) + assert.ok( + Object.hasOwn(payload[0][0].meta, '_dd.appsec.json'), + `Available keys: ${inspect(Object.keys(payload[0][0].meta))}` + ) assert.match(payload[0][0].meta['_dd.appsec.json'], /"rasp-sqli-rule-id-2"/) }) } diff --git a/packages/dd-trace/test/appsec/rasp/ssrf.express.plugin.spec.js b/packages/dd-trace/test/appsec/rasp/ssrf.express.plugin.spec.js index f986af52f6..a3123bb49e 100644 --- a/packages/dd-trace/test/appsec/rasp/ssrf.express.plugin.spec.js +++ b/packages/dd-trace/test/appsec/rasp/ssrf.express.plugin.spec.js @@ -130,13 +130,18 @@ describe('RASP - ssrf', () => { withVersions('express', 'axios', axiosVersion => { let axiosToTest - beforeEach((done) => { + before(async () => { axiosToTest = require(`../../../../../versions/axios@${axiosVersion}`).get() - // we preload axios because it's lazyloading a debug dependency - // that in turns trigger LFI - - axiosToTest.get('http://preloadaxios', { timeout: 10 }).catch(noop).then(done) + // Preload axios to trigger its lazily-loaded debug dependency outside of any + // request context, which would otherwise cause a false-positive RASP LFI event. + // We drain the resulting span synchronously within this `before` hook so it + // cannot bleed into any test's assertion window. + const preloadSpanDrained = agent.assertSomeTraces(noop).catch(noop) + await Promise.all([ + axiosToTest.get('http://preloadaxios', { timeout: 10 }).catch(noop), + preloadSpanDrained, + ]) }) it('Should not detect threat', async () => { diff --git a/packages/dd-trace/test/appsec/rasp/utils.js b/packages/dd-trace/test/appsec/rasp/utils.js index 8ae1cc19a0..fbbdc320d9 100644 --- a/packages/dd-trace/test/appsec/rasp/utils.js +++ b/packages/dd-trace/test/appsec/rasp/utils.js @@ -1,13 +1,17 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { getWebSpan } = require('../utils') function checkRaspExecutedAndNotThreat (agent, checkRuleEval = true) { return agent.assertSomeTraces((traces) => { const span = getWebSpan(traces) assert.ok(!('_dd.appsec.json' in span.meta)) - assert.ok(!span.meta_struct || !('_dd.stack' in span.meta_struct)) + assert.ok( + !span.meta_struct || !('_dd.stack' in span.meta_struct), + `Got meta_struct: ${inspect(span.meta_struct)}` + ) if (checkRuleEval) { assert.strictEqual(span.metrics['_dd.appsec.rasp.rule.eval'], 1) } @@ -17,12 +21,15 @@ function checkRaspExecutedAndNotThreat (agent, checkRuleEval = true) { function checkRaspExecutedAndHasThreat (agent, ruleId, ruleEvalCount = 1) { return agent.assertSomeTraces((traces) => { const span = getWebSpan(traces) - assert.ok(Object.hasOwn(span.meta, '_dd.appsec.json')) - assert(span.meta['_dd.appsec.json'].includes(ruleId)) + assert.ok(Object.hasOwn(span.meta, '_dd.appsec.json'), `Available keys: ${inspect(Object.keys(span.meta))}`) + assert(span.meta['_dd.appsec.json'].includes(ruleId), `Got: ${inspect(span.meta['_dd.appsec.json'])}`) assert.strictEqual(span.metrics['_dd.appsec.rasp.rule.eval'], ruleEvalCount) - assert(span.metrics['_dd.appsec.rasp.duration'] > 0) - assert(span.metrics['_dd.appsec.rasp.duration_ext'] > 0) - assert.ok(Object.hasOwn(span.meta_struct, '_dd.stack')) + assert(span.metrics['_dd.appsec.rasp.duration'] > 0, `Expected ${span.metrics['_dd.appsec.rasp.duration']} > 0`) + assert( + span.metrics['_dd.appsec.rasp.duration_ext'] > 0, + `Expected ${span.metrics['_dd.appsec.rasp.duration_ext']} > 0` + ) + assert.ok(Object.hasOwn(span.meta_struct, '_dd.stack'), `Available keys: ${inspect(Object.keys(span.meta_struct))}`) return span }) diff --git a/packages/dd-trace/test/appsec/reporter.spec.js b/packages/dd-trace/test/appsec/reporter.spec.js index 41ac5a1407..3d534fbde7 100644 --- a/packages/dd-trace/test/appsec/reporter.spec.js +++ b/packages/dd-trace/test/appsec/reporter.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const zlib = require('node:zlib') +const { inspect } = require('node:util') const dc = require('dc-polyfill') const { after, afterEach, beforeEach, describe, it } = require('mocha') @@ -41,11 +42,17 @@ describe('reporter', () => { setPriority: sinon.stub(), } + const spanTags = {} + const spanContext = { + _tags: spanTags, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, + hasTag (key) { return key in this._tags }, + } span = { _prioritySampler: prioritySampler, - context: sinon.stub().returns({ - _tags: {}, - }), + context: sinon.stub().returns(spanContext), addTags: sinon.stub(), setTag: sinon.stub(), keep: sinon.stub(), @@ -665,7 +672,10 @@ describe('reporter', () => { const { truncated, value: truncatedRequestBody } = Reporter.truncateRequestBody(requestBody) assert.strictEqual(truncated, true) - assert.ok(Object.hasOwn(truncatedRequestBody, 'str')) + assert.ok( + Object.hasOwn(truncatedRequestBody, 'str'), + `Available keys: ${inspect(Object.keys(truncatedRequestBody))}` + ) assert.strictEqual(truncatedRequestBody.str.length, 4096) assert.strictEqual(objectDepth(truncatedRequestBody.nestedObj), 19) assert.strictEqual(Object.keys(truncatedRequestBody.objectWithLotsOfNodes).length, 256) diff --git a/packages/dd-trace/test/appsec/sdk/track_event-integration.spec.js b/packages/dd-trace/test/appsec/sdk/track_event-integration.spec.js index 647f025d21..37d8ae74d3 100644 --- a/packages/dd-trace/test/appsec/sdk/track_event-integration.spec.js +++ b/packages/dd-trace/test/appsec/sdk/track_event-integration.spec.js @@ -63,10 +63,7 @@ describe('track_event - Integration with the tracer', () => { res.end() } agent.assertSomeTraces(traces => { - assert.ok( - !('appsec.events.users.login.success.track' in traces[0][0].meta) || - traces[0][0].meta['appsec.events.users.login.success.track'] !== 'true' - ) + assert.notStrictEqual(traces[0][0].meta['appsec.events.users.login.success.track'], 'true') }).then(done).catch(done) axios.get(`http://localhost:${port}/`) }) @@ -76,10 +73,7 @@ describe('track_event - Integration with the tracer', () => { res.end() } agent.assertSomeTraces(traces => { - assert.ok( - !('appsec.events.users.login.success.track' in traces[0][0].meta) || - traces[0][0].meta['appsec.events.users.login.success.track'] !== 'true' - ) + assert.notStrictEqual(traces[0][0].meta['appsec.events.users.login.success.track'], 'true') }).then(done).catch(done) axios.get(`http://localhost:${port}/`) }) @@ -122,10 +116,7 @@ describe('track_event - Integration with the tracer', () => { res.end() } agent.assertSomeTraces(traces => { - assert.ok( - !('appsec.events.users.login.failure.track' in traces[0][0].meta) || - traces[0][0].meta['appsec.events.users.login.failure.track'] !== 'true' - ) + assert.notStrictEqual(traces[0][0].meta['appsec.events.users.login.failure.track'], 'true') }).then(done).catch(done) axios.get(`http://localhost:${port}/`) }) @@ -135,10 +126,7 @@ describe('track_event - Integration with the tracer', () => { res.end() } agent.assertSomeTraces(traces => { - assert.ok( - !('appsec.events.users.login.failure.track' in traces[0][0].meta) || - traces[0][0].meta['appsec.events.users.login.failure.track'] !== 'true' - ) + assert.notStrictEqual(traces[0][0].meta['appsec.events.users.login.failure.track'], 'true') }).then(done).catch(done) axios.get(`http://localhost:${port}/`) }) @@ -165,10 +153,7 @@ describe('track_event - Integration with the tracer', () => { res.end() } agent.assertSomeTraces(traces => { - assert.ok( - !('_sampling_priority_v1' in traces[0][0].metrics) || - traces[0][0].metrics._sampling_priority_v1 !== USER_KEEP - ) + assert.notStrictEqual(traces[0][0].metrics._sampling_priority_v1, USER_KEEP) }).then(done).catch(done) axios.get(`http://localhost:${port}/`) }) diff --git a/packages/dd-trace/test/appsec/sdk/user_blocking-integration.spec.js b/packages/dd-trace/test/appsec/sdk/user_blocking-integration.spec.js index 4437b16c65..2e15b31fb2 100644 --- a/packages/dd-trace/test/appsec/sdk/user_blocking-integration.spec.js +++ b/packages/dd-trace/test/appsec/sdk/user_blocking-integration.spec.js @@ -153,7 +153,7 @@ describe('user_blocking - Integration with the tracer', () => { assert.strictEqual(ret, false) } agent.assertSomeTraces(traces => { - assert.ok(!('appsec.blocked' in traces[0][0].meta) || traces[0][0].meta['appsec.blocked'] !== 'true') + assert.notStrictEqual(traces[0][0].meta['appsec.blocked'], 'true') assert.strictEqual(traces[0][0].meta['http.status_code'], '200') assert.strictEqual(traces[0][0].metrics['_dd.appsec.block.failed'], 1) }).then(done).catch(done) diff --git a/packages/dd-trace/test/appsec/sdk/user_blocking.spec.js b/packages/dd-trace/test/appsec/sdk/user_blocking.spec.js index ae923095d5..92821411e6 100644 --- a/packages/dd-trace/test/appsec/sdk/user_blocking.spec.js +++ b/packages/dd-trace/test/appsec/sdk/user_blocking.spec.js @@ -35,9 +35,16 @@ describe('user_blocking - Internal API', () => { }) beforeEach(() => { + const tags = {} rootSpan = { context: () => { - return { _tags: {} } + return { + _tags: tags, + getTag: (key) => tags[key], + getTags: () => tags, + setTag: (key, value) => { tags[key] = value }, + hasTag: (key) => key in tags, + } }, setTag: sinon.stub(), } @@ -85,8 +92,15 @@ describe('user_blocking - Internal API', () => { }) it('should not override user when already set', () => { + const tags = { 'usr.id': 'mockUser' } rootSpan.context = () => { - return { _tags: { 'usr.id': 'mockUser' } } + return { + _tags: tags, + getTag: (key) => tags[key], + getTags: () => tags, + setTag: (key, value) => { tags[key] = value }, + hasTag: (key) => key in tags, + } } const ret = userBlocking.checkUserAndSetUser(tracer, { id: 'user' }) diff --git a/packages/dd-trace/test/appsec/stack_trace.spec.js b/packages/dd-trace/test/appsec/stack_trace.spec.js index d2c15bb553..1c2179cfcf 100644 --- a/packages/dd-trace/test/appsec/stack_trace.spec.js +++ b/packages/dd-trace/test/appsec/stack_trace.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const { reportStackTrace, getCallsiteFrames } = require('../../src/appsec/stack_trace') @@ -148,7 +149,10 @@ describe('Stack trace reporter', () => { assert.strictEqual(rootSpan.meta_struct['_dd.stack'].exploit[0].id, stackId) assert.strictEqual(rootSpan.meta_struct['_dd.stack'].exploit[0].language, 'nodejs') assert.deepStrictEqual(rootSpan.meta_struct['_dd.stack'].exploit[0].frames, expectedFrames) - assert.ok(Object.hasOwn(rootSpan.meta_struct, 'another_tag')) + assert.ok( + Object.hasOwn(rootSpan.meta_struct, 'another_tag'), + `Available keys: ${inspect(Object.keys(rootSpan.meta_struct))}` + ) }) it('should add stack trace to rootSpan when meta_struct is already present and contains another stack', () => { @@ -181,7 +185,10 @@ describe('Stack trace reporter', () => { assert.strictEqual(rootSpan.meta_struct['_dd.stack'].exploit[1].id, stackId) assert.strictEqual(rootSpan.meta_struct['_dd.stack'].exploit[1].language, 'nodejs') assert.deepStrictEqual(rootSpan.meta_struct['_dd.stack'].exploit[1].frames, expectedFrames) - assert.ok(Object.hasOwn(rootSpan.meta_struct, 'another_tag')) + assert.ok( + Object.hasOwn(rootSpan.meta_struct, 'another_tag'), + `Available keys: ${inspect(Object.keys(rootSpan.meta_struct))}` + ) }) it('should add stack trace when the max stack trace is 0', () => { @@ -201,7 +208,10 @@ describe('Stack trace reporter', () => { reportStackTrace(rootSpan, stackId, frames) assert.strictEqual(rootSpan.meta_struct['_dd.stack'].exploit.length, 3) - assert.ok(Object.hasOwn(rootSpan.meta_struct, 'another_tag')) + assert.ok( + Object.hasOwn(rootSpan.meta_struct, 'another_tag'), + `Available keys: ${inspect(Object.keys(rootSpan.meta_struct))}` + ) }) it('should add stack trace when the max stack trace is negative', () => { @@ -221,7 +231,10 @@ describe('Stack trace reporter', () => { reportStackTrace(rootSpan, stackId, frames) assert.strictEqual(rootSpan.meta_struct['_dd.stack'].exploit.length, 3) - assert.ok(Object.hasOwn(rootSpan.meta_struct, 'another_tag')) + assert.ok( + Object.hasOwn(rootSpan.meta_struct, 'another_tag'), + `Available keys: ${inspect(Object.keys(rootSpan.meta_struct))}` + ) }) it('should not report stackTraces if callSiteList is undefined', () => { @@ -232,7 +245,10 @@ describe('Stack trace reporter', () => { } const stackId = 'test_stack_id' reportStackTrace(rootSpan, stackId, undefined) - assert.ok(Object.hasOwn(rootSpan.meta_struct, 'another_tag')) + assert.ok( + Object.hasOwn(rootSpan.meta_struct, 'another_tag'), + `Available keys: ${inspect(Object.keys(rootSpan.meta_struct))}` + ) assert.ok(!('_dd.stack' in rootSpan.meta_struct)) }) }) diff --git a/packages/dd-trace/test/appsec/user_tracking.spec.js b/packages/dd-trace/test/appsec/user_tracking.spec.js index 64f9c7aaf4..45e991a752 100644 --- a/packages/dd-trace/test/appsec/user_tracking.spec.js +++ b/packages/dd-trace/test/appsec/user_tracking.spec.js @@ -28,7 +28,13 @@ describe('User Tracking', () => { currentTags = {} rootSpan = { - context: () => ({ _tags: currentTags }), + context: () => ({ + _tags: currentTags, + getTag: (key) => currentTags[key], + getTags: () => currentTags, + setTag: (key, value) => { currentTags[key] = value }, + hasTag: (key) => key in currentTags, + }), addTags: sinon.stub(), setTag: sinon.stub(), } diff --git a/packages/dd-trace/test/appsec/waf-metrics.integration.spec.js b/packages/dd-trace/test/appsec/waf-metrics.integration.spec.js index 691bd51638..a59dc03d6b 100644 --- a/packages/dd-trace/test/appsec/waf-metrics.integration.spec.js +++ b/packages/dd-trace/test/appsec/waf-metrics.integration.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const path = require('path') +const { inspect } = require('node:util') const Axios = require('axios') const { sandboxCwd, useSandbox, FakeAgent, spawnProc, stopProc } = require('../../../../integration-tests/helpers') describe('WAF Metrics', () => { @@ -66,13 +67,13 @@ describe('WAF Metrics', () => { assert.ok(wafRequests) assert.strictEqual(wafRequests.type, 'count') - assert.ok(wafRequests.tags.includes('waf_error:true')) - assert.ok(wafRequests.tags.includes('rate_limited:false')) + assert.ok(wafRequests.tags.includes('waf_error:true'), `Got: ${inspect(wafRequests.tags)}`) + assert.ok(wafRequests.tags.includes('rate_limited:false'), `Got: ${inspect(wafRequests.tags)}`) const wafError = series.find(s => s.metric === 'waf.error') assert.ok(wafError) assert.strictEqual(wafError.type, 'count') - assert.ok(wafError.tags.includes('waf_error:-127')) + assert.ok(wafError.tags.includes('waf_error:-127'), `Got: ${inspect(wafError.tags)}`) } }, requestType: 'generate-metrics', @@ -129,7 +130,7 @@ describe('WAF Metrics', () => { assert.ok(wafRequests) assert.strictEqual(wafRequests.type, 'count') - assert.ok(wafRequests.tags.includes('waf_timeout:true')) + assert.ok(wafRequests.tags.includes('waf_timeout:true'), `Got: ${inspect(wafRequests.tags)}`) } }, requestType: 'generate-metrics', @@ -187,11 +188,11 @@ describe('WAF Metrics', () => { assert.ok(inputTruncated) assert.strictEqual(inputTruncated.type, 'count') - assert.ok(inputTruncated.tags.includes('truncation_reason:7')) + assert.ok(inputTruncated.tags.includes('truncation_reason:7'), `Got: ${inspect(inputTruncated.tags)}`) const wafRequests = series.find(s => s.metric === 'waf.requests') assert.ok(wafRequests) - assert.ok(wafRequests.tags.includes('input_truncated:true')) + assert.ok(wafRequests.tags.includes('input_truncated:true'), `Got: ${inspect(wafRequests.tags)}`) } }, requestType: 'generate-metrics', diff --git a/packages/dd-trace/test/asserts/profile.js b/packages/dd-trace/test/asserts/profile.js index 8cefd76750..537c10d670 100644 --- a/packages/dd-trace/test/asserts/profile.js +++ b/packages/dd-trace/test/asserts/profile.js @@ -1,11 +1,12 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') module.exports = ({ Assertion, expect }, { expectTypes }) => { Assertion.addProperty('valueType', function () { const obj = this._obj - assert.ok(typeof obj === 'object' && obj !== null) + assert.ok(typeof obj === 'object' && obj !== null, `Expected non-null object, got ${inspect(obj)}`) assert.strictEqual(typeof obj.type, 'number') assert.strictEqual(typeof obj.unit, 'number') }) @@ -17,18 +18,18 @@ module.exports = ({ Assertion, expect }, { expectTypes }) => { Assertion.addProperty('profile', function () { const obj = this._obj - assert.ok(typeof obj === 'object' && obj !== null) + assert.ok(typeof obj === 'object' && obj !== null, `Expected non-null object, got ${inspect(obj)}`) assert.strictEqual(typeof obj.timeNanos, 'bigint') expect(obj.period).to.be.numeric expect(obj.periodType).to.be.a.valueType - assert.ok(Array.isArray(obj.sampleType)) + assert.ok(Array.isArray(obj.sampleType), `Expected array, got ${inspect(obj.sampleType)}`) assert.strictEqual(obj.sampleType.length, 2) - assert.ok(Array.isArray(obj.sample)) - assert.ok(Array.isArray(obj.location)) - assert.ok(Array.isArray(obj.function)) - assert.ok(Array.isArray(obj.stringTable.strings)) - assert.ok(obj.stringTable.strings.length >= 1) + assert.ok(Array.isArray(obj.sample), `Expected array, got ${inspect(obj.sample)}`) + assert.ok(Array.isArray(obj.location), `Expected array, got ${inspect(obj.location)}`) + assert.ok(Array.isArray(obj.function), `Expected array, got ${inspect(obj.function)}`) + assert.ok(Array.isArray(obj.stringTable.strings), `Expected array, got ${inspect(obj.stringTable.strings)}`) + assert.ok(obj.stringTable.strings.length >= 1, `Expected ${obj.stringTable.strings.length} >= 1`) assert.strictEqual(obj.stringTable.strings[0], '') for (const sampleType of obj.sampleType) { @@ -39,23 +40,23 @@ module.exports = ({ Assertion, expect }, { expectTypes }) => { assert.strictEqual(typeof fn.filename, 'number') assert.strictEqual(typeof fn.systemName, 'number') assert.strictEqual(typeof fn.name, 'number') - assert.ok(Number.isSafeInteger(fn.id)) + assert.ok(Number.isSafeInteger(fn.id), `Expected isSafeInteger, got ${inspect(fn.id)}`) } for (const location of obj.location) { - assert.ok(Number.isSafeInteger(location.id)) - assert.ok(Array.isArray(location.line)) + assert.ok(Number.isSafeInteger(location.id), `Expected isSafeInteger, got ${inspect(location.id)}`) + assert.ok(Array.isArray(location.line), `Expected array, got ${inspect(location.line)}`) for (const line of location.line) { - assert.ok(Number.isSafeInteger(line.functionId)) + assert.ok(Number.isSafeInteger(line.functionId), `Expected isSafeInteger, got ${inspect(line.functionId)}`) assert.strictEqual(typeof line.line, 'number') } } for (const sample of obj.sample) { - assert.ok(Array.isArray(sample.locationId)) - assert.ok(sample.locationId.length >= 1) - assert.ok(Array.isArray(sample.value)) + assert.ok(Array.isArray(sample.locationId), `Expected array, got ${inspect(sample.locationId)}`) + assert.ok(sample.locationId.length >= 1, `Expected ${sample.locationId.length} >= 1`) + assert.ok(Array.isArray(sample.value), `Expected array, got ${inspect(sample.value)}`) assert.strictEqual(sample.value.length, obj.sampleType.length) } }) diff --git a/packages/dd-trace/test/baggage.spec.js b/packages/dd-trace/test/baggage.spec.js new file mode 100644 index 0000000000..55e271792f --- /dev/null +++ b/packages/dd-trace/test/baggage.spec.js @@ -0,0 +1,80 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it, beforeEach, afterEach } = require('mocha') +const sinon = require('sinon') + +require('./setup/core') +const { storage } = require('../../datadog-core') +const { + setBaggageItem, + setAllBaggageItems, + getAllBaggageItems, + removeAllBaggageItems, +} = require('../src/baggage') + +describe('baggage', () => { + let enterWith + + beforeEach(() => { + removeAllBaggageItems() + enterWith = sinon.spy(storage('baggage'), 'enterWith') + }) + + afterEach(() => { + enterWith.restore() + storage('baggage').enterWith(undefined) + }) + + describe('removeAllBaggageItems', () => { + it('does not call enterWith when no store has been entered yet', () => { + storage('baggage').enterWith(undefined) + enterWith.resetHistory() + + removeAllBaggageItems() + + sinon.assert.notCalled(enterWith) + assert.deepStrictEqual(getAllBaggageItems(), {}) + }) + + it('does not call enterWith when the store is already the empty sentinel', () => { + removeAllBaggageItems() + enterWith.resetHistory() + + removeAllBaggageItems() + + sinon.assert.notCalled(enterWith) + }) + + it('calls enterWith once to clear a non-empty store', () => { + setBaggageItem('foo', 'bar') + assert.deepStrictEqual(getAllBaggageItems(), { foo: 'bar' }) + enterWith.resetHistory() + + removeAllBaggageItems() + + sinon.assert.calledOnce(enterWith) + assert.deepStrictEqual(getAllBaggageItems(), {}) + }) + + it('calls enterWith when the store is a separate empty object', () => { + setAllBaggageItems({}) + enterWith.resetHistory() + + removeAllBaggageItems() + + sinon.assert.calledOnce(enterWith) + assert.deepStrictEqual(getAllBaggageItems(), {}) + }) + + it('returns the frozen empty sentinel', () => { + const first = removeAllBaggageItems() + const second = removeAllBaggageItems() + + assert.strictEqual(first, second) + assert.ok(Object.isFrozen(first)) + assert.deepStrictEqual(first, {}) + }) + }) +}) diff --git a/packages/dd-trace/test/ci-visibility/exporters/agent-proxy/agent-proxy.spec.js b/packages/dd-trace/test/ci-visibility/exporters/agent-proxy/agent-proxy.spec.js index 1ac470eb00..d867964d94 100644 --- a/packages/dd-trace/test/ci-visibility/exporters/agent-proxy/agent-proxy.spec.js +++ b/packages/dd-trace/test/ci-visibility/exporters/agent-proxy/agent-proxy.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach } = require('mocha') const context = describe @@ -69,8 +70,12 @@ describe('AgentProxyCiVisibilityExporter', () => { await agentProxyCiVisibilityExporter._canUseCiVisProtocolPromise - assert.ok(!(agentProxyCiVisibilityExporter.getUncodedTraces()).includes(trace)) - assert.ok(!(agentProxyCiVisibilityExporter._coverageBuffer).includes(coverage)) + const uncodedTraces = agentProxyCiVisibilityExporter.getUncodedTraces() + assert.ok(!uncodedTraces.includes(trace), `Got: ${inspect(uncodedTraces)}`) + assert.ok( + !(agentProxyCiVisibilityExporter._coverageBuffer).includes(coverage), + `Got: ${inspect(agentProxyCiVisibilityExporter._coverageBuffer)}` + ) // old traces and coverages are exported at once sinon.assert.calledWith(agentProxyCiVisibilityExporter.export, trace) sinon.assert.calledWith(agentProxyCiVisibilityExporter.exportCoverage, coverage) diff --git a/packages/dd-trace/test/ci-visibility/exporters/agentless/exporter.spec.js b/packages/dd-trace/test/ci-visibility/exporters/agentless/exporter.spec.js index d2dbbeeba0..ae67536a69 100644 --- a/packages/dd-trace/test/ci-visibility/exporters/agentless/exporter.spec.js +++ b/packages/dd-trace/test/ci-visibility/exporters/agentless/exporter.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const cp = require('node:child_process') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach, before, after } = require('mocha') const context = describe @@ -188,9 +189,8 @@ describe('CI Visibility Agentless Exporter', () => { agentlessExporter.getLibraryConfiguration({}, (err) => { assert.notStrictEqual(scope.isDone(), true) assert.ok( - err.message.includes( - 'Request to settings endpoint was not done because Datadog API key is not defined' - ) + err.message.includes('Request to settings endpoint was not done because Datadog API key is not defined'), + `Got: ${inspect(err.message)}` ) assert.strictEqual(agentlessExporter.shouldRequestSkippableSuites(), false) done() diff --git a/packages/dd-trace/test/ci-visibility/exporters/ci-visibility-exporter.spec.js b/packages/dd-trace/test/ci-visibility/exporters/ci-visibility-exporter.spec.js index c4864efc1a..a9d208e562 100644 --- a/packages/dd-trace/test/ci-visibility/exporters/ci-visibility-exporter.spec.js +++ b/packages/dd-trace/test/ci-visibility/exporters/ci-visibility-exporter.spec.js @@ -4,6 +4,7 @@ const assert = require('node:assert/strict') const cp = require('node:child_process') const fs = require('node:fs') const zlib = require('node:zlib') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const context = describe @@ -44,7 +45,7 @@ describe('CI Visibility Exporter', () => { const ciVisibilityExporter = new CiVisibilityExporter({ url: urlObj, isGitUploadEnabled: true }) ciVisibilityExporter._gitUploadPromise.then((err) => { - assert.ok(err == null) + assert.ok(err == null, `Expected ${err} == null`) assert.strictEqual(scope.isDone(), true) done() }) @@ -205,7 +206,7 @@ describe('CI Visibility Exporter', () => { isSuitesSkippingEnabled: true, isEarlyFlakeDetectionEnabled: false, }) - assert.ok(err == null) + assert.ok(err == null, `Expected ${err} == null`) assert.strictEqual(scope.isDone(), true) done() }) @@ -582,7 +583,10 @@ describe('CI Visibility Exporter', () => { ciVisibilityExporter._isInitialized = true ciVisibilityExporter._writer = writer ciVisibilityExporter.export(trace) - assert.ok(!ciVisibilityExporter._traceBuffer.includes(trace)) + assert.ok( + !ciVisibilityExporter._traceBuffer.includes(trace), + `Got: ${inspect(ciVisibilityExporter._traceBuffer)}` + ) sinon.assert.called(ciVisibilityExporter._writer.append) }) }) @@ -600,7 +604,10 @@ describe('CI Visibility Exporter', () => { ciVisibilityExporter._isInitialized = true ciVisibilityExporter._writer = writer ciVisibilityExporter.export(trace) - assert.ok(!ciVisibilityExporter._traceBuffer.includes(trace)) + assert.ok( + !ciVisibilityExporter._traceBuffer.includes(trace), + `Got: ${inspect(ciVisibilityExporter._traceBuffer)}` + ) sinon.assert.notCalled(ciVisibilityExporter._writer.append) }) }) @@ -619,7 +626,10 @@ describe('CI Visibility Exporter', () => { ciVisibilityExporter._writer = writer ciVisibilityExporter._canUseCiVisProtocol = true ciVisibilityExporter.export(trace) - assert.ok(!ciVisibilityExporter._traceBuffer.includes(trace)) + assert.ok( + !ciVisibilityExporter._traceBuffer.includes(trace), + `Got: ${inspect(ciVisibilityExporter._traceBuffer)}` + ) sinon.assert.called(ciVisibilityExporter._writer.append) }) }) @@ -648,7 +658,10 @@ describe('CI Visibility Exporter', () => { ciVisibilityExporter._isInitialized = true ciVisibilityExporter._coverageWriter = writer ciVisibilityExporter.exportCoverage(coverage) - assert.ok(!ciVisibilityExporter._coverageBuffer.includes(coverage)) + assert.ok( + !ciVisibilityExporter._coverageBuffer.includes(coverage), + `Got: ${inspect(ciVisibilityExporter._coverageBuffer)}` + ) sinon.assert.notCalled(ciVisibilityExporter._coverageWriter.append) }) }) @@ -670,7 +683,10 @@ describe('CI Visibility Exporter', () => { ciVisibilityExporter._canUseCiVisProtocol = true ciVisibilityExporter.exportCoverage(coverage) - assert.ok(!ciVisibilityExporter._coverageBuffer.includes(coverage)) + assert.ok( + !ciVisibilityExporter._coverageBuffer.includes(coverage), + `Got: ${inspect(ciVisibilityExporter._coverageBuffer)}` + ) sinon.assert.called(ciVisibilityExporter._coverageWriter.append) }) }) diff --git a/packages/dd-trace/test/ci-visibility/exporters/git/git_metadata.spec.js b/packages/dd-trace/test/ci-visibility/exporters/git/git_metadata.spec.js index 08d066946b..09d7c20fa2 100644 --- a/packages/dd-trace/test/ci-visibility/exporters/git/git_metadata.spec.js +++ b/packages/dd-trace/test/ci-visibility/exporters/git/git_metadata.spec.js @@ -15,6 +15,9 @@ const { validateGitRepositoryUrl, validateGitCommitSha } = require('../../../../ describe('git_metadata', () => { let gitMetadata + // Used only by the retry test, which must exercise request.js retry logic end-to-end. + let gitMetadataWithFastRequest + let requestStub const latestCommits = ['87ce64f636853fbebc05edfcefe9cccc28a7968b', 'cc424c261da5e261b76d982d5d361a023556e2aa'] // same character range but invalid length @@ -37,9 +40,8 @@ describe('git_metadata', () => { before(() => { fs.writeFileSync(temporaryPackFile, '') fs.writeFileSync(secondTemporaryPackFile, '') - // Any request that escapes a nock interceptor used to hang up to the per - // request 15 s timeout and blow the mocha wall on slow Windows CI; flip the - // failure into an immediate `NetConnectNotAllowedError` at the call site. + // The retry test uses nock; disableNetConnect ensures escaped requests fail + // immediately rather than hanging for the 15 s request timeout. nock.disableNetConnect() }) @@ -60,10 +62,29 @@ describe('git_metadata', () => { fakeConfig = { apiKey: 'api-key', DD_CIVISIBILITY_GIT_UNSHALLOW_ENABLED: true } - // Build a copy of the shared request module wired against an instant retry - // helper so the retry-success test does not pay the real backoff. The - // post-startup retry delay is 5 to 7.5 s and would blow the default mocha - // timeout once a previous spec marks the endpoint as reached. + // Most tests inject requestStub directly so they never touch nock or the + // real HTTP stack. This avoids the Windows CI hang caused by nock's + // process.nextTick-based connectSocket() being skipped when the request is + // considered destroyed before the tick fires, leaving done() uncalled. + requestStub = sinon.stub() + + const gitStubs = { + getLatestCommits: getLatestCommitsStub, + getRepositoryUrl: getRepositoryUrlStub, + generatePackFilesForCommits: generatePackFilesForCommitsStub, + getCommitsRevList: getCommitsRevListStub, + isShallowRepository: isShallowRepositoryStub, + unshallowRepository: unshallowRepositoryStub, + } + + gitMetadata = proxyquire('../../../../src/ci-visibility/exporters/git/git_metadata', { + '../../../plugins/util/git': gitStubs, + '../../../config': () => fakeConfig, + '../../../exporters/common/request': requestStub, + }) + + // gitMetadataWithFastRequest keeps the real request.js (including retry + // logic) wired through nock for the one test that validates retry behaviour. const fastRequest = proxyquire('../../../../src/exporters/common/request', { './retry': { ...require('../../../../src/exporters/common/retry'), @@ -71,15 +92,8 @@ describe('git_metadata', () => { }, }) - gitMetadata = proxyquire('../../../../src/ci-visibility/exporters/git/git_metadata', { - '../../../plugins/util/git': { - getLatestCommits: getLatestCommitsStub, - getRepositoryUrl: getRepositoryUrlStub, - generatePackFilesForCommits: generatePackFilesForCommitsStub, - getCommitsRevList: getCommitsRevListStub, - isShallowRepository: isShallowRepositoryStub, - unshallowRepository: unshallowRepositoryStub, - }, + gitMetadataWithFastRequest = proxyquire('../../../../src/ci-visibility/exporters/git/git_metadata', { + '../../../plugins/util/git': gitStubs, '../../../config': () => fakeConfig, '../../../exporters/common/request': fastRequest, }) @@ -90,146 +104,124 @@ describe('git_metadata', () => { }) it('does not unshallow if every commit is already in backend', (done) => { - const scope = nock('https://api.test.com') - .post('/api/v2/git/repository/search_commits') - .reply(200, JSON.stringify({ data: latestCommits.map((sha) => ({ id: sha, type: 'commit' })) })) + requestStub.callsArgWith(2, null, + JSON.stringify({ data: latestCommits.map((sha) => ({ id: sha, type: 'commit' })) }), + 200) isShallowRepositoryStub.returns(true) gitMetadata.sendGitMetadata(new URL('https://api.test.com'), { isEvpProxy: false }, '', (err) => { sinon.assert.notCalled(unshallowRepositoryStub) assert.strictEqual(err, null) - assert.strictEqual(scope.isDone(), true) + sinon.assert.calledOnce(requestStub) done() }) }) it('should unshallow if the repo is shallow and not every commit is in the backend', (done) => { - const scope = nock('https://api.test.com') - .post('/api/v2/git/repository/search_commits') - .reply(200, JSON.stringify({ data: [] })) - .post('/api/v2/git/repository/search_commits') // calls a second time after unshallowing - .reply(200, JSON.stringify({ data: [] })) - .post('/api/v2/git/repository/packfile') - .reply(204) + requestStub.onCall(0).callsArgWith(2, null, JSON.stringify({ data: [] }), 200) + requestStub.onCall(1).callsArgWith(2, null, JSON.stringify({ data: [] }), 200) + requestStub.onCall(2).callsArgWith(2, null, '', 204) isShallowRepositoryStub.returns(true) gitMetadata.sendGitMetadata(new URL('https://api.test.com'), { isEvpProxy: false }, '', (err) => { sinon.assert.called(unshallowRepositoryStub) assert.strictEqual(err, null) - assert.strictEqual(scope.isDone(), true) + sinon.assert.calledThrice(requestStub) done() }) }) it('should not unshallow if the parameter to enable unshallow is false', (done) => { fakeConfig.DD_CIVISIBILITY_GIT_UNSHALLOW_ENABLED = false - const scope = nock('https://api.test.com') - .post('/api/v2/git/repository/search_commits') - .reply(200, JSON.stringify({ data: [] })) - .post('/api/v2/git/repository/search_commits') // calls a second time after unshallowing - .reply(200, JSON.stringify({ data: [] })) - .post('/api/v2/git/repository/packfile') - .reply(204) + requestStub.onCall(0).callsArgWith(2, null, JSON.stringify({ data: [] }), 200) + requestStub.onCall(1).callsArgWith(2, null, JSON.stringify({ data: [] }), 200) + requestStub.onCall(2).callsArgWith(2, null, '', 204) isShallowRepositoryStub.returns(true) gitMetadata.sendGitMetadata(new URL('https://api.test.com'), { isEvpProxy: false }, '', (err) => { sinon.assert.notCalled(unshallowRepositoryStub) assert.strictEqual(err, null) - assert.strictEqual(scope.isDone(), true) + sinon.assert.calledThrice(requestStub) done() }) }) it('should request to /api/v2/git/repository/search_commits and /api/v2/git/repository/packfile', (done) => { - const scope = nock('https://api.test.com') - .post('/api/v2/git/repository/search_commits') - .reply(200, JSON.stringify({ data: [] })) - .post('/api/v2/git/repository/packfile') - .reply(204) + requestStub.onCall(0).callsArgWith(2, null, JSON.stringify({ data: [] }), 200) + requestStub.onCall(1).callsArgWith(2, null, '', 204) gitMetadata.sendGitMetadata(new URL('https://api.test.com'), { isEvpProxy: false }, '', (err) => { assert.strictEqual(err, null) - assert.strictEqual(scope.isDone(), true) + sinon.assert.calledTwice(requestStub) + assert.match(requestStub.getCall(0).args[1].path, /\/api\/v2\/git\/repository\/search_commits/) + assert.match(requestStub.getCall(1).args[1].path, /\/api\/v2\/git\/repository\/packfile/) done() }) }) it('should not request to /api/v2/git/repository/packfile if the backend has the commit info', (done) => { - const scope = nock('https://api.test.com') - .post('/api/v2/git/repository/search_commits') - .reply(200, JSON.stringify({ data: latestCommits.map((sha) => ({ id: sha, type: 'commit' })) })) - .post('/api/v2/git/repository/packfile') - .reply(204) + requestStub.callsArgWith(2, null, + JSON.stringify({ data: latestCommits.map((sha) => ({ id: sha, type: 'commit' })) }), + 200) getCommitsRevListStub.returns([]) gitMetadata.sendGitMetadata(new URL('https://api.test.com'), { isEvpProxy: false }, '', (err) => { assert.strictEqual(err, null) - // to check that it is not called - assert.strictEqual(scope.isDone(), false) - assertObjectContains(scope.pendingMocks(), ['POST https://api.test.com:443/api/v2/git/repository/packfile']) + sinon.assert.calledOnce(requestStub) done() }) }) it('should fail and not continue if first query results in anything other than 200', (done) => { - const scope = nock('https://api.test.com') - .post('/api/v2/git/repository/search_commits') - .reply(404, 'Not found SHA') - .post('/api/v2/git/repository/packfile') - .reply(204) + const requestErr = Object.assign( + new Error( + 'Error from https://api.test.com/api/v2/git/repository/search_commits: ' + + '404 Not Found. Response from the endpoint: "Not found SHA"' + ), + { status: 404 } + ) + requestStub.callsArgWith(2, requestErr, null, 404) gitMetadata.sendGitMetadata(new URL('https://api.test.com'), { isEvpProxy: false }, '', (err) => { - assertObjectContains(err.message, 'Error fetching commits to exclude: Error from https://api.test.com/api/v2/git/repository/search_commits: 404 Not Found. Response from the endpoint: "Not found SHA"') - // to check that it is not called - assert.strictEqual(scope.isDone(), false) - assertObjectContains(scope.pendingMocks(), ['POST https://api.test.com:443/api/v2/git/repository/packfile']) + assertObjectContains(err.message, + 'Error fetching commits to exclude: Error from https://api.test.com/' + + 'api/v2/git/repository/search_commits: 404 Not Found. ' + + 'Response from the endpoint: "Not found SHA"') + sinon.assert.calledOnce(requestStub) done() }) }) it('should fail and not continue if the response are not correct commits', (done) => { - const scope = nock('https://api.test.com') - .post('/api/v2/git/repository/search_commits') - .reply(200, JSON.stringify({ data: ['; rm -rf ;'] })) - .post('/api/v2/git/repository/packfile') - .reply(204) + requestStub.callsArgWith(2, null, JSON.stringify({ data: ['; rm -rf ;'] }), 200) gitMetadata.sendGitMetadata(new URL('https://api.test.com'), { isEvpProxy: false }, '', (err) => { assertObjectContains(err.message, "Can't parse commits to exclude response: Invalid commit type response") - // to check that it is not called - assert.strictEqual(scope.isDone(), false) - assertObjectContains(scope.pendingMocks(), ['POST https://api.test.com:443/api/v2/git/repository/packfile']) + sinon.assert.calledOnce(requestStub) done() }) }) it('should fail and not continue if the response are badly formatted commits', (done) => { - const scope = nock('https://api.test.com') - .post('/api/v2/git/repository/search_commits') - .reply(200, JSON.stringify({ data: badLatestCommits.map((sha) => ({ id: sha, type: 'commit' })) })) - .post('/api/v2/git/repository/packfile') - .reply(204) + requestStub.callsArgWith(2, null, + JSON.stringify({ data: badLatestCommits.map((sha) => ({ id: sha, type: 'commit' })) }), + 200) gitMetadata.sendGitMetadata(new URL('https://api.test.com'), { isEvpProxy: false }, '', (err) => { assertObjectContains(err.message, "Can't parse commits to exclude response: Invalid commit format") - // to check that it is not called - assert.strictEqual(scope.isDone(), false) - assertObjectContains(scope.pendingMocks(), ['POST https://api.test.com:443/api/v2/git/repository/packfile']) + sinon.assert.calledOnce(requestStub) done() }) }) it('should fail if the packfile request returns anything other than 204', (done) => { - const scope = nock('https://api.test.com') - .post('/api/v2/git/repository/search_commits') - .reply(200, JSON.stringify({ data: [] })) - .post('/api/v2/git/repository/packfile') - .reply(502) + requestStub.onCall(0).callsArgWith(2, null, JSON.stringify({ data: [] }), 200) + requestStub.onCall(1).callsArgWith(2, Object.assign(new Error('502 Bad Gateway'), { status: 502 }), null, 502) gitMetadata.sendGitMetadata(new URL('https://api.test.com'), { isEvpProxy: false }, '', (err) => { assert.match(err.message, /Could not upload packfiles: status code 502/) - assert.strictEqual(scope.isDone(), true) + sinon.assert.calledTwice(requestStub) done() }) }) @@ -237,29 +229,21 @@ describe('git_metadata', () => { it('should fail if the getCommitsRevList fails because the repository is too big', (done) => { // returning null means that the git rev-list failed getCommitsRevListStub.returns(null) - const scope = nock('https://api.test.com') - .post('/api/v2/git/repository/search_commits') - .reply(200, JSON.stringify({ data: [] })) + requestStub.callsArgWith(2, null, JSON.stringify({ data: [] }), 200) gitMetadata.sendGitMetadata(new URL('https://api.test.com'), { isEvpProxy: false }, '', (err) => { assert.match(err.message, /git rev-list failed/) - assert.strictEqual(scope.isDone(), true) + sinon.assert.calledOnce(requestStub) done() }) }) it('should fire a request per packfile', (done) => { - const scope = nock('https://api.test.com') - .post('/api/v2/git/repository/search_commits') - .reply(200, JSON.stringify({ data: [] })) - .post('/api/v2/git/repository/packfile') - .reply(204) - .post('/api/v2/git/repository/packfile') - .reply(204) - .post('/api/v2/git/repository/packfile') - .reply(204) - .post('/api/v2/git/repository/packfile') - .reply(204) + requestStub.onCall(0).callsArgWith(2, null, JSON.stringify({ data: [] }), 200) + requestStub.onCall(1).callsArgWith(2, null, '', 204) + requestStub.onCall(2).callsArgWith(2, null, '', 204) + requestStub.onCall(3).callsArgWith(2, null, '', 204) + requestStub.onCall(4).callsArgWith(2, null, '', 204) generatePackFilesForCommitsStub.returns([ temporaryPackFile, @@ -270,7 +254,7 @@ describe('git_metadata', () => { gitMetadata.sendGitMetadata(new URL('https://api.test.com'), { isEvpProxy: false }, '', (err) => { assert.strictEqual(err, null) - assert.strictEqual(scope.isDone(), true) + sinon.assert.callCount(requestStub, 5) done() }) }) @@ -332,11 +316,7 @@ describe('git_metadata', () => { }) it('should not crash if packfiles can not be accessed', (done) => { - const scope = nock('https://api.test.com') - .post('/api/v2/git/repository/search_commits') - .reply(200, JSON.stringify({ data: [] })) - .post('/api/v2/git/repository/packfile') - .reply(204) + requestStub.callsArgWith(2, null, JSON.stringify({ data: [] }), 200) generatePackFilesForCommitsStub.returns([ 'not-there', @@ -345,23 +325,19 @@ describe('git_metadata', () => { gitMetadata.sendGitMetadata(new URL('https://api.test.com'), { isEvpProxy: false }, '', (err) => { assert.match(err.message, /Could not read "not-there"/) - assert.strictEqual(scope.isDone(), false) + sinon.assert.calledOnce(requestStub) done() }) }) it('should not crash if generatePackFiles returns an empty array', (done) => { - const scope = nock('https://api.test.com') - .post('/api/v2/git/repository/search_commits') - .reply(200, JSON.stringify({ data: [] })) - .post('/api/v2/git/repository/packfile') - .reply(204) + requestStub.callsArgWith(2, null, JSON.stringify({ data: [] }), 200) generatePackFilesForCommitsStub.returns([]) gitMetadata.sendGitMetadata(new URL('https://api.test.com'), { isEvpProxy: false }, '', (err) => { assert.match(err.message, /Failed to generate packfiles/) - assert.strictEqual(scope.isDone(), false) + sinon.assert.calledOnce(requestStub) done() }) }) @@ -371,21 +347,17 @@ describe('git_metadata', () => { // git will not be found process.env.PATH = '' - const scope = nock('https://api.test.com') - .post('/api/v2/git/repository/search_commits') - .reply(200, JSON.stringify({ data: [] })) - .post('/api/v2/git/repository/packfile') - .reply(204) - gitMetadata.sendGitMetadata(new URL('https://api.test.com'), { isEvpProxy: false }, '', (err) => { assert.match(err.message, /Git is not available/) - assert.strictEqual(scope.isDone(), false) + sinon.assert.notCalled(requestStub) process.env.PATH = oldPath done() }) }) it('should retry if backend temporarily fails', (done) => { + // This test exercises request.js retry logic end-to-end, so it uses the + // real request module (gitMetadataWithFastRequest) backed by nock. // The shared retry helper only treats network errors with a transient `code` // (`ECONNRESET`, `ECONNREFUSED`, `ETIMEDOUT`, …) as retriable; uncoded errors // are no longer retried, matching real production failure modes. @@ -401,7 +373,7 @@ describe('git_metadata', () => { .post('/api/v2/git/repository/packfile') .reply(204) - gitMetadata.sendGitMetadata(new URL('https://api.test.com'), { isEvpProxy: false }, '', (err) => { + gitMetadataWithFastRequest.sendGitMetadata(new URL('https://api.test.com'), { isEvpProxy: false }, '', (err) => { assert.strictEqual(err, null) assert.strictEqual(scope.isDone(), true) done() @@ -409,14 +381,8 @@ describe('git_metadata', () => { }) it('should append evp proxy prefix if configured', (done) => { - const scope = nock('https://api.test.com') - .post('/evp_proxy/v2/api/v2/git/repository/search_commits') - .reply(200, JSON.stringify({ data: [] })) - .post('/evp_proxy/v2/api/v2/git/repository/packfile') - .reply(204, function (uri, body) { - assert.strictEqual(this.req.headers['x-datadog-evp-subdomain'], 'api') - done() - }) + requestStub.onCall(0).callsArgWith(2, null, JSON.stringify({ data: [] }), 200) + requestStub.onCall(1).callsArgWith(2, null, '', 204) gitMetadata.sendGitMetadata( new URL('https://api.test.com'), @@ -424,24 +390,23 @@ describe('git_metadata', () => { '', (err) => { assert.strictEqual(err, null) - assert.strictEqual(scope.isDone(), true) + sinon.assert.calledTwice(requestStub) + assert.match( + requestStub.getCall(0).args[1].path, + /\/evp_proxy\/v2\/api\/v2\/git\/repository\/search_commits/ + ) + assert.strictEqual(requestStub.getCall(1).args[1].headers['X-Datadog-EVP-Subdomain'], 'api') + assert.match( + requestStub.getCall(1).args[1].path, + /\/evp_proxy\/v2\/api\/v2\/git\/repository\/packfile/ + ) + done() }) }) it('should use the input repository url and not call getRepositoryUrl', (done) => { - let resolvePromise - const requestPromise = new Promise(resolve => { - resolvePromise = resolve - }) - const scope = nock('https://api.test.com') - .post('/evp_proxy/v2/api/v2/git/repository/search_commits') - .reply(200, function () { - const { meta: { repository_url: repositoryUrl } } = JSON.parse(this.req.requestBodyBuffers.toString()) - resolvePromise(repositoryUrl) - return JSON.stringify({ data: [] }) - }) - .post('/evp_proxy/v2/api/v2/git/repository/packfile') - .reply(204) + requestStub.onCall(0).callsArgWith(2, null, JSON.stringify({ data: [] }), 200) + requestStub.onCall(1).callsArgWith(2, null, '', 204) gitMetadata.sendGitMetadata( new URL('https://api.test.com'), @@ -449,12 +414,10 @@ describe('git_metadata', () => { 'https://custom-git@datadog.com', (err) => { assert.strictEqual(err, null) - assert.strictEqual(scope.isDone(), true) - requestPromise.then((repositoryUrl) => { - sinon.assert.notCalled(getRepositoryUrlStub) - assert.strictEqual(repositoryUrl, 'https://custom-git@datadog.com') - done() - }) + sinon.assert.notCalled(getRepositoryUrlStub) + const { meta: { repository_url: repositoryUrl } } = JSON.parse(requestStub.getCall(0).args[0]) + assert.strictEqual(repositoryUrl, 'https://custom-git@datadog.com') + done() }) }) }) diff --git a/packages/dd-trace/test/ci-visibility/intelligent-test-runner/get-skippable-suites.spec.js b/packages/dd-trace/test/ci-visibility/intelligent-test-runner/get-skippable-suites.spec.js index 75fbd554c8..bafe54093b 100644 --- a/packages/dd-trace/test/ci-visibility/intelligent-test-runner/get-skippable-suites.spec.js +++ b/packages/dd-trace/test/ci-visibility/intelligent-test-runner/get-skippable-suites.spec.js @@ -9,6 +9,7 @@ const nock = require('nock') require('../../setup/core') const { getSkippableSuites } = require('../../../src/ci-visibility/intelligent-test-runner/get-skippable-suites') +const getConfig = require('../../../src/config') const { buildCacheKey, getCachePath, @@ -43,11 +44,61 @@ const SKIPPABLE_RESPONSE = { meta: { correlation_id: 'corr-123' }, } +const SKIPPABLE_RESPONSE_WITH_COVERAGE = { + data: [ + { + type: 'suite', + attributes: { + suite: 'suite1.spec.js', + }, + }, + { + type: 'suite', + attributes: { + suite: 'suite2.spec.js', + }, + }, + ], + meta: { + correlation_id: 'corr-123', + coverage: { + 'src/file1.js': 'gA==', + 'src/file2.js': 'IA==', + }, + }, +} + +const SKIPPABLE_RESPONSE_WITH_MISSING_LINE_COVERAGE = { + data: [ + { + type: 'suite', + attributes: { + suite: 'suite1.spec.js', + _is_missing_line_code_coverage: true, + }, + }, + { + type: 'suite', + attributes: { + suite: 'suite2.spec.js', + _is_missing_line_code_coverage: false, + }, + }, + ], + meta: { + correlation_id: 'corr-123', + coverage: { + 'src/file1.js': 'gA==', + }, + }, +} + function cacheKeyForParams (params) { return buildCacheKey('skippable', [ params.sha, params.service, params.env, params.repositoryUrl, params.osPlatform, params.osVersion, params.osArchitecture, params.runtimeName, params.runtimeVersion, params.testLevel, params.custom, + params.isCoverageReportUploadEnabled || false, ]) } @@ -60,14 +111,20 @@ function cleanup (params) { describe('get-skippable-suites', () => { beforeEach(() => { process.env.DD_API_KEY = 'test-api-key' + getConfig().apiKey = 'test-api-key' process.env.DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE = 'true' + getConfig().DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE = true cleanup(DEFAULT_PARAMS) + cleanup({ ...DEFAULT_PARAMS, isCoverageReportUploadEnabled: true }) }) afterEach(() => { delete process.env.DD_API_KEY + getConfig().apiKey = undefined delete process.env.DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE + getConfig().DD_EXPERIMENTAL_TEST_REQUESTS_FS_CACHE = false cleanup(DEFAULT_PARAMS) + cleanup({ ...DEFAULT_PARAMS, isCoverageReportUploadEnabled: true }) nock.cleanAll() }) @@ -84,6 +141,82 @@ describe('get-skippable-suites', () => { }) }) + it('should return skippable suite coverage from response metadata', (done) => { + nock(BASE_URL) + .post('/api/v2/ci/tests/skippable') + .reply(200, JSON.stringify(SKIPPABLE_RESPONSE_WITH_COVERAGE)) + + getSkippableSuites(DEFAULT_PARAMS, (err, skippableSuites, correlationId, coverage) => { + assert.strictEqual(err, null) + assert.deepStrictEqual(skippableSuites, ['suite1.spec.js', 'suite2.spec.js']) + assert.strictEqual(correlationId, 'corr-123') + assert.deepStrictEqual(coverage, { + 'src/file1.js': 'gA==', + 'src/file2.js': 'IA==', + }) + done() + }) + }) + + it('should skip suites with response metadata coverage when coverage report upload is enabled', (done) => { + const params = { ...DEFAULT_PARAMS, isCoverageReportUploadEnabled: true } + nock(BASE_URL) + .post('/api/v2/ci/tests/skippable') + .reply(200, JSON.stringify(SKIPPABLE_RESPONSE_WITH_COVERAGE)) + + getSkippableSuites(params, (err, skippableSuites, correlationId, coverage) => { + assert.strictEqual(err, null) + assert.deepStrictEqual(skippableSuites, ['suite1.spec.js', 'suite2.spec.js']) + assert.strictEqual(correlationId, 'corr-123') + assert.deepStrictEqual(coverage, { + 'src/file1.js': 'gA==', + 'src/file2.js': 'IA==', + }) + done() + }) + }) + + it('should not skip suites with missing line coverage when coverage report upload is enabled', (done) => { + const params = { ...DEFAULT_PARAMS, isCoverageReportUploadEnabled: true } + nock(BASE_URL) + .post('/api/v2/ci/tests/skippable') + .reply(200, JSON.stringify(SKIPPABLE_RESPONSE_WITH_MISSING_LINE_COVERAGE)) + + getSkippableSuites(params, (err, skippableSuites, correlationId) => { + assert.strictEqual(err, null) + assert.deepStrictEqual(skippableSuites, ['suite2.spec.js']) + assert.strictEqual(correlationId, 'corr-123') + done() + }) + }) + + it('should keep suites with missing line coverage when coverage report upload is disabled', (done) => { + nock(BASE_URL) + .post('/api/v2/ci/tests/skippable') + .reply(200, JSON.stringify(SKIPPABLE_RESPONSE_WITH_MISSING_LINE_COVERAGE)) + + getSkippableSuites(DEFAULT_PARAMS, (err, skippableSuites, correlationId) => { + assert.strictEqual(err, null) + assert.deepStrictEqual(skippableSuites, ['suite1.spec.js', 'suite2.spec.js']) + assert.strictEqual(correlationId, 'corr-123') + done() + }) + }) + + it('should return suites without coverage when coverage report upload is enabled', (done) => { + const params = { ...DEFAULT_PARAMS, isCoverageReportUploadEnabled: true } + nock(BASE_URL) + .post('/api/v2/ci/tests/skippable') + .reply(200, JSON.stringify(SKIPPABLE_RESPONSE)) + + getSkippableSuites(params, (err, skippableSuites, correlationId) => { + assert.strictEqual(err, null) + assert.deepStrictEqual(skippableSuites, ['suite1.spec.js', 'suite2.spec.js']) + assert.strictEqual(correlationId, 'corr-123') + done() + }) + }) + it('should return cached data on second call preserving correlationId', (done) => { const scope = nock(BASE_URL) .post('/api/v2/ci/tests/skippable') diff --git a/packages/dd-trace/test/config/index.spec.js b/packages/dd-trace/test/config/index.spec.js index c1461cd482..14e997cc9f 100644 --- a/packages/dd-trace/test/config/index.spec.js +++ b/packages/dd-trace/test/config/index.spec.js @@ -6,6 +6,7 @@ const dns = require('node:dns') const { once } = require('node:events') const path = require('node:path') const os = require('node:os') +const { inspect } = require('node:util') const sinon = require('sinon') const { it, describe, beforeEach, afterEach } = require('mocha') @@ -216,7 +217,10 @@ describe('Config', () => { assert.strictEqual('DD_TRACE_EXPERIMENTAL_B3_ENABLED' in supported, false) assert.strictEqual('DD_TRACE_EXPERIMENTAL_RUNTIME_ID_ENABLED' in supported, false) const cpuEntry = supported.DD_PROFILING_CPU_ENABLED[0] - assert.ok(!cpuEntry.aliases?.some((alias) => alias.startsWith('DD_PROFILING_EXPERIMENTAL_'))) + assert.ok( + !cpuEntry.aliases?.some((alias) => alias.startsWith('DD_PROFILING_EXPERIMENTAL_')), + `Got: ${inspect(cpuEntry.aliases)}` + ) assert.deepStrictEqual(cpuEntry.aliases, ['DD_PROFILING_TEST_ALIAS']) const runtimeIdEntry = supported.DD_RUNTIME_METRICS_RUNTIME_ID_ENABLED[0] assert.strictEqual(runtimeIdEntry.aliases, undefined) @@ -1802,7 +1806,7 @@ describe('Config', () => { ], }) assert.deepStrictEqual(config.serviceMapping, { a: 'aa', b: 'bb' }) - assert.ok(Object.hasOwn(config.tags, 'runtime-id')) + assert.ok(Object.hasOwn(config.tags, 'runtime-id'), `Available keys: ${inspect(Object.keys(config.tags))}`) assert.match(config.tags['runtime-id'], /^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/) if (DD_MAJOR < 6) { assert.deepStrictEqual(config.tracePropagationStyle.extract, ['datadog', 'b3', 'b3 single header']) @@ -1979,10 +1983,12 @@ describe('Config', () => { { name: 'DD_TRACE_PROPAGATION_STYLE_INJECT', value: 'datadog', origin: 'calculated' }, ].sort(comparator)) + const configEntries = updateConfig.getCall(0).args[0] assert.ok( - !updateConfig.getCall(0).args[0].some(entry => { + !configEntries.some(entry => { return entry.name === 'DD_TRACE_PROPAGATION_STYLE_EXTRACT' && entry.origin === 'calculated' - }) + }), + `Got: ${inspect(configEntries)}` ) }) @@ -3517,7 +3523,10 @@ describe('Config', () => { request: undefined, response: undefined, }) - assert.ok(!(Object.hasOwn(cloudPayloadTagging, 'rules'))) + assert.ok( + !(Object.hasOwn(cloudPayloadTagging, 'rules')), + `Available keys: ${inspect(Object.keys(cloudPayloadTagging))}` + ) }) }) diff --git a/packages/dd-trace/test/crashtracking/crashtracker.spec.js b/packages/dd-trace/test/crashtracking/crashtracker.spec.js index aec9f52be8..5d185fd418 100644 --- a/packages/dd-trace/test/crashtracking/crashtracker.spec.js +++ b/packages/dd-trace/test/crashtracking/crashtracker.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const os = require('node:os') +const { inspect } = require('node:util') const proxyquire = require('proxyquire') const sinon = require('sinon') @@ -164,7 +165,7 @@ describeNotWindows('crashtracker', () => { const metadata = binding.init.firstCall.args[2] assert.ok(metadata) - assert.ok(Array.isArray(metadata.tags)) + assert.ok(Array.isArray(metadata.tags), `Expected array, got ${inspect(metadata.tags)}`) // Check that process tags are included const hasEntrypointType = metadata.tags.some(tag => tag.startsWith('entrypoint.type:')) @@ -200,7 +201,7 @@ describeNotWindows('crashtracker', () => { const metadata = binding.updateMetadata.firstCall.args[0] assert.ok(metadata) - assert.ok(Array.isArray(metadata.tags)) + assert.ok(Array.isArray(metadata.tags), `Expected array, got ${inspect(metadata.tags)}`) // Verify process tags are in the updated metadata const hasProcessTags = metadata.tags.some(tag => tag.startsWith('entrypoint.')) diff --git a/packages/dd-trace/test/datastreams/data_streams_checkpointer.spec.js b/packages/dd-trace/test/datastreams/data_streams_checkpointer.spec.js index 3c0c260a1c..249bb8660c 100644 --- a/packages/dd-trace/test/datastreams/data_streams_checkpointer.spec.js +++ b/packages/dd-trace/test/datastreams/data_streams_checkpointer.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, before, after } = require('mocha') const sinon = require('sinon') @@ -127,7 +128,7 @@ describe('data streams checkpointer manual api', () => { tracer.dataStreamsCheckpointer.setConsumeCheckpoint('kinesis', 'stream-123', headers, false) const calledTags = mockSetCheckpoint.getCall(0).args[0] - assert.ok(!calledTags.includes('manual_checkpoint:true')) + assert.ok(!calledTags.includes('manual_checkpoint:true'), `Got: ${inspect(calledTags)}`) }) it('should call trackTransaction on the processor with correct args', function () { diff --git a/packages/dd-trace/test/datastreams/processor.spec.js b/packages/dd-trace/test/datastreams/processor.spec.js index 5456f7e2e9..ea64d1b3af 100644 --- a/packages/dd-trace/test/datastreams/processor.spec.js +++ b/packages/dd-trace/test/datastreams/processor.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { hostname } = require('node:os') +const { inspect } = require('node:util') const { describe, it, beforeEach } = require('mocha') const sinon = require('sinon') @@ -414,7 +415,7 @@ describe('CheckpointRegistry', () => { it('encodedKeys returns empty Buffer when empty', () => { const encoded = registry.encodedKeys - assert.ok(Buffer.isBuffer(encoded)) + assert.ok(Buffer.isBuffer(encoded), `Expected Buffer, got ${inspect(encoded)}`) assert.strictEqual(encoded.length, 0) }) @@ -564,9 +565,12 @@ describe('_serializeBuckets with transactions', () => { processor.trackTransaction('tx-001', 'ingested') const { Stats } = processor._serializeBuckets() assert.strictEqual(Stats.length, 1) - assert.ok(Buffer.isBuffer(Stats[0].Transactions)) - assert.ok(Buffer.isBuffer(Stats[0].TransactionCheckpointIds)) - assert.ok(Stats[0].TransactionCheckpointIds.length > 0) + assert.ok(Buffer.isBuffer(Stats[0].Transactions), `Expected Buffer, got ${inspect(Stats[0].Transactions)}`) + assert.ok( + Buffer.isBuffer(Stats[0].TransactionCheckpointIds), + `Expected Buffer, got ${inspect(Stats[0].TransactionCheckpointIds)}` + ) + assert.ok(Stats[0].TransactionCheckpointIds.length > 0, `Expected ${Stats[0].TransactionCheckpointIds.length} > 0`) }) it('omits Transactions and TransactionCheckpointIds when no transactions in bucket', () => { diff --git a/packages/dd-trace/test/dd-trace.spec.js b/packages/dd-trace/test/dd-trace.spec.js index cd7d593532..0a881dc460 100644 --- a/packages/dd-trace/test/dd-trace.spec.js +++ b/packages/dd-trace/test/dd-trace.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') @@ -38,10 +39,16 @@ describe('dd-trace', () => { // small `duration` values decode as `Number` and large `start` // timestamps decode as `BigInt`. Coerce both to BigInt before checking // the round-trip values so the test is encoding-agnostic. - assert.ok(BigInt(payload[0][0].start) > 0n) - assert.ok(BigInt(payload[0][0].duration) >= 0n) - assert.ok(Object.hasOwn(payload[0][0].metrics, SAMPLING_PRIORITY_KEY)) - assert.ok(Object.hasOwn(payload[0][0].meta, DECISION_MAKER_KEY)) + assert.ok(BigInt(payload[0][0].start) > 0n, `Expected ${BigInt(payload[0][0].start)} > 0n`) + assert.ok(BigInt(payload[0][0].duration) >= 0n, `Expected ${BigInt(payload[0][0].duration)} >= 0n`) + assert.ok( + Object.hasOwn(payload[0][0].metrics, SAMPLING_PRIORITY_KEY), + `Available keys: ${inspect(Object.keys(payload[0][0].metrics))}` + ) + assert.ok( + Object.hasOwn(payload[0][0].meta, DECISION_MAKER_KEY), + `Available keys: ${inspect(Object.keys(payload[0][0].meta))}` + ) }) }) }) diff --git a/packages/dd-trace/test/debugger/devtools_client/breakpoints.spec.js b/packages/dd-trace/test/debugger/devtools_client/breakpoints.spec.js index 6a7c48e18c..ec4852b53b 100644 --- a/packages/dd-trace/test/debugger/devtools_client/breakpoints.spec.js +++ b/packages/dd-trace/test/debugger/devtools_client/breakpoints.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { beforeEach, describe, it } = require('mocha') const proxyquire = require('proxyquire') @@ -441,8 +442,14 @@ describe('breakpoints', function () { { name: 'myVar', expression: 'myVar' }, { name: 'obj.foo' }, ]) - assert.ok(probe.compiledCaptureExpressions[1].expression.includes('myObj')) - assert.ok(probe.compiledCaptureExpressions[1].expression.includes('myProp')) + assert.ok( + probe.compiledCaptureExpressions[1].expression.includes('myObj'), + `Got: ${inspect(probe.compiledCaptureExpressions[1].expression)}` + ) + assert.ok( + probe.compiledCaptureExpressions[1].expression.includes('myProp'), + `Got: ${inspect(probe.compiledCaptureExpressions[1].expression)}` + ) }) it('should store per-expression capture limits', async function () { diff --git a/packages/dd-trace/test/debugger/devtools_client/condition-test-cases.js b/packages/dd-trace/test/debugger/devtools_client/condition-test-cases.js index 88a3257129..8ee7d8b20e 100644 --- a/packages/dd-trace/test/debugger/devtools_client/condition-test-cases.js +++ b/packages/dd-trace/test/debugger/devtools_client/condition-test-cases.js @@ -110,6 +110,12 @@ const references = [ // Old standard reserved words, no need to disallow them [{ ref: 'abstract' }, { abstract: 42 }, 42], + // `@`-prefixed identifiers are desugared to `$dd_` so they translate to valid JS + { ast: { ref: '@it' }, expected: '$dd_it', execute: false }, + { ast: { ref: '@key' }, expected: '$dd_key', execute: false }, + { ast: { ref: '@value' }, expected: '$dd_value', execute: false }, + { ast: { ref: '@foo' }, expected: '$dd_foo', execute: false }, + // Input sanitization { ast: { ref: 'break' }, @@ -171,6 +177,31 @@ const references = [ expected: new SyntaxError('Illegal identifier: throw new Error()'), execute: false, }, + { + ast: { ref: '@x; throw new Error("injected"); //' }, + expected: new SyntaxError('Illegal identifier: @x; throw new Error("injected"); //'), + execute: false, + }, + { + ast: { ref: '@x.y' }, + expected: new SyntaxError('Illegal identifier: @x.y'), + execute: false, + }, + { + ast: { ref: '@x-y' }, + expected: new SyntaxError('Illegal identifier: @x-y'), + execute: false, + }, + { + ast: { ref: '@(1)' }, + expected: new SyntaxError('Illegal identifier: @(1)'), + execute: false, + }, + { + ast: { ref: '@' }, + expected: new SyntaxError('Illegal identifier: @'), + execute: false, + }, ] /** @type {TestCase[]} */ @@ -364,32 +395,32 @@ const equality = [ [ { gt: [{ ref: 'obj' }, 5] }, { obj: objectWithToPrimitiveSymbol }, - new Error('Possibility of side effect due to coercion method'), + new Error('Possibility of side effect due to coercion methods'), ], [ { gt: [5, { ref: 'obj' }] }, { obj: objectWithToPrimitiveSymbol }, - new Error('Possibility of side effect due to coercion method'), + new Error('Possibility of side effect due to coercion methods'), ], [ { gt: [{ ref: 'obj' }, 5] }, { obj: { valueOf () { throw new Error('This should never throw!') } } }, - new Error('Possibility of side effect due to coercion method'), + new Error('Possibility of side effect due to coercion methods'), ], [ { gt: [5, { ref: 'obj' }] }, { obj: { valueOf () { throw new Error('This should never throw!') } } }, - new Error('Possibility of side effect due to coercion method'), + new Error('Possibility of side effect due to coercion methods'), ], [ { gt: [{ ref: 'obj' }, 5] }, { obj: { toString () { throw new Error('This should never throw!') } } }, - new Error('Possibility of side effect due to coercion method'), + new Error('Possibility of side effect due to coercion methods'), ], [ { gt: [5, { ref: 'obj' }] }, { obj: { toString () { throw new Error('This should never throw!') } } }, - new Error('Possibility of side effect due to coercion method'), + new Error('Possibility of side effect due to coercion methods'), ], [ { gt: [{ ref: 'obj' }, 5] }, @@ -413,17 +444,17 @@ const equality = [ [ { ge: [{ ref: 'obj' }, 5] }, { obj: objectWithToPrimitiveSymbol }, - new Error('Possibility of side effect due to coercion method'), + new Error('Possibility of side effect due to coercion methods'), ], [ { ge: [{ ref: 'obj' }, 5] }, { obj: { valueOf () { throw new Error('This should never throw!') } } }, - new Error('Possibility of side effect due to coercion method'), + new Error('Possibility of side effect due to coercion methods'), ], [ { ge: [{ ref: 'obj' }, 5] }, { obj: { toString () { throw new Error('This should never throw!') } } }, - new Error('Possibility of side effect due to coercion method'), + new Error('Possibility of side effect due to coercion methods'), ], [{ lt: [{ ref: 'num' }, 42] }, { num: 43 }, false], @@ -437,17 +468,17 @@ const equality = [ [ { lt: [{ ref: 'obj' }, 5] }, { obj: objectWithToPrimitiveSymbol }, - new Error('Possibility of side effect due to coercion method'), + new Error('Possibility of side effect due to coercion methods'), ], [ { lt: [{ ref: 'obj' }, 5] }, { obj: { valueOf () { throw new Error('This should never throw!') } } }, - new Error('Possibility of side effect due to coercion method'), + new Error('Possibility of side effect due to coercion methods'), ], [ { lt: [{ ref: 'obj' }, 5] }, { obj: { toString () { throw new Error('This should never throw!') } } }, - new Error('Possibility of side effect due to coercion method'), + new Error('Possibility of side effect due to coercion methods'), ], [{ le: [{ ref: 'num' }, 42] }, { num: 43 }, false], @@ -461,17 +492,17 @@ const equality = [ [ { le: [{ ref: 'obj' }, 5] }, { obj: objectWithToPrimitiveSymbol }, - new Error('Possibility of side effect due to coercion method'), + new Error('Possibility of side effect due to coercion methods'), ], [ { le: [{ ref: 'obj' }, 5] }, { obj: { valueOf () { throw new Error('This should never throw!') } } }, - new Error('Possibility of side effect due to coercion method'), + new Error('Possibility of side effect due to coercion methods'), ], [ { le: [{ ref: 'obj' }, 5] }, { obj: { toString () { throw new Error('This should never throw!') } } }, - new Error('Possibility of side effect due to coercion method'), + new Error('Possibility of side effect due to coercion methods'), ], ] diff --git a/packages/dd-trace/test/debugger/devtools_client/snapshot-pruner.spec.js b/packages/dd-trace/test/debugger/devtools_client/snapshot-pruner.spec.js index ec93197bcc..d5e2b248f3 100644 --- a/packages/dd-trace/test/debugger/devtools_client/snapshot-pruner.spec.js +++ b/packages/dd-trace/test/debugger/devtools_client/snapshot-pruner.spec.js @@ -317,7 +317,7 @@ describe('snapshot-pruner', function () { // The algorithm tries to prune to target but may not always hit exactly // Just verify significant reduction happened const reduction = size - Buffer.byteLength(result) - assert.ok(reduction > size * 0.9) // At least 90% reduction + assert.ok(reduction > size * 0.9, `Expected ${reduction} > ${size * 0.9}`) // At least 90% reduction // Should complete in reasonable time assert.ok(elapsed < 30, `Expected elapsed time to be less than 30ms, but got ${elapsed}ms`) diff --git a/packages/dd-trace/test/debugger/devtools_client/snapshot/complex-types.spec.js b/packages/dd-trace/test/debugger/devtools_client/snapshot/complex-types.spec.js index a0567dad24..12ec08ab95 100644 --- a/packages/dd-trace/test/debugger/devtools_client/snapshot/complex-types.spec.js +++ b/packages/dd-trace/test/debugger/devtools_client/snapshot/complex-types.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { afterEach, beforeEach, describe, it } = require('mocha') const NODE_20_PLUS = require('semver').gte(process.version, '20.0.0') @@ -160,10 +161,10 @@ describe('debugger -> devtools client -> snapshot.getLocalStateForCallFrame', fu }) it('WeakMap', function () { - assert.ok(Object.hasOwn(state, 'wmap')) + assert.ok(Object.hasOwn(state, 'wmap'), `Available keys: ${inspect(Object.keys(state))}`) assert.strictEqual(Object.keys(state.wmap).length, (['type', 'entries']).length) - assert.ok((['type', 'entries']).every(k => Object.hasOwn(state.wmap, k))) - assert.ok(Array.isArray(state.wmap.entries)) + assert.ok((['type', 'entries']).every(k => Object.hasOwn(state.wmap, k)), `Got: ${inspect(['type', 'entries'])}`) + assert.ok(Array.isArray(state.wmap.entries), `Expected array, got ${inspect(state.wmap.entries)}`) state.wmap.entries = state.wmap.entries.sort((a, b) => a[1].value - b[1].value) assert.ok('wmap' in state) assert.deepStrictEqual(state.wmap, { @@ -179,10 +180,13 @@ describe('debugger -> devtools client -> snapshot.getLocalStateForCallFrame', fu }) it('WeakSet', function () { - assert.ok(Object.hasOwn(state, 'wset')) + assert.ok(Object.hasOwn(state, 'wset'), `Available keys: ${inspect(Object.keys(state))}`) assert.strictEqual(Object.keys(state.wset).length, (['type', 'elements']).length) - assert.ok((['type', 'elements']).every(k => Object.hasOwn(state.wset, k))) - assert.ok(Array.isArray(state.wset.elements)) + assert.ok( + (['type', 'elements']).every(k => Object.hasOwn(state.wset, k)), + `Got: ${inspect(['type', 'elements'])}` + ) + assert.ok(Array.isArray(state.wset.elements), `Expected array, got ${inspect(state.wset.elements)}`) state.wset.elements = state.wset.elements.sort((a, b) => a.fields.a.value - b.fields.a.value) assert.ok('wset' in state) assert.deepStrictEqual(state.wset, { @@ -204,23 +208,32 @@ describe('debugger -> devtools client -> snapshot.getLocalStateForCallFrame', fu }) it('Error', function () { - assert.ok(Object.hasOwn(state, 'err')) + assert.ok(Object.hasOwn(state, 'err'), `Available keys: ${inspect(Object.keys(state))}`) assert.strictEqual(Object.keys(state.err).length, (['type', 'fields']).length) - assert.ok((['type', 'fields']).every(k => Object.hasOwn(state.err, k))) + assert.ok((['type', 'fields']).every(k => Object.hasOwn(state.err, k)), `Got: ${inspect(['type', 'fields'])}`) assert.strictEqual(state.err.type, 'CustomError') - assert.ok(typeof state.err.fields === 'object' && state.err.fields !== null) + assert.ok( + typeof state.err.fields === 'object' && state.err.fields !== null, + `Expected non-null object, got ${inspect(state.err.fields)}` + ) assert.strictEqual(Object.keys(state.err.fields).length, (['stack', 'message', 'foo']).length) - assert.ok((['stack', 'message', 'foo']).every(k => Object.hasOwn(state.err.fields, k))) + assert.ok( + (['stack', 'message', 'foo']).every(k => Object.hasOwn(state.err.fields, k)), + `Got: ${inspect(['stack', 'message', 'foo'])}` + ) assertObjectContains(state.err.fields, { message: { type: 'string', value: 'boom!' }, foo: { type: 'number', value: '42' }, }) assert.strictEqual(Object.keys(state.err.fields.stack).length, (['type', 'value', 'truncated', 'size']).length) - assert.ok((['type', 'value', 'truncated', 'size']).every(k => Object.hasOwn(state.err.fields.stack, k))) + assert.ok( + (['type', 'value', 'truncated', 'size']).every(k => Object.hasOwn(state.err.fields.stack, k)), + `Got: ${inspect(['type', 'value', 'truncated', 'size'])}` + ) assert.strictEqual(typeof state.err.fields.stack.value, 'string') assert.match(state.err.fields.stack.value, /^Error: boom!/) assert.strictEqual(typeof state.err.fields.stack.size, 'number') - assert.ok(((state.err.fields.stack.size) > (255))) + assert.ok(state.err.fields.stack.size > 255, `Expected ${state.err.fields.stack.size} > 255`) assertObjectContains(state.err.fields.stack, { type: 'string', truncated: true, @@ -356,9 +369,9 @@ describe('debugger -> devtools client -> snapshot.getLocalStateForCallFrame', fu }) it('circular reference in object', function () { - assert.ok(Object.hasOwn(state, 'circular')) + assert.ok(Object.hasOwn(state, 'circular'), `Available keys: ${inspect(Object.keys(state))}`) assert.strictEqual(state.circular.type, 'Object') - assert.ok(Object.hasOwn(state.circular, 'fields')) + assert.ok(Object.hasOwn(state.circular, 'fields'), `Available keys: ${inspect(Object.keys(state.circular))}`) // For the circular field, just check that at least one of the expected properties are present assertObjectContains(state.circular.fields, { regex: { type: 'RegExp', value: '/foo/' }, diff --git a/packages/dd-trace/test/debugger/devtools_client/snapshot/error-handling.spec.js b/packages/dd-trace/test/debugger/devtools_client/snapshot/error-handling.spec.js index 05fdca1de5..611cd77b63 100644 --- a/packages/dd-trace/test/debugger/devtools_client/snapshot/error-handling.spec.js +++ b/packages/dd-trace/test/debugger/devtools_client/snapshot/error-handling.spec.js @@ -3,6 +3,7 @@ require('../../../setup/mocha') const assert = require('node:assert') +const { inspect } = require('node:util') const sinon = require('sinon') const { getLocalStateForCallFrame, evaluateCaptureExpressions, DEFAULT_CAPTURE_LIMITS, session } = require('./utils') @@ -112,7 +113,10 @@ describe('debugger -> devtools client -> snapshot', function () { // Should have one fatal error assert.strictEqual(result.fatalErrors.length, 1) - assert.ok(result.fatalErrors[0].message.includes('secondExpr')) + assert.ok( + result.fatalErrors[0].message.includes('secondExpr'), + `Got: ${inspect(result.fatalErrors[0].message)}` + ) const captured = result.processCaptureExpressions() diff --git a/packages/dd-trace/test/debugger/devtools_client/snapshot/max-collection-size.spec.js b/packages/dd-trace/test/debugger/devtools_client/snapshot/max-collection-size.spec.js index 92c4782664..b8bb28e947 100644 --- a/packages/dd-trace/test/debugger/devtools_client/snapshot/max-collection-size.spec.js +++ b/packages/dd-trace/test/debugger/devtools_client/snapshot/max-collection-size.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { afterEach, beforeEach, describe, it } = require('mocha') @@ -103,9 +104,12 @@ describe('debugger -> devtools client -> snapshot.getLocalStateForCallFrame', fu for (const entry of state.wmap.entries) { assert.strictEqual(entry.length, 2) assert.strictEqual(entry[0].type, 'Object') - assert.ok(Object.hasOwn(entry[0].fields, 'i')) + assert.ok(Object.hasOwn(entry[0].fields, 'i'), `Available keys: ${inspect(Object.keys(entry[0].fields))}`) assert.strictEqual(entry[0].fields.i.type, 'number') - assert.ok(Object.hasOwn(entry[0].fields.i, 'value')) + assert.ok( + Object.hasOwn(entry[0].fields.i, 'value'), + `Available keys: ${inspect(Object.keys(entry[0].fields.i))}` + ) assert.match(entry[0].fields.i.value, /^\d+$/) assert.strictEqual(entry[1].type, 'number') assert.strictEqual(entry[1].value, entry[0].fields.i.value) @@ -124,9 +128,12 @@ describe('debugger -> devtools client -> snapshot.getLocalStateForCallFrame', fu // The order of the elements is not guaranteed, so we don't know which were removed for (const element of state.wset.elements) { assert.strictEqual(element.type, 'Object') - assert.ok(Object.hasOwn(element.fields, 'i')) + assert.ok(Object.hasOwn(element.fields, 'i'), `Available keys: ${inspect(Object.keys(element.fields))}`) assert.strictEqual(element.fields.i.type, 'number') - assert.ok(Object.hasOwn(element.fields.i, 'value')) + assert.ok( + Object.hasOwn(element.fields.i, 'value'), + `Available keys: ${inspect(Object.keys(element.fields.i))}` + ) assert.match(element.fields.i.value, /^\d+$/) } }) diff --git a/packages/dd-trace/test/debugger/devtools_client/snapshot/max-field-count.spec.js b/packages/dd-trace/test/debugger/devtools_client/snapshot/max-field-count.spec.js index f1f79e446c..39950c5865 100644 --- a/packages/dd-trace/test/debugger/devtools_client/snapshot/max-field-count.spec.js +++ b/packages/dd-trace/test/debugger/devtools_client/snapshot/max-field-count.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { afterEach, beforeEach, describe, it } = require('mocha') require('../../../setup/mocha') @@ -41,7 +42,10 @@ function generateTestCases (config) { it('should capture expected snapshot', function () { assert.strictEqual(Object.keys(state).length, ((Array.isArray(['obj']) ? ['obj'] : [['obj']])).length) - assert.ok(((Array.isArray(['obj']) ? ['obj'] : [['obj']])).every(k => Object.hasOwn(state, k))) + assert.ok( + ((Array.isArray(['obj']) ? ['obj'] : [['obj']])).every(k => Object.hasOwn(state, k)), + `Got: ${inspect(Array.isArray(['obj']) ? ['obj'] : [['obj']])}` + ) assert.ok('obj' in state) assert.deepStrictEqual(state.obj, { type: 'Object', diff --git a/packages/dd-trace/test/debugger/devtools_client/snapshot/max-reference-depth.spec.js b/packages/dd-trace/test/debugger/devtools_client/snapshot/max-reference-depth.spec.js index 6fb9c588b0..a33f9fdd42 100644 --- a/packages/dd-trace/test/debugger/devtools_client/snapshot/max-reference-depth.spec.js +++ b/packages/dd-trace/test/debugger/devtools_client/snapshot/max-reference-depth.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { afterEach, beforeEach, describe, it } = require('mocha') require('../../../setup/mocha') @@ -19,9 +20,12 @@ describe('debugger -> devtools client -> snapshot.getLocalStateForCallFrame', fu assertOnBreakpoint(done, { maxReferenceDepth: 1 }, (state) => { assert.strictEqual(Object.keys(state).length, 1) - assert.ok(Object.hasOwn(state, 'myNestedObj')) + assert.ok(Object.hasOwn(state, 'myNestedObj'), `Available keys: ${inspect(Object.keys(state))}`) assert.strictEqual(state.myNestedObj.type, 'Object') - assert.ok(Object.hasOwn(state.myNestedObj, 'fields')) + assert.ok( + Object.hasOwn(state.myNestedObj, 'fields'), + `Available keys: ${inspect(Object.keys(state.myNestedObj))}` + ) assert.strictEqual(Object.keys(state.myNestedObj).length, 2) assert.ok('deepObj' in state.myNestedObj.fields) @@ -42,9 +46,12 @@ describe('debugger -> devtools client -> snapshot.getLocalStateForCallFrame', fu assertOnBreakpoint(done, { maxReferenceDepth: 5 }, (state) => { assert.strictEqual(Object.entries(state).length, 1) - assert.ok(Object.hasOwn(state, 'myNestedObj')) + assert.ok(Object.hasOwn(state, 'myNestedObj'), `Available keys: ${inspect(Object.keys(state))}`) assert.strictEqual(state.myNestedObj.type, 'Object') - assert.ok(Object.hasOwn(state.myNestedObj, 'fields')) + assert.ok( + Object.hasOwn(state.myNestedObj, 'fields'), + `Available keys: ${inspect(Object.keys(state.myNestedObj))}` + ) assert.strictEqual(Object.entries(state.myNestedObj).length, 2) assert.ok('deepObj' in state.myNestedObj.fields) @@ -93,9 +100,12 @@ describe('debugger -> devtools client -> snapshot.getLocalStateForCallFrame', fu assertOnBreakpoint(done, (state) => { assert.strictEqual(Object.entries(state).length, 1) - assert.ok(Object.hasOwn(state, 'myNestedObj')) + assert.ok(Object.hasOwn(state, 'myNestedObj'), `Available keys: ${inspect(Object.keys(state))}`) assert.strictEqual(state.myNestedObj.type, 'Object') - assert.ok(Object.hasOwn(state.myNestedObj, 'fields')) + assert.ok( + Object.hasOwn(state.myNestedObj, 'fields'), + `Available keys: ${inspect(Object.keys(state.myNestedObj))}` + ) assert.strictEqual(Object.entries(state.myNestedObj).length, 2) assert.ok('deepObj' in state.myNestedObj.fields) diff --git a/packages/dd-trace/test/debugger/devtools_client/state.spec.js b/packages/dd-trace/test/debugger/devtools_client/state.spec.js index f7b6db9aca..61d8aa6e73 100644 --- a/packages/dd-trace/test/debugger/devtools_client/state.spec.js +++ b/packages/dd-trace/test/debugger/devtools_client/state.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { before, describe, it } = require('mocha') const proxyquire = require('proxyquire') @@ -212,11 +213,11 @@ describe('findScriptFromPartialPath', function () { it('should be cleared when calling clearState', function () { const path = 'server/index.js' - assert.ok(state._loadedScripts.length > 0) - assert.ok(state._scriptUrls.size > 0) + assert.ok(state._loadedScripts.length > 0, `Expected ${state._loadedScripts.length} > 0`) + assert.ok(state._scriptUrls.size > 0, `Expected ${state._scriptUrls.size} > 0`) const result = state.findScriptFromPartialPath(path) - assert.ok(typeof result === 'object' && result !== null) + assert.ok(typeof result === 'object' && result !== null, `Expected non-null object, got ${inspect(result)}`) state.clearState() diff --git a/packages/dd-trace/test/debugger/index.spec.js b/packages/dd-trace/test/debugger/index.spec.js index 96e8c41233..1c9da03b38 100644 --- a/packages/dd-trace/test/debugger/index.spec.js +++ b/packages/dd-trace/test/debugger/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') @@ -471,8 +472,11 @@ describe('debugger/index', () => { const error3 = ackCallback3.firstCall.args[0] assert.ok(error1 instanceof Error) - assert.ok(error1.message.includes('Dynamic Instrumentation worker thread exited unexpectedly')) - assert.ok(error1.message.includes('code 1')) + assert.ok( + error1.message.includes('Dynamic Instrumentation worker thread exited unexpectedly'), + `Got: ${inspect(error1.message)}` + ) + assert.ok(error1.message.includes('code 1'), `Got: ${inspect(error1.message)}`) // All callbacks should receive the same error instance assert.strictEqual(error2, error1) diff --git a/packages/dd-trace/test/dogstatsd.spec.js b/packages/dd-trace/test/dogstatsd.spec.js index 44ddfc8183..e449de0d7d 100644 --- a/packages/dd-trace/test/dogstatsd.spec.js +++ b/packages/dd-trace/test/dogstatsd.spec.js @@ -734,7 +734,10 @@ describe('dogstatsd', () => { aggregator.histogram('test.hist', 10) aggregator.flush() - assert(gaugeCalls.length > 0 && incrementCalls.length > 0) + assert( + gaugeCalls.length > 0 && incrementCalls.length > 0, + `Got gauge=${gaugeCalls.length}, increment=${incrementCalls.length}` + ) gaugeCalls.length = 0 incrementCalls.length = 0 diff --git a/packages/dd-trace/test/encode/0.4.spec.js b/packages/dd-trace/test/encode/0.4.spec.js index 8b5937f541..38ae09c719 100644 --- a/packages/dd-trace/test/encode/0.4.spec.js +++ b/packages/dd-trace/test/encode/0.4.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach } = require('mocha') const msgpack = require('@msgpack/msgpack') @@ -63,7 +64,7 @@ describe('encode', () => { const decoded = msgpack.decode(buffer, { useBigInt64: true }) const trace = decoded[0] - assert.ok(Array.isArray(trace)) + assert.ok(Array.isArray(trace), `Expected array, got ${inspect(trace)}`) assert.ok(trace[0] instanceof Object) assert.strictEqual(trace[0].trace_id.toString(16), data[0].trace_id.toString()) assert.strictEqual(trace[0].span_id.toString(16), data[0].span_id.toString()) @@ -138,7 +139,8 @@ describe('encode', () => { const debugEncoder = new AgentEncoder(writer) debugEncoder.encode(data) - const message = logger.debug.firstCall.args[0]() + const [formatter, ...args] = logger.debug.firstCall.args + const message = formatter(...args) assert.match(message, /^Adding encoded trace to buffer:(\s[a-f\d]{2})+$/) }) @@ -198,7 +200,7 @@ describe('encode', () => { }) it('should not pin previous _stringBytes buffers in the cache after a resize', () => { - // Force enough unique strings to overflow the 2 MB initial chunk so + // Force enough unique strings to overflow the 1 MiB initial chunk so // _stringBytes resizes mid-encode. Probes _stringMap to make sure no // entry is left pointing at a now-orphaned ArrayBuffer; the public // surface does not expose this retention directly. @@ -297,7 +299,7 @@ describe('encode', () => { const buffer = encoder.makePayload() const decoded = msgpack.decode(buffer, { useBigInt64: true }) const trace = decoded[0] - assert.ok(Array.isArray(trace)) + assert.ok(Array.isArray(trace), `Expected array, got ${inspect(trace)}`) assert.ok(trace[0] instanceof Object) assert.strictEqual(trace[0].trace_id.toString(16), data[0].trace_id.toString()) assert.strictEqual(trace[0].span_id.toString(16), data[0].span_id.toString()) @@ -319,7 +321,7 @@ describe('encode', () => { const buffer = encoder.makePayload() const decoded = msgpack.decode(buffer, { useBigInt64: true }) const trace = decoded[0] - assert.ok(Array.isArray(trace)) + assert.ok(Array.isArray(trace), `Expected array, got ${inspect(trace)}`) assert.ok(trace[0] instanceof Object) assert.strictEqual(trace[0].trace_id.toString(16), data[0].trace_id.toString()) assert.strictEqual(trace[0].span_id.toString(16), data[0].span_id.toString()) @@ -331,6 +333,35 @@ describe('encode', () => { assert.deepStrictEqual(trace[0].metrics, { example: 1 }) }) + it('emits error: 1 via the fallback when start does not fit u64', () => { + // The fused per-span block only fires for the steady-state shape + // (`error: 0/1` AND a nanosecond `start` ≥ 2³²). Synthetic test + // inputs with small starts fall back to the per-field emit chain; + // pin both the `error === 1` arm and the `error: 0` arm in one + // place so a future refactor can't drop either silently. + data[0].error = 1 + + encoder.encode(data) + + const trace = msgpack.decode(encoder.makePayload(), { useBigInt64: true })[0] + assert.strictEqual(trace[0].error, 1) + assert.strictEqual(trace[0].start, 123) + assert.strictEqual(trace[0].duration, 456) + }) + + it('emits unusual error flags via writeIntOrFloat in the fallback', () => { + // OTel-bridge spans and a handful of plugins push non-binary error + // values. The pre-fused `[KEY_ERROR, 0x00/0x01]` constants would + // miscode them; the fallback routes through `writeIntOrFloat` so + // each value picks the shortest msgpack encoding. + data[0].error = 42 + + encoder.encode(data) + + const trace = msgpack.decode(encoder.makePayload(), { useBigInt64: true })[0] + assert.strictEqual(trace[0].error, 42) + }) + describe('meta_struct', () => { it('should encode meta_struct with simple key value object', () => { const metaStruct = { @@ -676,11 +707,11 @@ describe('encode', () => { encoder.encode(data) - // Assert that log.debug was called only once for 'unsupported_key' sinon.assert.calledOnce(logger.debug) sinon.assert.calledWith( logger.debug, - sinon.match(/Encountered unsupported data type for span event v0\.4 encoding, key: unsupported_key/) + sinon.match(/Encountered unsupported data type for span event v0\.4 encoding, key: %s/), + 'unsupported_key' ) }) @@ -707,11 +738,10 @@ describe('encode', () => { encoder.encode(data) - // Assert that log.debug was called once for each unique unsupported key assert.strictEqual(logger.debug.callCount, 3) - assert.match(logger.debug.getCall(0).args[0], /unsupported_key1/) - assert.match(logger.debug.getCall(1).args[0], /unsupported_key2/) - assert.match(logger.debug.getCall(2).args[0], /unsupported_key3/) + assert.strictEqual(logger.debug.getCall(0).args[1], 'unsupported_key1') + assert.strictEqual(logger.debug.getCall(1).args[1], 'unsupported_key2') + assert.strictEqual(logger.debug.getCall(2).args[1], 'unsupported_key3') }) it('should skip events whose name is not a string without throwing', () => { diff --git a/packages/dd-trace/test/encode/0.5.spec.js b/packages/dd-trace/test/encode/0.5.spec.js index 6e31af9fbd..4ee948f69d 100644 --- a/packages/dd-trace/test/encode/0.5.spec.js +++ b/packages/dd-trace/test/encode/0.5.spec.js @@ -244,6 +244,33 @@ describe('encode 0.5', () => { assert.strictEqual(buffer[parentIdOffset + idMarker.length + 2], 0x01) }) + it('should rewind the fixint speculation in _encodeMap for non-fixint metrics', () => { + // Exercise the speculation-miss branch: the speculative value byte + // is rewound and the value goes through writeIntOrFloat to pick the + // shortest valid encoding. The string entry shares the fused-pair + // path; both have to round-trip correctly. + data[0].metrics = { + smallInt: 1, // fixint, speculation hits + bigInt: 65_536, // does not fit a fixint, speculation rewinds to uint32 + negative: -5, // signed, speculation rewinds to int8 + float: 1.5, // not an integer, speculation rewinds to float64 + } + + encoder.encode(data) + + const buffer = encoder.makePayload() + const decoded = msgpack.decode(buffer, { useBigInt64: true }) + const stringMap = decoded[0] + const metrics = decoded[1][0][0][10] + + assert.deepStrictEqual(metrics, { + [stringMap.indexOf('smallInt')]: 1, + [stringMap.indexOf('bigInt')]: 65_536, + [stringMap.indexOf('negative')]: -5, + [stringMap.indexOf('float')]: 1.5, + }) + }) + it('should ignore meta_struct property', () => { data[0].meta_struct = { foo: 'bar' } diff --git a/packages/dd-trace/test/encode/agentless-ci-visibility.spec.js b/packages/dd-trace/test/encode/agentless-ci-visibility.spec.js index b11f1112fb..5c75901c7d 100644 --- a/packages/dd-trace/test/encode/agentless-ci-visibility.spec.js +++ b/packages/dd-trace/test/encode/agentless-ci-visibility.spec.js @@ -257,6 +257,7 @@ describe('agentless-ci-visibility-encode', () => { describe('addMetadataTags', () => { afterEach(() => { encoder.metadataTags = {} + encoder.wildcardMetadataTags = {} }) it('should add simple metadata tags', () => { @@ -334,5 +335,47 @@ describe('agentless-ci-visibility-encode', () => { 'second.flush.tag': '2', }) }) + + it('stores wildcard tags in wildcardMetadataTags and leaves metadataTags untouched', () => { + encoder.addMetadataTags({ + '*': { 'test.command': 'mocha', 'test_session.name': 'my-session' }, + test: { 'test_session.name': 'my-session' }, + }) + + assert.deepStrictEqual(encoder.wildcardMetadataTags, { + 'test.command': 'mocha', + 'test_session.name': 'my-session', + }) + assert.deepStrictEqual(encoder.metadataTags, { + test: { 'test_session.name': 'my-session' }, + }) + }) + + it('merges successive wildcard tags without clearing previously set ones', () => { + encoder.addMetadataTags({ '*': { 'test.command': 'mocha' } }) + encoder.addMetadataTags({ '*': { 'test_session.name': 'my-session' } }) + + assert.deepStrictEqual(encoder.wildcardMetadataTags, { + 'test.command': 'mocha', + 'test_session.name': 'my-session', + }) + }) + + it('encodes wildcard tags into metadata["*"] in the payload', () => { + encoder.addMetadataTags({ + '*': { 'test.command': 'mocha', 'test_session.name': 'my-session' }, + test: { '_dd.library_capabilities.auto_test_retries': '1' }, + }) + encoder.encode(trace) + + const buffer = encoder.makePayload() + const decoded = msgpack.decode(buffer, { useBigInt64: true }) + + assert.strictEqual(decoded.metadata['*']['test.command'], 'mocha') + assert.strictEqual(decoded.metadata['*']['test_session.name'], 'my-session') + assert.deepStrictEqual(decoded.metadata.test, { + '_dd.library_capabilities.auto_test_retries': '1', + }) + }) }) }) diff --git a/packages/dd-trace/test/encode/agentless-json.spec.js b/packages/dd-trace/test/encode/agentless-json.spec.js index 8380eb8efd..1566ede7d0 100644 --- a/packages/dd-trace/test/encode/agentless-json.spec.js +++ b/packages/dd-trace/test/encode/agentless-json.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const sinon = require('sinon') const { describe, it, beforeEach } = require('mocha') @@ -71,9 +72,9 @@ describe('AgentlessJSONEncoder', () => { const decoded = JSON.parse(buffer.toString()) assert.ok(decoded.traces) - assert.ok(Array.isArray(decoded.traces)) + assert.ok(Array.isArray(decoded.traces), `Expected array, got ${inspect(decoded.traces)}`) assert.strictEqual(decoded.traces.length, 1) - assert.ok(Array.isArray(decoded.traces[0].spans)) + assert.ok(Array.isArray(decoded.traces[0].spans), `Expected array, got ${inspect(decoded.traces[0].spans)}`) assert.strictEqual(decoded.traces[0].spans.length, 1) }) @@ -330,10 +331,11 @@ describe('AgentlessJSONEncoder', () => { }) it('should trigger writer flush when estimated size exceeds soft limit', () => { - // Set estimated size just under the 8MB soft limit, then encode a span to push over - encoder._estimatedSize = 8 * 1024 * 1024 + // Construct an encoder with a 1-byte soft limit so any non-empty span + // pushes over and triggers the flush, no reach-in required. + const tinyEncoder = new AgentlessJSONEncoder(writer, metadata, 1) - encoder.encode(data) + tinyEncoder.encode(data) sinon.assert.calledOnce(writer.flush) }) @@ -375,7 +377,7 @@ describe('AgentlessJSONEncoder', () => { encoder.encode(data) const buffer = encoder.makePayload() - assert.ok(Buffer.isBuffer(buffer)) + assert.ok(Buffer.isBuffer(buffer), `Expected Buffer, got ${inspect(buffer)}`) }) it('should reset after making payload', () => { @@ -388,7 +390,7 @@ describe('AgentlessJSONEncoder', () => { it('should return empty buffer when no spans encoded', () => { const buffer = encoder.makePayload() - assert.ok(Buffer.isBuffer(buffer)) + assert.ok(Buffer.isBuffer(buffer), `Expected Buffer, got ${inspect(buffer)}`) assert.strictEqual(buffer.length, 0) }) diff --git a/packages/dd-trace/test/encode/coverage-ci-visibility.spec.js b/packages/dd-trace/test/encode/coverage-ci-visibility.spec.js index 82cd116229..eeb91d6be7 100644 --- a/packages/dd-trace/test/encode/coverage-ci-visibility.spec.js +++ b/packages/dd-trace/test/encode/coverage-ci-visibility.spec.js @@ -14,7 +14,12 @@ const id = require('../../src/id') /** * @typedef {{ * version: number, - * coverages: { test_session_id: number, test_suite_id: number, files: { filename: string }[] }[] } + * coverages: { + * test_session_id: number, + * test_suite_id?: number, + * span_id?: number, + * files: { filename: string, bitmap?: Uint8Array }[] + * }[] } * } CoverageObject */ @@ -35,7 +40,10 @@ describe('coverage-ci-visibility', () => { formattedCoverage = { sessionId: id('1'), suiteId: id('2'), - files: ['file.js'], + files: [{ + filename: 'file.js', + bitmap: Buffer.from([0, 0, 0, 0x40, 0x01, 0x60]), + }], } formattedCoverage2 = { sessionId: id('3'), @@ -70,7 +78,8 @@ describe('coverage-ci-visibility', () => { assert.strictEqual(decodedCoverages.version, 2) assert.strictEqual(decodedCoverages.coverages.length, 2) assertObjectContains(decodedCoverages.coverages[0], { test_session_id: 1, test_suite_id: 2 }) - assert.deepStrictEqual(decodedCoverages.coverages[0].files[0], { filename: 'file.js' }) + assert.strictEqual(decodedCoverages.coverages[0].files[0].filename, 'file.js') + assert.strictEqual(Buffer.from(decodedCoverages.coverages[0].files[0].bitmap).toString('base64'), 'AAAAQAFg') assertObjectContains(decodedCoverages.coverages[1], { test_session_id: 3, test_suite_id: 4 }) assert.deepStrictEqual(decodedCoverages.coverages[1].files[0], { filename: 'file2.js' }) @@ -133,4 +142,23 @@ describe('coverage-ci-visibility', () => { assertObjectContains(decodedCoverages.coverages[0], { test_session_id: 5, test_suite_id: 6, span_id: 7 }) assert.deepStrictEqual(decodedCoverages.coverages[0].files[0], { filename: 'file3.js' }) }) + + it('should be able to encode session executable line coverage', () => { + encoder.encode({ + sessionId: id('8'), + files: [{ + filename: 'file4.js', + bitmap: Buffer.from([0x80]), + }], + }) + + const form = encoder.makePayload() + const decodedCoverages = /** @type {CoverageObject} */ (msgpack.decode(form._data[3])) + + assert.strictEqual(decodedCoverages.coverages.length, 1) + assertObjectContains(decodedCoverages.coverages[0], { test_session_id: 8 }) + assert.ok(!('test_suite_id' in decodedCoverages.coverages[0])) + assert.strictEqual(decodedCoverages.coverages[0].files[0].filename, 'file4.js') + assert.strictEqual(Buffer.from(decodedCoverages.coverages[0].files[0].bitmap).toString('base64'), 'gA==') + }) }) diff --git a/packages/dd-trace/test/encode/encode-int-or-float.spec.js b/packages/dd-trace/test/encode/encode-int-or-float.spec.js index 1c661964bf..01060e75c3 100644 --- a/packages/dd-trace/test/encode/encode-int-or-float.spec.js +++ b/packages/dd-trace/test/encode/encode-int-or-float.spec.js @@ -35,7 +35,7 @@ const cases = [ prefix: 0xCF, expected: BigInt(Number.MAX_SAFE_INTEGER), }, - // `MsgpackEncoder.encodeNumber` would coerce NaN to fixint 0 — `_encodeIntOrFloat` + // `MsgpackChunk.writeNumber` would coerce NaN to fixint 0 — `writeIntOrFloat` // keeps it as float64 so the agent sees what the application produced. { label: 'NaN as float64 (not coerced to fixint 0)', value: Number.NaN, prefix: 0xCB, expectedNaN: true }, { label: 'Infinity as float64', value: Number.POSITIVE_INFINITY, prefix: 0xCB, expected: Number.POSITIVE_INFINITY }, @@ -46,7 +46,7 @@ const cases = [ { label: '-0 collapses to positive fixint zero', value: -0, prefix: 0x00, expected: 0 }, ] -describe('encode 0.4 _encodeIntOrFloat', () => { +describe('MsgpackChunk#writeIntOrFloat (via 0.4 encoder)', () => { let encoder beforeEach(() => { diff --git a/packages/dd-trace/test/encode/tags-processors.spec.js b/packages/dd-trace/test/encode/tags-processors.spec.js index 024ff81da0..65d5ff7bf1 100644 --- a/packages/dd-trace/test/encode/tags-processors.spec.js +++ b/packages/dd-trace/test/encode/tags-processors.spec.js @@ -8,7 +8,9 @@ require('../setup/core') const { truncateSpan, + truncateSpanTestOpt, MAX_RESOURCE_NAME_LENGTH, + MAX_META_VALUE_LENGTH_TEST_OPTIMIZATION, } = require('../../src/encode/tags-processors') describe('tags-processors', () => { @@ -24,4 +26,39 @@ describe('tags-processors', () => { ) }) }) + + describe('truncateSpanTestOpt', () => { + it('truncates resource the same way truncateSpan does', () => { + const overlong = `${'a'.repeat(MAX_RESOURCE_NAME_LENGTH)}X` + assert.strictEqual( + truncateSpanTestOpt({ resource: overlong }).resource, + `${overlong.slice(0, MAX_RESOURCE_NAME_LENGTH)}...` + ) + }) + + it('leaves a meta value at the limit untouched and truncates one past it', () => { + const accepted = 'a'.repeat(MAX_META_VALUE_LENGTH_TEST_OPTIMIZATION) + const overlong = `${'a'.repeat(MAX_META_VALUE_LENGTH_TEST_OPTIMIZATION)}X` + + assert.strictEqual(truncateSpanTestOpt({ meta: { tag: accepted } }).meta.tag, accepted) + assert.strictEqual( + truncateSpanTestOpt({ meta: { tag: overlong } }).meta.tag, + `${overlong.slice(0, MAX_META_VALUE_LENGTH_TEST_OPTIMIZATION)}...` + ) + }) + + it('truncates all overlong meta values independently', () => { + const overlong = 'b'.repeat(MAX_META_VALUE_LENGTH_TEST_OPTIMIZATION + 1) + const fine = 'c'.repeat(10) + const span = { meta: { big: overlong, small: fine } } + + const result = truncateSpanTestOpt(span) + assert.strictEqual(result.meta.big, `${'b'.repeat(MAX_META_VALUE_LENGTH_TEST_OPTIMIZATION)}...`) + assert.strictEqual(result.meta.small, fine) + }) + + it('does nothing when meta is absent', () => { + assert.deepStrictEqual(truncateSpanTestOpt({ resource: 'r' }), { resource: 'r' }) + }) + }) }) diff --git a/packages/dd-trace/test/exporters/agentless/exporter.spec.js b/packages/dd-trace/test/exporters/agentless/exporter.spec.js index 3f89475c21..1d284e0292 100644 --- a/packages/dd-trace/test/exporters/agentless/exporter.spec.js +++ b/packages/dd-trace/test/exporters/agentless/exporter.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { URL } = require('node:url') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') @@ -242,7 +243,7 @@ describe('AgentlessExporter', () => { assert.strictEqual(result, false) sinon.assert.calledOnce(log.error) const call = log.error.getCall(0) - assert.ok(call.args[0].includes('Invalid URL')) + assert.ok(call.args[0].includes('Invalid URL'), `Got: ${inspect(call.args[0])}`) // Invalid URL is passed as second argument (printf-style) assert.strictEqual(call.args[1], 'not-a-valid-url') sinon.assert.notCalled(writer.setUrl) diff --git a/packages/dd-trace/test/exporters/agentless/writer.spec.js b/packages/dd-trace/test/exporters/agentless/writer.spec.js index 8c8aff0629..a3609bcf92 100644 --- a/packages/dd-trace/test/exporters/agentless/writer.spec.js +++ b/packages/dd-trace/test/exporters/agentless/writer.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { URL } = require('node:url') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') @@ -156,8 +157,8 @@ describe('AgentlessWriter', () => { sinon.assert.calledOnce(log.error) const call = log.error.getCall(0) - assert.ok(call.args[0].includes('DD_API_KEY is required')) - assert.ok(call.args[0].includes('Set DD_API_KEY')) + assert.ok(call.args[0].includes('DD_API_KEY is required'), `Got: ${inspect(call.args[0])}`) + assert.ok(call.args[0].includes('Set DD_API_KEY'), `Got: ${inspect(call.args[0])}`) }) it('should skip sending when API key is missing', (done) => { @@ -190,7 +191,7 @@ describe('AgentlessWriter', () => { sinon.assert.notCalled(request) sinon.assert.calledOnce(log.error) const call = log.error.getCall(0) - assert.ok(call.args[0].includes('No valid URL configured')) + assert.ok(call.args[0].includes('No valid URL configured'), `Got: ${inspect(call.args[0])}`) done() }) }) @@ -216,8 +217,8 @@ describe('AgentlessWriter', () => { writer.flush(() => { sinon.assert.calledOnce(log.error) const call = log.error.getCall(0) - assert.ok(call.args[0].includes('Authentication failed')) - assert.ok(call.args[0].includes('Verify DD_API_KEY')) + assert.ok(call.args[0].includes('Authentication failed'), `Got: ${inspect(call.args[0])}`) + assert.ok(call.args[0].includes('Verify DD_API_KEY'), `Got: ${inspect(call.args[0])}`) done() }) }) @@ -232,8 +233,8 @@ describe('AgentlessWriter', () => { writer.flush(() => { sinon.assert.calledOnce(log.error) const call = log.error.getCall(0) - assert.ok(call.args[0].includes('Authentication failed')) - assert.ok(call.args[0].includes('Verify DD_API_KEY')) + assert.ok(call.args[0].includes('Authentication failed'), `Got: ${inspect(call.args[0])}`) + assert.ok(call.args[0].includes('Verify DD_API_KEY'), `Got: ${inspect(call.args[0])}`) done() }) }) @@ -248,8 +249,8 @@ describe('AgentlessWriter', () => { writer.flush(() => { sinon.assert.calledOnce(log.error) const call = log.error.getCall(0) - assert.ok(call.args[0].includes('endpoint not found')) - assert.ok(call.args[0].includes('DD_SITE')) + assert.ok(call.args[0].includes('endpoint not found'), `Got: ${inspect(call.args[0])}`) + assert.ok(call.args[0].includes('DD_SITE'), `Got: ${inspect(call.args[0])}`) done() }) }) @@ -264,7 +265,7 @@ describe('AgentlessWriter', () => { writer.flush(() => { sinon.assert.calledOnce(log.error) const call = log.error.getCall(0) - assert.ok(call.args[0].includes('Rate limited')) + assert.ok(call.args[0].includes('Rate limited'), `Got: ${inspect(call.args[0])}`) done() }) }) @@ -279,8 +280,8 @@ describe('AgentlessWriter', () => { writer.flush(() => { sinon.assert.calledOnce(log.error) const call = log.error.getCall(0) - assert.ok(call.args[0].includes('server error')) - assert.ok(call.args[0].includes('transient')) + assert.ok(call.args[0].includes('server error'), `Got: ${inspect(call.args[0])}`) + assert.ok(call.args[0].includes('transient'), `Got: ${inspect(call.args[0])}`) done() }) }) @@ -295,7 +296,7 @@ describe('AgentlessWriter', () => { writer.flush(() => { sinon.assert.calledOnce(log.error) const call = log.error.getCall(0) - assert.ok(call.args[0].includes('Network error')) + assert.ok(call.args[0].includes('Network error'), `Got: ${inspect(call.args[0])}`) done() }) }) @@ -310,7 +311,7 @@ describe('AgentlessWriter', () => { writer.flush(() => { sinon.assert.calledOnce(log.error) const call = log.error.getCall(0) - assert.ok(call.args[0].includes('Error sending agentless payload')) + assert.ok(call.args[0].includes('Error sending agentless payload'), `Got: ${inspect(call.args[0])}`) // Status code is passed as second argument (printf-style) assert.strictEqual(call.args[1], 400) done() @@ -327,7 +328,7 @@ describe('AgentlessWriter', () => { sinon.assert.calledOnce(encoder.reset) sinon.assert.calledOnce(log.error) const call = log.error.getCall(0) - assert.ok(call.args[0].includes('Maximum number of active requests')) + assert.ok(call.args[0].includes('Maximum number of active requests'), `Got: ${inspect(call.args[0])}`) assert.strictEqual(call.args[1], 3) done() }) diff --git a/packages/dd-trace/test/exporters/common/buffering-exporter.spec.js b/packages/dd-trace/test/exporters/common/buffering-exporter.spec.js index b717d439a8..1e5e90f706 100644 --- a/packages/dd-trace/test/exporters/common/buffering-exporter.spec.js +++ b/packages/dd-trace/test/exporters/common/buffering-exporter.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it } = require('mocha') const sinon = require('sinon') @@ -36,7 +37,8 @@ describe('BufferingExporter', () => { sinon.assert.calledWith(writer.append, trace) sinon.assert.notCalled(writer.flush) - assert.ok(!(exporter.getUncodedTraces()).includes(trace)) + const uncodedTraces = exporter.getUncodedTraces() + assert.ok(!uncodedTraces.includes(trace), `Got: ${inspect(uncodedTraces)}`) setTimeout(() => { sinon.assert.called(writer.flush) diff --git a/packages/dd-trace/test/exporters/common/request.spec.js b/packages/dd-trace/test/exporters/common/request.spec.js index a590feb00c..2ae2c2215f 100644 --- a/packages/dd-trace/test/exporters/common/request.spec.js +++ b/packages/dd-trace/test/exporters/common/request.spec.js @@ -439,7 +439,7 @@ describe('request', function () { const charLength = body.length const byteLength = Buffer.byteLength(body, 'utf-8') - assert.ok(charLength < byteLength) + assert.ok(charLength < byteLength, `Expected ${charLength} < ${byteLength}`) nock('http://test:123').post('/').reply(200, 'OK') diff --git a/packages/dd-trace/test/external-logger/index.spec.js b/packages/dd-trace/test/external-logger/index.spec.js index c4a197e7a3..18d55bedc6 100644 --- a/packages/dd-trace/test/external-logger/index.spec.js +++ b/packages/dd-trace/test/external-logger/index.spec.js @@ -77,7 +77,7 @@ describe('External Logger', () => { assert.strictEqual(request[0].level, 'info') assert.strictEqual(request[0]['dd.trace_id'], '000001000') assert.strictEqual(request[0]['dd.span_id'], '9999991999') - assert.ok(request[0].timestamp >= currentTime) + assert.ok(request[0].timestamp >= currentTime, `Expected ${request[0].timestamp} >= ${currentTime}`) assert.strictEqual(request[0].ddsource, 'logging_from_space') assert.strictEqual(request[0].ddtags, 'env:external_logger,version:1.2.3,service:external') } catch (e) { diff --git a/packages/dd-trace/test/git_metadata_tagger.spec.js b/packages/dd-trace/test/git_metadata_tagger.spec.js index d6abf1e457..d8f9b781bf 100644 --- a/packages/dd-trace/test/git_metadata_tagger.spec.js +++ b/packages/dd-trace/test/git_metadata_tagger.spec.js @@ -50,8 +50,8 @@ describe('git metadata tagging', () => { assert.strictEqual(firstSpan.meta[SCI_REPOSITORY_URL], DUMMY_REPOSITORY_URL) const secondSpan = payload[0][1] - assert.ok(secondSpan.meta[SCI_COMMIT_SHA] == null) - assert.ok(secondSpan.meta[SCI_REPOSITORY_URL] == null) + assert.ok(secondSpan.meta[SCI_COMMIT_SHA] == null, `Expected ${secondSpan.meta[SCI_COMMIT_SHA]} == null`) + assert.ok(secondSpan.meta[SCI_REPOSITORY_URL] == null, `Expected ${secondSpan.meta[SCI_REPOSITORY_URL]} == null`) }) }) }) diff --git a/packages/dd-trace/test/histogram.spec.js b/packages/dd-trace/test/histogram.spec.js index b490e661d8..63ab4820d7 100644 --- a/packages/dd-trace/test/histogram.spec.js +++ b/packages/dd-trace/test/histogram.spec.js @@ -30,12 +30,12 @@ describe('Histogram', () => { assert.strictEqual(typeof histogram.median, 'number') assert.strictEqual(histogram.count, 99) assert.strictEqual(typeof histogram.p95, 'number') - assert.ok(median >= 49) - assert.ok(median <= 51) - assert.ok(p50 >= 49) - assert.ok(p50 <= 51) - assert.ok(p95 >= 94) - assert.ok(p95 <= 96) + assert.ok(median >= 49, `Expected ${median} >= 49`) + assert.ok(median <= 51, `Expected ${median} <= 51`) + assert.ok(p50 >= 49, `Expected ${p50} >= 49`) + assert.ok(p50 <= 51, `Expected ${p50} <= 51`) + assert.ok(p95 >= 94, `Expected ${p95} >= 94`) + assert.ok(p95 <= 96, `Expected ${p95} <= 96`) }) it('should reset all stats', () => { diff --git a/packages/dd-trace/test/llmobs/plugins/anthropic/index.spec.js b/packages/dd-trace/test/llmobs/plugins/anthropic/index.spec.js index 1ba4ae3ad6..6914c469bd 100644 --- a/packages/dd-trace/test/llmobs/plugins/anthropic/index.spec.js +++ b/packages/dd-trace/test/llmobs/plugins/anthropic/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert') +const { inspect } = require('node:util') const { describe, before, it } = require('mocha') const semifies = require('semifies') const { withVersions } = require('../../../setup/mocha') @@ -67,6 +68,14 @@ describe('Plugin', () => { const { apmSpans, llmobsSpans } = await getEvents() assertLLMObsSpan(apmSpans, llmobsSpans) + + // MLOS-591 regression: the default `LLMObsPlugin.start` registration + // path must emit OTel bridge tags onto the local trace so dd-go can + // correlate manual OTel `gen_ai.*` spans with this LLMObs span. + const apmMeta = apmSpans[0].meta + assert.match(apmMeta.llmobs_trace_id, /^[0-9a-f]{32}$/) + assert.ok(apmMeta.llmobs_parent_id) + assert.strictEqual(apmMeta['_dd.llmobs.submitted'], '1') }) it('sets model_provider to unknown for unrecognized base URLs', async () => { @@ -205,7 +214,10 @@ describe('Plugin', () => { assert.ok(response) const { apmSpans, llmobsSpans } = await getEvents() - assert.ok(!llmobsSpans[0].meta.output.messages[0].content.includes('signature')) + assert.ok( + !llmobsSpans[0].meta.output.messages[0].content.includes('signature'), + `Got: ${inspect(llmobsSpans[0].meta.output.messages[0].content)}` + ) assertLlmObsSpanEvent(llmobsSpans[0], { span: apmSpans[0], diff --git a/packages/dd-trace/test/llmobs/plugins/aws-sdk/bedrockruntime.spec.js b/packages/dd-trace/test/llmobs/plugins/aws-sdk/bedrockruntime.spec.js index b88b55dfe4..9c6d997194 100644 --- a/packages/dd-trace/test/llmobs/plugins/aws-sdk/bedrockruntime.spec.js +++ b/packages/dd-trace/test/llmobs/plugins/aws-sdk/bedrockruntime.spec.js @@ -1,5 +1,7 @@ 'use strict' +const assert = require('node:assert') + const { describe, it, before } = require('mocha') const { assertLlmObsSpanEvent, useLlmObs } = require('../../util') @@ -327,6 +329,30 @@ describe('Plugin', () => { tags: { ml_app: 'test', integration: 'bedrock' }, }) }) + + // MLOS-591 regression: `bedrockruntime` registers its LLMObs span from + // `setLLMObsTags` rather than the inherited `LLMObsPlugin.start`. The + // dd-go LLMObs trace-indexer needs `llmobs_trace_id` / + // `llmobs_parent_id` on the local trace tags so OTel `gen_ai.*` spans + // share an LLMObs trace with this bedrock span. The first model is + // enough — bridge-tag plumbing is not per-model. + it('writes otel bridge tags onto the apm span meta', async () => { + const model = models[0] + const command = new AWS.InvokeModelCommand({ + body: JSON.stringify(model.requestBody), + contentType: 'application/json', + accept: 'application/json', + modelId: model.modelId, + }) + + await bedrockRuntimeClient.send(command) + + const { apmSpans } = await getEvents() + const apmMeta = apmSpans[0].meta + assert.match(apmMeta.llmobs_trace_id, /^[0-9a-f]{32}$/) + assert.ok(apmMeta.llmobs_parent_id) + assert.strictEqual(apmMeta['_dd.llmobs.submitted'], '1') + }) }) }) }) diff --git a/packages/dd-trace/test/llmobs/plugins/langgraph/index.spec.js b/packages/dd-trace/test/llmobs/plugins/langgraph/index.spec.js index 4005e09e6c..7c67fbbada 100644 --- a/packages/dd-trace/test/llmobs/plugins/langgraph/index.spec.js +++ b/packages/dd-trace/test/llmobs/plugins/langgraph/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert') +const { inspect } = require('node:util') const { describe, it, beforeEach } = require('mocha') const { withVersions } = require('../../../setup/mocha') @@ -56,7 +57,7 @@ describe('integrations', () => { chunks.push(chunk) } - assert.ok(chunks.length > 0) + assert.ok(chunks.length > 0, `Expected ${chunks.length} > 0`) const { apmSpans, llmobsSpans } = await getEvents() @@ -100,7 +101,7 @@ describe('integrations', () => { chunks.push(chunk) } - assert.ok(chunks.length > 0) + assert.ok(chunks.length > 0, `Expected ${chunks.length} > 0`) const { apmSpans, llmobsSpans } = await getEvents() @@ -141,7 +142,7 @@ describe('integrations', () => { if (chunk.add2?.text) finalOutput += chunk.add2.text } - assert.ok(finalOutput.length > 0) + assert.ok(finalOutput.length > 0, `Expected ${finalOutput.length} > 0`) const { apmSpans, llmobsSpans } = await getEvents() @@ -185,7 +186,7 @@ describe('integrations', () => { chunks.push(chunk) } - assert.ok(chunks.length > 0) + assert.ok(chunks.length > 0, `Expected ${chunks.length} > 0`) const { llmobsSpans } = await getEvents() @@ -198,7 +199,7 @@ describe('integrations', () => { ) const parsedOutput = JSON.parse(workflowSpan.meta.output.value) - assert.ok(Array.isArray(parsedOutput.messages)) + assert.ok(Array.isArray(parsedOutput.messages), `Expected array, got ${inspect(parsedOutput.messages)}`) const lastMessage = parsedOutput.messages[parsedOutput.messages.length - 1] assert.deepStrictEqual(lastMessage, { content: 'Pong', role: 'assistant' }) }) diff --git a/packages/dd-trace/test/llmobs/plugins/modelcontextprotocol-sdk/index.spec.js b/packages/dd-trace/test/llmobs/plugins/modelcontextprotocol-sdk/index.spec.js index 6bc1c1c0bd..51d49ae63c 100644 --- a/packages/dd-trace/test/llmobs/plugins/modelcontextprotocol-sdk/index.spec.js +++ b/packages/dd-trace/test/llmobs/plugins/modelcontextprotocol-sdk/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert') +const { inspect } = require('node:util') const { describe, it, before, after } = require('mocha') const { withVersions } = require('../../../setup/mocha') @@ -172,7 +173,10 @@ describe('integrations', () => { // In MCP SDK 1.27+, tool errors are returned as isError:true results, not thrown exceptions const result = await client.callTool({ name: 'error-tool', arguments: {} }) assert.ok(result.isError, 'callTool result should have isError: true') - assert.ok(result.content?.[0]?.text?.includes('Intentional test error')) + assert.ok( + result.content?.[0]?.text?.includes('Intentional test error'), + `Got: ${inspect(result.content?.[0]?.text)}` + ) const { apmSpans, llmobsSpans } = await getEvents() diff --git a/packages/dd-trace/test/llmobs/plugins/openai/openaiv4.spec.js b/packages/dd-trace/test/llmobs/plugins/openai/openaiv4.spec.js index 3f69a27c71..bc40bd520f 100644 --- a/packages/dd-trace/test/llmobs/plugins/openai/openaiv4.spec.js +++ b/packages/dd-trace/test/llmobs/plugins/openai/openaiv4.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert') +const { inspect } = require('node:util') const { describe, it, beforeEach } = require('mocha') const semifies = require('semifies') @@ -763,7 +764,7 @@ describe('integrations', () => { }) for await (const part of stream) { - assert.ok(Object.hasOwn(part, 'type')) + assert.ok(Object.hasOwn(part, 'type'), `Available keys: ${inspect(Object.keys(part))}`) } const { apmSpans, llmobsSpans } = await getEvents() diff --git a/packages/dd-trace/test/llmobs/sdk/index.spec.js b/packages/dd-trace/test/llmobs/sdk/index.spec.js index 9b51310a40..8361a454e4 100644 --- a/packages/dd-trace/test/llmobs/sdk/index.spec.js +++ b/packages/dd-trace/test/llmobs/sdk/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert') +const { inspect } = require('node:util') const { channel } = require('dc-polyfill') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') @@ -179,7 +180,8 @@ describe('sdk', () => { tracer._tracer._config.llmobs.enabled = false llmobs.trace({ kind: 'workflow', name: 'myWorkflow' }, (span, cb) => { - assert.ok(LLMObsTagger.tagMap.get(span) == null) + const tag = LLMObsTagger.tagMap.get(span) + assert.ok(tag == null, `Expected no LLMObs tag for span, got ${inspect(tag)}`) span.setTag('k', 'v') cb() }) @@ -311,11 +313,11 @@ describe('sdk', () => { tracer.trace('apmRootSpan', apmRootSpan => { apmTraceId = apmRootSpan.context().toTraceId(true) llmobs.trace('workflow', llmobsSpan1 => { - traceId1 = llmobsSpan1.context()._tags['_ml_obs.trace_id'] + traceId1 = llmobsSpan1.context().getTag('_ml_obs.trace_id') }) llmobs.trace('workflow', llmobsSpan2 => { - traceId2 = llmobsSpan2.context()._tags['_ml_obs.trace_id'] + traceId2 = llmobsSpan2.context().getTag('_ml_obs.trace_id') }) }) @@ -426,7 +428,8 @@ describe('sdk', () => { const fn = llmobs.wrap({ kind: 'workflow' }, (a) => { assert.strictEqual(a, 1) - assert.ok(LLMObsTagger.tagMap.get(llmobs._active()) == null) + const tag = LLMObsTagger.tagMap.get(llmobs._active()) + assert.ok(tag == null, `Expected no LLMObs tag for active span, got ${inspect(tag)}`) }) fn(1) @@ -604,7 +607,7 @@ describe('sdk', () => { const wrappedMyWorkflow = llmobs.wrap({ kind: 'workflow' }, myWorkflow) wrappedMyWorkflow('input', (err, res) => { - assert.ok(err == null) + assert.ok(err == null, `Expected ${err} == null`) assert.strictEqual(res, 'output') }) @@ -686,7 +689,7 @@ describe('sdk', () => { workflowSpan = _workflow tracer.trace('apmOperation', () => { myWrappedLlm('input', (err, res) => { - assert.ok(err == null) + assert.ok(err == null, `Expected ${err} == null`) assert.strictEqual(res, 'output') llmobs.trace({ kind: 'task', name: 'afterLlmTask' }, _task => { taskSpan = _task @@ -721,7 +724,7 @@ describe('sdk', () => { const fn = llmobs.wrap('workflow', { name: 'test' }, () => { const span = llmobs._active() - const traceId = span.context()._tags['_ml_obs.trace_id'] + const traceId = span.context().getTag('_ml_obs.trace_id') assert.ok(traceId) assert.notStrictEqual(traceId, span.context().toTraceId(true)) }) @@ -909,8 +912,8 @@ describe('sdk', () => { tracer.trace('test', span => { assert.throws(() => llmobs.annotate(span, {})) - // no span in registry, should not throw - assert.ok(LLMObsTagger.tagMap.get(span) == null) + const tag = LLMObsTagger.tagMap.get(span) + assert.ok(tag == null, `Expected no LLMObs tag for span, got ${inspect(tag)}`) }) }) diff --git a/packages/dd-trace/test/llmobs/span_processor.spec.js b/packages/dd-trace/test/llmobs/span_processor.spec.js index 6b25877c52..3c414d1e2b 100644 --- a/packages/dd-trace/test/llmobs/span_processor.spec.js +++ b/packages/dd-trace/test/llmobs/span_processor.spec.js @@ -56,6 +56,9 @@ describe('span processor', () => { context () { return { _tags: {}, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, // should not use this toSpanId () { return '456' }, } @@ -130,6 +133,9 @@ describe('span processor', () => { context () { return { _tags: {}, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -160,6 +166,9 @@ describe('span processor', () => { context () { return { _tags: {}, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -195,6 +204,9 @@ describe('span processor', () => { context () { return { _tags: {}, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -229,6 +241,9 @@ describe('span processor', () => { context () { return { _tags: {}, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -254,6 +269,9 @@ describe('span processor', () => { context () { return { _tags: {}, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -286,6 +304,9 @@ describe('span processor', () => { context () { return { _tags: {}, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -313,6 +334,9 @@ describe('span processor', () => { context () { return { _tags: {}, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -340,6 +364,9 @@ describe('span processor', () => { context () { return { _tags: {}, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -366,6 +393,9 @@ describe('span processor', () => { 'error.type': 'error type', 'error.stack': 'error stack', }, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -397,6 +427,9 @@ describe('span processor', () => { _tags: { error: new Error('error message'), }, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -424,6 +457,9 @@ describe('span processor', () => { context () { return { _tags: {}, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -446,6 +482,9 @@ describe('span processor', () => { context () { return { _tags: {}, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -469,6 +508,9 @@ describe('span processor', () => { context () { return { _tags: {}, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -492,6 +534,9 @@ describe('span processor', () => { context () { return { _tags: apmTags, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -514,6 +559,9 @@ describe('span processor', () => { context () { return { _tags: apmTags, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -533,6 +581,9 @@ describe('span processor', () => { context () { return { _tags: apmTags, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -558,6 +609,9 @@ describe('span processor', () => { context () { return { _tags: apmTags, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -581,6 +635,9 @@ describe('span processor', () => { context () { return { _tags: apmTags, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } @@ -604,6 +661,9 @@ describe('span processor', () => { context () { return { _tags: apmTags, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, toTraceId () { return '123' }, toSpanId () { return '456' }, } diff --git a/packages/dd-trace/test/llmobs/tagger.spec.js b/packages/dd-trace/test/llmobs/tagger.spec.js index 24c03d2b86..372321080b 100644 --- a/packages/dd-trace/test/llmobs/tagger.spec.js +++ b/packages/dd-trace/test/llmobs/tagger.spec.js @@ -6,6 +6,7 @@ const { beforeEach, describe, it } = require('mocha') const proxyquire = require('proxyquire') const sinon = require('sinon') const { INPUT_PROMPT } = require('../../src/llmobs/constants/tags') +const { writeBridgeTags, findGenAIAncestorSpanId } = require('../../src/llmobs/util') function unserializableObject () { const obj = {} @@ -25,6 +26,8 @@ describe('tagger', () => { spanContext = { _tags: {}, _trace: { tags: {} }, + toTraceId () { return '00000000000000001111111111111111' }, + toSpanId () { return '2222222222222222' }, } span = { @@ -34,8 +37,14 @@ describe('tagger', () => { }, } + // Pass real helpers through so bridge-tag logic is exercised end-to-end. + // `findGenAIAncestorSpanId` is defaulted to a stub returning null so + // existing tests get the "no gen_ai ancestor" branch; individual tests + // can call `.returns(id)` on the stub to exercise suppression. util = { generateTraceId: sinon.stub().returns('0123'), + writeBridgeTags, + findGenAIAncestorSpanId: sinon.stub().returns(null), } logger = { @@ -198,6 +207,139 @@ describe('tagger', () => { assert.strictEqual(tags['_ml_obs.meta.ml_app'], 'my-service') }) }) + + describe('bridge tags for otel correlation', () => { + it('writes llmobs_trace_id and llmobs_parent_id to _trace.tags after a successful register', () => { + tagger.registerLLMObsSpan(span, { kind: 'workflow' }) + + assert.strictEqual(spanContext._trace.tags.llmobs_trace_id, '00000000000000001111111111111111') + assert.strictEqual(spanContext._trace.tags.llmobs_parent_id, '2222222222222222') + }) + + it('does not overwrite bridge tags when a second llmobs span registers on the same trace', () => { + tagger.registerLLMObsSpan(span, { kind: 'workflow' }) + + const secondSpanContext = { + _tags: {}, + _trace: spanContext._trace, // sibling shares the local trace + toTraceId () { return 'ffffffffffffffffffffffffffffffff' }, + toSpanId () { return '9999999999999999' }, + } + const secondSpan = { context () { return secondSpanContext } } + + tagger.registerLLMObsSpan(secondSpan, { kind: 'task' }) + + assert.strictEqual(spanContext._trace.tags.llmobs_trace_id, '00000000000000001111111111111111') + assert.strictEqual(spanContext._trace.tags.llmobs_parent_id, '2222222222222222') + }) + + it('does not write bridge tags when llmobs is disabled', () => { + tagger = new Tagger({ llmobs: { enabled: false } }) + tagger.registerLLMObsSpan(span, { kind: 'workflow' }) + + assert.strictEqual(spanContext._trace.tags.llmobs_trace_id, undefined) + assert.strictEqual(spanContext._trace.tags.llmobs_parent_id, undefined) + }) + + it('does not write bridge tags when no span kind is provided', () => { + tagger.registerLLMObsSpan(span, {}) + + assert.strictEqual(spanContext._trace.tags.llmobs_trace_id, undefined) + assert.strictEqual(spanContext._trace.tags.llmobs_parent_id, undefined) + }) + + // MLOS-591: when the registering LLMObs span sits below an OTel + // `gen_ai.*` ancestor in the APM trace, we suppress + // `llmobs_parent_id` (which would otherwise tell the indexer to + // reparent gen_ai ancestors under this leaf) and use the ancestor + // as the SDK-emitted event's `parent_id` so the span renders under + // the OTel workflow rather than as a parallel root. + describe('with an OTel gen_ai.* APM ancestor', () => { + beforeEach(() => { + // Mutate the existing sinon stub in place; reassigning + // `util.findGenAIAncestorSpanId` here would create a new stub + // that Tagger's captured destructured reference doesn't see. + util.findGenAIAncestorSpanId.returns('444444') + }) + + it('writes llmobs_trace_id but omits llmobs_parent_id', () => { + tagger.registerLLMObsSpan(span, { kind: 'llm' }) + + assert.strictEqual(spanContext._trace.tags.llmobs_trace_id, '00000000000000001111111111111111') + assert.strictEqual(spanContext._trace.tags.llmobs_parent_id, undefined) + }) + + it('uses the gen_ai ancestor span_id as the SDK-emitted parent_id', () => { + tagger.registerLLMObsSpan(span, { kind: 'llm' }) + + const tags = Tagger.tagMap.get(span) + assert.strictEqual(tags['_ml_obs.llmobs_parent_id'], '444444') + }) + + it('still prefers an explicit LLMObs storage parent over the gen_ai ancestor', () => { + const sdkParent = { context () { return { toSpanId () { return '777777' } } } } + Tagger.tagMap.set(sdkParent, { '_ml_obs.meta.ml_app': 'app' }) + + tagger.registerLLMObsSpan(span, { kind: 'llm', parent: sdkParent }) + + const tags = Tagger.tagMap.get(span) + assert.strictEqual(tags['_ml_obs.llmobs_parent_id'], '777777') + }) + }) + + // Integration test: real findGenAIAncestorSpanId detection (no stub). + // Verifies the full pipeline from APM span shape → detection → bridge + // tag suppression → LLMObs event parent_id assignment. + describe('with real gen_ai.* detection (unstubbed)', () => { + let RealTagger + let realTagger + + before(() => { + RealTagger = proxyquire('../../src/llmobs/tagger', { + '../log': { warn () {} }, + './util': { generateTraceId: sinon.stub().returns('0123'), writeBridgeTags, findGenAIAncestorSpanId }, + }) + realTagger = new RealTagger({ llmobs: { enabled: true, mlApp: 'test-app' } }) + }) + + it('detects a real gen_ai.* ancestor, suppresses llmobs_parent_id, and uses ancestor as event parent', () => { + const genAISpanId = '333333333333333' + const leafSpanId = '444444444444444' + const traceTags = {} + const traceStarted = [] + + const genAISpanCtx = { + _spanId: { toString: () => genAISpanId }, + _parentId: null, + getTags () { return { 'gen_ai.operation.name': 'invoke_agent' } }, + _trace: { tags: traceTags, started: traceStarted }, + } + const genAISpan = { context: () => genAISpanCtx } + + const leafTags = {} + const leafSpanCtx = { + _spanId: { toString: () => leafSpanId }, + _parentId: { toString: () => genAISpanId }, + getTags () { return leafTags }, + _trace: { tags: traceTags, started: traceStarted }, + toTraceId () { return '00000000000000009999999999999999' }, + toSpanId () { return leafSpanId }, + } + const leafSpan = { + context: () => leafSpanCtx, + setTag (k, v) { leafTags[k] = v }, + } + + traceStarted.push(genAISpan, leafSpan) + + realTagger.registerLLMObsSpan(leafSpan, { kind: 'llm' }) + + assert.strictEqual(traceTags.llmobs_trace_id, '00000000000000009999999999999999') + assert.strictEqual(traceTags.llmobs_parent_id, undefined) + assert.strictEqual(RealTagger.tagMap.get(leafSpan)['_ml_obs.llmobs_parent_id'], genAISpanId) + }) + }) + }) }) describe('tagMetadata', () => { diff --git a/packages/dd-trace/test/llmobs/util.js b/packages/dd-trace/test/llmobs/util.js index f8b346d8ac..7c05c99f53 100644 --- a/packages/dd-trace/test/llmobs/util.js +++ b/packages/dd-trace/test/llmobs/util.js @@ -2,6 +2,7 @@ const util = require('node:util') const assert = require('node:assert') +const { inspect } = require('node:util') const { before, beforeEach, after } = require('mocha') const agent = require('../plugins/agent') const { useEnv } = require('../../../../integration-tests/helpers') @@ -504,9 +505,12 @@ function assertPromptTracking ( // Verify tags assert(spanEvent.tags, 'Span event should include tags') - assert(spanEvent.tags.includes(`prompt_tracking_instrumentation_method:${promptTrackingInstrumentationMethod}`)) + assert( + spanEvent.tags.includes(`prompt_tracking_instrumentation_method:${promptTrackingInstrumentationMethod}`), + `Got: ${inspect(spanEvent.tags)}` + ) if (promptMultimodal) { - assert(spanEvent.tags.includes('prompt_multimodal:true')) + assert(spanEvent.tags.includes('prompt_multimodal:true'), `Got: ${inspect(spanEvent.tags)}`) } } diff --git a/packages/dd-trace/test/llmobs/util.spec.js b/packages/dd-trace/test/llmobs/util.spec.js index 14319bc09f..9339095c4f 100644 --- a/packages/dd-trace/test/llmobs/util.spec.js +++ b/packages/dd-trace/test/llmobs/util.spec.js @@ -7,11 +7,13 @@ const { before, describe, it } = require('mocha') const getConfig = require('../../src/config') const { encodeUnicode, + findGenAIAncestorSpanId, getFunctionArguments, validateCostTags, safeJsonParse, validateKind, spanHasError, + writeBridgeTags, } = require('../../src/llmobs/util') describe('util', () => { @@ -236,4 +238,114 @@ describe('util', () => { assert.strictEqual(spanHasError(span), true) }) }) + + describe('writeBridgeTags', () => { + function makeSpan (traceTags = {}) { + return { + context () { + return { + _trace: { tags: traceTags }, + toTraceId () { return '00000000000000001111111111111111' }, + toSpanId () { return '2222222222222222' }, + } + }, + } + } + + it('writes llmobs_trace_id and llmobs_parent_id to _trace.tags', () => { + const traceTags = {} + writeBridgeTags(makeSpan(traceTags)) + assert.strictEqual(traceTags.llmobs_trace_id, '00000000000000001111111111111111') + assert.strictEqual(traceTags.llmobs_parent_id, '2222222222222222') + }) + + it('does not overwrite bridge tags when already set', () => { + const traceTags = { llmobs_trace_id: 'preexisting', llmobs_parent_id: 'preexisting' } + writeBridgeTags(makeSpan(traceTags)) + assert.strictEqual(traceTags.llmobs_trace_id, 'preexisting') + assert.strictEqual(traceTags.llmobs_parent_id, 'preexisting') + }) + + it('is a no-op when _trace.tags is absent', () => { + const span = { context () { return { _trace: undefined } } } + writeBridgeTags(span) + }) + + it('is a no-op when span is undefined', () => { + writeBridgeTags(undefined) + }) + + it('omits llmobs_parent_id when includeParentId is false', () => { + const traceTags = {} + writeBridgeTags(makeSpan(traceTags), { includeParentId: false }) + assert.strictEqual(traceTags.llmobs_trace_id, '00000000000000001111111111111111') + assert.strictEqual(traceTags.llmobs_parent_id, undefined) + }) + }) + + describe('findGenAIAncestorSpanId', () => { + // Build a minimal Datadog-shaped span fixture: each span has `_spanId`, + // optional `_parentId`, `_tags`, and shares the `_trace.started` array + // so the helper can walk up the chain via `_parentId` lookup. + function makeTrace (spanDefs) { + const started = [] + const trace = { started, tags: {} } + for (const def of spanDefs) { + const tags = def.tags || {} + started.push({ + context: () => ({ + _spanId: { toString: () => def.spanId }, + _parentId: def.parentId ? { toString: () => def.parentId } : null, + getTags () { return tags }, + _trace: trace, + }), + }) + } + return started + } + + it('returns the nearest gen_ai.* ancestor span_id', () => { + const [root, agent, workflow, leaf] = makeTrace([ + { spanId: '100', tags: {} }, // http.request + { spanId: '200', parentId: '100', tags: { 'gen_ai.operation.name': 'invoke_agent' } }, + { spanId: '300', parentId: '200', tags: { 'gen_ai.operation.name': 'workflow' } }, + { spanId: '400', parentId: '300', tags: {} }, // the LLMObs leaf + ]) + void root; void agent; void workflow + assert.strictEqual(findGenAIAncestorSpanId(leaf), '300') + }) + + it('skips non-gen_ai ancestors and returns the first gen_ai.* match', () => { + const [root, plain, agent, leaf] = makeTrace([ + { spanId: '100', tags: {} }, + { spanId: '200', parentId: '100', tags: { 'http.method': 'GET' } }, + { spanId: '300', parentId: '200', tags: { 'gen_ai.system': 'gemini' } }, + { spanId: '400', parentId: '300', tags: {} }, + ]) + void root; void plain; void agent + assert.strictEqual(findGenAIAncestorSpanId(leaf), '300') + }) + + it('returns null when no ancestor has gen_ai.* tags', () => { + const [root, plain, leaf] = makeTrace([ + { spanId: '100', tags: { 'service.name': 'web' } }, + { spanId: '200', parentId: '100', tags: { 'http.method': 'GET' } }, + { spanId: '300', parentId: '200', tags: {} }, + ]) + void root; void plain + assert.strictEqual(findGenAIAncestorSpanId(leaf), null) + }) + + it('returns null when the span has no parent', () => { + const [orphan] = makeTrace([ + { spanId: '100', tags: {} }, + ]) + assert.strictEqual(findGenAIAncestorSpanId(orphan), null) + }) + + it('is a no-op-safe when span has no context', () => { + assert.strictEqual(findGenAIAncestorSpanId(undefined), null) + assert.strictEqual(findGenAIAncestorSpanId({}), null) + }) + }) }) diff --git a/packages/dd-trace/test/llmobs/writers/multi-tenant.spec.js b/packages/dd-trace/test/llmobs/writers/multi-tenant.spec.js index 0aa28e3ca0..5d5b52da06 100644 --- a/packages/dd-trace/test/llmobs/writers/multi-tenant.spec.js +++ b/packages/dd-trace/test/llmobs/writers/multi-tenant.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { after, afterEach, before, beforeEach, describe, it } = require('mocha') const proxyquire = require('proxyquire') const sinon = require('sinon') @@ -110,8 +111,8 @@ describe('Multi-Tenant Routing', () => { writer.flush() const payload = request.getCall(0).args[0] - assert.ok(!payload.includes('secret-tenant-key')) - assert.ok(!payload.includes('default-key')) + assert.ok(!payload.includes('secret-tenant-key'), `Got: ${inspect(payload)}`) + assert.ok(!payload.includes('default-key'), `Got: ${inspect(payload)}`) }) describe('routing context behavior', () => { @@ -213,7 +214,7 @@ describe('Multi-Tenant Routing', () => { const spanAIndex = callNames.indexOf('span-a') assert.notStrictEqual(spanBIndex, -1) assert.notStrictEqual(spanAIndex, -1) - assert.ok(spanBIndex < spanAIndex) + assert.ok(spanBIndex < spanAIndex, `Expected ${spanBIndex} < ${spanAIndex}`) const routingFor = (name) => calls.find(c => c.args[0].name === name).args[1] diff --git a/packages/dd-trace/test/log.spec.js b/packages/dd-trace/test/log.spec.js index f7c66fad35..150d4f0890 100644 --- a/packages/dd-trace/test/log.spec.js +++ b/packages/dd-trace/test/log.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') @@ -302,10 +303,12 @@ describe('log', () => { log.trace('argument', { hello: 'world' }, new Foo()) sinon.assert.calledOnce(console.debug) - assert.match(console.debug.firstCall.args[0], + const debugMessage = console.debug.firstCall.args[0] + assert.match(debugMessage, /^Trace: Context.foo\('argument', { hello: 'world' }, Foo { bar: 'baz' }\)/ ) - assert.ok(console.debug.firstCall.args[0].split('\n').length >= 3) + const lineCount = debugMessage.split('\n').length + assert.ok(lineCount >= 3, `Expected at least 3 lines in trace, got ${lineCount}: ${inspect(debugMessage)}`) }) }) diff --git a/packages/dd-trace/test/msgpack/chunk.spec.js b/packages/dd-trace/test/msgpack/chunk.spec.js new file mode 100644 index 0000000000..5a0b8c4c17 --- /dev/null +++ b/packages/dd-trace/test/msgpack/chunk.spec.js @@ -0,0 +1,520 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it } = require('mocha') +const msgpack = require('@msgpack/msgpack') + +require('../setup/core') +const MsgpackChunk = require('../../src/msgpack/chunk') + +const DEFAULT_MIN_SIZE = 1024 * 1024 +const SHRINK_AFTER_FLUSHES = 32 + +function used (chunk) { + return chunk.buffer.subarray(0, chunk.length) +} + +describe('MsgpackChunk', () => { + describe('reserve', () => { + it('keeps the initial capacity until the cursor crosses it', () => { + const chunk = new MsgpackChunk() + + assert.equal(chunk.buffer.length, DEFAULT_MIN_SIZE) + chunk.reserve(DEFAULT_MIN_SIZE) + assert.equal(chunk.buffer.length, DEFAULT_MIN_SIZE) + assert.equal(chunk.length, DEFAULT_MIN_SIZE) + }) + + it('doubles the buffer when the requested size overflows', () => { + const chunk = new MsgpackChunk() + + chunk.reserve(DEFAULT_MIN_SIZE + 1) + + assert.equal(chunk.buffer.length, DEFAULT_MIN_SIZE * 2) + assert.equal(chunk.length, DEFAULT_MIN_SIZE + 1) + }) + + it('doubles repeatedly when a single write blows past several capacities', () => { + const chunk = new MsgpackChunk() + + chunk.reserve(DEFAULT_MIN_SIZE * 5) + + assert.equal(chunk.buffer.length, DEFAULT_MIN_SIZE * 8) + assert.equal(chunk.length, DEFAULT_MIN_SIZE * 5) + }) + + it('honours an explicit minSize floor', () => { + const chunk = new MsgpackChunk(2048) + + assert.equal(chunk.buffer.length, 2048) + chunk.reserve(2049) + assert.equal(chunk.buffer.length, 4096) + }) + }) + + describe('reset', () => { + it('zeros the cursor', () => { + const chunk = new MsgpackChunk() + + chunk.reserve(1024) + chunk.reset() + + assert.equal(chunk.length, 0) + }) + + it('does not shrink while the buffer is at minSize', () => { + const chunk = new MsgpackChunk() + const buffer = chunk.buffer + + for (let i = 0; i < SHRINK_AFTER_FLUSHES * 2; i++) { + chunk.reset() + } + + assert.equal(chunk.buffer, buffer) + }) + + it('halves the buffer after the streak of low-usage flushes', () => { + const chunk = new MsgpackChunk() + + chunk.reserve(DEFAULT_MIN_SIZE * 4) + const grown = chunk.buffer + assert.equal(grown.length, DEFAULT_MIN_SIZE * 4) + + // Drain back to a small payload; subsequent flushes stay tiny. + chunk.length = 1 + for (let i = 0; i < SHRINK_AFTER_FLUSHES - 1; i++) { + chunk.reset() + assert.equal(chunk.buffer, grown, `flush ${i} should not have shrunk yet`) + chunk.length = 1 + } + + chunk.reset() + assert.equal(chunk.buffer.length, DEFAULT_MIN_SIZE * 2) + assert.notEqual(chunk.buffer, grown) + }) + + it('does not shrink below minSize even after many quiet flushes', () => { + const chunk = new MsgpackChunk() + + chunk.reserve(DEFAULT_MIN_SIZE * 2) + chunk.length = 0 + + for (let i = 0; i < SHRINK_AFTER_FLUSHES * 10; i++) { + chunk.reset() + } + + assert.equal(chunk.buffer.length, DEFAULT_MIN_SIZE) + }) + + it('resets the streak when a flush fills above the shrink threshold', () => { + const chunk = new MsgpackChunk() + + chunk.reserve(DEFAULT_MIN_SIZE * 2) + const grown = chunk.buffer + + for (let i = 0; i < SHRINK_AFTER_FLUSHES - 1; i++) { + chunk.length = 1 + chunk.reset() + } + // One peak above 1/4 cancels the pending shrink. + chunk.length = (DEFAULT_MIN_SIZE * 2 / 4) + 1 + chunk.reset() + assert.equal(chunk.buffer, grown) + + // A new streak must still take SHRINK_AFTER_FLUSHES quiet flushes. + for (let i = 0; i < SHRINK_AFTER_FLUSHES - 1; i++) { + chunk.length = 1 + chunk.reset() + assert.equal(chunk.buffer, grown) + } + chunk.length = 1 + chunk.reset() + assert.equal(chunk.buffer.length, DEFAULT_MIN_SIZE) + }) + }) + + describe('write', () => { + it('emits a fixstr for strings shorter than 32 UTF-8 bytes', () => { + const chunk = new MsgpackChunk() + + const written = chunk.write('hello') + + assert.equal(written, 6) + assert.equal(chunk.length, 6) + assert.equal(chunk.buffer[0], 0xA5) + assert.equal(chunk.buffer.subarray(1, 6).toString('utf8'), 'hello') + }) + + it('emits a str32 for strings that overflow fixstr (length >= 32)', () => { + const chunk = new MsgpackChunk() + const value = 'a'.repeat(32) + + chunk.write(value) + + assert.equal(chunk.length, 37) + assert.equal(chunk.buffer[0], 0xDB) + assert.equal(chunk.buffer.readUInt32BE(1), 32) + assert.equal(msgpack.decode(used(chunk)), value) + }) + + it('emits an empty fixstr for the empty string', () => { + const chunk = new MsgpackChunk() + + const written = chunk.write('') + + assert.equal(written, 1) + assert.equal(chunk.buffer[0], 0xA0) + }) + }) + + describe('copy', () => { + it('copies the used bytes into the target buffer', () => { + const chunk = new MsgpackChunk() + chunk.write('hello') + + const target = Buffer.alloc(6) + chunk.copy(target, 0, chunk.length) + + assert.deepStrictEqual(target, Buffer.from([0xA5, 0x68, 0x65, 0x6C, 0x6C, 0x6F])) + }) + }) + + describe('with a pool-allocated backing buffer', () => { + // `Buffer.allocUnsafe(2048)` cycles offsets 0, 2048, 4096, 6144 inside + // the shared 8 KiB pool. Retry until the chunk lands at a non-zero offset. + function poolOffsetChunk () { + for (let attempts = 0; attempts < 8; attempts++) { + const chunk = new MsgpackChunk(2048) + if (chunk.buffer.byteOffset !== 0) return chunk + } + throw new Error('Buffer.allocUnsafe pool layout unexpected; refresh the test helper') + } + + it('writeFloat lands in the chunk slice', () => { + const chunk = poolOffsetChunk() + + chunk.writeFloat(1.5) + + const expected = Buffer.alloc(9) + expected[0] = 0xCB + expected.writeDoubleBE(1.5, 1) + assert.deepStrictEqual(used(chunk), expected) + }) + + it('writeBigInt lands in the chunk slice for positive and negative values', () => { + const positive = poolOffsetChunk() + positive.writeBigInt(9_223_372_036_854_775_807n) + const expectedPos = Buffer.alloc(9) + expectedPos[0] = 0xCF + expectedPos.writeBigUInt64BE(9_223_372_036_854_775_807n, 1) + assert.deepStrictEqual(used(positive), expectedPos) + + const negative = poolOffsetChunk() + negative.writeBigInt(-9_223_372_036_854_775_807n) + const expectedNeg = Buffer.alloc(9) + expectedNeg[0] = 0xD3 + expectedNeg.writeBigInt64BE(-9_223_372_036_854_775_807n, 1) + assert.deepStrictEqual(used(negative), expectedNeg) + }) + + it('copy returns the chunk slice bytes, not the underlying slab', () => { + const chunk = poolOffsetChunk() + chunk.write('hello') + + const target = Buffer.alloc(6) + chunk.copy(target, 0, chunk.length) + + assert.deepStrictEqual(target, Buffer.from([0xA5, 0x68, 0x65, 0x6C, 0x6C, 0x6F])) + }) + }) + + describe('set', () => { + it('appends raw bytes and advances the cursor', () => { + const chunk = new MsgpackChunk() + + chunk.set(Buffer.from([0xC2, 0xC3])) + + assert.equal(chunk.length, 2) + assert.deepStrictEqual(used(chunk), Buffer.from([0xC2, 0xC3])) + }) + }) + + describe('writeNull', () => { + it('emits a single 0xC0 byte', () => { + const chunk = new MsgpackChunk() + + chunk.writeNull() + + assert.equal(chunk.length, 1) + assert.equal(chunk.buffer[0], 0xC0) + assert.equal(msgpack.decode(used(chunk)), null) + }) + }) + + describe('writeBoolean', () => { + it('emits 0xC3 for true and 0xC2 for false', () => { + const chunk = new MsgpackChunk() + + chunk.writeBoolean(true) + chunk.writeBoolean(false) + + assert.deepStrictEqual(used(chunk), Buffer.from([0xC3, 0xC2])) + }) + }) + + describe('writeFixArray', () => { + it('emits 0x90 + size for sizes that fit in fixarray', () => { + const chunk = new MsgpackChunk() + + chunk.writeFixArray(0) + chunk.writeFixArray(15) + + assert.deepStrictEqual(used(chunk), Buffer.from([0x90, 0x9F])) + }) + }) + + describe('writeArrayPrefix', () => { + it('emits an array32 header with the value length', () => { + const chunk = new MsgpackChunk() + + chunk.writeArrayPrefix({ length: 16 }) + + assert.equal(chunk.length, 5) + assert.equal(chunk.buffer[0], 0xDD) + assert.equal(chunk.buffer.readUInt32BE(1), 16) + }) + }) + + describe('writeMapPrefix', () => { + it('emits a map32 header with the entry count', () => { + const chunk = new MsgpackChunk() + + chunk.writeMapPrefix(42) + + assert.equal(chunk.length, 5) + assert.equal(chunk.buffer[0], 0xDF) + assert.equal(chunk.buffer.readUInt32BE(1), 42) + }) + }) + + describe('writeByte', () => { + it('writes a single raw byte', () => { + const chunk = new MsgpackChunk() + + chunk.writeByte(0x9C) + + assert.deepStrictEqual(used(chunk), Buffer.from([0x9C])) + }) + }) + + describe('writeBin', () => { + it('emits a bin8 header for byteLength < 256', () => { + const chunk = new MsgpackChunk() + const value = Buffer.from([1, 2, 3]) + + chunk.writeBin(value) + + assert.equal(chunk.buffer[0], 0xC4) + assert.equal(chunk.buffer[1], 3) + assert.deepStrictEqual(used(chunk).subarray(2), value) + }) + + it('emits a bin16 header for byteLength < 65 536', () => { + const chunk = new MsgpackChunk() + const value = Buffer.alloc(256, 0xAB) + + chunk.writeBin(value) + + assert.equal(chunk.buffer[0], 0xC5) + assert.equal(chunk.buffer.readUInt16BE(1), 256) + assert.deepStrictEqual(msgpack.decode(used(chunk)), value) + }) + + it('emits a bin32 header for byteLength >= 65 536', () => { + const chunk = new MsgpackChunk() + const value = Buffer.alloc(65_536, 0xCD) + + chunk.writeBin(value) + + assert.equal(chunk.buffer[0], 0xC6) + assert.equal(chunk.buffer.readUInt32BE(1), 65_536) + assert.deepStrictEqual(msgpack.decode(used(chunk)), value) + }) + }) + + describe('writeInteger', () => { + it('always emits a uint32 (0xCE + 4 bytes), regardless of magnitude', () => { + const chunk = new MsgpackChunk() + + chunk.writeInteger(1) + + assert.equal(chunk.length, 5) + assert.equal(chunk.buffer[0], 0xCE) + assert.equal(chunk.buffer.readUInt32BE(1), 1) + }) + }) + + describe('writeLong', () => { + it('emits a uint64 (0xCF + 8 bytes)', () => { + const chunk = new MsgpackChunk() + const value = 2 ** 40 + + chunk.writeLong(value) + + assert.equal(chunk.buffer[0], 0xCF) + assert.equal(msgpack.decode(used(chunk), { useBigInt64: true }).toString(), String(value)) + }) + }) + + describe('writeUnsigned', () => { + it('picks the shortest encoding across the magnitude boundaries', () => { + const cases = [ + [0, [0x00]], + [127, [0x7F]], // last fixint + [128, [0xCC, 0x80]], // first uint8 + [255, [0xCC, 0xFF]], // last uint8 + [256, [0xCD, 0x01, 0x00]], // first uint16 + [0xFF_FF, [0xCD, 0xFF, 0xFF]], // last uint16 + [0x1_00_00, [0xCE, 0x00, 0x01, 0x00, 0x00]], // first uint32 + [0xFF_FF_FF_FF, [0xCE, 0xFF, 0xFF, 0xFF, 0xFF]], // last uint32 + [0x1_00_00_00_00, [0xCF, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]], // first uint64 + ] + for (const [value, expected] of cases) { + const chunk = new MsgpackChunk() + chunk.writeUnsigned(value) + assert.deepStrictEqual(used(chunk), Buffer.from(expected), `writeUnsigned(${value})`) + } + }) + }) + + describe('writeSigned', () => { + it('picks the shortest encoding across the magnitude boundaries', () => { + // -33 lands in int8 — the path AgentEncoder never reaches because + // span numerics only round-trip through `writeIntOrFloat`'s fixint + // fast path or `writeFloat`. Test it directly so the int8 branch is + // pinned. + const cases = [ + [-1, [0xFF]], // negative fixint (5-bit two's complement) + [-0x20, [0xE0]], // last negative fixint + [-0x21, [0xD0, 0xDF]], // first int8 — 0xD0 + 0xDF = -33 + [-0x80, [0xD0, 0x80]], // last int8 + [-0x81, [0xD1, 0xFF, 0x7F]], // first int16 + [-0x80_00, [0xD1, 0x80, 0x00]], // last int16 + [-0x80_01, [0xD2, 0xFF, 0xFF, 0x7F, 0xFF]], // first int32 + [-0x80_00_00_00, [0xD2, 0x80, 0x00, 0x00, 0x00]], // last int32 + [-0x80_00_00_01, [0xD3, 0xFF, 0xFF, 0xFF, 0xFF, 0x7F, 0xFF, 0xFF, 0xFF]], // first int64 + ] + for (const [value, expected] of cases) { + const chunk = new MsgpackChunk() + chunk.writeSigned(value) + assert.deepStrictEqual(used(chunk), Buffer.from(expected), `writeSigned(${value})`) + } + }) + }) + + describe('writeBigInt', () => { + it('emits 0xCF + uint64 for non-negative bigints', () => { + const chunk = new MsgpackChunk() + const value = 9_223_372_036_854_775_807n + + chunk.writeBigInt(value) + + assert.equal(chunk.buffer[0], 0xCF) + assert.equal(msgpack.decode(used(chunk), { useBigInt64: true }), value) + }) + + it('emits 0xD3 + int64 for negative bigints', () => { + const chunk = new MsgpackChunk() + const value = -9_223_372_036_854_775_807n + + chunk.writeBigInt(value) + + assert.equal(chunk.buffer[0], 0xD3) + assert.equal(msgpack.decode(used(chunk), { useBigInt64: true }), value) + }) + }) + + describe('writeFloat', () => { + it('emits 0xCB + 8-byte float64', () => { + const chunk = new MsgpackChunk() + + chunk.writeFloat(1.5) + + assert.equal(chunk.length, 9) + assert.equal(chunk.buffer[0], 0xCB) + assert.equal(msgpack.decode(used(chunk)), 1.5) + }) + }) + + describe('writeNumber', () => { + it('collapses NaN to fixint 0 (datastreams writer never reads NaN)', () => { + const chunk = new MsgpackChunk() + + chunk.writeNumber(Number.NaN) + + assert.deepStrictEqual(used(chunk), Buffer.from([0x00])) + }) + + it('routes integers through the unsigned / signed encoders and floats through writeFloat', () => { + const cases = [ + [0, [0x00]], + [-1, [0xFF]], + [1024, [0xCD, 0x04, 0x00]], + [-1024, [0xD1, 0xFC, 0x00]], + ] + for (const [value, expected] of cases) { + const chunk = new MsgpackChunk() + chunk.writeNumber(value) + assert.deepStrictEqual(used(chunk), Buffer.from(expected), `writeNumber(${value})`) + } + + const floatChunk = new MsgpackChunk() + floatChunk.writeNumber(1.5) + assert.equal(floatChunk.buffer[0], 0xCB) + assert.equal(msgpack.decode(used(floatChunk)), 1.5) + }) + }) + + describe('writeIntOrFloat', () => { + it('uses the fixint fast path for 0..127', () => { + const chunk = new MsgpackChunk() + + chunk.writeIntOrFloat(0) + chunk.writeIntOrFloat(127) + + assert.deepStrictEqual(used(chunk), Buffer.from([0x00, 0x7F])) + }) + + it('preserves NaN as float64 instead of coercing to 0', () => { + // The tracer's span numeric path must see exactly the value the + // application produced; coercing NaN here would drop information that + // `writeNumber` is explicitly happy to discard. + const chunk = new MsgpackChunk() + + chunk.writeIntOrFloat(Number.NaN) + + assert.equal(chunk.buffer[0], 0xCB) + assert.ok(Number.isNaN(msgpack.decode(used(chunk)))) + }) + + it('routes magnitudes outside the fast path through the right shortest encoder', () => { + const cases = [ + [128, [0xCC, 0x80]], + [-1, [0xFF]], + [-1024, [0xD1, 0xFC, 0x00]], + [0xFF_FF_FF_FF, [0xCE, 0xFF, 0xFF, 0xFF, 0xFF]], + ] + for (const [value, expected] of cases) { + const chunk = new MsgpackChunk() + chunk.writeIntOrFloat(value) + assert.deepStrictEqual(used(chunk), Buffer.from(expected), `writeIntOrFloat(${value})`) + } + + const floatChunk = new MsgpackChunk() + floatChunk.writeIntOrFloat(1.5) + assert.equal(floatChunk.buffer[0], 0xCB) + assert.equal(msgpack.decode(used(floatChunk)), 1.5) + }) + }) +}) diff --git a/packages/dd-trace/test/msgpack/encode.spec.js b/packages/dd-trace/test/msgpack/encode.spec.js new file mode 100644 index 0000000000..2c11dd6ef8 --- /dev/null +++ b/packages/dd-trace/test/msgpack/encode.spec.js @@ -0,0 +1,130 @@ +'use strict' + +const assert = require('node:assert/strict') +const { inspect } = require('node:util') + +const { describe, it } = require('mocha') +const msgpack = require('@msgpack/msgpack') + +require('../setup/core') +const { encode } = require('../../src/msgpack') + +function randString (length) { + return Array.from({ length }, () => { + return String.fromCharCode(Math.floor(Math.random() * 256)) + }).join('') +} + +describe('msgpack/encode', () => { + it('should encode to msgpack', () => { + const data = [ + { first: 'test' }, + { + fixstr: 'foo', + str: randString(1000), + fixuint: 127, + fixint: -31, + uint8: 255, + uint16: 65535, + uint32: 4294967295, + uint53: 9007199254740991, + int8: -15, + int16: -32767, + int32: -2147483647, + int53: -9007199254740991, + float: 12345.6789, + biguint: BigInt('9223372036854775807'), + bigint: BigInt('-9223372036854775807'), + buffer: Buffer.from('test'), + uint8array: new Uint8Array([1, 2, 3, 4]), + }, + ] + + const buffer = encode(data) + const decoded = msgpack.decode(buffer, { useBigInt64: true }) + + assert.ok(Array.isArray(decoded), `Expected array, got ${inspect(decoded)}`) + assert.ok( + typeof decoded[0] === 'object' && decoded[0] !== null, + `Expected non-null object, got ${inspect(decoded[0])}` + ) + assert.strictEqual(decoded[0].first, 'test') + assert.ok( + typeof decoded[1] === 'object' && decoded[1] !== null, + `Expected non-null object, got ${inspect(decoded[1])}` + ) + assert.strictEqual(decoded[1].fixstr, 'foo') + assert.ok(Object.hasOwn(decoded[1], 'str'), `Available keys: ${inspect(Object.keys(decoded[1]))}`) + assert.strictEqual(decoded[1].str.length, 1000) + assert.strictEqual(decoded[1].fixuint, 127) + assert.strictEqual(decoded[1].fixint, -31) + assert.strictEqual(decoded[1].uint8, 255) + assert.strictEqual(decoded[1].uint16, 65535) + assert.strictEqual(decoded[1].uint32, 4294967295) + assert.ok(Object.hasOwn(decoded[1], 'uint53'), `Available keys: ${inspect(Object.keys(decoded[1]))}`) + assert.strictEqual(decoded[1].uint53.toString(), '9007199254740991') + assert.strictEqual(decoded[1].int8, -15) + assert.strictEqual(decoded[1].int16, -32767) + assert.strictEqual(decoded[1].int32, -2147483647) + assert.ok(Object.hasOwn(decoded[1], 'int53'), `Available keys: ${inspect(Object.keys(decoded[1]))}`) + assert.strictEqual(decoded[1].int53.toString(), '-9007199254740991') + assert.strictEqual(decoded[1].float, 12345.6789) + assert.ok(Object.hasOwn(decoded[1], 'biguint'), `Available keys: ${inspect(Object.keys(decoded[1]))}`) + assert.strictEqual(decoded[1].biguint.toString(), '9223372036854775807') + assert.ok(Object.hasOwn(decoded[1], 'bigint'), `Available keys: ${inspect(Object.keys(decoded[1]))}`) + assert.strictEqual(decoded[1].bigint.toString(), '-9223372036854775807') + assert.ok(Object.hasOwn(decoded[1], 'buffer'), `Available keys: ${inspect(Object.keys(decoded[1]))}`) + assert.strictEqual(decoded[1].buffer.toString('utf8'), 'test') + assert.ok(Object.hasOwn(decoded[1], 'buffer'), `Available keys: ${inspect(Object.keys(decoded[1]))}`) + assert.strictEqual(decoded[1].buffer.toString('utf8'), 'test') + assert.ok(Object.hasOwn(decoded[1], 'uint8array'), `Available keys: ${inspect(Object.keys(decoded[1]))}`) + assert.strictEqual(decoded[1].uint8array[0], 1) + assert.strictEqual(decoded[1].uint8array[1], 2) + assert.strictEqual(decoded[1].uint8array[2], 3) + assert.strictEqual(decoded[1].uint8array[3], 4) + }) + + it('emits 0xC0 for explicit null values', () => { + const buffer = encode({ value: null }) + + assert.deepStrictEqual(msgpack.decode(buffer), { value: null }) + }) + + it('emits explicit msgpack booleans', () => { + const buffer = encode({ yes: true, no: false }) + + assert.deepStrictEqual(msgpack.decode(buffer), { yes: true, no: false }) + }) + + it('encodes symbols as their `.toString()` representation', () => { + // `DataStreamsWriter` ships pipeline-stat shapes the caller decides at + // runtime, so the dispatcher accepts anything `typeof` can name. Symbols + // collapse to their string form so the agent receives a stable label + // instead of an opaque payload — and so the encoder never throws when a + // caller drops a `Symbol` into a stats blob. + const buffer = encode(Symbol('pipeline')) + + assert.strictEqual(msgpack.decode(buffer), 'Symbol(pipeline)') + }) + + it('falls back to msgpack null for unsupported value types (functions, undefined)', () => { + // `typeof undefined === 'undefined'` and `typeof () => {} === 'function'` + // both hit the dispatcher's `default` arm. Encoding them as `nil` keeps + // the surrounding payload well-formed instead of letting the chunk + // emit zero bytes for the value, which would desync the map header + // count from the actual entries. + const buffer = encode({ fn: () => {}, missing: undefined }) + + assert.deepStrictEqual(msgpack.decode(buffer), { fn: null, missing: null }) + }) + + it('emits an array32 header for arrays with 16 or more entries', () => { + const value = Array.from({ length: 16 }, (_, index) => index) + + const buffer = encode(value) + + assert.equal(buffer[0], 0xDD) + assert.equal(buffer.readUInt32BE(1), 16) + assert.deepStrictEqual(msgpack.decode(buffer), value) + }) +}) diff --git a/packages/dd-trace/test/msgpack/encoder.spec.js b/packages/dd-trace/test/msgpack/encoder.spec.js deleted file mode 100644 index 93263816cf..0000000000 --- a/packages/dd-trace/test/msgpack/encoder.spec.js +++ /dev/null @@ -1,85 +0,0 @@ -'use strict' - -const assert = require('node:assert/strict') - -const { describe, it, beforeEach } = require('mocha') -const msgpack = require('@msgpack/msgpack') - -require('../setup/core') -const { MsgpackEncoder } = require('../../src/msgpack/encoder') - -function randString (length) { - return Array.from({ length }, () => { - return String.fromCharCode(Math.floor(Math.random() * 256)) - }).join('') -} - -describe('msgpack/encoder', () => { - let encoder - - beforeEach(() => { - encoder = new MsgpackEncoder() - }) - - it('should encode to msgpack', () => { - const data = [ - { first: 'test' }, - { - fixstr: 'foo', - str: randString(1000), - fixuint: 127, - fixint: -31, - uint8: 255, - uint16: 65535, - uint32: 4294967295, - uint53: 9007199254740991, - int8: -15, - int16: -32767, - int32: -2147483647, - int53: -9007199254740991, - float: 12345.6789, - biguint: BigInt('9223372036854775807'), - bigint: BigInt('-9223372036854775807'), - buffer: Buffer.from('test'), - uint8array: new Uint8Array([1, 2, 3, 4]), - }, - ] - - const buffer = encoder.encode(data) - const decoded = msgpack.decode(buffer, { useBigInt64: true }) - - assert.ok(Array.isArray(decoded)) - assert.ok(typeof decoded[0] === 'object' && decoded[0] !== null) - assert.strictEqual(decoded[0].first, 'test') - assert.ok(typeof decoded[1] === 'object' && decoded[1] !== null) - assert.strictEqual(decoded[1].fixstr, 'foo') - assert.ok(Object.hasOwn(decoded[1], 'str')) - assert.strictEqual(decoded[1].str.length, 1000) - assert.strictEqual(decoded[1].fixuint, 127) - assert.strictEqual(decoded[1].fixint, -31) - assert.strictEqual(decoded[1].uint8, 255) - assert.strictEqual(decoded[1].uint16, 65535) - assert.strictEqual(decoded[1].uint32, 4294967295) - assert.ok(Object.hasOwn(decoded[1], 'uint53')) - assert.strictEqual(decoded[1].uint53.toString(), '9007199254740991') - assert.strictEqual(decoded[1].int8, -15) - assert.strictEqual(decoded[1].int16, -32767) - assert.strictEqual(decoded[1].int32, -2147483647) - assert.ok(Object.hasOwn(decoded[1], 'int53')) - assert.strictEqual(decoded[1].int53.toString(), '-9007199254740991') - assert.strictEqual(decoded[1].float, 12345.6789) - assert.ok(Object.hasOwn(decoded[1], 'biguint')) - assert.strictEqual(decoded[1].biguint.toString(), '9223372036854775807') - assert.ok(Object.hasOwn(decoded[1], 'bigint')) - assert.strictEqual(decoded[1].bigint.toString(), '-9223372036854775807') - assert.ok(Object.hasOwn(decoded[1], 'buffer')) - assert.strictEqual(decoded[1].buffer.toString('utf8'), 'test') - assert.ok(Object.hasOwn(decoded[1], 'buffer')) - assert.strictEqual(decoded[1].buffer.toString('utf8'), 'test') - assert.ok(Object.hasOwn(decoded[1], 'uint8array')) - assert.strictEqual(decoded[1].uint8array[0], 1) - assert.strictEqual(decoded[1].uint8array[1], 2) - assert.strictEqual(decoded[1].uint8array[2], 3) - assert.strictEqual(decoded[1].uint8array[3], 4) - }) -}) diff --git a/packages/dd-trace/test/openfeature/encoding.spec.js b/packages/dd-trace/test/openfeature/encoding.spec.js new file mode 100644 index 0000000000..f0e4936f64 --- /dev/null +++ b/packages/dd-trace/test/openfeature/encoding.spec.js @@ -0,0 +1,131 @@ +'use strict' + +const assert = require('node:assert/strict') +const { describe, it } = require('mocha') + +require('../setup/core') + +const { encodeVarint, encodeDeltaVarint, hashTargetingKey } = require('../../src/openfeature/encoding') + +describe('encoding', () => { + describe('encodeVarint()', () => { + it('should encode single-byte values (0-127)', () => { + assert.deepStrictEqual(encodeVarint(0), [0]) + assert.deepStrictEqual(encodeVarint(1), [1]) + assert.deepStrictEqual(encodeVarint(127), [127]) + }) + + it('should encode two-byte values (128-16383)', () => { + // 128 = 0b10000000 -> [0x80 | 0x00, 0x01] = [0x80, 0x01] + assert.deepStrictEqual(encodeVarint(128), [0x80, 0x01]) + // 300 = 0b100101100 -> [0xAC, 0x02] + assert.deepStrictEqual(encodeVarint(300), [0xAC, 0x02]) + }) + + it('should encode larger values', () => { + // 16384 = 0b100000000000000 -> [0x80, 0x80, 0x01] + assert.deepStrictEqual(encodeVarint(16384), [0x80, 0x80, 0x01]) + }) + }) + + describe('encodeDeltaVarint()', () => { + it('should return empty string for empty array', () => { + assert.strictEqual(encodeDeltaVarint([]), '') + }) + + it('should return empty string for null/undefined', () => { + assert.strictEqual(encodeDeltaVarint(null), '') + assert.strictEqual(encodeDeltaVarint(undefined), '') + }) + + it('should encode a single value', () => { + const encoded = encodeDeltaVarint([42]) + const decoded = Buffer.from(encoded, 'base64') + // 42 as varint is just [42] + assert.deepStrictEqual([...decoded], [42]) + }) + + it('should sort values before encoding', () => { + // [130, 100, 128, 108] should be sorted to [100, 108, 128, 130] + // Deltas: [100, 8, 20, 2] + const encoded = encodeDeltaVarint([130, 100, 128, 108]) + const decoded = Buffer.from(encoded, 'base64') + assert.deepStrictEqual([...decoded], [100, 8, 20, 2]) + }) + + it('should encode known values correctly', () => { + // Test case from system tests: + // [100, 108, 128, 130] -> deltas [100, 8, 20, 2] -> base64 "ZAgUAg==" + const encoded = encodeDeltaVarint([100, 108, 128, 130]) + assert.strictEqual(encoded, 'ZAgUAg==') + }) + + it('should handle duplicate values', () => { + // Duplicates should result in 0 deltas + const encoded = encodeDeltaVarint([100, 100, 100]) + const decoded = Buffer.from(encoded, 'base64') + // After sorting and deduplication via Set in actual usage, but encoding handles dupes + // [100, 100, 100] sorted -> deltas [100, 0, 0] + assert.deepStrictEqual([...decoded], [100, 0, 0]) + }) + + it('should handle values requiring multi-byte varints', () => { + // 128 requires 2 bytes: [0x80, 0x01] + // 256 requires 2 bytes: [0x80, 0x02] + // [128, 256] -> sorted [128, 256] -> deltas [128, 128] + const encoded = encodeDeltaVarint([128, 256]) + const decoded = Buffer.from(encoded, 'base64') + // First delta: 128 -> [0x80, 0x01] + // Second delta: 128 -> [0x80, 0x01] + assert.deepStrictEqual([...decoded], [0x80, 0x01, 0x80, 0x01]) + }) + + it('should encode large deltas correctly', () => { + // [1, 1000] -> deltas [1, 999] + // 999 = 0b1111100111 -> [0xE7, 0x07] + const encoded = encodeDeltaVarint([1, 1000]) + const decoded = Buffer.from(encoded, 'base64') + assert.deepStrictEqual([...decoded], [1, 0xE7, 0x07]) + }) + }) + + describe('hashTargetingKey()', () => { + it('should return SHA256 hex digest', () => { + // Known SHA256 hash of "test-user-sha256" + const hash = hashTargetingKey('test-user-sha256') + assert.strictEqual(hash.length, 64) // SHA256 produces 64 hex chars + assert.match(hash, /^[0-9a-f]{64}$/) + }) + + it('should return consistent hash for same input', () => { + const hash1 = hashTargetingKey('user-123') + const hash2 = hashTargetingKey('user-123') + assert.strictEqual(hash1, hash2) + }) + + it('should return different hash for different input', () => { + const hash1 = hashTargetingKey('user-123') + const hash2 = hashTargetingKey('user-456') + assert.notStrictEqual(hash1, hash2) + }) + + it('should match expected hash values', () => { + // Pre-computed SHA256 hashes for known values + // echo -n "test-user-sha256" | sha256sum + const hash = hashTargetingKey('test-user-sha256') + assert.strictEqual(hash, '03730d38b223ba74db02c81f18c1fd0d1f0d63939d09a1e1413341c56b748eca') + }) + + it('should handle empty string', () => { + const hash = hashTargetingKey('') + // SHA256 of empty string + assert.strictEqual(hash, 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855') + }) + + it('should handle unicode characters', () => { + const hash = hashTargetingKey('用户-123') + assert.strictEqual(hash.length, 64) + assert.match(hash, /^[0-9a-f]{64}$/) + }) + }) +}) diff --git a/packages/dd-trace/test/openfeature/eval-metrics-hook.spec.js b/packages/dd-trace/test/openfeature/eval-metrics-hook.spec.js index f74a45358e..925bf30300 100644 --- a/packages/dd-trace/test/openfeature/eval-metrics-hook.spec.js +++ b/packages/dd-trace/test/openfeature/eval-metrics-hook.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach } = require('mocha') const sinon = require('sinon') @@ -142,7 +143,7 @@ describe('EvalMetricsHook', () => { metrics.finally(hookContext(), evalDetails()) const [, attributes] = mockCounter.add.firstCall.args - assert.ok(!Object.hasOwn(attributes, 'error.type')) + assert.ok(!Object.hasOwn(attributes, 'error.type'), `Available keys: ${inspect(Object.keys(attributes))}`) }) it('should include allocation_key when set', () => { @@ -166,7 +167,10 @@ describe('EvalMetricsHook', () => { metrics.finally(hookContext(), evalDetails()) const [, attributes] = mockCounter.add.firstCall.args - assert.ok(!Object.hasOwn(attributes, 'feature_flag.result.allocation_key')) + assert.ok( + !Object.hasOwn(attributes, 'feature_flag.result.allocation_key'), + `Available keys: ${inspect(Object.keys(attributes))}` + ) }) it('should omit allocation_key when flagMetadata is empty', () => { @@ -174,7 +178,10 @@ describe('EvalMetricsHook', () => { metrics.finally(hookContext(), evalDetails({ flagMetadata: {} })) const [, attributes] = mockCounter.add.firstCall.args - assert.ok(!Object.hasOwn(attributes, 'feature_flag.result.allocation_key')) + assert.ok( + !Object.hasOwn(attributes, 'feature_flag.result.allocation_key'), + `Available keys: ${inspect(Object.keys(attributes))}` + ) }) it('should skip when OTel api throws', () => { diff --git a/packages/dd-trace/test/openfeature/flagging_provider.spec.js b/packages/dd-trace/test/openfeature/flagging_provider.spec.js index fc698e6940..b38e2c24de 100644 --- a/packages/dd-trace/test/openfeature/flagging_provider.spec.js +++ b/packages/dd-trace/test/openfeature/flagging_provider.spec.js @@ -17,6 +17,8 @@ describe('FlaggingProvider', () => { let channelStub let mockEvalMetricsHook let mockEvalMetricsHookClass + let mockSpanEnrichmentHook + let mockSpanEnrichmentHookClass beforeEach(() => { mockTracer = { @@ -31,6 +33,9 @@ describe('FlaggingProvider', () => { flaggingProvider: { enabled: true, initializationTimeoutMs: 30_000, + spanEnrichment: { + enabled: true, + }, }, }, } @@ -43,6 +48,7 @@ describe('FlaggingProvider', () => { log = { debug: sinon.spy(), + info: sinon.spy(), error: sinon.spy(), warn: sinon.spy(), } @@ -52,12 +58,18 @@ describe('FlaggingProvider', () => { } mockEvalMetricsHookClass = sinon.stub().returns(mockEvalMetricsHook) + mockSpanEnrichmentHook = { + destroy: sinon.spy(), + } + mockSpanEnrichmentHookClass = sinon.stub().returns(mockSpanEnrichmentHook) + FlaggingProvider = proxyquire('../../src/openfeature/flagging_provider', { 'dc-polyfill': { channel: channelStub, }, '../log': log, './eval-metrics-hook': mockEvalMetricsHookClass, + './span-enrichment-hook': mockSpanEnrichmentHookClass, }) }) @@ -102,6 +114,16 @@ describe('FlaggingProvider', () => { provider._setConfiguration(null) provider._setConfiguration(undefined) }) + + it('should not throw when setConfiguration is not a function', () => { + const provider = new FlaggingProvider(mockTracer, mockConfig) + provider.setConfiguration = null // Remove the method + + provider._setConfiguration({ flags: {} }) + + // Should still log the debug message + sinon.assert.calledWith(log.debug, '%s provider configuration updated', 'FlaggingProvider') + }) }) describe('hooks', () => { @@ -111,12 +133,73 @@ describe('FlaggingProvider', () => { sinon.assert.calledOnceWithExactly(mockEvalMetricsHookClass, mockConfig) }) - it('should register EvalMetricsHook as a hook', () => { + it('should create SpanEnrichmentHook with tracer when span enrichment is enabled', () => { + new FlaggingProvider(mockTracer, mockConfig) // eslint-disable-line no-new + + sinon.assert.calledOnceWithExactly(mockSpanEnrichmentHookClass, mockTracer) + }) + + it('should not create SpanEnrichmentHook when span enrichment is disabled', () => { + mockConfig.experimental.flaggingProvider.spanEnrichment.enabled = false + new FlaggingProvider(mockTracer, mockConfig) // eslint-disable-line no-new + + sinon.assert.notCalled(mockSpanEnrichmentHookClass) + }) + + it('should not create SpanEnrichmentHook when spanEnrichment config is missing', () => { + delete mockConfig.experimental.flaggingProvider.spanEnrichment + new FlaggingProvider(mockTracer, mockConfig) // eslint-disable-line no-new + + sinon.assert.notCalled(mockSpanEnrichmentHookClass) + }) + + it('should register EvalMetricsHook and SpanEnrichmentHook as hooks when enabled', () => { + const provider = new FlaggingProvider(mockTracer, mockConfig) + + assert.strictEqual(provider.hooks.length, 2) + assert.strictEqual(provider.hooks[0], mockEvalMetricsHook) + assert.strictEqual(provider.hooks[1], mockSpanEnrichmentHook) + }) + + it('should only register EvalMetricsHook when span enrichment is disabled', () => { + mockConfig.experimental.flaggingProvider.spanEnrichment.enabled = false const provider = new FlaggingProvider(mockTracer, mockConfig) assert.strictEqual(provider.hooks.length, 1) assert.strictEqual(provider.hooks[0], mockEvalMetricsHook) }) + + it('should log info message when span enrichment is enabled', () => { + new FlaggingProvider(mockTracer, mockConfig) // eslint-disable-line no-new + + sinon.assert.calledWith(log.info, '%s span enrichment enabled', 'FlaggingProvider') + }) + + it('should log info message when span enrichment is disabled', () => { + mockConfig.experimental.flaggingProvider.spanEnrichment.enabled = false + new FlaggingProvider(mockTracer, mockConfig) // eslint-disable-line no-new + + sinon.assert.calledWith(log.info, '%s span enrichment disabled', 'FlaggingProvider') + }) + }) + + describe('onClose', () => { + it('should call destroy on SpanEnrichmentHook when enabled', () => { + const provider = new FlaggingProvider(mockTracer, mockConfig) + + provider.onClose() + + sinon.assert.calledOnce(mockSpanEnrichmentHook.destroy) + }) + + it('should not throw when span enrichment is disabled', () => { + mockConfig.experimental.flaggingProvider.spanEnrichment.enabled = false + const provider = new FlaggingProvider(mockTracer, mockConfig) + + provider.onClose() + + sinon.assert.notCalled(mockSpanEnrichmentHook.destroy) + }) }) describe('inheritance', () => { diff --git a/packages/dd-trace/test/openfeature/flagging_provider_timeout.spec.js b/packages/dd-trace/test/openfeature/flagging_provider_timeout.spec.js index fbdd9bf37c..1449280d19 100644 --- a/packages/dd-trace/test/openfeature/flagging_provider_timeout.spec.js +++ b/packages/dd-trace/test/openfeature/flagging_provider_timeout.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { ProviderEvents } = require('@openfeature/server-sdk') const { afterEach, beforeEach, describe, it } = require('mocha') @@ -307,8 +308,8 @@ describe('FlaggingProvider Initialization Timeout', () => { assert.strictEqual(setErrorSpy.calledOnce, true) const errorArg = setErrorSpy.firstCall.args[0] assert.ok(errorArg instanceof Error) - assert.ok(errorArg.message.includes('Initialization timeout')) - assert.ok(errorArg.message.includes('6000ms')) + assert.ok(errorArg.message.includes('Initialization timeout'), `Got: ${inspect(errorArg.message)}`) + assert.ok(errorArg.message.includes('6000ms'), `Got: ${inspect(errorArg.message)}`) }) it('should use config object value over environment variables', async () => { diff --git a/packages/dd-trace/test/openfeature/noop.spec.js b/packages/dd-trace/test/openfeature/noop.spec.js index d80fe988af..f06bf4cb04 100644 --- a/packages/dd-trace/test/openfeature/noop.spec.js +++ b/packages/dd-trace/test/openfeature/noop.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach } = require('mocha') @@ -136,10 +137,22 @@ describe('NoopFlaggingProvider', () => { const numberResult = noopProvider.resolveNumberEvaluation('test', 42, {}, {}) const objectResult = noopProvider.resolveObjectEvaluation('test', {}, {}, {}) - assert.ok(booleanResult && typeof booleanResult.then === 'function') - assert.ok(stringResult && typeof stringResult.then === 'function') - assert.ok(numberResult && typeof numberResult.then === 'function') - assert.ok(objectResult && typeof objectResult.then === 'function') + assert.ok( + booleanResult && typeof booleanResult.then === 'function', + `Expected a thenable, got: ${inspect(booleanResult)}` + ) + assert.ok( + stringResult && typeof stringResult.then === 'function', + `Expected a thenable, got: ${inspect(stringResult)}` + ) + assert.ok( + numberResult && typeof numberResult.then === 'function', + `Expected a thenable, got: ${inspect(numberResult)}` + ) + assert.ok( + objectResult && typeof objectResult.then === 'function', + `Expected a thenable, got: ${inspect(objectResult)}` + ) }) it('should resolve promises immediately', async () => { @@ -153,7 +166,7 @@ describe('NoopFlaggingProvider', () => { ]) const duration = Date.now() - start - assert.ok(duration < 10) + assert.ok(duration < 10, `Expected ${duration} < 10`) }) }) }) diff --git a/packages/dd-trace/test/openfeature/span-enrichment-hook.spec.js b/packages/dd-trace/test/openfeature/span-enrichment-hook.spec.js new file mode 100644 index 0000000000..d9347b4d56 --- /dev/null +++ b/packages/dd-trace/test/openfeature/span-enrichment-hook.spec.js @@ -0,0 +1,461 @@ +'use strict' + +const assert = require('node:assert/strict') +const { describe, it, beforeEach, afterEach } = require('mocha') +const sinon = require('sinon') +const proxyquire = require('proxyquire') + +require('../setup/core') + +describe('SpanEnrichmentHook', () => { + let SpanEnrichmentHook + let mockTracer + let mockSpan + let mockRootSpan + let mockScope + let mockFinishChannel + let finishSubscriber + let log + + beforeEach(() => { + // Create mock spans + mockRootSpan = { + context: sinon.stub().returns({ + _parentId: null, + _trace: null, + }), + setTag: sinon.spy(), + } + + mockSpan = { + context: sinon.stub().returns({ + _parentId: 'parent-123', + _trace: { + started: [mockRootSpan, { context: () => ({ _parentId: 'parent-123' }) }], + }, + }), + setTag: sinon.spy(), + } + + mockScope = { + active: sinon.stub().returns(mockSpan), + } + + mockTracer = { + scope: sinon.stub().returns(mockScope), + } + + // Capture the subscriber function when subscribe is called + finishSubscriber = null + mockFinishChannel = { + subscribe: sinon.stub().callsFake((fn) => { + finishSubscriber = fn + }), + unsubscribe: sinon.spy(), + } + + log = { + warn: sinon.spy(), + debug: sinon.spy(), + } + + SpanEnrichmentHook = proxyquire('../../src/openfeature/span-enrichment-hook', { + 'dc-polyfill': { + channel: sinon.stub().returns(mockFinishChannel), + }, + '../log': log, + }) + }) + + afterEach(() => { + sinon.restore() + }) + + function hookContext (overrides = {}) { + return { + flagKey: 'test-flag', + context: { targetingKey: 'user-123' }, + ...overrides, + } + } + + function evalDetails (overrides = {}) { + return { + flagMetadata: { __dd_split_serial_id: 100, __dd_do_log: false }, + reason: 'TARGETING_MATCH', + value: true, + ...overrides, + } + } + + describe('constructor', () => { + it('should subscribe to span finish channel', () => { + new SpanEnrichmentHook(mockTracer) // eslint-disable-line no-new + + sinon.assert.calledOnce(mockFinishChannel.subscribe) + assert.strictEqual(typeof finishSubscriber, 'function') + }) + }) + + describe('finally()', () => { + it('should do nothing when no active span', () => { + mockScope.active.returns(null) + const hook = new SpanEnrichmentHook(mockTracer) + + hook.finally(hookContext(), evalDetails()) + + // Should not throw and should not have any state + sinon.assert.notCalled(log.warn) + }) + + it('should add serial ID when present in flagMetadata', () => { + const hook = new SpanEnrichmentHook(mockTracer) + + hook.finally(hookContext(), evalDetails({ flagMetadata: { __dd_split_serial_id: 42 } })) + + // Trigger span finish to verify state was accumulated + finishSubscriber(mockRootSpan) + + sinon.assert.called(mockRootSpan.setTag) + const tagCall = mockRootSpan.setTag.getCalls().find(c => c.args[0] === 'ffe_flags_enc') + assert.ok(tagCall, 'ffe_flags_enc tag should be set') + }) + + it('should add subject when __dd_do_log is true and targetingKey present', () => { + const hook = new SpanEnrichmentHook(mockTracer) + + hook.finally( + hookContext({ context: { targetingKey: 'user-456' } }), + evalDetails({ flagMetadata: { __dd_split_serial_id: 100, __dd_do_log: true } }) + ) + + finishSubscriber(mockRootSpan) + + const tagCall = mockRootSpan.setTag.getCalls().find(c => c.args[0] === 'ffe_subjects_enc') + assert.ok(tagCall, 'ffe_subjects_enc tag should be set') + const subjects = JSON.parse(tagCall.args[1]) + assert.strictEqual(Object.keys(subjects).length, 1) + }) + + it('should not add subject when __dd_do_log is false', () => { + const hook = new SpanEnrichmentHook(mockTracer) + + hook.finally( + hookContext({ context: { targetingKey: 'user-456' } }), + evalDetails({ flagMetadata: { __dd_split_serial_id: 100, __dd_do_log: false } }) + ) + + finishSubscriber(mockRootSpan) + + const tagCall = mockRootSpan.setTag.getCalls().find(c => c.args[0] === 'ffe_subjects_enc') + assert.strictEqual(tagCall, undefined, 'ffe_subjects_enc should not be set when doLog is false') + }) + + it('should not add subject when targetingKey is missing', () => { + const hook = new SpanEnrichmentHook(mockTracer) + + hook.finally( + hookContext({ context: {} }), + evalDetails({ flagMetadata: { __dd_split_serial_id: 100, __dd_do_log: true } }) + ) + + finishSubscriber(mockRootSpan) + + const tagCall = mockRootSpan.setTag.getCalls().find(c => c.args[0] === 'ffe_subjects_enc') + assert.strictEqual(tagCall, undefined, 'ffe_subjects_enc should not be set without targetingKey') + }) + + it('should add default when reason is DEFAULT and no serialId', () => { + const hook = new SpanEnrichmentHook(mockTracer) + + hook.finally( + hookContext({ flagKey: 'missing-flag' }), + evalDetails({ flagMetadata: {}, reason: 'DEFAULT', value: 'fallback' }) + ) + + finishSubscriber(mockRootSpan) + + const tagCall = mockRootSpan.setTag.getCalls().find(c => c.args[0] === 'ffe_runtime_defaults') + assert.ok(tagCall, 'ffe_runtime_defaults tag should be set') + const defaults = JSON.parse(tagCall.args[1]) + assert.strictEqual(defaults['missing-flag'], 'fallback') + }) + + it('should add default when reason is ERROR and no serialId', () => { + const hook = new SpanEnrichmentHook(mockTracer) + + hook.finally( + hookContext({ flagKey: 'error-flag' }), + evalDetails({ flagMetadata: {}, reason: 'ERROR', value: false }) + ) + + finishSubscriber(mockRootSpan) + + const tagCall = mockRootSpan.setTag.getCalls().find(c => c.args[0] === 'ffe_runtime_defaults') + assert.ok(tagCall, 'ffe_runtime_defaults tag should be set') + const defaults = JSON.parse(tagCall.args[1]) + assert.strictEqual(defaults['error-flag'], 'false') + }) + + it('should not add default when serialId is present', () => { + const hook = new SpanEnrichmentHook(mockTracer) + + hook.finally( + hookContext(), + evalDetails({ flagMetadata: { __dd_split_serial_id: 100 }, reason: 'DEFAULT', value: 'ignored' }) + ) + + finishSubscriber(mockRootSpan) + + const tagCall = mockRootSpan.setTag.getCalls().find(c => c.args[0] === 'ffe_runtime_defaults') + assert.strictEqual(tagCall, undefined, 'ffe_runtime_defaults should not be set when serialId present') + }) + + it('should accumulate multiple flag evaluations on same span', () => { + const hook = new SpanEnrichmentHook(mockTracer) + + hook.finally(hookContext(), evalDetails({ flagMetadata: { __dd_split_serial_id: 100 } })) + hook.finally(hookContext(), evalDetails({ flagMetadata: { __dd_split_serial_id: 200 } })) + hook.finally(hookContext(), evalDetails({ flagMetadata: { __dd_split_serial_id: 300 } })) + + finishSubscriber(mockRootSpan) + + const tagCall = mockRootSpan.setTag.getCalls().find(c => c.args[0] === 'ffe_flags_enc') + assert.ok(tagCall, 'ffe_flags_enc should be set') + // Decode to verify all 3 IDs are present + const decoded = Buffer.from(tagCall.args[1], 'base64') + // [100, 200, 300] sorted -> deltas [100, 100, 100] + assert.deepStrictEqual([...decoded], [100, 100, 100]) + }) + + it('should handle null/undefined inputs gracefully', () => { + const hook = new SpanEnrichmentHook(mockTracer) + + hook.finally(null, evalDetails()) + hook.finally(hookContext(), null) + hook.finally(hookContext(), { reason: 'TARGETING_MATCH' }) + + sinon.assert.notCalled(log.warn) + }) + + it('should catch and log errors', () => { + const hook = new SpanEnrichmentHook(mockTracer) + // Force an error by making context() throw + mockSpan.context.throws(new Error('context error')) + + hook.finally(hookContext(), evalDetails()) + + sinon.assert.calledOnce(log.warn) + assert.ok( + log.warn.firstCall.args[1].includes('context error'), + `Expected warning message to include 'context error', got: ${log.warn.firstCall.args[1]}` + ) + }) + }) + + describe('_getRootSpan()', () => { + it('should return null when no active span', () => { + mockScope.active.returns(null) + const hook = new SpanEnrichmentHook(mockTracer) + + const result = hook._getRootSpan() + + assert.strictEqual(result, null) + }) + + it('should return current span when no trace object', () => { + mockSpan.context.returns({ _parentId: 'parent', _trace: null }) + const hook = new SpanEnrichmentHook(mockTracer) + + const result = hook._getRootSpan() + + assert.strictEqual(result, mockSpan) + }) + + it('should return current span when trace.started is missing', () => { + mockSpan.context.returns({ _parentId: 'parent', _trace: {} }) + const hook = new SpanEnrichmentHook(mockTracer) + + const result = hook._getRootSpan() + + assert.strictEqual(result, mockSpan) + }) + + it('should find root span in trace.started array', () => { + const hook = new SpanEnrichmentHook(mockTracer) + + const result = hook._getRootSpan() + + assert.strictEqual(result, mockRootSpan) + }) + + it('should return first span in trace.started as root', () => { + const firstSpan = { context: () => ({ _parentId: null }) } + const secondSpan = { context: () => ({ _parentId: 'p1' }) } + mockSpan.context.returns({ + _parentId: 'parent', + _trace: { + started: [firstSpan, secondSpan], + }, + }) + const hook = new SpanEnrichmentHook(mockTracer) + + const result = hook._getRootSpan() + + assert.strictEqual(result, firstSpan) + }) + }) + + describe('_getOrCreateState()', () => { + it('should create new state for span', () => { + const hook = new SpanEnrichmentHook(mockTracer) + + const state1 = hook._getOrCreateState(mockSpan) + const state2 = hook._getOrCreateState(mockSpan) + + assert.strictEqual(state1, state2, 'Should return same state for same span') + }) + + it('should create different state for different spans', () => { + const hook = new SpanEnrichmentHook(mockTracer) + const otherSpan = { context: () => ({}) } + + const state1 = hook._getOrCreateState(mockSpan) + const state2 = hook._getOrCreateState(otherSpan) + + assert.notStrictEqual(state1, state2, 'Should return different state for different spans') + }) + }) + + describe('_onSpanFinish()', () => { + it('should do nothing when span has no state', () => { + new SpanEnrichmentHook(mockTracer) // eslint-disable-line no-new + + finishSubscriber(mockSpan) + + sinon.assert.notCalled(mockSpan.setTag) + }) + + it('should do nothing when state has no data', () => { + const hook = new SpanEnrichmentHook(mockTracer) + // Create empty state + hook._getOrCreateState(mockSpan) + + finishSubscriber(mockSpan) + + sinon.assert.notCalled(mockSpan.setTag) + }) + + it('should apply all tag types when present', () => { + const hook = new SpanEnrichmentHook(mockTracer) + const state = hook._getOrCreateState(mockSpan) + state.addSerialId(100) + state.addSubject('user-123', 100) + state.addDefault('flag-key', 'value') + + finishSubscriber(mockSpan) + + assert.strictEqual(mockSpan.setTag.callCount, 3) + const tagNames = mockSpan.setTag.getCalls().map(c => c.args[0]) + assert.ok( + tagNames.includes('ffe_flags_enc'), + `Expected tagNames to include 'ffe_flags_enc', got: ${tagNames}` + ) + assert.ok( + tagNames.includes('ffe_subjects_enc'), + `Expected tagNames to include 'ffe_subjects_enc', got: ${tagNames}` + ) + assert.ok( + tagNames.includes('ffe_runtime_defaults'), + `Expected tagNames to include 'ffe_runtime_defaults', got: ${tagNames}` + ) + }) + + it('should clean up state after applying tags', () => { + const hook = new SpanEnrichmentHook(mockTracer) + const state = hook._getOrCreateState(mockSpan) + state.addSerialId(100) + + finishSubscriber(mockSpan) + + // Second call should do nothing since state was deleted + mockSpan.setTag.resetHistory() + finishSubscriber(mockSpan) + + sinon.assert.notCalled(mockSpan.setTag) + }) + + it('should catch and log errors when applying tags', () => { + const hook = new SpanEnrichmentHook(mockTracer) + const state = hook._getOrCreateState(mockSpan) + state.addSerialId(100) + mockSpan.setTag = sinon.stub().throws(new Error('setTag failed')) + + finishSubscriber(mockSpan) + + sinon.assert.calledOnce(log.warn) + assert.ok( + log.warn.firstCall.args[1].includes('setTag failed'), + `Expected warning message to include 'setTag failed', got: ${log.warn.firstCall.args[1]}` + ) + }) + + it('should clean up state even when setTag throws', () => { + const hook = new SpanEnrichmentHook(mockTracer) + const state = hook._getOrCreateState(mockSpan) + state.addSerialId(100) + mockSpan.setTag = sinon.stub().throws(new Error('setTag failed')) + + finishSubscriber(mockSpan) + + // State should be cleaned up even after error + mockSpan.setTag = sinon.spy() // Reset to non-throwing + finishSubscriber(mockSpan) + + sinon.assert.notCalled(mockSpan.setTag) + }) + + it('should not set tag when value is falsy', () => { + const hook = new SpanEnrichmentHook(mockTracer) + const state = hook._getOrCreateState(mockSpan) + // Mock toSpanTags to return an empty string value + state.toSpanTags = () => ({ ffe_flags_enc: '', ffe_runtime_defaults: null }) + + finishSubscriber(mockSpan) + + sinon.assert.notCalled(mockSpan.setTag) + }) + + it('should skip falsy values but set truthy values', () => { + const hook = new SpanEnrichmentHook(mockTracer) + const state = hook._getOrCreateState(mockSpan) + // Add data so hasData() returns true + state.addSerialId(100) + // Mock toSpanTags to return mixed truthy/falsy values + state.toSpanTags = () => ({ + ffe_flags_enc: 'validValue', + ffe_subjects_enc: '', + ffe_runtime_defaults: null, + }) + + finishSubscriber(mockSpan) + + // Should only set the truthy value + sinon.assert.calledOnce(mockSpan.setTag) + sinon.assert.calledWith(mockSpan.setTag, 'ffe_flags_enc', 'validValue') + }) + }) + + describe('destroy()', () => { + it('should unsubscribe from finish channel', () => { + const hook = new SpanEnrichmentHook(mockTracer) + const subscribedFn = finishSubscriber + + hook.destroy() + + sinon.assert.calledOnce(mockFinishChannel.unsubscribe) + // Verify the same function that was subscribed is unsubscribed + sinon.assert.calledWith(mockFinishChannel.unsubscribe, subscribedFn) + }) + }) +}) diff --git a/packages/dd-trace/test/openfeature/span-enrichment.spec.js b/packages/dd-trace/test/openfeature/span-enrichment.spec.js new file mode 100644 index 0000000000..7ba9ffa106 --- /dev/null +++ b/packages/dd-trace/test/openfeature/span-enrichment.spec.js @@ -0,0 +1,217 @@ +'use strict' + +const assert = require('node:assert/strict') +const { describe, it, beforeEach } = require('mocha') + +require('../setup/core') + +const { + SpanEnrichmentState, + MAX_SERIAL_IDS, + MAX_SUBJECTS, + MAX_EXPERIMENTS_PER_SUBJECT, + MAX_DEFAULTS, + MAX_DEFAULT_VALUE_LENGTH, +} = require('../../src/openfeature/span-enrichment') + +describe('SpanEnrichmentState', () => { + let state + + beforeEach(() => { + state = new SpanEnrichmentState() + }) + + describe('addSerialId()', () => { + it('should add serial IDs', () => { + assert.strictEqual(state.addSerialId(100), true) + assert.strictEqual(state.addSerialId(200), true) + assert.strictEqual(state.hasData(), true) + }) + + it('should handle duplicate serial IDs (Set behavior)', () => { + state.addSerialId(100) + state.addSerialId(100) + const tags = state.toSpanTags() + // Only one 100 should be encoded + const decoded = Buffer.from(tags.ffe_flags_enc, 'base64') + assert.deepStrictEqual([...decoded], [100]) + }) + + it('should enforce MAX_SERIAL_IDS limit', () => { + for (let i = 0; i < MAX_SERIAL_IDS; i++) { + assert.strictEqual(state.addSerialId(i), true) + } + // 129th should fail + assert.strictEqual(state.addSerialId(999), false) + }) + }) + + describe('addSubject()', () => { + it('should add subjects with hashed targeting key', () => { + assert.strictEqual(state.addSubject('user-123', 100), true) + const tags = state.toSpanTags() + assert.ok(tags.ffe_subjects_enc) + const subjects = JSON.parse(tags.ffe_subjects_enc) + // Should have one key (hashed) + assert.strictEqual(Object.keys(subjects).length, 1) + }) + + it('should accumulate serial IDs for same subject', () => { + state.addSubject('user-123', 100) + state.addSubject('user-123', 200) + const tags = state.toSpanTags() + const subjects = JSON.parse(tags.ffe_subjects_enc) + // Should still have one subject + assert.strictEqual(Object.keys(subjects).length, 1) + // The encoded value should contain both serial IDs + const key = Object.keys(subjects)[0] + const decoded = Buffer.from(subjects[key], 'base64') + // [100, 200] sorted -> deltas [100, 100] + assert.deepStrictEqual([...decoded], [100, 100]) + }) + + it('should enforce MAX_SUBJECTS limit', () => { + for (let i = 0; i < MAX_SUBJECTS; i++) { + assert.strictEqual(state.addSubject(`user-${i}`, i), true) + } + // 11th subject should fail (MAX_SUBJECTS = 10) + assert.strictEqual(state.addSubject('user-new', 999), false) + }) + + it('should allow adding serial IDs to existing subject when below per-subject limit', () => { + for (let i = 0; i < MAX_SUBJECTS; i++) { + state.addSubject(`user-${i}`, i) + } + // Adding to existing subject should still work (until per-subject limit) + assert.strictEqual(state.addSubject('user-0', 999), true) + }) + + it('should enforce MAX_EXPERIMENTS_PER_SUBJECT limit', () => { + // Add max experiments for one subject + for (let i = 0; i < MAX_EXPERIMENTS_PER_SUBJECT; i++) { + assert.strictEqual(state.addSubject('user-0', i), true) + } + // 21st experiment for same subject should fail + assert.strictEqual(state.addSubject('user-0', 999), false) + }) + }) + + describe('addDefault()', () => { + it('should add defaults', () => { + assert.strictEqual(state.addDefault('my-flag', 'my-value'), true) + const tags = state.toSpanTags() + const defaults = JSON.parse(tags.ffe_runtime_defaults) + assert.strictEqual(defaults['my-flag'], 'my-value') + }) + + it('should truncate values to MAX_DEFAULT_VALUE_LENGTH', () => { + const longValue = 'x'.repeat(100) + state.addDefault('my-flag', longValue) + const tags = state.toSpanTags() + const defaults = JSON.parse(tags.ffe_runtime_defaults) + assert.strictEqual(defaults['my-flag'].length, MAX_DEFAULT_VALUE_LENGTH) + }) + + it('should enforce MAX_DEFAULTS limit', () => { + for (let i = 0; i < MAX_DEFAULTS; i++) { + assert.strictEqual(state.addDefault(`flag-${i}`, `value-${i}`), true) + } + // 6th should fail + assert.strictEqual(state.addDefault('flag-new', 'value-new'), false) + }) + + it('should not add duplicate flag keys', () => { + state.addDefault('my-flag', 'value1') + assert.strictEqual(state.addDefault('my-flag', 'value2'), true) + const tags = state.toSpanTags() + const defaults = JSON.parse(tags.ffe_runtime_defaults) + // Should still have first value + assert.strictEqual(defaults['my-flag'], 'value1') + }) + + it('should handle non-string default values', () => { + state.addDefault('bool-flag', true) + state.addDefault('num-flag', 42) + const tags = state.toSpanTags() + const defaults = JSON.parse(tags.ffe_runtime_defaults) + assert.strictEqual(defaults['bool-flag'], 'true') + assert.strictEqual(defaults['num-flag'], '42') + }) + }) + + describe('hasData()', () => { + it('should return false for empty state', () => { + assert.strictEqual(state.hasData(), false) + }) + + it('should return true when serial IDs present', () => { + state.addSerialId(100) + assert.strictEqual(state.hasData(), true) + }) + + it('should return true when defaults present', () => { + state.addDefault('flag', 'value') + assert.strictEqual(state.hasData(), true) + }) + + it('should return false when only subjects present (edge case)', () => { + // Subjects without serial IDs shouldn't happen in practice + // but hasData checks serialIds and defaults only + state.addSubject('user', 100) + // This actually adds to serialIds too via the subject tracking + // Let's verify hasData logic directly + const emptyState = new SpanEnrichmentState() + assert.strictEqual(emptyState.hasData(), false) + }) + }) + + describe('toSpanTags()', () => { + it('should return empty object when no data', () => { + const tags = state.toSpanTags() + assert.deepStrictEqual(tags, {}) + }) + + it('should include ffe_flags_enc when serial IDs present', () => { + state.addSerialId(100) + const tags = state.toSpanTags() + assert.ok(tags.ffe_flags_enc) + assert.strictEqual(typeof tags.ffe_flags_enc, 'string') + }) + + it('should include ffe_subjects_enc when subjects present', () => { + state.addSubject('user', 100) + const tags = state.toSpanTags() + assert.ok(tags.ffe_subjects_enc) + const parsed = JSON.parse(tags.ffe_subjects_enc) + assert.strictEqual(typeof parsed, 'object') + }) + + it('should include ffe_runtime_defaults when defaults present', () => { + state.addDefault('flag', 'value') + const tags = state.toSpanTags() + assert.ok(tags.ffe_runtime_defaults) + const parsed = JSON.parse(tags.ffe_runtime_defaults) + assert.strictEqual(typeof parsed, 'object') + }) + + it('should include all tags when all data present', () => { + state.addSerialId(100) + state.addSubject('user', 100) + state.addDefault('flag', 'value') + const tags = state.toSpanTags() + assert.ok(tags.ffe_flags_enc) + assert.ok(tags.ffe_subjects_enc) + assert.ok(tags.ffe_runtime_defaults) + }) + }) +}) + +describe('constants', () => { + it('should have correct limit values', () => { + assert.strictEqual(MAX_SERIAL_IDS, 200) + assert.strictEqual(MAX_SUBJECTS, 10) + assert.strictEqual(MAX_EXPERIMENTS_PER_SUBJECT, 20) + assert.strictEqual(MAX_DEFAULTS, 5) + assert.strictEqual(MAX_DEFAULT_VALUE_LENGTH, 64) + }) +}) diff --git a/packages/dd-trace/test/openfeature/writers/exposures.spec.js b/packages/dd-trace/test/openfeature/writers/exposures.spec.js index 726fb8a4aa..3368d57e74 100644 --- a/packages/dd-trace/test/openfeature/writers/exposures.spec.js +++ b/packages/dd-trace/test/openfeature/writers/exposures.spec.js @@ -1,7 +1,7 @@ 'use strict' const assert = require('node:assert/strict') -const { format } = require('node:util') +const { format, inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') @@ -103,7 +103,7 @@ describe('OpenFeature Exposures Writer', () => { writer.append(exposureEvent) - assert.ok(writer._bufferSize > initialSize) + assert.ok(writer._bufferSize > initialSize, `Expected ${writer._bufferSize} > ${initialSize}`) }) it('should drop events when buffer is full', () => { @@ -230,9 +230,12 @@ describe('OpenFeature Exposures Writer', () => { const events = [exposureEvent] const payload = writer.makePayload(events) - assert.ok(payload !== null && typeof payload === 'object' && !Array.isArray(payload)) - assert.ok(Object.hasOwn(payload, 'context')) - assert.ok(Object.hasOwn(payload, 'exposures')) + assert.ok( + payload !== null && typeof payload === 'object' && !Array.isArray(payload), + `Expected a non-null non-array object, got: ${inspect(payload)}` + ) + assert.ok(Object.hasOwn(payload, 'context'), `Available keys: ${inspect(Object.keys(payload))}`) + assert.ok(Object.hasOwn(payload, 'exposures'), `Available keys: ${inspect(Object.keys(payload))}`) assert.strictEqual(payload.exposures?.length, 1) }) @@ -278,8 +281,11 @@ describe('OpenFeature Exposures Writer', () => { assert.deepStrictEqual(payload.context, { service: 'test-service', }) - assert.ok(!(Object.hasOwn(payload.context, 'version'))) - assert.ok(!(Object.hasOwn(payload.context, 'env'))) + assert.ok( + !(Object.hasOwn(payload.context, 'version')), + `Available keys: ${inspect(Object.keys(payload.context))}` + ) + assert.ok(!(Object.hasOwn(payload.context, 'env')), `Available keys: ${inspect(Object.keys(payload.context))}`) }) it('should handle flat format with dot notation', () => { @@ -347,9 +353,12 @@ describe('OpenFeature Exposures Writer', () => { assert.strictEqual(options.headers['X-Datadog-EVP-Subdomain'], 'event-platform-intake') const parsedPayload = JSON.parse(payload) - assert.ok(parsedPayload !== null && typeof parsedPayload === 'object' && !Array.isArray(parsedPayload)) - assert.ok(Object.hasOwn(parsedPayload, 'context')) - assert.ok(Object.hasOwn(parsedPayload, 'exposures')) + assert.ok( + parsedPayload !== null && typeof parsedPayload === 'object' && !Array.isArray(parsedPayload), + `Expected non-null non-array object, got ${inspect(parsedPayload)}` + ) + assert.ok(Object.hasOwn(parsedPayload, 'context'), `Available keys: ${inspect(Object.keys(parsedPayload))}`) + assert.ok(Object.hasOwn(parsedPayload, 'exposures'), `Available keys: ${inspect(Object.keys(parsedPayload))}`) assert.strictEqual(parsedPayload.exposures?.length, 1) assert.ok(parsedPayload.exposures[0].timestamp) assert.strictEqual(parsedPayload.context.service, 'test-service') @@ -442,7 +451,11 @@ describe('OpenFeature Exposures Writer', () => { writer.destroy() - assert(log.warn.getCalls().some(call => /dropped 5 events/.test(format(...call.args)))) + const warnCalls = log.warn.getCalls() + assert( + warnCalls.some(call => /dropped 5 events/.test(format(...call.args))), + `Got warn calls: ${inspect(warnCalls.map(c => c.args))}` + ) }) it('should prevent multiple destruction', () => { diff --git a/packages/dd-trace/test/opentelemetry/context_manager.spec.js b/packages/dd-trace/test/opentelemetry/context_manager.spec.js index 13ae9d86bf..49488c68c4 100644 --- a/packages/dd-trace/test/opentelemetry/context_manager.spec.js +++ b/packages/dd-trace/test/opentelemetry/context_manager.spec.js @@ -234,9 +234,9 @@ describe('OTel Context Manager', () => { assert.strictEqual(ddSpan._links.length, 1) assert.deepStrictEqual({ tags: { - 'my.otel.attr': ddSpan.context()._tags['my.otel.attr'], - 'my.otel.attrs': ddSpan.context()._tags['my.otel.attrs'], - 'error.message': ddSpan.context()._tags['error.message'], + 'my.otel.attr': ddSpan.context().getTag('my.otel.attr'), + 'my.otel.attrs': ddSpan.context().getTag('my.otel.attrs'), + 'error.message': ddSpan.context().getTag('error.message'), }, link: { traceId: ddSpan._links[0].context.toTraceId(true), @@ -257,7 +257,7 @@ describe('OTel Context Manager', () => { }) active.recordException(new Error('boom')) - assert.strictEqual(ddSpan.context()._tags['error.message'], 'boom') + assert.strictEqual(ddSpan.context().getTag('error.message'), 'boom') }) }) @@ -382,7 +382,7 @@ describe('OTel Context Manager', () => { const ddContext = ddSpan.context() assert.strictEqual(ddContext._name, 'dd-active') - assert.strictEqual(ddContext._tags['resource.name'], 'renamed') + assert.strictEqual(ddContext.getTag('resource.name'), 'renamed') }) }) @@ -394,7 +394,7 @@ describe('OTel Context Manager', () => { active.setStatus({ code: 2, message: 'late error' }) active.setStatus({ code: 0, message: 'late unset' }) - assert.ok(!('error.message' in ddSpan.context()._tags)) + assert.ok(!ddSpan.context().hasTag('error.message')) }) }) @@ -404,7 +404,7 @@ describe('OTel Context Manager', () => { active.setStatus({ code: 2, message: 'first error' }) active.setStatus({ code: 2, message: 'second error' }) - assert.strictEqual(ddSpan.context()._tags['error.message'], 'second error') + assert.strictEqual(ddSpan.context().getTag('error.message'), 'second error') }) }) }) @@ -425,7 +425,7 @@ describe('OTel Context Manager', () => { active.setStatus({ code: 2, message: 'after end' }) active.updateName('after end') - const tags = ddSpan.context()._tags + const tags = ddSpan.context().getTags() assert.ok(!('after.end' in tags)) assert.ok(!('after.end.batch' in tags)) assert.ok(!('error.message' in tags)) diff --git a/packages/dd-trace/test/opentelemetry/logs.spec.js b/packages/dd-trace/test/opentelemetry/logs.spec.js index f11f305c17..dca01f35f6 100644 --- a/packages/dd-trace/test/opentelemetry/logs.spec.js +++ b/packages/dd-trace/test/opentelemetry/logs.spec.js @@ -15,6 +15,21 @@ const { protoLogsService } = require('../../src/opentelemetry/otlp/protobuf_load const { getConfigFresh } = require('../helpers/config') const { assertObjectContains } = require('../../../../integration-tests/helpers') +/** + * @param {object} type protobufjs Type instance for the OTLP service message + * @param {object} message Decoded protobufjs Message + * @param {Buffer} originalPayload Raw wire bytes captured from the exporter + */ +function assertWireRoundTrip (type, message, originalPayload) { + assert(Buffer.isBuffer(originalPayload) && originalPayload.length > 0) + const reEncoded = Buffer.from(type.encode(message).finish()) + const projectOptions = { longs: String, bytes: String, enums: String } + assert.deepStrictEqual( + type.toObject(type.decode(reEncoded), projectOptions), + type.toObject(message, projectOptions), + ) +} + describe('OpenTelemetry Logs', () => { let originalEnv @@ -63,9 +78,14 @@ describe('OpenTelemetry Logs', () => { const mockReq = { write: (data) => { capturedPayload = data }, end: () => { - const decoded = protocol === 'json' - ? JSON.parse(capturedPayload.toString()) - : protoLogsService.decode(capturedPayload) + let decoded + if (protocol === 'json') { + decoded = JSON.parse(capturedPayload.toString()) + } else { + const message = protoLogsService.decode(capturedPayload) + assertWireRoundTrip(protoLogsService, message, capturedPayload) + decoded = message + } validator(decoded, capturedHeaders) validatorCalled = true }, @@ -255,8 +275,11 @@ describe('OpenTelemetry Logs', () => { process.env.DD_TRACE_OTEL_ENABLED = 'true' mockOtlpExport((decoded, capturedHeaders) => { - // Validate payload body - const actual = JSON.parse(JSON.stringify(decoded.toJSON())) + const actual = JSON.parse(JSON.stringify(protoLogsService.toObject(decoded, { + longs: String, + bytes: String, + enums: String, + }))) const attrs = actual.resourceLogs[0].resource.attributes const runtimeId = attrs.find(a => a.key === 'runtime-id').value.stringValue const clientId = attrs.find(a => a.key === '_dd.rc.client_id').value.stringValue @@ -272,15 +295,12 @@ describe('OpenTelemetry Logs', () => { { key: 'runtime-id', value: { stringValue: runtimeId } }, { key: '_dd.rc.client_id', value: { stringValue: clientId } }, ], - droppedAttributesCount: 0, }, scopeLogs: [{ scope: { name: 'test-service', version: '1.0.0', - droppedAttributesCount: 0, }, - schemaUrl: '', logRecords: [{ body: { stringValue: 'HTTP test message' }, severityText: 'ERROR', @@ -402,7 +422,10 @@ describe('OpenTelemetry Logs', () => { // Double/float body assert.notStrictEqual(logRecords[2].body.doubleValue, undefined) - assert(Math.abs(logRecords[2].body.doubleValue - 3.14159) < 0.00001) + assert( + Math.abs(logRecords[2].body.doubleValue - 3.14159) < 0.00001, + `Expected ${Math.abs(logRecords[2].body.doubleValue - 3.14159)} < 0.00001` + ) // Boolean body assert.strictEqual(logRecords[3].body.boolValue, true) diff --git a/packages/dd-trace/test/opentelemetry/metrics.spec.js b/packages/dd-trace/test/opentelemetry/metrics.spec.js index bba3846701..35f8446703 100644 --- a/packages/dd-trace/test/opentelemetry/metrics.spec.js +++ b/packages/dd-trace/test/opentelemetry/metrics.spec.js @@ -2,7 +2,7 @@ const assert = require('assert') const http = require('http') -const { format } = require('util') +const { format, inspect } = require('util') const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') @@ -14,6 +14,21 @@ const { protoMetricsService } = require('../../src/opentelemetry/otlp/protobuf_l const { getConfigFresh } = require('../helpers/config') const { DEFAULT_MAX_MEASUREMENT_QUEUE_SIZE } = require('../../src/opentelemetry/metrics/constants') +/** + * @param {object} type protobufjs Type instance for the OTLP service message + * @param {Buffer} originalPayload Raw wire bytes captured from the exporter + */ +function assertWireRoundTrip (type, originalPayload) { + assert(Buffer.isBuffer(originalPayload) && originalPayload.length > 0) + const message = type.decode(originalPayload) + const reEncoded = Buffer.from(type.encode(message).finish()) + const projectOptions = { longs: Number } + assert.deepStrictEqual( + type.toObject(type.decode(reEncoded), projectOptions), + type.toObject(message, projectOptions), + ) +} + describe('OpenTelemetry Meter Provider', () => { let originalEnv let httpStub @@ -76,12 +91,15 @@ describe('OpenTelemetry Meter Provider', () => { const contentType = capturedHeaders['Content-Type'] const isJson = contentType && contentType.includes('application/json') - const decoded = isJson - ? JSON.parse(capturedPayload.toString()) - : protoMetricsService.toObject(protoMetricsService.decode(capturedPayload), { + let decoded + if (isJson) { + decoded = JSON.parse(capturedPayload.toString()) + } else { + assertWireRoundTrip(protoMetricsService, capturedPayload) + decoded = protoMetricsService.toObject(protoMetricsService.decode(capturedPayload), { longs: Number, - defaults: false, }) + } validator(decoded, capturedHeaders) validatorCalled = true @@ -217,7 +235,7 @@ describe('OpenTelemetry Meter Provider', () => { const validator = mockOtlpExport((decoded) => { const updown = decoded.resourceMetrics[0].scopeMetrics[0].metrics[0] assert.strictEqual(updown.name, 'queue') - assert.strictEqual(updown.sum.isMonotonic, false) + assert.strictEqual(updown.sum.isMonotonic ?? false, false) assert.strictEqual(updown.sum.dataPoints[0].asInt, 7) }) @@ -236,7 +254,7 @@ describe('OpenTelemetry Meter Provider', () => { assert.strictEqual(gauge.name, 'memory') const dp = gauge.gauge.dataPoints[0] const value = dp.asDouble !== undefined ? dp.asDouble : dp.asInt - assert(value > 0) + assert(value > 0, `Expected ${value} > 0`) assert.strictEqual(dp.attributes.find(a => a.key === 'type').value.stringValue, 'heap') }) @@ -268,7 +286,7 @@ describe('OpenTelemetry Meter Provider', () => { const validator = mockOtlpExport((decoded) => { const updown = decoded.resourceMetrics[0].scopeMetrics[0].metrics[0] assert.strictEqual(updown.name, 'tasks') - assert.strictEqual(updown.sum.isMonotonic, false) + assert.strictEqual(updown.sum.isMonotonic ?? false, false) assert.strictEqual(updown.sum.dataPoints[0].asInt, 15) }) @@ -287,7 +305,7 @@ describe('OpenTelemetry Meter Provider', () => { assert.strictEqual(headers['Content-Type'], 'application/x-protobuf') const dataPoint = decoded.resourceMetrics[0].scopeMetrics[0].metrics[0].sum.dataPoints[0] assert.strictEqual(dataPoint.asInt, 5) - assert(dataPoint.timeUnixNano > 0) + assert(dataPoint.timeUnixNano > 0, `Expected ${dataPoint.timeUnixNano} > 0`) }) setupMetrics() @@ -724,7 +742,11 @@ describe('OpenTelemetry Meter Provider', () => { assert.strictEqual(meterProvider.reader.exporter.transformer.protocol, 'http/protobuf') const expectedMsg = 'OTLP gRPC protocol is not supported for metrics. ' + 'Defaulting to http/protobuf. gRPC protobuf support may be added in a future release.' - assert(warnSpy.getCalls().some(call => format(...call.args) === expectedMsg)) + const warnCalls = warnSpy.getCalls() + assert( + warnCalls.some(call => format(...call.args) === expectedMsg), + `Expected warn call ${inspect(expectedMsg)}, got: ${inspect(warnCalls.map(c => format(...c.args)))}` + ) warnSpy.restore() }) }) @@ -910,10 +932,18 @@ describe('OpenTelemetry Meter Provider', () => { obsGauge.addCallback(() => {}) obsGauge.addCallback('not a function') provider.reader.forceFlush() - assert(warnSpy.getCalls().some(call => - format(...call.args) === 'PeriodicMetricReader is shutdown. 4 measurement(s) were dropped')) + let warnCalls = warnSpy.getCalls() + const expectedShutdownMsg = 'PeriodicMetricReader is shutdown. 4 measurement(s) were dropped' + assert( + warnCalls.some(call => format(...call.args) === expectedShutdownMsg), + `Got warn calls: ${inspect(warnCalls.map(c => format(...c.args)))}` + ) provider.reader.shutdown() - assert(warnSpy.getCalls().some(call => format(...call.args) === 'PeriodicMetricReader is already shutdown')) + warnCalls = warnSpy.getCalls() + assert( + warnCalls.some(call => format(...call.args) === 'PeriodicMetricReader is already shutdown'), + `Got warn calls: ${inspect(warnCalls.map(c => format(...c.args)))}` + ) warnSpy.restore() }) }) @@ -927,9 +957,11 @@ describe('OpenTelemetry Meter Provider', () => { const warnSpy = sinon.spy(log, 'warn') const validator = mockOtlpExport((metrics) => { assert.strictEqual(countMetrics(metrics), 3) - assert(warnSpy.getCalls().some(call => - format(...call.args).includes('Metric queue exceeded limit (max: 3)') - )) + const warnCalls = warnSpy.getCalls() + assert( + warnCalls.some(call => format(...call.args).includes('Metric queue exceeded limit (max: 3)')), + `Got warn calls: ${inspect(warnCalls.map(c => format(...c.args)))}` + ) }) setupMetrics( @@ -952,7 +984,11 @@ describe('OpenTelemetry Meter Provider', () => { const validator = mockOtlpExport((metrics) => { if (++callCount === 1) { assert.strictEqual(countMetrics(metrics), 3) - assert(warnSpy.getCalls().some(call => format(...call.args).includes('Metric queue exceeded limit'))) + const warnCalls = warnSpy.getCalls() + assert( + warnCalls.some(call => format(...call.args).includes('Metric queue exceeded limit')), + `Got warn calls: ${inspect(warnCalls.map(c => format(...c.args)))}` + ) } }) @@ -977,7 +1013,11 @@ describe('OpenTelemetry Meter Provider', () => { if (!firstExport) return firstExport = false assert.strictEqual(countMetrics(metrics), 3) - assert(warnSpy.getCalls().some(call => format(...call.args).includes('Metric queue exceeded limit'))) + const warnCalls = warnSpy.getCalls() + assert( + warnCalls.some(call => format(...call.args).includes('Metric queue exceeded limit')), + `Got warn calls: ${inspect(warnCalls.map(c => format(...c.args)))}` + ) }) setupMetrics( @@ -1010,12 +1050,16 @@ describe('OpenTelemetry Meter Provider', () => { const counter1Metric = exportedMetrics.find(m => m.name === 'counter.sync') assert(counter1Metric, 'counter.sync should be exported') assert.strictEqual(counter1Metric.sum.dataPoints.length, DEFAULT_MAX_MEASUREMENT_QUEUE_SIZE) - assert(warnSpy.getCalls().some(call => { - const formatted = format(...call.args) - return formatted.includes('Metric queue exceeded limit') && - formatted.includes(`max: ${DEFAULT_MAX_MEASUREMENT_QUEUE_SIZE}`) && - formatted.includes('Dropping 2 measurements') - })) + const warnCalls = warnSpy.getCalls() + assert( + warnCalls.some(call => { + const formatted = format(...call.args) + return formatted.includes('Metric queue exceeded limit') && + formatted.includes(`max: ${DEFAULT_MAX_MEASUREMENT_QUEUE_SIZE}`) && + formatted.includes('Dropping 2 measurements') + }), + `Got warn calls: ${inspect(warnCalls.map(c => format(...c.args)))}` + ) }) setupMetrics({ DD_METRICS_OTEL_ENABLED: 'true', OTEL_METRIC_EXPORT_INTERVAL: '30000' }, false) @@ -1044,7 +1088,7 @@ describe('OpenTelemetry Meter Provider', () => { httpStub = sinon.stub(http, 'request').callsFake((options, callback) => { requestCount++ - assert(options.headers['Content-Length'] > 0) + assert(options.headers['Content-Length'] > 0, `Expected ${options.headers['Content-Length']} > 0`) const handler = (event, handler) => { handlers[event] = handler diff --git a/packages/dd-trace/test/opentelemetry/span-helpers.spec.js b/packages/dd-trace/test/opentelemetry/span-helpers.spec.js index d8ae776efc..b431b0aac3 100644 --- a/packages/dd-trace/test/opentelemetry/span-helpers.spec.js +++ b/packages/dd-trace/test/opentelemetry/span-helpers.spec.js @@ -6,6 +6,7 @@ const { describe, it } = require('mocha') require('../setup/core') +const { DD_MAJOR } = require('../../../../version') const { ERROR_MESSAGE, ERROR_STACK, ERROR_TYPE, IGNORE_OTEL_ERROR } = require('../../src/constants') const { addOtelEvent, @@ -44,7 +45,14 @@ function createMockDdSpan ({ ended = false } = {}) { events.push({ name, attributes, startTime }) }, setOperationName (name) { operationName = name }, - context () { return { _tags: tags } }, + context () { + return { + _tags: tags, + getTag: (key) => tags[key], + getTags: () => tags, + setTag: (key, value) => { tags[key] = value }, + } + }, // Read-only inspection handles for assertions. get tags () { return tags }, @@ -130,7 +138,8 @@ describe('OTel bridge helpers', () => { assert.deepStrictEqual(ddSpan.links[0].attributes, { foo: 'bar' }) }) - it('accepts the deprecated (context, attrs) form', () => { + const legacyAddLinkTest = DD_MAJOR < 6 ? it : it.skip + legacyAddLinkTest('accepts the deprecated (context, attrs) form', () => { const ddSpan = createMockDdSpan() addOtelLink( ddSpan, diff --git a/packages/dd-trace/test/opentelemetry/span.spec.js b/packages/dd-trace/test/opentelemetry/span.spec.js index 782acd4d24..e4dbe75bd3 100644 --- a/packages/dd-trace/test/opentelemetry/span.spec.js +++ b/packages/dd-trace/test/opentelemetry/span.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const { performance } = require('perf_hooks') +const { inspect } = require('node:util') const api = require('@opentelemetry/api') const { describe, it } = require('mocha') @@ -43,7 +44,7 @@ describe('OTel Span', () => { const span = makeSpan('name') const context = span._ddSpan.context() - assert.strictEqual(context._tags[SERVICE_NAME], tracer._tracer._service) + assert.strictEqual(context.getTag(SERVICE_NAME), tracer._tracer._service) assert.strictEqual(context._hostname, tracer._hostname) }) @@ -234,14 +235,14 @@ describe('OTel Span', () => { const span = makeSpan('name') const context = span._ddSpan.context() - assert.strictEqual(context._tags[RESOURCE_NAME], 'name') + assert.strictEqual(context.getTag(RESOURCE_NAME), 'name') }) it('should copy span kind to span.kind', () => { const span = makeSpan('name', { kind: api.SpanKind.CONSUMER }) const context = span._ddSpan.context() - assert.strictEqual(context._tags[SPAN_KIND], kinds.CONSUMER) + assert.strictEqual(context.getTag(SPAN_KIND), kinds.CONSUMER) }) it('should expose span context', () => { @@ -302,32 +303,32 @@ describe('OTel Span', () => { it('should set attributes', () => { const span = makeSpan('name') - const { _tags } = span._ddSpan.context() + const tags = span._ddSpan.context().getTags() span.setAttribute('foo', 'bar') - assert.strictEqual(_tags.foo, 'bar') + assert.strictEqual(tags.foo, 'bar') span.setAttributes({ baz: 'buz' }) - assert.strictEqual(_tags.baz, 'buz') + assert.strictEqual(tags.baz, 'buz') }) describe('should remap http.response.status_code', () => { it('should remap when setting attributes', () => { const span = makeSpan('name') - const { _tags } = span._ddSpan.context() + const tags = span._ddSpan.context().getTags() span.setAttributes({ 'http.response.status_code': 200 }) - assert.strictEqual(_tags['http.status_code'], '200') + assert.strictEqual(tags['http.status_code'], '200') }) it('should remap when setting singular attribute', () => { const span = makeSpan('name') - const { _tags } = span._ddSpan.context() + const tags = span._ddSpan.context().getTags() span.setAttribute('http.response.status_code', 200) - assert.strictEqual(_tags['http.status_code'], '200') + assert.strictEqual(tags['http.status_code'], '200') }) }) @@ -366,7 +367,10 @@ describe('OTel Span', () => { span.end() const formatted = spanFormat(span._ddSpan) - assert.ok(Object.hasOwn(formatted.meta, '_dd.span_links')) + assert.ok( + Object.hasOwn(formatted.meta, '_dd.span_links'), + `Available keys: ${inspect(Object.keys(formatted.meta))}` + ) const links = JSON.parse(formatted.meta['_dd.span_links']) assert.strictEqual(links.length, 1) @@ -409,19 +413,19 @@ describe('OTel Span', () => { const unset = makeSpan('name') const unsetCtx = unset._ddSpan.context() unset.setStatus({ code: 0, message: 'unset' }) - assert.ok(!(ERROR_MESSAGE in unsetCtx._tags)) + assert.ok(!unsetCtx.hasTag(ERROR_MESSAGE)) const ok = makeSpan('name') const okCtx = ok._ddSpan.context() ok.setStatus({ code: 1, message: 'ok' }) - assert.ok(!(ERROR_MESSAGE in okCtx._tags)) - assert.ok(!(IGNORE_OTEL_ERROR in okCtx._tags)) + assert.ok(!okCtx.hasTag(ERROR_MESSAGE)) + assert.ok(!okCtx.hasTag(IGNORE_OTEL_ERROR)) const error = makeSpan('name') const errorCtx = error._ddSpan.context() error.setStatus({ code: 2, message: 'error' }) - assert.strictEqual(errorCtx._tags[ERROR_MESSAGE], 'error') - assert.strictEqual(errorCtx._tags[IGNORE_OTEL_ERROR], false) + assert.strictEqual(errorCtx.getTag(ERROR_MESSAGE), 'error') + assert.strictEqual(errorCtx.getTag(IGNORE_OTEL_ERROR), false) }) it('should record exceptions', () => { @@ -433,11 +437,11 @@ describe('OTel Span', () => { const datenow = Date.now() span.recordException(error, datenow) - const { _tags } = span._ddSpan.context() - assert.strictEqual(_tags[ERROR_TYPE], error.name) - assert.strictEqual(_tags[ERROR_MESSAGE], error.message) - assert.strictEqual(_tags[ERROR_STACK], error.stack) - assert.strictEqual(_tags[IGNORE_OTEL_ERROR], true) + const tags = span._ddSpan.context().getTags() + assert.strictEqual(tags[ERROR_TYPE], error.name) + assert.strictEqual(tags[ERROR_MESSAGE], error.message) + assert.strictEqual(tags[ERROR_STACK], error.stack) + assert.strictEqual(tags[IGNORE_OTEL_ERROR], true) const events = span._ddSpan._events assert.strictEqual(events.length, 1) @@ -465,7 +469,7 @@ describe('OTel Span', () => { // Keep the error set to 1 formatted = spanFormat(span._ddSpan) assert.strictEqual(formatted.error, 1) - assert.ok(Object.hasOwn(formatted, 'meta')) + assert.ok(Object.hasOwn(formatted, 'meta'), `Available keys: ${inspect(Object.keys(formatted))}`) assert.strictEqual(formatted.meta['error.message'], 'foobar') }) @@ -485,10 +489,10 @@ describe('OTel Span', () => { const error = new TestError() span.recordException(error) - const { _tags } = span._ddSpan.context() - assert.strictEqual(_tags[ERROR_TYPE], error.name) - assert.strictEqual(_tags[ERROR_MESSAGE], error.message) - assert.strictEqual(_tags[ERROR_STACK], error.stack) + const tags = span._ddSpan.context().getTags() + assert.strictEqual(tags[ERROR_TYPE], error.name) + assert.strictEqual(tags[ERROR_MESSAGE], error.message) + assert.strictEqual(tags[ERROR_STACK], error.stack) const events = span._ddSpan._events assert.strictEqual(events.length, 1) @@ -507,41 +511,44 @@ describe('OTel Span', () => { const span = makeSpan('name') span.end() - const { _tags } = span._ddSpan.context() + const tags = span._ddSpan.context().getTags() span.setStatus({ code: 2, message: 'error' }) - assert.ok(!(ERROR_MESSAGE in _tags) || _tags[ERROR_MESSAGE] !== 'error') + assert.ok( + !(ERROR_MESSAGE in tags) || tags[ERROR_MESSAGE] !== 'error', + `Got ${ERROR_MESSAGE}: ${inspect(tags[ERROR_MESSAGE])}` + ) }) describe('setStatus precedence (OTel spec)', () => { it('OK locks the status against subsequent ERROR and UNSET writes', () => { const span = makeSpan('name') - const { _tags } = span._ddSpan.context() + const tags = span._ddSpan.context().getTags() span.setStatus({ code: 1 }) span.setStatus({ code: 2, message: 'late error' }) span.setStatus({ code: 0, message: 'late unset' }) - assert.ok(!(ERROR_MESSAGE in _tags)) + assert.ok(!(ERROR_MESSAGE in tags)) }) it('ERROR can be overridden by a later ERROR with a fresh message', () => { const span = makeSpan('name') - const { _tags } = span._ddSpan.context() + const tags = span._ddSpan.context().getTags() span.setStatus({ code: 2, message: 'first error' }) span.setStatus({ code: 2, message: 'second error' }) - assert.strictEqual(_tags[ERROR_MESSAGE], 'second error') + assert.strictEqual(tags[ERROR_MESSAGE], 'second error') }) it('UNSET is always a no-op even before any successful write', () => { const span = makeSpan('name') - const { _tags } = span._ddSpan.context() + const tags = span._ddSpan.context().getTags() span.setStatus({ code: 0, message: 'ignored' }) - assert.ok(!(ERROR_MESSAGE in _tags)) + assert.ok(!(ERROR_MESSAGE in tags)) }) }) @@ -558,11 +565,11 @@ describe('OTel Span', () => { span.recordException(new Error('after end')) span.updateName('after end') - const { _tags } = span._ddSpan.context() - assert.ok(!('after.end' in _tags)) - assert.ok(!('after.end.batch' in _tags)) - assert.ok(!(ERROR_MESSAGE in _tags)) - assert.ok(!(ERROR_TYPE in _tags)) + const tags = span._ddSpan.context().getTags() + assert.ok(!('after.end' in tags)) + assert.ok(!('after.end.batch' in tags)) + assert.ok(!(ERROR_MESSAGE in tags)) + assert.ok(!(ERROR_TYPE in tags)) assert.strictEqual(span._ddSpan._links.length, 0) assert.strictEqual(span._ddSpan._events.length, 0) }) @@ -578,7 +585,7 @@ describe('OTel Span', () => { assert.strictEqual(span.ended, true) assert.strictEqual(span.isRecording(), false) - assert.ok(Object.hasOwn(span._ddSpan, '_duration')) + assert.ok(Object.hasOwn(span._ddSpan, '_duration'), `Available keys: ${inspect(Object.keys(span._ddSpan))}`) }) it('should trigger span processor events', () => { diff --git a/packages/dd-trace/test/opentelemetry/tracer.spec.js b/packages/dd-trace/test/opentelemetry/tracer.spec.js index 7e9521153f..38d0b83226 100644 --- a/packages/dd-trace/test/opentelemetry/tracer.spec.js +++ b/packages/dd-trace/test/opentelemetry/tracer.spec.js @@ -71,7 +71,7 @@ describe('OTel Tracer', () => { }) const ddSpanContext = span._ddSpan.context() - assert.strictEqual(ddSpanContext._tags.foo, 'bar') + assert.strictEqual(ddSpanContext.getTag('foo'), 'bar') }) it('returns a non-recording span when the inner tracer is the noop', () => { diff --git a/packages/dd-trace/test/opentracing/propagation/log.spec.js b/packages/dd-trace/test/opentracing/propagation/log.spec.js index b69fdd7918..52000b051e 100644 --- a/packages/dd-trace/test/opentracing/propagation/log.spec.js +++ b/packages/dd-trace/test/opentracing/propagation/log.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach } = require('mocha') @@ -41,7 +42,7 @@ describe('LogPropagator', () => { propagator.inject(spanContext, carrier) - assert.ok(Object.hasOwn(carrier, 'dd')) + assert.ok(Object.hasOwn(carrier, 'dd'), `Available keys: ${inspect(Object.keys(carrier))}`) assert.strictEqual(carrier.dd.trace_id, '123') assert.strictEqual(carrier.dd.span_id, '456') }) @@ -76,7 +77,7 @@ describe('LogPropagator', () => { propagator.inject(spanContext, carrier) - assert.ok(Object.hasOwn(carrier, 'dd')) + assert.ok(Object.hasOwn(carrier, 'dd'), `Available keys: ${inspect(Object.keys(carrier))}`) assert.strictEqual(carrier.dd.trace_id, '87654321876543211234567812345678') assert.strictEqual(carrier.dd.span_id, '456') }) @@ -96,7 +97,7 @@ describe('LogPropagator', () => { propagator.inject(spanContext, carrier) - assert.ok(Object.hasOwn(carrier, 'dd')) + assert.ok(Object.hasOwn(carrier, 'dd'), `Available keys: ${inspect(Object.keys(carrier))}`) assert.strictEqual(carrier.dd.trace_id, '4e2a9c1573d240b1a3b7e3c1d4c2f9a7') assert.strictEqual(carrier.dd.span_id, '456') }) @@ -116,7 +117,7 @@ describe('LogPropagator', () => { propagator.inject(spanContext, carrier) - assert.ok(Object.hasOwn(carrier, 'dd')) + assert.ok(Object.hasOwn(carrier, 'dd'), `Available keys: ${inspect(Object.keys(carrier))}`) assert.strictEqual(carrier.dd.trace_id, '123') assert.strictEqual(carrier.dd.span_id, '456') }) @@ -136,10 +137,19 @@ describe('LogPropagator', () => { propagator.inject(spanContext, carrier) - assert.ok(Object.hasOwn(carrier, 'dd')) + assert.ok(Object.hasOwn(carrier, 'dd'), `Available keys: ${inspect(Object.keys(carrier))}`) assert.strictEqual(carrier.dd.trace_id, '123') assert.strictEqual(carrier.dd.span_id, '456') }) + + it('should not assign dd when no span, service, env, or version is set', () => { + propagator = new LogPropagator({}) + const carrier = {} + + propagator.inject(null, carrier) + + assert.strictEqual(carrier.dd, undefined) + }) }) describe('extract', () => { 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 ffa8e77ba8..8d47b952a0 100644 --- a/packages/dd-trace/test/opentracing/propagation/text_map.spec.js +++ b/packages/dd-trace/test/opentracing/propagation/text_map.spec.js @@ -1,8 +1,9 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') -const { describe, it, beforeEach } = require('mocha') +const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') const proxyquire = require('proxyquire') const { channel } = require('dc-polyfill') @@ -51,6 +52,7 @@ describe('TextMapPropagator', () => { beforeEach(() => { log = { debug: sinon.spy(), + warn: sinon.spy(), } telemetryMetrics = { manager: { @@ -163,7 +165,10 @@ describe('TextMapPropagator', () => { propagator.inject(spanContext, carrier) assert.strictEqual(carrier['ot-baggage-sentry-release'], encodeURIComponent(value)) - assert.ok(!carrier['ot-baggage-sentry-release'].includes('\n')) + assert.ok( + !carrier['ot-baggage-sentry-release'].includes('\n'), + `Got: ${inspect(carrier['ot-baggage-sentry-release'])}` + ) }) it('should handle special characters in baggage', () => { @@ -1295,7 +1300,7 @@ describe('TextMapPropagator', () => { propagator.extract(carrier) const baggageItems = getAllBaggageItems() - assert.ok(Object.isFrozen(baggageItems)) + assert.ok(Object.isFrozen(baggageItems), `Expected isFrozen, got ${inspect(baggageItems)}`) assert.throws(() => { baggageItems.foo = 'tampered' }, TypeError) assert.throws(() => { baggageItems.added = 'value' }, TypeError) }) @@ -1903,6 +1908,10 @@ describe('TextMapPropagator', () => { } }) + afterEach(() => { + delete process.env.DD_TRACE_PROPAGATION_BEHAVIOR_EXTRACT + }) + it('should reset span links when Trace_Propagation_Behavior_Extract is set to ignore', () => { process.env.DD_TRACE_PROPAGATION_BEHAVIOR_EXTRACT = 'ignore' config = getConfigFresh({ @@ -1993,5 +2002,202 @@ describe('TextMapPropagator', () => { assert.strictEqual(extracted.toSpanId(), '456') }) }) + + describe('b3 extractor cheap early-return', () => { + let testPropagator + + beforeEach(() => { + config = getConfigFresh({ + tracePropagationStyle: { extract: ['b3 single header', 'b3multi'] }, + }) + testPropagator = new TextMapPropagator(config) + }) + + it('returns undefined without throwing when the b3 single-header carrier is empty', () => { + assert.strictEqual(testPropagator._extractB3SingleContext({}), undefined) + }) + + 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: ['0'] }), undefined) + assert.strictEqual(testPropagator._extractB3SingleContext({ b3: undefined }), undefined) + }) + + it('still parses a real b3 single header', () => { + const context = testPropagator._extractB3SingleContext({ + b3: '1111aaaa2222bbbb-3333cccc4444dddd-1', + }) + + assert.strictEqual(context.toTraceId(true), '0000000000000000' + '1111aaaa2222bbbb') + 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('still extracts when only the b3 sampled flag is present', () => { + const b3 = testPropagator._extractB3MultipleHeaders({ 'x-b3-sampled': '1' }) + + assert.deepStrictEqual(b3, { 'x-b3-sampled': '1' }) + }) + + it('still extracts a full b3-multi carrier', () => { + const b3 = testPropagator._extractB3MultipleHeaders({ + 'x-b3-traceid': '1111aaaa2222bbbb', + 'x-b3-spanid': '3333cccc4444dddd', + 'x-b3-sampled': '1', + }) + + assert.deepStrictEqual(b3, { + 'x-b3-traceid': '1111aaaa2222bbbb', + 'x-b3-spanid': '3333cccc4444dddd', + 'x-b3-sampled': '1', + }) + }) + }) + + describe('legacy baggage extractor cheap key scan', () => { + // Regression for the regex-per-carrier-key shape that previously ran + // `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() + }) + + it('skips keys that do not start with ot-baggage-', () => { + propagator._extractLegacyBaggageItems({ + 'x-datadog-trace-id': '123', + 'x-datadog-parent-id': '456', + 'x-some-unrelated-header': 'value', + }, baggageContext) + assert.deepStrictEqual(baggageContext._baggageItems, {}) + }) + + it('ignores uppercase prefixes (case-sensitive)', () => { + propagator._extractLegacyBaggageItems({ + 'OT-BAGGAGE-uppercase': 'ignored', + 'Ot-Baggage-Mixed': 'ignored', + }, baggageContext) + assert.deepStrictEqual(baggageContext._baggageItems, {}) + }) + + it('extracts every ot-baggage- prefixed key', () => { + propagator._extractLegacyBaggageItems({ + 'ot-baggage-foo': 'bar', + 'ot-baggage-x': 'y', + 'ot-baggage-multi-dash': 'still-works', + }, baggageContext) + assert.deepStrictEqual(baggageContext._baggageItems, { + foo: 'bar', + x: 'y', + 'multi-dash': 'still-works', + }) + }) + + it('skips the bare ot-baggage- prefix without a suffix', () => { + propagator._extractLegacyBaggageItems({ + 'ot-baggage-': 'ignored', + 'ot-baggage': 'ignored', + 'ot-baggage-foo': 'bar', + }, baggageContext) + assert.deepStrictEqual(baggageContext._baggageItems, { foo: 'bar' }) + }) + + it('skips the entire scan when legacyBaggageEnabled is false', () => { + const disabledConfig = getConfigFresh({ legacyBaggageEnabled: false }) + const disabledPropagator = new TextMapPropagator(disabledConfig) + disabledPropagator._extractLegacyBaggageItems({ + 'ot-baggage-foo': 'bar', + }, baggageContext) + assert.deepStrictEqual(baggageContext._baggageItems, {}) + }) + }) + + describe('extract dispatch table', () => { + it('skips the warn for the silent baggage entry', () => { + propagator._config.tracePropagationStyle.extract = ['baggage'] + + 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'] + + 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'] + + const extracted = propagator.extract({ + 'x-datadog-trace-id': '123', + 'x-datadog-parent-id': '456', + }) + + assert.strictEqual(extracted.toTraceId(), '123') + assert.strictEqual(extracted.toSpanId(), '456') + sinon.assert.calledOnceWithExactly(log.warn, 'Unknown propagation style:', 'unknown_style') + }) + }) + + 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': '' }) + + assert.strictEqual(b3, undefined) + }) + + it('returns undefined when invalid trace/span ids pair with a falsy sampled value', () => { + const b3 = propagator._extractB3MultipleHeaders({ + '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) + }) + }) + + 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', + }) + + assert.strictEqual(extracted, null) + }) + + it('extract() falls back to the live carrier when SQSD JSON is malformed', () => { + const extracted = propagator.extract({ + 'x-aws-sqsd-attr-_datadog': '{not valid json', + 'x-datadog-trace-id': '123', + 'x-datadog-parent-id': '456', + }) + + assert.strictEqual(extracted.toTraceId(), '123') + assert.strictEqual(extracted.toSpanId(), '456') + }) + }) }) }) diff --git a/packages/dd-trace/test/opentracing/span.spec.js b/packages/dd-trace/test/opentracing/span.spec.js index 9d03918ed7..f9c7af4d83 100644 --- a/packages/dd-trace/test/opentracing/span.spec.js +++ b/packages/dd-trace/test/opentracing/span.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') @@ -9,10 +10,13 @@ const proxyquire = require('proxyquire') const { assertObjectContains } = require('../../../../integration-tests/helpers') require('../setup/core') +const { MANUAL_KEEP } = require('../../../../ext/tags') +const { DD_MAJOR } = require('../../../../version') const getConfig = require('../../src/config') const TextMapPropagator = require('../../src/opentracing/propagation/text_map') const startCh = channel('dd-trace:span:start') +const tagsUpdateCh = channel('dd-trace:span:tags:update') describe('Span', () => { let Span @@ -160,8 +164,9 @@ describe('Span', () => { }) assert.deepStrictEqual(span.context()._traceId, '123') - assert.ok(Object.hasOwn(span.context()._trace.tags, '_dd.p.tid')) - assert.match(span.context()._trace.tags['_dd.p.tid'], /^[a-f0-9]{8}0{8}$/) + const traceTags = span.context()._trace.tags + assert.ok(Object.hasOwn(traceTags, '_dd.p.tid'), `Available keys: ${inspect(Object.keys(traceTags))}`) + assert.match(traceTags['_dd.p.tid'], /^[a-f0-9]{8}0{8}$/) }) it('should be published via dd-trace:span:start channel', () => { @@ -216,7 +221,10 @@ describe('Span', () => { assert.ok('foo' in span.context()._baggageItems) assert.strictEqual(span.context()._baggageItems.foo, 'bar') - assert.ok(!('foo' in parent._baggageItems) || parent._baggageItems.foo !== 'bar') + assert.ok( + !('foo' in parent._baggageItems) || parent._baggageItems.foo !== 'bar', + `Got parent._baggageItems: ${inspect(parent._baggageItems)}` + ) }) it('should pass baggage items to future causal spans', () => { @@ -245,8 +253,8 @@ describe('Span', () => { span = new Span(tracer, processor, prioritySampler, { operationName: 'operation' }) const span2 = new Span(tracer, processor, prioritySampler, { operationName: 'operation' }) - span.addLink(span2.context()) - assert.ok(Object.hasOwn(span, '_links')) + span.addLink({ context: span2.context() }) + assert.ok(Object.hasOwn(span, '_links'), `Available keys: ${inspect(Object.keys(span))}`) assert.strictEqual(span._links.length, 1) }) @@ -258,7 +266,7 @@ describe('Span', () => { foo: 'bar', baz: 'qux', } - span.addLink(span2.context(), attributes) + span.addLink({ context: span2.context(), attributes }) assert.deepStrictEqual(span._links[0].attributes, attributes) }) @@ -273,7 +281,7 @@ describe('Span', () => { qux: [1, 2, 3], } - span.addLink(span2.context(), attributes) + span.addLink({ context: span2.context(), attributes }) assert.deepStrictEqual(span._links[0].attributes, { foo: 'true', bar: 'hi', @@ -293,12 +301,22 @@ describe('Span', () => { baz: 'valid', } - span.addLink(span2.context(), attributes) + span.addLink({ context: span2.context(), attributes }) assert.deepStrictEqual(span._links[0].attributes, { baz: 'valid', }) }) + const legacyAddLinkTest = DD_MAJOR < 6 ? it : it.skip + legacyAddLinkTest('still accepts the deprecated (spanContext, attributes) form on v5', () => { + span = new Span(tracer, processor, prioritySampler, { operationName: 'operation' }) + const span2 = new Span(tracer, processor, prioritySampler, { operationName: 'operation' }) + + span.addLink(span2.context(), { foo: 'bar' }) + assert.strictEqual(span._links.length, 1) + assert.deepStrictEqual(span._links[0].attributes, { foo: 'bar' }) + }) + it('seeds links from constructor fields.links and sanitizes their attributes', () => { const seed = new Span(tracer, processor, prioritySampler, { operationName: 'seed' }) span = new Span(tracer, processor, prioritySampler, { @@ -435,7 +453,31 @@ describe('Span', () => { span = new Span(tracer, processor, prioritySampler, { operationName: 'operation' }) span.setTag('foo', 'bar') - sinon.assert.calledWith(tagger.add, span.context()._tags, { foo: 'bar' }) + assert.strictEqual(span.context().getTag('foo'), 'bar') + sinon.assert.notCalled(tagger.add) + sinon.assert.notCalled(prioritySampler.sample) + }) + + it('should sample based on manual sampling tags', () => { + span = new Span(tracer, processor, prioritySampler, { operationName: 'operation' }) + span.setTag(MANUAL_KEEP, true) + + assert.strictEqual(span.context().getTag(MANUAL_KEEP), true) + sinon.assert.calledWith(prioritySampler.sample, span, false) + }) + + it('should be published via dd-trace:span:tags:update channel', () => { + const onTagsUpdate = sinon.stub() + tagsUpdateCh.subscribe(onTagsUpdate) + + try { + span = new Span(tracer, processor, prioritySampler, { operationName: 'operation' }) + span.setTag('foo', 'bar') + + sinon.assert.calledOnceWithExactly(onTagsUpdate, span, 'dd-trace:span:tags:update') + } finally { + tagsUpdateCh.unsubscribe(onTagsUpdate) + } }) }) @@ -444,21 +486,65 @@ describe('Span', () => { span = new Span(tracer, processor, prioritySampler, { operationName: 'operation' }) }) - it('should add tags', () => { - const tags = { foo: 'bar' } + it('should add tags from an object without going through tagger.add', () => { + span.addTags({ foo: 'bar', baz: 'qux' }) + + assert.strictEqual(span.context().getTag('foo'), 'bar') + assert.strictEqual(span.context().getTag('baz'), 'qux') + sinon.assert.notCalled(tagger.add) + sinon.assert.notCalled(prioritySampler.sample) + }) + + it('should ignore unsupported argument types', () => { + const tagsBefore = { ...span.context().getTags() } + span.addTags(42) + span.addTags(null) + span.addTags(undefined) + + assert.deepStrictEqual(span.context().getTags(), tagsBefore) + sinon.assert.notCalled(tagger.add) + sinon.assert.notCalled(prioritySampler.sample) + }) - span.addTags(tags) + const legacyAddTagsShape = DD_MAJOR < 6 ? it : it.skip + legacyAddTagsShape('still accepts string and array inputs via tagger on v5', () => { + span.addTags('foo:bar') + span.addTags([{ baz: 'qux' }]) - sinon.assert.calledWith(tagger.add, span.context()._tags, tags) + sinon.assert.calledWith(tagger.add, span.context().getTags(), 'foo:bar') + sinon.assert.calledWith(tagger.add, span.context().getTags(), [{ baz: 'qux' }]) }) - it('should sample based on the tags', () => { - const tags = { foo: 'bar' } + const v6AddTagsShape = DD_MAJOR >= 6 ? it : it.skip + v6AddTagsShape('drops string and array inputs on v6', () => { + const tagsBefore = { ...span.context().getTags() } + span.addTags('foo:bar') + span.addTags([{ baz: 'qux' }]) - span.addTags(tags) + assert.deepStrictEqual(span.context().getTags(), tagsBefore) + sinon.assert.notCalled(tagger.add) + sinon.assert.notCalled(prioritySampler.sample) + }) + + it('should sample based on manual sampling tags', () => { + span.addTags({ [MANUAL_KEEP]: true }) + assert.strictEqual(span.context().getTag(MANUAL_KEEP), true) sinon.assert.calledWith(prioritySampler.sample, span, false) }) + + it('should be published via dd-trace:span:tags:update channel', () => { + const onTagsUpdate = sinon.stub() + tagsUpdateCh.subscribe(onTagsUpdate) + + try { + span.addTags({ foo: 'bar' }) + + sinon.assert.calledOnceWithExactly(onTagsUpdate, span, 'dd-trace:span:tags:update') + } finally { + tagsUpdateCh.unsubscribe(onTagsUpdate) + } + }) }) describe('finish', () => { @@ -496,7 +582,7 @@ describe('Span', () => { span = new Span(tracer, processor, prioritySampler, { operationName: 'operation' }) span.finish() - assertObjectContains(span._spanContext._tags, { '_dd.integration': 'opentracing' }) + assertObjectContains(span._spanContext.getTags(), { '_dd.integration': 'opentracing' }) }) describe('tracePropagationBehaviorExtract and Baggage', () => { diff --git a/packages/dd-trace/test/opentracing/span_context.spec.js b/packages/dd-trace/test/opentracing/span_context.spec.js index abe5e87b08..9e1d1a0eb1 100644 --- a/packages/dd-trace/test/opentracing/span_context.spec.js +++ b/packages/dd-trace/test/opentracing/span_context.spec.js @@ -25,7 +25,7 @@ describe('SpanContext', () => { isRemote: false, name: 'test', isFinished: true, - tags: {}, + tags: { testTag: 'testValue' }, metrics: {}, sampling: { priority: 2 }, baggageItems: { foo: 'bar' }, @@ -47,7 +47,7 @@ describe('SpanContext', () => { _isRemote: false, _name: 'test', _isFinished: true, - _tags: {}, + _tags: { testTag: 'testValue' }, _sampling: { priority: 2 }, _spanSampling: undefined, _links: [], @@ -166,4 +166,78 @@ describe('SpanContext', () => { assert.strictEqual(spanContext.toTraceparent(), '00-00000000000007890000000000000123-0000000000000456-00') }) }) + + describe('tag accessor API', () => { + let spanContext + + beforeEach(() => { + spanContext = new SpanContext({ + traceId: id('123', 10), + spanId: id('456', 10), + }) + }) + + it('setTag stores the value; getTag returns it', () => { + spanContext.setTag('foo', 'bar') + assert.strictEqual(spanContext.getTag('foo'), 'bar') + }) + + it('setTag overwrites a previous value', () => { + spanContext.setTag('foo', 'first') + spanContext.setTag('foo', 'second') + assert.strictEqual(spanContext.getTag('foo'), 'second') + }) + + it('getTag returns undefined for an unset key', () => { + assert.strictEqual(spanContext.getTag('missing'), undefined) + }) + + it('hasTag distinguishes "set to undefined" from "unset"', () => { + spanContext.setTag('explicit', undefined) + assert.strictEqual(spanContext.hasTag('explicit'), true) + assert.strictEqual(spanContext.hasTag('missing'), false) + assert.strictEqual(spanContext.getTag('explicit'), undefined) + }) + + it('hasTag uses Object.hasOwn — Object.prototype keys do not register', () => { + // The previous `key in this._tags` implementation matched + // `'toString'` / `'hasOwnProperty'` etc. via the prototype chain. + assert.strictEqual(spanContext.hasTag('toString'), false) + assert.strictEqual(spanContext.hasTag('hasOwnProperty'), false) + }) + + it('deleteTag removes the key; hasTag reflects the removal', () => { + spanContext.setTag('foo', 'bar') + spanContext.deleteTag('foo') + assert.strictEqual(spanContext.hasTag('foo'), false) + assert.strictEqual(spanContext.getTag('foo'), undefined) + }) + + it('getTags returns the live internal tag map (callers may mutate)', () => { + spanContext.setTag('a', '1') + const tags = spanContext.getTags() + assert.strictEqual(tags.a, '1') + + // Same reference on subsequent calls — `opentracing/span.js` relies on + // `Object.assign(getTags(), fields.tags)` mutating the live map. + assert.strictEqual(spanContext.getTags(), tags) + + tags.b = '2' + assert.strictEqual(spanContext.getTag('b'), '2') + }) + + it('clearTags empties the map and continues to accept further writes', () => { + spanContext.setTag('a', '1') + spanContext.setTag('b', '2') + spanContext.clearTags() + assert.strictEqual(spanContext.hasTag('a'), false) + assert.strictEqual(spanContext.hasTag('b'), false) + // After clear, the backing map is a fresh Object.create(null) — empty, + // but distinct from `{}` by prototype. Assert emptiness via key count. + assert.strictEqual(Object.keys(spanContext.getTags()).length, 0) + + spanContext.setTag('c', '3') + assert.strictEqual(spanContext.getTag('c'), '3') + }) + }) }) diff --git a/packages/dd-trace/test/plugins/database-dbm-cache.spec.js b/packages/dd-trace/test/plugins/database-dbm-cache.spec.js index 0ed75c5560..a28491c793 100644 --- a/packages/dd-trace/test/plugins/database-dbm-cache.spec.js +++ b/packages/dd-trace/test/plugins/database-dbm-cache.spec.js @@ -11,7 +11,13 @@ const DatabasePlugin = require('../../src/plugins/database') function makeSpan (tags = {}) { return { - context: () => ({ _tags: tags }), + context: () => ({ + _tags: tags, + getTag: (key) => tags[key], + getTags: () => tags, + setTag: (key, value) => { tags[key] = value }, + hasTag: (key) => Object.hasOwn(tags, key), + }), setTag () {}, _spanContext: { toTraceparent: () => '00-aaa-bbb-01' }, _processor: { sample () {} }, diff --git a/packages/dd-trace/test/plugins/database-dbm-hash.spec.js b/packages/dd-trace/test/plugins/database-dbm-hash.spec.js index 4036217e5c..9eee980001 100644 --- a/packages/dd-trace/test/plugins/database-dbm-hash.spec.js +++ b/packages/dd-trace/test/plugins/database-dbm-hash.spec.js @@ -34,18 +34,18 @@ describe('DatabasePlugin DBM Hash', () => { } // Create a mock span + const contextTags = { + 'out.host': 'localhost', + 'db.name': 'testdb', + } span = { context: () => ({ - _tags: { - 'out.host': 'localhost', - 'db.name': 'testdb', - }, + _tags: contextTags, + getTags: () => contextTags, }), _spanContext: { - _tags: { - 'out.host': 'localhost', - 'db.name': 'testdb', - }, + _tags: contextTags, + getTags: () => contextTags, toTraceparent: () => 'traceparent-value', }, setTag: function (key, value) { @@ -76,10 +76,10 @@ describe('DatabasePlugin DBM Hash', () => { assert.ok(comment.includes("ddsh='AQIDBAUG'"), 'Comment should include base64 hash') }) - it('should set _dd.dbm.propagation_hash tag on span', () => { + it('should set _dd.propagated_hash tag on span', () => { plugin.createDbmComment(span, 'test-service') - assert.strictEqual(span._tags['_dd.dbm.propagation_hash'], 'AQIDBAUG', + assert.strictEqual(span._tags['_dd.propagated_hash'], 'AQIDBAUG', 'Span should have propagation hash tag') }) @@ -96,7 +96,7 @@ describe('DatabasePlugin DBM Hash', () => { plugin.config.dbmPropagationMode = 'full' const fullComment = plugin.createDbmComment(span, 'test-service') assert.ok(fullComment.includes("ddsh='AQIDBAUG'"), 'Full mode should include hash') - assert.strictEqual(span._tags['_dd.dbm.propagation_hash'], 'AQIDBAUG', + assert.strictEqual(span._tags['_dd.propagated_hash'], 'AQIDBAUG', 'Full mode should set span tag') }) @@ -107,7 +107,7 @@ describe('DatabasePlugin DBM Hash', () => { assert.ok(comment, 'Comment should still be created') assert.ok(!comment.includes('ddsh='), 'Comment should not include hash') - assert.strictEqual(span._tags['_dd.dbm.propagation_hash'], undefined, + assert.strictEqual(span._tags['_dd.propagated_hash'], undefined, 'Span should not have hash tag') }) @@ -127,7 +127,7 @@ describe('DatabasePlugin DBM Hash', () => { assert.ok(comment, 'Comment should still be created') assert.ok(!comment.includes('ddsh='), 'Comment should not include hash when config is disabled') - assert.strictEqual(span._tags['_dd.dbm.propagation_hash'], undefined, + assert.strictEqual(span._tags['_dd.propagated_hash'], undefined, 'Span should not have hash tag when config is disabled') }) @@ -159,11 +159,59 @@ describe('DatabasePlugin DBM Hash', () => { const query = 'SELECT * FROM users' plugin.injectDbmQuery(span, query, 'test-service') - assert.strictEqual(span._tags['_dd.dbm.propagation_hash'], 'AQIDBAUG', + assert.strictEqual(span._tags['_dd.propagated_hash'], 'AQIDBAUG', 'Span should have hash tag after query injection') }) }) + describe('dynamic_service mode', () => { + beforeEach(() => { + plugin.config.dbmPropagationMode = 'dynamic_service' + }) + + it('should inject hash even when dbm.injectSqlBaseHash is false', () => { + plugin.config['dbm.injectSqlBaseHash'] = false + + const comment = plugin.createDbmComment(span, 'test-service') + + assert.ok(comment, 'Comment should be created') + assert.ok(comment.includes("ddsh='AQIDBAUG'"), + 'dynamic_service should inject hash regardless of injectSqlBaseHash') + }) + + it('should not inject traceparent', () => { + const comment = plugin.createDbmComment(span, 'test-service') + + assert.ok(comment, 'Comment should be created') + assert.ok(!comment.includes('traceparent='), 'dynamic_service should not inject traceparent') + }) + + it('should behave identically to service + injectSqlBaseHash=true', () => { + plugin.config['dbm.injectSqlBaseHash'] = false + + const dynamicComment = plugin.createDbmComment(span, 'test-service') + + // Reset span tags and compare with service + injectSqlBaseHash=true + span._tags = {} + plugin.config.dbmPropagationMode = 'service' + plugin.config['dbm.injectSqlBaseHash'] = true + + const serviceComment = plugin.createDbmComment(span, 'test-service') + + assert.strictEqual(dynamicComment, serviceComment, + 'dynamic_service should produce identical output to service + injectSqlBaseHash=true') + }) + + it('should not inject hash when propagation hash is disabled', () => { + propagationHash.isEnabled = () => false + + const comment = plugin.createDbmComment(span, 'test-service') + + assert.ok(comment, 'Comment should still be created') + assert.ok(!comment.includes('ddsh='), 'Should not inject hash when propagation hash is disabled') + }) + }) + describe('control matrix for process tags and SQL base hash', () => { it('should inject hash when both propagateProcessTags and injectSqlBaseHash are enabled', () => { // DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED=true + DD_DBM_INJECT_SQL_BASEHASH=true @@ -174,7 +222,7 @@ describe('DatabasePlugin DBM Hash', () => { assert.ok(comment.includes("ddsh='AQIDBAUG'"), 'Should inject hash') assert.ok(comment.includes('dddbs='), 'Should inject service tags') - assert.strictEqual(span._tags['_dd.dbm.propagation_hash'], 'AQIDBAUG', + assert.strictEqual(span._tags['_dd.propagated_hash'], 'AQIDBAUG', 'Should set hash tag on span') }) @@ -188,7 +236,7 @@ describe('DatabasePlugin DBM Hash', () => { assert.ok(comment, 'Should still create comment') assert.ok(comment.includes('dddbs='), 'Should inject service tags') assert.ok(!comment.includes('ddsh='), 'Should NOT inject hash') - assert.strictEqual(span._tags['_dd.dbm.propagation_hash'], undefined, + assert.strictEqual(span._tags['_dd.propagated_hash'], undefined, 'Should NOT set hash tag on span') }) @@ -201,7 +249,7 @@ describe('DatabasePlugin DBM Hash', () => { assert.ok(comment, 'Should still create comment with basic service info') assert.ok(!comment.includes('ddsh='), 'Should NOT inject hash') - assert.strictEqual(span._tags['_dd.dbm.propagation_hash'], undefined, + assert.strictEqual(span._tags['_dd.propagated_hash'], undefined, 'Should NOT set hash tag on span') }) @@ -214,7 +262,7 @@ describe('DatabasePlugin DBM Hash', () => { assert.ok(comment, 'Should still create comment') assert.ok(!comment.includes('ddsh='), 'Should NOT inject hash without process tags enabled') - assert.strictEqual(span._tags['_dd.dbm.propagation_hash'], undefined, + assert.strictEqual(span._tags['_dd.propagated_hash'], undefined, 'Should NOT set hash tag on span') }) }) diff --git a/packages/dd-trace/test/plugins/externals.js b/packages/dd-trace/test/plugins/externals.js index bd34a76dd1..c200285a50 100644 --- a/packages/dd-trace/test/plugins/externals.js +++ b/packages/dd-trace/test/plugins/externals.js @@ -3,6 +3,12 @@ const { DD_MAJOR } = require('../../../../version') module.exports = { + aerospike: [ + { + name: 'aerospike', + versions: ['4', '5', '>=6'], + }, + ], ai: [ { name: 'ai', @@ -565,6 +571,7 @@ module.exports = { name: 'mongodb', dep: true, forced: true, + node: '>=20.19.0', }, { name: 'mongodb-core', @@ -614,6 +621,10 @@ module.exports = { }, ], stripe: [ + { + name: 'stripe', + versions: ['9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19', '>=20.0.0 <22'], + }, { name: 'express', versions: ['^4'], diff --git a/packages/dd-trace/test/plugins/log_injection.spec.js b/packages/dd-trace/test/plugins/log_injection.spec.js new file mode 100644 index 0000000000..6c8ce93e0f --- /dev/null +++ b/packages/dd-trace/test/plugins/log_injection.spec.js @@ -0,0 +1,69 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it } = require('mocha') + +require('../setup/core') +const { buildLogHolder, messageProxy } = require('../../src/plugins/log_injection') + +describe('log_injection', () => { + describe('buildLogHolder', () => { + it('returns undefined when the propagator wrote nothing', () => { + const tracer = { inject () {} } + assert.strictEqual(buildLogHolder(tracer), undefined) + }) + + it('returns the log holder when the propagator wrote at least one field', () => { + const tracer = { + inject (_span, _format, carrier) { + carrier.dd = { service: 'svc' } + }, + } + const logHolder = buildLogHolder(tracer) + assert.deepStrictEqual(logHolder.dd, { service: 'svc' }) + }) + }) + + describe('messageProxy', () => { + const logHolder = { dd: { service: 'svc', env: 'dev' } } + + it('exposes logHolder.dd through proxy get', () => { + const message = { foo: 1 } + const proxied = messageProxy(message, logHolder) + assert.strictEqual(proxied.foo, 1) + assert.deepStrictEqual(proxied.dd, { service: 'svc', env: 'dev' }) + }) + + it('leaves the caller-owned object unchanged', () => { + const message = { foo: 1 } + messageProxy(message, logHolder) + assert.strictEqual(Object.hasOwn(message, 'dd'), false) + }) + + it('does not override dd when the caller already set one', () => { + const message = { dd: { mine: true } } + const proxied = messageProxy(message, logHolder) + assert.deepStrictEqual(proxied.dd, { mine: true }) + }) + + it('lists dd in ownKeys when the target is extensible without an own dd', () => { + const extensible = { foo: 1 } + const proxied = messageProxy(extensible, logHolder) + assert.deepStrictEqual(Reflect.ownKeys(proxied).sort(), ['dd', 'foo']) + }) + + it('omits dd from ownKeys when the target is non-extensible', () => { + const frozen = Object.freeze({ foo: 1 }) + const proxied = messageProxy(frozen, logHolder) + assert.deepStrictEqual(Reflect.ownKeys(proxied), ['foo']) + }) + + it('forwards writes to the target', () => { + const message = { foo: 1 } + const proxied = messageProxy(message, logHolder) + proxied.bar = 2 + assert.strictEqual(message.bar, 2) + }) + }) +}) diff --git a/packages/dd-trace/test/plugins/log_plugin.spec.js b/packages/dd-trace/test/plugins/log_plugin.spec.js index 8205dbb9d9..ff83d51001 100644 --- a/packages/dd-trace/test/plugins/log_plugin.spec.js +++ b/packages/dd-trace/test/plugins/log_plugin.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it } = require('mocha') const { channel } = require('dc-polyfill') @@ -9,6 +10,7 @@ const { storage } = require('../../../datadog-core') const { assertObjectContains } = require('../../../../integration-tests/helpers') require('../setup/core') const LogPlugin = require('../../src/plugins/log_plugin') +const { buildLogHolder, messageProxy } = require('../../src/plugins/log_injection') const Tracer = require('../../src/tracer') const getConfig = require('../../src/config') @@ -16,6 +18,15 @@ const testLogChannel = channel('apm:test:log') class TestLog extends LogPlugin { static id = 'test' + + constructor (...args) { + super(...args) + this.addSub('apm:test:log', (arg) => { + const logHolder = buildLogHolder(this.tracer) + if (!logHolder) return + arg.message = messageProxy(arg.message, logHolder) + }) + } } const config = { @@ -83,7 +94,7 @@ describe('LogPlugin', () => { assert.deepStrictEqual(JSON.parse(JSON.stringify(data.message)), { dd: override, }) - assert.ok(Object.hasOwn(data.message, 'dd')) + assert.ok(Object.hasOwn(data.message, 'dd'), `Available keys: ${inspect(Object.keys(data.message))}`) }) it('should allow defining dd after injection', () => { @@ -103,6 +114,6 @@ describe('LogPlugin', () => { }) assert.strictEqual(data.message.dd, override) - assert.ok(Object.hasOwn(data.message, 'dd')) + assert.ok(Object.hasOwn(data.message, 'dd'), `Available keys: ${inspect(Object.keys(data.message))}`) }) }) diff --git a/packages/dd-trace/test/plugins/outbound.spec.js b/packages/dd-trace/test/plugins/outbound.spec.js index 2272f4c90e..2edd27f354 100644 --- a/packages/dd-trace/test/plugins/outbound.spec.js +++ b/packages/dd-trace/test/plugins/outbound.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach, before } = require('mocha') const sinon = require('sinon') @@ -34,7 +35,7 @@ describe('OuboundPlugin', () => { it('should attempt to remap when we found peer service', () => { computePeerServiceStub.value({ spanComputePeerService: true }) getPeerServiceStub.returns({ foo: 'bar' }) - instance.tagPeerService({ context: () => { return { _tags: {} } }, addTags: () => {} }) + instance.tagPeerService({ context: () => ({ _tags: {}, getTags () { return this._tags } }), addTags: () => {} }) sinon.assert.called(getPeerServiceStub) sinon.assert.called(getRemapStub) @@ -43,7 +44,7 @@ describe('OuboundPlugin', () => { it('should not attempt to remap if we found no peer service', () => { computePeerServiceStub.value({ spanComputePeerService: true }) getPeerServiceStub.returns(undefined) - instance.tagPeerService({ context: () => { return { _tags: {} } }, addTags: () => {} }) + instance.tagPeerService({ context: () => ({ _tags: {}, getTags () { return this._tags } }), addTags: () => {} }) sinon.assert.called(getPeerServiceStub) sinon.assert.notCalled(getRemapStub) @@ -51,7 +52,7 @@ describe('OuboundPlugin', () => { it('should do nothing when disabled', () => { computePeerServiceStub.value({ spanComputePeerService: false }) - instance.tagPeerService({ context: () => { return { _tags: {} } }, addTags: () => {} }) + instance.tagPeerService({ context: () => ({ _tags: {}, getTags () { return this._tags } }), addTags: () => {} }) sinon.assert.notCalled(getPeerServiceStub) sinon.assert.notCalled(getRemapStub) }) @@ -209,16 +210,19 @@ describe('OuboundPlugin', () => { const tags = parseTags(args[0]) assertObjectContains(tags, { _dd: { code_origin: { type: 'exit' } } }) - assert.ok(Array.isArray(tags._dd.code_origin.frames)) - assert.ok(tags._dd.code_origin.frames.length > 0) + assert.ok( + Array.isArray(tags._dd.code_origin.frames), + `Expected array, got ${inspect(tags._dd.code_origin.frames)}` + ) + assert.ok(tags._dd.code_origin.frames.length > 0, `Expected ${tags._dd.code_origin.frames.length} > 0`) for (const frame of tags._dd.code_origin.frames) { assert.strictEqual(frame.file, __filename) - assert.ok(Object.hasOwn(frame, 'line')) + assert.ok(Object.hasOwn(frame, 'line'), `Available keys: ${inspect(Object.keys(frame))}`) assert.match(frame.line, /^\d+$/) - assert.ok(Object.hasOwn(frame, 'column')) + assert.ok(Object.hasOwn(frame, 'column'), `Available keys: ${inspect(Object.keys(frame))}`) assert.match(frame.column, /^\d+$/) - assert.ok(Object.hasOwn(frame, 'type')) + assert.ok(Object.hasOwn(frame, 'type'), `Available keys: ${inspect(Object.keys(frame))}`) assert.strictEqual(typeof frame.type, 'string') } diff --git a/packages/dd-trace/test/plugins/plugin.spec.js b/packages/dd-trace/test/plugins/plugin.spec.js index 47f705dfc6..7ffa6847c3 100644 --- a/packages/dd-trace/test/plugins/plugin.spec.js +++ b/packages/dd-trace/test/plugins/plugin.spec.js @@ -96,4 +96,32 @@ describe('Plugin', () => { }) }) }) + + it('should suppress subscribers when publishing inside a noop scope', () => { + const handler = sinon.spy() + + class NoopAwarePlugin extends Plugin { + static id = 'noopAware' + + constructor () { + super() + this.addSub('apm:noopAware:start', handler) + } + } + + plugin = new NoopAwarePlugin() + plugin.configure({ enabled: true }) + + channel('apm:noopAware:start').publish({ outside: true }) + sinon.assert.calledOnce(handler) + handler.resetHistory() + + storage('legacy').run({ noop: true }, () => { + channel('apm:noopAware:start').publish({ inside: true }) + }) + sinon.assert.notCalled(handler) + + channel('apm:noopAware:start').publish({ outside: 'again' }) + sinon.assert.calledOnce(handler) + }) }) diff --git a/packages/dd-trace/test/plugins/tracing.spec.js b/packages/dd-trace/test/plugins/tracing.spec.js index af6f121f5e..bacfab4062 100644 --- a/packages/dd-trace/test/plugins/tracing.spec.js +++ b/packages/dd-trace/test/plugins/tracing.spec.js @@ -9,6 +9,12 @@ const { channel } = require('dc-polyfill') require('../setup/core') const TracingPlugin = require('../../src/plugins/tracing') const { SVC_SRC_KEY } = require('../../src/constants') +const DatadogSpanContext = require('../../src/opentracing/span_context') +const { + INTEGRATION_SERVICE, + MANUAL, + resolveServiceSource, +} = require('../../src/service-naming/source-resolver') const agent = require('../plugins/agent') const plugins = require('../../src/plugins') @@ -18,10 +24,13 @@ describe('TracingPlugin', () => { let plugin beforeEach(() => { - startSpanSpy = sinon.spy() + startSpanSpy = sinon.stub().callsFake((_name, opts) => ({ + _spanContext: new DatadogSpanContext({ tags: { ...opts.tags } }), + })) plugin = new TracingPlugin({ _tracer: { startSpan: startSpanSpy, + _service: 'tracer-default', }, }) plugin.configure({}) @@ -71,6 +80,91 @@ describe('TracingPlugin', () => { const callArgs = startSpanSpy.firstCall.args[1] assert.ok(!(SVC_SRC_KEY in callArgs.tags), 'SVC_SRC_KEY should not be present when service is not provided') }) + + it('records the integration claim so a user override is detected at finish', () => { + const span = plugin.startSpan('Test span', { service: { name: 'kafka-broker', source: 'kafka' } }) + + span._spanContext.setTag('service.name', 'user-svc') + resolveServiceSource(span, 'tracer-default') + + assert.strictEqual(span._spanContext.getTag(SVC_SRC_KEY), MANUAL) + }) + + it('records the integration claim when service is supplied via meta.service', () => { + // Regression: inferred-proxy spans (packages/dd-trace/src/plugins/util/inferred_proxy.js) + // pass the service through `meta.service`, leaving the top-level `service` undefined. + // Without recording the claim, a later override would be indistinguishable from a manual write. + const span = plugin.startSpan('Test span', { meta: { service: 'inferred-proxy-svc' } }) + + span._spanContext.setTag('service.name', 'user-svc') + resolveServiceSource(span, 'tracer-default') + + assert.strictEqual(span._spanContext.getTag(SVC_SRC_KEY), MANUAL) + }) + + it('keeps the integration source when the user does not override service.name', () => { + const span = plugin.startSpan('Test span', { service: { name: 'kafka-broker', source: 'kafka' } }) + + resolveServiceSource(span, 'tracer-default') + + assert.strictEqual(span._spanContext.getTag(SVC_SRC_KEY), 'kafka') + }) + + it('clears SVC_SRC_KEY when the user overrides service.name back to the tracer default', () => { + const span = plugin.startSpan('Test span', { service: { name: 'kafka-broker', source: 'kafka' } }) + + assert.strictEqual(span._spanContext.getTag(SVC_SRC_KEY), 'kafka') + + span._spanContext.setTag('service.name', 'tracer-default') + resolveServiceSource(span, 'tracer-default') + + assert.strictEqual(span._spanContext.getTag(SVC_SRC_KEY), undefined) + }) + }) + + describe('stampIntegrationService method', () => { + let plugin + + beforeEach(() => { + plugin = new TracingPlugin({ _tracer: { _service: 'tracer-default' } }) + plugin.configure({}) + }) + + it('records the integration claim using the tracer service', () => { + const span = { _spanContext: new DatadogSpanContext() } + + plugin.stampIntegrationService(span, 'kafka-broker') + + assert.strictEqual(span[INTEGRATION_SERVICE], 'kafka-broker') + }) + }) + + describe('setServiceName method', () => { + let plugin + + beforeEach(() => { + plugin = new TracingPlugin({ _tracer: { _service: 'tracer-default' } }) + plugin.configure({}) + }) + + it('sets service.name and stamps the integration claim', () => { + const span = { _spanContext: new DatadogSpanContext() } + + plugin.setServiceName(span, 'express-app') + + assert.deepStrictEqual(span._spanContext.getTags(), { 'service.name': 'express-app' }) + assert.strictEqual(span[INTEGRATION_SERVICE], 'express-app') + }) + + it('detects user override at finish when service.name is later mutated', () => { + const span = { _spanContext: new DatadogSpanContext({ tags: { [SVC_SRC_KEY]: 'opt.plugin' } }) } + plugin.setServiceName(span, 'express-app') + + span._spanContext.setTag('service.name', 'user-svc') + resolveServiceSource(span, 'tracer-default') + + assert.strictEqual(span._spanContext.getTag(SVC_SRC_KEY), MANUAL) + }) }) }) @@ -158,7 +252,7 @@ describe('common Plugin behaviour', () => { done, 'commonPlugin', {}, span => { assert.strictEqual(span.service, 'test') - assert.ok(!('_dd.base_service' in span.meta) || span.meta['_dd.base_service'] !== 'test') + assert.notStrictEqual(span.meta['_dd.base_service'], 'test') } ) }) diff --git a/packages/dd-trace/test/plugins/util/test.spec.js b/packages/dd-trace/test/plugins/util/test.spec.js index f900fc4c6f..c562abec97 100644 --- a/packages/dd-trace/test/plugins/util/test.spec.js +++ b/packages/dd-trace/test/plugins/util/test.spec.js @@ -2,6 +2,7 @@ const assert = require('node:assert/strict') const path = require('node:path') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const context = describe @@ -17,6 +18,11 @@ const { getCodeOwnersFileEntries, getCodeOwnersForFilename, getCoveredFilenamesFromCoverage, + getCoveredFilesFromCoverage, + getExecutableFilesFromCoverage, + getLineCoverageBitmap, + getTestCoverageLinesPercentage, + applySkippedCoverageToCoverage, mergeCoverage, resetCoverage, removeInvalidMetadata, @@ -297,9 +303,9 @@ describe('attempt to fix summary', () => { assert.match(summary, /Attempt to fix failed: 1 of 1 execution\(s\) failed across 1 of 1 test\(s\)\./) assert.match(summary, /suite\.js › fails/) - assert.ok(!summary.includes('Errors are suppressed because')) - assert.ok(!summary.includes('Error:')) - assert.ok(!summary.includes('execution 1:')) + assert.ok(!summary.includes('Errors are suppressed because'), `Got: ${inspect(summary)}`) + assert.ok(!summary.includes('Error:'), `Got: ${inspect(summary)}`) + assert.ok(!summary.includes('execution 1:'), `Got: ${inspect(summary)}`) }) it('reports when quarantine and disabled were ignored for attempt to fix', () => { @@ -401,9 +407,9 @@ describe('attempt to fix summary', () => { const summary = formatAttemptToFixSummary(executions) assert.match(summary, /worker-suite\.js › worker test/) - assert.ok(!summary.includes('worker failure')) - assert.ok(!summary.includes('worker-suite.js:10:5')) - assert.ok(!summary.includes('Errors are suppressed because')) + assert.ok(!summary.includes('worker failure'), `Got: ${inspect(summary)}`) + assert.ok(!summary.includes('worker-suite.js:10:5'), `Got: ${inspect(summary)}`) + assert.ok(!summary.includes('Errors are suppressed because'), `Got: ${inspect(summary)}`) assert.match(summary, /Test was marked as quarantined but was not quarantined because it is attempt to fix\./) }) @@ -898,6 +904,144 @@ describe('coverage utils', () => { }) }) + describe('getCoveredFilesFromCoverage', () => { + const getPartialCoverage = (filename = 'file.js') => ({ + [filename]: { + path: filename, + statementMap: { + 0: { start: { line: 1, column: 0 }, end: { line: 1, column: 1 } }, + 1: { start: { line: 2, column: 0 }, end: { line: 2, column: 1 } }, + 2: { start: { line: 3, column: 0 }, end: { line: 3, column: 1 } }, + 3: { start: { line: 4, column: 0 }, end: { line: 4, column: 1 } }, + }, + s: { + 0: 1, + 1: 0, + 2: 0, + 3: 0, + }, + fnMap: {}, + f: {}, + branchMap: {}, + b: {}, + }, + }) + + it('returns a bitmap for covered lines', () => { + const lineCoverage = { + 30: 1, + 32: 1, + 45: 1, + 46: 1, + } + const bitmap = getLineCoverageBitmap(lineCoverage, true) + + assert.strictEqual(bitmap.toString('base64'), 'AAAAQAFg') + }) + + it('returns covered and executable files with bitmaps', () => { + const coveredFiles = getCoveredFilesFromCoverage(coverage) + const executableFiles = getExecutableFilesFromCoverage(coverage) + + assert.deepStrictEqual(coveredFiles.map(({ filename }) => filename), ['subtract.js', 'add.js']) + assert.deepStrictEqual(executableFiles.map(({ filename }) => filename), ['subtract.js', 'add.js']) + assert.ok(coveredFiles.every(({ bitmap }) => Buffer.isBuffer(bitmap)), inspect(coveredFiles)) + assert.ok(executableFiles.every(({ bitmap }) => Buffer.isBuffer(bitmap)), inspect(executableFiles)) + }) + + it('returns exact covered and executable line bitmaps', () => { + const partialCoverage = getPartialCoverage() + const [coveredFile] = getCoveredFilesFromCoverage(partialCoverage) + const [executableFile] = getExecutableFilesFromCoverage(partialCoverage) + + assert.deepStrictEqual(coveredFile, { + filename: 'file.js', + bitmap: Buffer.from('Ag==', 'base64'), + }) + assert.deepStrictEqual(executableFile, { + filename: 'file.js', + bitmap: Buffer.from('Hg==', 'base64'), + }) + }) + + it('calculates total coverage using skipped-suite coverage bitmaps', () => { + const partialCoverage = getPartialCoverage() + const skippedCoverage = { + 'file.js': getLineCoverageBitmap({ + 2: 1, + 3: 1, + }, true).toString('base64'), + } + + assert.strictEqual(getTestCoverageLinesPercentage(partialCoverage, skippedCoverage), 75) + }) + + 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')) + const skippedCoverage = { + 'file.js': getLineCoverageBitmap({ + 2: 1, + 3: 1, + }, true).toString('base64'), + } + + assert.strictEqual(getTestCoverageLinesPercentage(coverage, skippedCoverage, rootDir), 75) + }) + + it('ignores skipped coverage for files outside the executable coverage map', () => { + const partialCoverage = getPartialCoverage() + const skippedCoverage = { + 'other-file.js': getLineCoverageBitmap({ + 1: 1, + 2: 1, + 3: 1, + 4: 1, + }, true).toString('base64'), + } + + assert.strictEqual(getTestCoverageLinesPercentage(partialCoverage, skippedCoverage), 25) + }) + + it('applies skipped-suite coverage to an Istanbul coverage map', () => { + const partialCoverage = getPartialCoverage() + const coverageMap = istanbul.createCoverageMap(partialCoverage) + const skippedCoverage = { + 'file.js': getLineCoverageBitmap({ + 2: 1, + 3: 1, + }, true).toString('base64'), + } + + assert.strictEqual(applySkippedCoverageToCoverage(coverageMap, skippedCoverage), true) + assert.strictEqual(getTestCoverageLinesPercentage(coverageMap), 75) + }) + + it('reports skipped-suite coverage as applied when covered lines overlap', () => { + const partialCoverage = getPartialCoverage() + partialCoverage['file.js'].s[1] = 1 + partialCoverage['file.js'].s[2] = 1 + const coverageMap = istanbul.createCoverageMap(partialCoverage) + const skippedCoverage = { + 'file.js': getLineCoverageBitmap({ + 2: 1, + 3: 1, + }, true).toString('base64'), + } + + assert.strictEqual(applySkippedCoverageToCoverage(coverageMap, skippedCoverage), true) + assert.strictEqual(getTestCoverageLinesPercentage(coverageMap), 75) + }) + + it('does not alter coverage when skipped coverage is missing', () => { + const partialCoverage = getPartialCoverage() + const coverageMap = istanbul.createCoverageMap(partialCoverage) + + assert.strictEqual(applySkippedCoverageToCoverage(coverageMap, {}), false) + assert.strictEqual(getTestCoverageLinesPercentage(coverageMap), 25) + }) + }) + describe('resetCoverage', () => { it('resets the code coverage', () => { resetCoverage(coverage) diff --git a/packages/dd-trace/test/plugins/util/web.spec.js b/packages/dd-trace/test/plugins/util/web.spec.js index da734a5b4b..51dbd84f1a 100644 --- a/packages/dd-trace/test/plugins/util/web.spec.js +++ b/packages/dd-trace/test/plugins/util/web.spec.js @@ -6,12 +6,15 @@ const { describe, it, beforeEach } = require('mocha') const sinon = require('sinon') require('../../setup/core') -const tags = require('../../../../../ext/tags') +const tagsExt = require('../../../../../ext/tags') -const ERROR = tags.ERROR -const HTTP_ENDPOINT = tags.HTTP_ENDPOINT -const HTTP_ROUTE = tags.HTTP_ROUTE -const RESOURCE_NAME = tags.RESOURCE_NAME +const ERROR = tagsExt.ERROR +const HTTP_CLIENT_IP = tagsExt.HTTP_CLIENT_IP +const HTTP_ENDPOINT = tagsExt.HTTP_ENDPOINT +const HTTP_REQUEST_HEADERS = tagsExt.HTTP_REQUEST_HEADERS +const HTTP_RESPONSE_HEADERS = tagsExt.HTTP_RESPONSE_HEADERS +const HTTP_ROUTE = tagsExt.HTTP_ROUTE +const RESOURCE_NAME = tagsExt.RESOURCE_NAME describe('plugins/util/web', () => { let web @@ -122,6 +125,55 @@ describe('plugins/util/web', () => { assert.strictEqual(config.queryStringObfuscation, true) }) }) + + describe('clientIpEnabled', () => { + it('leaves extractIp undefined when clientIpEnabled is not set', () => { + const config = web.normalizeConfig({}) + + assert.strictEqual(config.extractIp, undefined) + }) + + it('resolves extractIp to the ip_extractor implementation when clientIpEnabled is true', () => { + const config = web.normalizeConfig({ clientIpEnabled: true }) + const { extractIp } = require('../../../src/plugins/util/ip_extractor') + + assert.strictEqual(config.extractIp, extractIp) + }) + }) + }) + + describe('startSpan client IP extraction', () => { + it('tags the span with the extracted client IP when clientIpEnabled is set', () => { + const config = web.normalizeConfig({ clientIpEnabled: true }) + req.headers['x-forwarded-for'] = '8.8.8.8' + + const span = web.startSpan(tracer, config, req, res, 'test.request') + + assert.strictEqual(span.context().getTag(HTTP_CLIENT_IP), '8.8.8.8') + }) + + it('leaves the client IP tag unset when clientIpEnabled is not set', () => { + const config = web.normalizeConfig({}) + req.headers['x-forwarded-for'] = '8.8.8.8' + + const span = web.startSpan(tracer, config, req, res, 'test.request') + + assert.strictEqual(span.context().hasTag(HTTP_CLIENT_IP), false) + }) + + // Regression for the per-plugin scoping fix: a later normalizeConfig call + // for a different plugin must not disable IP extraction on the earlier + // plugin's config. Used to fail because extractIp lived on the module. + it('keeps extraction enabled on the first config after a second plugin normalizes without clientIpEnabled', + () => { + const enabledConfig = web.normalizeConfig({ clientIpEnabled: true }) + web.normalizeConfig({}) + req.headers['x-forwarded-for'] = '8.8.8.8' + + const span = web.startSpan(tracer, enabledConfig, req, res, 'test.request') + + assert.strictEqual(span.context().getTag(HTTP_CLIENT_IP), '8.8.8.8') + }) }) describe('root', () => { @@ -139,7 +191,7 @@ describe('plugins/util/web', () => { describe('addError', () => { beforeEach(() => { span = tracer.startSpan('test.request') - tags = span.context()._tags + tags = span.context().getTags() web.patch(req) const context = web.getContext(req) @@ -172,7 +224,7 @@ describe('plugins/util/web', () => { describe('addStatusError', () => { beforeEach(() => { span = tracer.startSpan('test.request') - tags = span.context()._tags + tags = span.context().getTags() web.patch(req) const context = web.getContext(req) @@ -197,6 +249,49 @@ describe('plugins/util/web', () => { }) }) + describe('setConfig service', () => { + const SVC_SRC_KEY = '_dd.svc_src' + + beforeEach(() => { + req.url = '/' + web.plugin = null + }) + + it('writes service.name from config.service onto the span', () => { + const customConfig = web.normalizeConfig({ service: 'integration-svc' }) + + const span = web.startSpan(tracer, customConfig, req, res, 'test.request') + const spanContext = span.context() + + assert.strictEqual(spanContext.getTag('service.name'), 'integration-svc') + }) + + it('stamps the integration claim so a user override is flagged manual at finish', () => { + const customConfig = web.normalizeConfig({ service: 'integration-svc' }) + + const span = web.startSpan(tracer, customConfig, req, res, 'test.request') + const spanContext = span.context() + + span.setTag('service.name', 'user-override') + span.finish() + + assert.strictEqual(spanContext.getTag('service.name'), 'user-override') + assert.strictEqual(spanContext.getTag(SVC_SRC_KEY), 'm') + }) + + it('does not stamp manual when the user does not override the integration service', () => { + const customConfig = web.normalizeConfig({ service: 'integration-svc' }) + + const span = web.startSpan(tracer, customConfig, req, res, 'test.request') + const spanContext = span.context() + + span.finish() + + assert.strictEqual(spanContext.getTag('service.name'), 'integration-svc') + assert.strictEqual(spanContext.getTag(SVC_SRC_KEY), undefined) + }) + }) + describe('allowlistFilter', () => { beforeEach(() => { config = { allowlist: ['/_okay'] } @@ -268,7 +363,7 @@ describe('plugins/util/web', () => { describe('http.endpoint tagging', () => { beforeEach(() => { span = tracer.startSpan('test.request') - tags = span.context()._tags + tags = span.context().getTags() req.url = '/' @@ -316,7 +411,7 @@ describe('plugins/util/web', () => { beforeEach(() => { span = tracer.startSpan('test.request') - tags = span.context()._tags + tags = span.context().getTags() req.url = '/' @@ -383,4 +478,285 @@ describe('plugins/util/web', () => { ) }) }) + + describe('setRouteOrEndpointTag http.route fast path', () => { + let context + + beforeEach(() => { + span = tracer.startSpan('test.request') + tags = span.context().getTags() + + req.url = '/' + + web.patch(req) + context = web.getContext(req) + context.span = span + context.req = req + context.res = res + context.config = config + }) + + it('leaves http.route unset when no segments were collected', () => { + context.paths = [] + + web.setRouteOrEndpointTag(req) + + assert.ok(!Object.hasOwn(tags, HTTP_ROUTE)) + }) + + it('uses the single segment directly without entering Array.join', () => { + context.paths = ['/users/:id'] + + web.setRouteOrEndpointTag(req) + + assert.strictEqual(tags[HTTP_ROUTE], '/users/:id') + }) + + it('leaves http.route unset for a single empty-string segment', () => { + context.paths = [''] + + web.setRouteOrEndpointTag(req) + + assert.ok(!Object.hasOwn(tags, HTTP_ROUTE)) + }) + + it('joins two segments byte-identical to the legacy join shape', () => { + context.paths = ['/api', '/users/:id'] + + web.setRouteOrEndpointTag(req) + + assert.strictEqual(tags[HTTP_ROUTE], '/api/users/:id') + }) + + it('joins three segments byte-identical to the legacy join shape', () => { + context.paths = ['/api', '/users', '/:id/items'] + + web.setRouteOrEndpointTag(req) + + assert.strictEqual(tags[HTTP_ROUTE], '/api/users/:id/items') + }) + }) + + describe('configured header tagging across the request lifecycle', () => { + const USER_AGENT_TAG = `${HTTP_REQUEST_HEADERS}.user-agent` + const SERVER_TAG = `${HTTP_RESPONSE_HEADERS}.server` + + beforeEach(() => { + req.url = '/users' + req.headers['user-agent'] = 'test' + }) + + it('honours headers added to the plugin config after startSpan', () => { + const httpConfig = web.normalizeConfig({}) + const frameworkConfig = web.normalizeConfig({ headers: ['user-agent', 'server'] }) + + web.startSpan(tracer, httpConfig, req, res, 'test.request') + span = web.root(req) + tags = span.context().getTags() + + assert.ok(Object.hasOwn(tags, 'http.url')) + assert.ok(!Object.hasOwn(tags, USER_AGENT_TAG)) + + web.setFramework(req, 'test-framework', frameworkConfig) + + web.finishAll(web.getContext(req)) + + assert.strictEqual(tags[USER_AGENT_TAG], 'test') + assert.strictEqual(tags[SERVER_TAG], 'test') + }) + + it('still tags headers when the http-side config already lists them', () => { + const httpConfig = web.normalizeConfig({ headers: ['user-agent'] }) + + web.startSpan(tracer, httpConfig, req, res, 'test.request') + span = web.root(req) + tags = span.context().getTags() + + web.finishAll(web.getContext(req)) + + assert.strictEqual(tags[USER_AGENT_TAG], 'test') + }) + }) + + describe('normalizeConfig clientIpEnabled', () => { + beforeEach(() => { + req.url = '/' + req.headers['x-forwarded-for'] = '203.0.113.5' + }) + + it('does not tag http.client_ip when clientIpEnabled is not set', () => { + const httpConfig = web.normalizeConfig({}) + + web.startSpan(tracer, httpConfig, req, res, 'test.request') + span = web.root(req) + + web.finishAll(web.getContext(req)) + + assert.ok(!span.context().hasTag(HTTP_CLIENT_IP)) + }) + + it('tags http.client_ip when clientIpEnabled is true', () => { + const httpConfig = web.normalizeConfig({ clientIpEnabled: true }) + + web.startSpan(tracer, httpConfig, req, res, 'test.request') + span = web.root(req) + + web.finishAll(web.getContext(req)) + + assert.strictEqual(span.context().getTag(HTTP_CLIENT_IP), '203.0.113.5') + }) + }) + + describe('wrapWriteHead', () => { + const ALLOW_HEADERS = 'access-control-allow-headers' + const ALLOW_ORIGIN = 'access-control-allow-origin' + let context + + beforeEach(() => { + span = tracer.startSpan('test.request') + + web.patch(req) + context = web.getContext(req) + context.span = span + context.req = req + context.res = res + context.config = config + }) + + it('does not touch CORS headers for non-OPTIONS requests', () => { + req.method = 'GET' + req.headers.origin = 'https://example.com' + req.headers['access-control-request-headers'] = 'x-datadog-trace-id' + res.getHeaders.returns({ [ALLOW_ORIGIN]: '*' }) + + const wrapped = web.wrapWriteHead(context) + wrapped.call(res, 200) + + assert.ok(res.setHeader.notCalled) + }) + + it('skips allow-header tagging on OPTIONS when the origin is not allowed', () => { + req.method = 'OPTIONS' + req.headers.origin = 'https://evil.example.com' + req.headers['access-control-request-headers'] = 'x-datadog-trace-id' + res.getHeaders.returns({ [ALLOW_ORIGIN]: 'https://good.example.com' }) + + const wrapped = web.wrapWriteHead(context) + wrapped.call(res, 200) + + assert.ok(res.setHeader.notCalled) + }) + + it('merges datadog allow-headers on OPTIONS when allow-origin is *', () => { + req.method = 'OPTIONS' + req.headers.origin = 'https://example.com' + req.headers['access-control-request-headers'] = + 'x-datadog-trace-id, x-datadog-parent-id, x-other' + res.getHeaders.returns({ [ALLOW_ORIGIN]: '*' }) + + const wrapped = web.wrapWriteHead(context) + wrapped.call(res, 200) + + assert.ok(res.setHeader.calledOnce) + assert.deepStrictEqual( + res.setHeader.firstCall.args, + [ALLOW_HEADERS, 'x-datadog-parent-id,x-datadog-trace-id'] + ) + }) + + it('honours headers passed as the second writeHead argument', () => { + req.method = 'OPTIONS' + req.headers.origin = 'https://example.com' + req.headers['access-control-request-headers'] = 'x-datadog-trace-id' + res.getHeaders.returns({}) + + const wrapped = web.wrapWriteHead(context) + wrapped.call(res, 200, { [ALLOW_ORIGIN]: 'https://example.com' }) + + assert.ok(res.setHeader.calledOnce) + assert.deepStrictEqual( + res.setHeader.firstCall.args, + [ALLOW_HEADERS, 'x-datadog-trace-id'] + ) + }) + + it('honours headers passed as the third writeHead argument with a status message', () => { + req.method = 'OPTIONS' + req.headers.origin = 'https://example.com' + req.headers['access-control-request-headers'] = 'x-datadog-trace-id' + res.getHeaders.returns({}) + + const wrapped = web.wrapWriteHead(context) + wrapped.call(res, 200, 'OK', { [ALLOW_ORIGIN]: '*' }) + + assert.ok(res.setHeader.calledOnce) + assert.deepStrictEqual( + res.setHeader.firstCall.args, + [ALLOW_HEADERS, 'x-datadog-trace-id'] + ) + }) + + it('treats lowercase req.method "options" as OPTIONS', () => { + req.method = 'options' + req.headers.origin = 'https://example.com' + req.headers['access-control-request-headers'] = 'x-datadog-trace-id' + res.getHeaders.returns({ [ALLOW_ORIGIN]: '*' }) + + const wrapped = web.wrapWriteHead(context) + wrapped.call(res, 200) + + assert.ok(res.setHeader.calledOnce) + assert.deepStrictEqual( + res.setHeader.firstCall.args, + [ALLOW_HEADERS, 'x-datadog-trace-id'] + ) + }) + + it('preserves existing allow-headers and de-duplicates datadog additions', () => { + req.method = 'OPTIONS' + req.headers.origin = 'https://example.com' + req.headers['access-control-request-headers'] = 'x-datadog-trace-id, x-datadog-trace-id' + res.getHeaders.returns({ + [ALLOW_ORIGIN]: '*', + [ALLOW_HEADERS]: 'content-type, x-datadog-trace-id', + }) + + const wrapped = web.wrapWriteHead(context) + wrapped.call(res, 200) + + assert.ok(res.setHeader.calledOnce) + assert.deepStrictEqual( + res.setHeader.firstCall.args, + [ALLOW_HEADERS, 'content-type,x-datadog-trace-id'] + ) + }) + + it('leaves allow-headers untouched when no datadog header was requested', () => { + req.method = 'OPTIONS' + req.headers.origin = 'https://example.com' + req.headers['access-control-request-headers'] = 'content-type, x-other' + res.getHeaders.returns({ [ALLOW_ORIGIN]: '*' }) + + const wrapped = web.wrapWriteHead(context) + wrapped.call(res, 200) + + assert.ok(res.setHeader.notCalled) + }) + + it('delegates to the original writeHead with the same arguments', () => { + req.method = 'OPTIONS' + req.headers.origin = 'https://example.com' + res.getHeaders.returns({ [ALLOW_ORIGIN]: '*' }) + res.writeHead = sinon.spy() + + const wrapped = web.wrapWriteHead(context) + wrapped.call(res, 204, 'No Content', { 'x-test': '1' }) + + assert.ok(res.writeHead.calledOnce) + assert.deepStrictEqual( + res.writeHead.firstCall.args, + [204, 'No Content', { 'x-test': '1' }] + ) + }) + }) }) diff --git a/packages/dd-trace/test/plugins/versions/package.json b/packages/dd-trace/test/plugins/versions/package.json index 13a3a52766..dcdcb83661 100644 --- a/packages/dd-trace/test/plugins/versions/package.json +++ b/packages/dd-trace/test/plugins/versions/package.json @@ -6,214 +6,222 @@ "dependencies": { "@babel/core": "7.29.0", "@babel/preset-typescript": "7.28.5", - "@ai-sdk/openai": "3.0.12", - "@anthropic-ai/sdk": "0.73.0", - "@apollo/gateway": "2.12.2", - "@apollo/server": "5.2.0", - "@apollo/subgraph": "2.12.2", - "@aws-sdk/client-bedrock-runtime": "3.971.0", - "@aws-sdk/client-dynamodb": "3.971.0", - "@aws-sdk/client-kinesis": "3.971.0", - "@aws-sdk/client-lambda": "3.971.0", - "@aws-sdk/client-s3": "3.971.0", - "@aws-sdk/client-sfn": "3.971.0", - "@aws-sdk/client-sns": "3.971.0", - "@aws-sdk/client-sqs": "3.971.0", + "@ai-sdk/openai": "3.0.65", + "@anthropic-ai/sdk": "0.98.0", + "@apollo/gateway": "2.14.0", + "@apollo/server": "5.5.1", + "@apollo/subgraph": "2.14.0", + "@aws-sdk/client-bedrock-runtime": "3.1053.0", + "@aws-sdk/client-dynamodb": "3.1053.0", + "@aws-sdk/client-kinesis": "3.1053.0", + "@aws-sdk/client-lambda": "3.1053.0", + "@aws-sdk/client-s3": "3.1053.0", + "@aws-sdk/client-sfn": "3.1053.0", + "@aws-sdk/client-sns": "3.1053.0", + "@aws-sdk/client-sqs": "3.1053.0", "@aws-sdk/node-http-handler": "3.374.0", "@aws-sdk/smithy-client": "3.374.0", - "@azure/event-hubs": "6.0.2", - "@azure/functions": "4.11.0", - "@modelcontextprotocol/sdk": "1.27.1", - "durable-functions": "3.3.0", + "@azure/cosmos": "4.9.3", + "@azure/event-hubs": "6.0.4", + "@azure/functions": "4.16.0", + "@modelcontextprotocol/sdk": "1.29.0", + "durable-functions": "3.3.1", "@azure/service-bus": "7.9.5", - "@confluentinc/kafka-javascript": "1.8.0", - "@cucumber/cucumber": "12.8.2", - "@datadog/openfeature-node-server": "0.3.1", - "@elastic/elasticsearch": "9.3.2", - "@elastic/transport": "9.3.3", - "@electron/packager": "19.1.0", - "@fast-check/jest": "2.1.1", + "@confluentinc/kafka-javascript": "1.9.0", + "@cucumber/cucumber": "12.9.0", + "@datadog/openfeature-node-server": "1.2.1", + "@elastic/elasticsearch": "9.4.0", + "@elastic/transport": "9.3.5", + "@electron/packager": "20.0.0", "@fastify/cookie": "11.0.2", - "@fastify/multipart": "9.4.0", - "@google-cloud/pubsub": "5.2.2", - "@google-cloud/vertexai": "1.10.0", - "@google/genai": "1.37.0", - "@graphql-tools/executor": "1.5.1", + "@fastify/multipart": "10.0.0", + "@google-cloud/pubsub": "5.3.0", + "@google-cloud/vertexai": "1.12.0", + "@google/genai": "2.6.0", + "@graphql-tools/executor": "1.5.3", "@grpc/grpc-js": "1.14.3", - "@grpc/proto-loader": "0.8.0", + "@grpc/proto-loader": "0.8.1", "@hapi/boom": "10.0.1", - "@hapi/hapi": "21.4.4", - "@happy-dom/jest-environment": "20.3.1", - "@hono/node-server": "1.19.9", - "@jest/core": "30.4.1", + "@hapi/hapi": "21.4.9", + "@happy-dom/jest-environment": "20.9.0", + "@hono/node-server": "2.0.3", + "@jest/core": "30.4.2", "@jest/globals": "30.4.1", "@jest/reporters": "30.4.1", "@jest/test-sequencer": "30.4.1", "@jest/transform": "30.4.1", - "@koa/router": "15.2.0", - "@langchain/anthropic": "1.3.10", - "@langchain/classic": "1.0.9", - "@langchain/cohere": "1.0.1", - "@langchain/core": "1.1.16", - "@langchain/google-genai": "2.1.10", - "@langchain/langgraph": "1.1.2", - "@langchain/openai": "1.2.2", + "@koa/router": "15.5.0", + "@langchain/anthropic": "1.4.0", + "@langchain/classic": "1.0.34", + "@langchain/cohere": "1.0.5", + "@langchain/core": "1.1.48", + "@langchain/google-genai": "2.1.31", + "@langchain/langgraph": "1.3.2", + "@langchain/openai": "1.4.7", + "@nats-io/nats-core": "3.4.0", + "@nats-io/transport-node": "3.4.0", "@node-redis/client": "1.0.6", - "@openai/agents": "0.3.9", - "@openai/agents-core": "0.4.5", - "@openfeature/core": "^1.9.0", - "@openfeature/server-sdk": "~1.20.0", - "@opensearch-project/opensearch": "3.5.1", + "@openai/agents": "0.11.5", + "@openai/agents-core": "0.11.5", + "@openfeature/core": "1.10.0", + "@openfeature/server-sdk": "1.21.0", + "@opensearch-project/opensearch": "3.6.0", "@opentelemetry/api": "1.9.1", - "@opentelemetry/api-logs": "0.215.0", - "@opentelemetry/exporter-jaeger": "2.4.0", - "@opentelemetry/instrumentation": "0.210.0", - "@opentelemetry/instrumentation-express": "0.58.0", - "@opentelemetry/instrumentation-http": "0.210.0", - "@opentelemetry/sdk-node": "0.210.0", - "@playwright/test": "1.59.1", - "@prisma/client": "7.2.0", - "@prisma/adapter-pg": "7.2.0", - "@prisma/adapter-mariadb": "7.2.0", - "@prisma/adapter-mssql": "7.2.0", - "@redis/client": "5.10.0", - "@smithy/smithy-client": "4.10.9", - "@types/node": "25.0.9", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/runner": "4.1.5", - "aerospike": "6.5.2", - "ai": "6.0.39", + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/exporter-jaeger": "2.7.1", + "@opentelemetry/instrumentation": "0.218.0", + "@opentelemetry/instrumentation-express": "0.66.0", + "@opentelemetry/instrumentation-http": "0.218.0", + "@opentelemetry/sdk-node": "0.218.0", + "@playwright/test": "1.60.0", + "@prisma/client": "7.8.0", + "@prisma/adapter-pg": "7.8.0", + "@prisma/adapter-mariadb": "7.8.0", + "@prisma/adapter-mssql": "7.8.0", + "@redis/client": "5.12.1", + "@smithy/core": "3.24.4", + "@smithy/smithy-client": "4.13.4", + "@types/node": "25.9.0", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/runner": "4.1.7", + "aerospike": "6.7.0", + "ai": "6.0.191", "amqp10": "3.6.0", - "amqplib": "0.10.9", + "amqplib": "2.0.1", "apollo-server-core": "3.13.0", "apollo-server-express": "3.13.0", "apollo-server-fastify": "3.13.0", "avsc": "5.7.9", "aws-sdk": "2.1693.0", - "axios": "1.13.2", + "axios": "1.16.1", "babel-jest": "30.4.1", - "azure-functions-core-tools": "4.6.0", + "azure-functions-core-tools": "4.11.0", "bluebird": "3.7.2", "body-parser": "2.2.2", - "bson": "7.1.1", - "bullmq": "5.66.5", + "bson": "7.2.0", + "bullmq": "5.76.10", "bunyan": "2.0.5", - "cassandra-driver": "4.8.0", + "cassandra-driver": "4.9.0", "collections": "5.1.13", "connect": "3.7.0", "cookie": "1.1.1", "cookie-parser": "1.4.7", - "couchbase": "4.6.0", - "cypress": "15.14.2", - "cypress-fail-fast": "7.1.1", - "dd-trace-api": "1.0.0", - "ejs": "4.0.1", + "couchbase": "4.7.0", + "cypress": "15.16.0", + "cypress-fail-fast": "8.1.0", + "dd-trace-api": "1.0.1", + "ejs": "5.0.2", "elasticsearch": "16.7.3", - "electron": "39.2.4", - "esbuild": "0.27.2", + "electron": "42.1.0", + "esbuild": "0.28.0", "express": "5.2.1", "express-mongo-sanitize": "2.2.0", - "express-session": "1.18.2", - "fastify": "5.7.1", - "find-my-way": "9.4.0", - "fs": "0.0.2", + "express-session": "1.19.0", + "fastify": "5.8.5", + "find-my-way": "9.6.0", + "fs": "0.0.1-security", "generic-pool": "3.9.0", - "graphql": "16.12.0", + "graphql": "16.14.0", "graphql-tag": "2.12.6", - "graphql-tools": "9.0.26", - "graphql-yoga": "5.18.0", - "handlebars": "4.7.8", + "graphql-tools": "9.0.28", + "graphql-yoga": "5.21.0", + "handlebars": "4.7.9", "hapi": "18.1.0", - "hono": "4.11.7", - "ioredis": "5.9.2", + "hono": "4.12.19", + "ioredis": "5.10.1", "iovalkey": "0.3.3", - "jest": "30.4.1", - "jest-circus": "30.4.1", - "jest-config": "30.4.1", + "jest": "30.4.2", + "jest-circus": "30.4.2", + "jest-config": "30.4.2", "jest-environment-jsdom": "30.4.1", "jest-environment-node": "30.4.1", - "jest-image-snapshot": "6.5.1", - "jest-jasmine2": "30.4.1", - "jest-runtime": "30.4.1", + "jest-image-snapshot": "6.5.2", + "jest-jasmine2": "30.4.2", + "jest-runtime": "30.4.2", "jest-worker": "30.4.1", "kafkajs": "2.2.4", - "knex": "3.1.0", - "koa": "3.1.1", + "knex": "3.2.10", + "koa": "3.2.0", "koa-route": "4.0.1", "koa-router": "14.0.0", "koa-websocket": "7.0.0", - "langchain": "1.2.10", + "langchain": "1.4.2", "ldapjs": "3.0.7", "ldapjs-promise": "3.0.8", "light-my-request": "6.6.0", "limitd-client": "2.14.1", - "lodash": "4.17.21", + "lodash": "4.18.1", "loopback": "3.28.0", "mariadb": "3.4.5", "memcached": "2.2.2", "microgateway-core": "3.3.7", "middie": "7.1.0", - "mocha": "11.7.5", + "mocha": "11.7.6", "mocha-each": "2.0.1", - "moleculer": "0.14.35", - "mongodb": "7.0.0", + "moleculer": "0.15.0", + "mongodb": "7.2.0", "mongodb-core": "3.2.7", - "mongoose": "9.1.4", + "mongoose": "9.6.2", "mquery": "6.0.0", - "multer": "2.0.2", + "multer": "2.1.1", "mysql": "2.18.1", - "mysql2": "3.18.2", - "next": "16.1.3", - "nock": "14.0.10", + "mysql2": "3.22.3", + "next": "16.2.6", + "nock": "14.0.15", + "node-18": "npm:node@18.20.8", + "node-20": "npm:node@20.20.2", + "node-22": "npm:node@22.22.3", + "node-24": "npm:node@24.16.0", + "node-26": "npm:node@26.2.0", "node-serialize": "0.0.4", - "npm": "11.7.0", - "nyc": "17.1.0", + "npm": "11.14.1", + "nyc": "18.0.0", "office-addin-mock": "2.4.6", - "openai": "6.18.0", + "openai": "6.39.0", "oracledb": "6.10.0", "passport": "0.7.0", "passport-http": "0.3.0", "passport-local": "1.0.0", - "pg": "8.17.1", - "pg-cursor": "2.16.1", - "pg-native": "3.5.2", - "pg-query-stream": "4.11.1", - "pino": "10.2.0", + "pg": "8.21.0", + "pg-cursor": "2.20.0", + "pg-native": "3.8.0", + "pg-query-stream": "4.15.0", + "pino": "10.3.1", "pino-pretty": "13.1.3", - "playwright": "1.59.1", - "playwright-core": "1.59.1", - "pnpm": "10.28.0", - "prisma": "7.2.0", + "playwright": "1.60.0", + "playwright-core": "1.60.0", + "pnpm": "11.1.3", + "prisma": "7.8.0", "promise": "8.3.0", "promise-js": "0.0.7", - "protobufjs": "8.0.0", - "pug": "3.0.3", + "protobufjs": "8.4.0", + "pug": "3.0.4", "q": "2.0.3", - "react": "19.2.3", - "react-dom": "19.2.3", - "redis": "5.10.0", + "react": "19.2.6", + "react-dom": "19.2.6", + "redis": "5.12.1", "request": "2.88.2", "restify": "11.1.0", - "rhea": "3.0.4", + "rhea": "3.0.5", "router": "2.2.0", - "selenium-webdriver": "4.39.0", - "sequelize": "6.37.7", + "selenium-webdriver": "4.44.0", + "sequelize": "6.37.8", "sharedb": "5.2.2", - "sinon": "21.0.1", - "sqlite3": "5.1.7", - "stripe": "22.1.0", - "tedious": "19.2.0", + "sinon": "22.0.0", + "sqlite3": "6.0.1", + "stripe": "22.1.1", + "tedious": "19.2.1", "tinypool": "2.1.0", - "typescript": "6.0.2", - "undici": "7.18.2", - "vitest": "4.1.5", + "typescript": "6.0.3", + "undici": "8.3.0", + "vitest": "4.1.7", "when": "3.7.8", "winston": "3.19.0", - "workerpool": "10.0.1", - "ws": "8.19.0", + "workerpool": "10.0.2", + "ws": "8.20.1", "yarn": "1.22.22", - "zod": "4.3.6", - "zod-to-json-schema": "3.23.1" + "zod": "4.4.3", + "zod-to-json-schema": "3.25.2" } } diff --git a/packages/dd-trace/test/priority_sampler.spec.js b/packages/dd-trace/test/priority_sampler.spec.js index 7e2c821b01..9fc1315e50 100644 --- a/packages/dd-trace/test/priority_sampler.spec.js +++ b/packages/dd-trace/test/priority_sampler.spec.js @@ -51,6 +51,10 @@ describe('PrioritySampler', () => { started: [], tags: {}, }, + getTags () { return this._tags }, + getTag (key) { return this._tags[key] }, + setTag (key, value) { this._tags[key] = value }, + hasTag (key) { return key in this._tags }, } span = { diff --git a/packages/dd-trace/test/process-tags.spec.js b/packages/dd-trace/test/process-tags.spec.js index c2f0c45deb..387d73fbf2 100644 --- a/packages/dd-trace/test/process-tags.spec.js +++ b/packages/dd-trace/test/process-tags.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') @@ -32,10 +33,10 @@ describe('process-tags', () => { describe('processTags', () => { it('should return an object with tags, serialized, and tagsObject properties', () => { - assert.ok(Object.hasOwn(processTags, 'tags')) - assert.ok(Object.hasOwn(processTags, 'serialized')) - assert.ok(Object.hasOwn(processTags, 'tagsObject')) - assert.ok(Array.isArray(processTags.tags)) + assert.ok(Object.hasOwn(processTags, 'tags'), `Available keys: ${inspect(Object.keys(processTags))}`) + assert.ok(Object.hasOwn(processTags, 'serialized'), `Available keys: ${inspect(Object.keys(processTags))}`) + assert.ok(Object.hasOwn(processTags, 'tagsObject'), `Available keys: ${inspect(Object.keys(processTags))}`) + assert.ok(Array.isArray(processTags.tags), `Expected array, got ${inspect(processTags.tags)}`) assert.strictEqual(typeof processTags.serialized, 'string') assert.strictEqual(typeof processTags.tagsObject, 'object') }) @@ -71,14 +72,14 @@ describe('process-tags', () => { it('should have entrypoint.type set to "script"', () => { const typeTag = processTags.tags.find(([name]) => name === 'entrypoint.type') - assert.ok(Array.isArray(typeTag)) + assert.ok(Array.isArray(typeTag), `Expected array, got ${inspect(typeTag)}`) assert.strictEqual(typeTag[1], 'script') }) it('should set entrypoint.workdir to the basename of cwd', () => { const workdirTag = processTags.tags.find(([name]) => name === 'entrypoint.workdir') - assert.ok(Array.isArray(workdirTag)) + assert.ok(Array.isArray(workdirTag), `Expected array, got ${inspect(workdirTag)}`) assert.strictEqual(typeof workdirTag[1], 'string') assert.doesNotMatch(workdirTag[1], /\//) }) @@ -121,7 +122,7 @@ describe('process-tags', () => { // serialized should be comma-separated and not include undefined values if (processTags.serialized) { const parts = processTags.serialized.split(',') - assert.ok(parts.length > 0) + assert.ok(parts.length > 0, `Expected ${parts.length} > 0`) parts.forEach(part => { assert.match(part, /:/) assert.doesNotMatch(part, /undefined/) diff --git a/packages/dd-trace/test/profiling/config.spec.js b/packages/dd-trace/test/profiling/config.spec.js index 2d4c950ad6..8210983598 100644 --- a/packages/dd-trace/test/profiling/config.spec.js +++ b/packages/dd-trace/test/profiling/config.spec.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict') const os = require('node:os') const path = require('node:path') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const proxyquire = require('proxyquire') @@ -74,7 +75,7 @@ describe('config', () => { }, }) assert.strictEqual(typeof config.service, 'string') - assert.ok(config.service.length > 0) + assert.ok(config.service.length > 0, `Expected ${config.service.length} > 0`) assert.strictEqual(typeof config.version, 'string') assertObjectContains(config.tags, { service: config.service, @@ -152,7 +153,7 @@ describe('config', () => { const { config } = getProfilerConfig({ reportHostname: true }) assert.strictEqual(typeof config.tags.host, 'string') - assert.ok(config.tags.host.length > 0) + assert.ok(config.tags.host.length > 0, `Expected ${config.tags.host.length} > 0`) assert.strictEqual(config.tags.host, os.hostname()) }) @@ -443,8 +444,14 @@ describe('config', () => { }) function assertOomExportCommand (config) { - assert.ok(config.oomMonitoring.exportCommand[3].includes(`service:${config.service}`)) - assert.ok(config.oomMonitoring.exportCommand[3].includes('snapshot:on_oom')) + assert.ok( + config.oomMonitoring.exportCommand[3].includes(`service:${config.service}`), + `Got: ${inspect(config.oomMonitoring.exportCommand[3])}` + ) + assert.ok( + config.oomMonitoring.exportCommand[3].includes('snapshot:on_oom'), + `Got: ${inspect(config.oomMonitoring.exportCommand[3])}` + ) } it('should enable OOM heap profiler by default and use process as default strategy', () => { diff --git a/packages/dd-trace/test/profiling/exporters/agent.spec.js b/packages/dd-trace/test/profiling/exporters/agent.spec.js index 3fdd632378..dccd32adb5 100644 --- a/packages/dd-trace/test/profiling/exporters/agent.spec.js +++ b/packages/dd-trace/test/profiling/exporters/agent.spec.js @@ -279,13 +279,13 @@ describe('exporters/agent', function () { failed = true } assert.strictEqual(failed, true) - assert.ok(attempt > 0) + assert.ok(attempt > 0, `Expected ${attempt} > 0`) // Verify computeRetries produces correct starting values for (let i = 1; i <= 100; i++) { const [retries, timeout] = computeRetries(i * 1000) - assert.ok(retries >= 2) - assert.ok(timeout <= 1000) + assert.ok(retries >= 2, `Expected ${retries} >= 2`) + assert.ok(timeout <= 1000, `Expected ${timeout} <= 1000`) assert.strictEqual(Number.isInteger(timeout), true) } diff --git a/packages/dd-trace/test/profiling/profiler.spec.js b/packages/dd-trace/test/profiling/profiler.spec.js index 7029b31909..96d24d85c5 100644 --- a/packages/dd-trace/test/profiling/profiler.spec.js +++ b/packages/dd-trace/test/profiling/profiler.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') @@ -273,10 +274,10 @@ describe('profiler', function () { const { profiles, start, end, tags } = await exporterPromise - assert.ok(Object.hasOwn(profiles, 'wall')) + assert.ok(Object.hasOwn(profiles, 'wall'), `Available keys: ${inspect(Object.keys(profiles))}`) assert.ok(profiles.wall instanceof Buffer) assert.strictEqual(profiles.wall.indexOf(magicBytes), 0) - assert.ok(Object.hasOwn(profiles, 'space')) + assert.ok(Object.hasOwn(profiles, 'space'), `Available keys: ${inspect(Object.keys(profiles))}`) assert.ok(profiles.space instanceof Buffer) assert.strictEqual(profiles.space.indexOf(magicBytes), 0) assert.ok(start instanceof Date) @@ -363,7 +364,7 @@ describe('profiler', function () { await waitForExport() const { start: start2, end: end2 } = exporter.export.args[0][0] - assert.ok(start2 >= end) + assert.ok(start2 >= end, `Expected ${start2} >= ${end}`) assert.ok(start2 instanceof Date) assert.ok(end2 instanceof Date) assert.strictEqual(end2.getTime() - start2.getTime(), 65000) @@ -382,7 +383,7 @@ describe('profiler', function () { profiler.start(makeStartOptions({ sourceMap: true })) const options = profilers[0].start.args[0][0] - assert.ok(Object.hasOwn(options, 'mapper')) + assert.ok(Object.hasOwn(options, 'mapper'), `Available keys: ${inspect(Object.keys(options))}`) assert.strictEqual(mapperInstance, options.mapper) }) diff --git a/packages/dd-trace/test/profiling/profilers/events.spec.js b/packages/dd-trace/test/profiling/profilers/events.spec.js index c3fe703c3c..d60b430488 100644 --- a/packages/dd-trace/test/profiling/profilers/events.spec.js +++ b/packages/dd-trace/test/profiling/profilers/events.spec.js @@ -57,7 +57,7 @@ describe('profilers/events', () => { it('should provide info', () => { const info = new EventsProfiler(getProfilerConfig()).getInfo() - assert(info.maxSamples > 0) + assert(info.maxSamples > 0, `Expected ${info.maxSamples} > 0`) }) it('should limit the number of events', async () => { diff --git a/packages/dd-trace/test/profiling/profilers/poisson.spec.js b/packages/dd-trace/test/profiling/profilers/poisson.spec.js index 2260ad104a..b6b0fb07dc 100644 --- a/packages/dd-trace/test/profiling/profilers/poisson.spec.js +++ b/packages/dd-trace/test/profiling/profilers/poisson.spec.js @@ -83,7 +83,7 @@ describe('PoissonProcessSamplingFilter', () => { assert.strictEqual(typeof filter.currentSamplingInstant, 'number') assert.strictEqual(filter.currentSamplingInstant, 0) assert.strictEqual(typeof filter.nextSamplingInstant, 'number') - assert.ok(filter.nextSamplingInstant > 0) + assert.ok(filter.nextSamplingInstant > 0, `Expected ${filter.nextSamplingInstant} > 0`) assert.strictEqual(filter.samplingInstantCount, 1) }) @@ -101,9 +101,12 @@ describe('PoissonProcessSamplingFilter', () => { assert.strictEqual(filter.currentSamplingInstant, 0) nowValue = prevNextSamplingInstant + 15 filter.filter(event) - assert.ok(filter.nextSamplingInstant > prevNextSamplingInstant) - assert.ok(filter.currentSamplingInstant > 0) - assert.ok(filter.samplingInstantCount > 1) + assert.ok( + filter.nextSamplingInstant > prevNextSamplingInstant, + `Expected ${filter.nextSamplingInstant} > ${prevNextSamplingInstant}` + ) + assert.ok(filter.currentSamplingInstant > 0, `Expected ${filter.currentSamplingInstant} > 0`) + assert.ok(filter.samplingInstantCount > 1, `Expected ${filter.samplingInstantCount} > 1`) }) it('should not advance sampling instant if event endTime < nextSamplingInstant', () => { @@ -128,13 +131,20 @@ describe('PoissonProcessSamplingFilter', () => { now, }) const prevNextSamplingInstant = filter.nextSamplingInstant - nowValue = 1000 + // nowValue must comfortably exceed the initial nextSamplingInstant, which is an + // exponential RV with mean = samplingInterval = 100. P(initial > 100000) = e^-1000, + // so the first assertion below is effectively never flaky. The resetInterval still + // bounds the while loop in filter() to ~2 iterations, keeping the other assertions tight. + nowValue = 100000 const event = { startTime: 0, duration: 1e6 } filter.filter(event) - assert.ok(filter.currentSamplingInstant >= prevNextSamplingInstant) + assert.ok( + filter.currentSamplingInstant >= prevNextSamplingInstant, + `Expected ${filter.currentSamplingInstant} >= ${prevNextSamplingInstant}` + ) assert.strictEqual(typeof filter.nextSamplingInstant, 'number') - assert.ok(filter.nextSamplingInstant < 500000) - assert.ok(filter.samplingInstantCount < 30) + assert.ok(filter.nextSamplingInstant < 500000, `Expected ${filter.nextSamplingInstant} < 500000`) + assert.ok(filter.samplingInstantCount < 30, `Expected ${filter.samplingInstantCount} < 30`) }) it('should reset nextSamplingInstant if it is too far in the past', () => { @@ -146,10 +156,10 @@ describe('PoissonProcessSamplingFilter', () => { const event = { startTime: 100000, duration: 100 } nowValue = event.startTime + event.duration filter.filter(event) - assert.ok(filter.nextSamplingInstant > nowValue) + assert.ok(filter.nextSamplingInstant > nowValue, `Expected ${filter.nextSamplingInstant} > ${nowValue}`) // With the feature, the expected value is 2. Without it, the expected value // would be 1000. 100 should be enough not to be flaky. - assert.ok(filter.samplingInstantCount < 100) + assert.ok(filter.samplingInstantCount < 100, `Expected ${filter.samplingInstantCount} < 100`) }) it('should return true if event.startTime < currentSamplingInstant', () => { @@ -184,6 +194,6 @@ describe('PoissonProcessSamplingFilter', () => { const event = { startTime: 0, duration: filter.nextSamplingInstant } filter.filter(event) } - assert.ok(filter.samplingInstantCount > initialCount) + assert.ok(filter.samplingInstantCount > initialCount, `Expected ${filter.samplingInstantCount} > ${initialCount}`) }) }) diff --git a/packages/dd-trace/test/profiling/profilers/wall.spec.js b/packages/dd-trace/test/profiling/profilers/wall.spec.js index d87b349c0b..b57d44da34 100644 --- a/packages/dd-trace/test/profiling/profilers/wall.spec.js +++ b/packages/dd-trace/test/profiling/profilers/wall.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach } = require('mocha') const dc = require('dc-polyfill') @@ -23,6 +24,7 @@ describe('profilers/native/wall', () => { start: sinon.stub(), stop: sinon.stub().returns(profile0), setContext: sinon.stub(), + getContext: sinon.stub(), v8ProfilerStuckEventLoopDetected: sinon.stub().returns(false), constants: { kSampleCount: 0, @@ -446,7 +448,7 @@ describe('profilers/native/wall', () => { assert.ok(called) sinon.assert.calledOnce(localPprof.time.runWithContext) const [ctx] = localPprof.time.runWithContext.firstCall.args - assert.ok(Array.isArray(ctx)) + assert.ok(Array.isArray(ctx), `Expected array, got ${inspect(ctx)}`) assert.deepStrictEqual(ctx[0], { spanId: '123' }) assert.deepStrictEqual(ctx[1], { customer: 'acme' }) @@ -471,7 +473,7 @@ describe('profilers/native/wall', () => { }) const innerCtx = localPprof.time.runWithContext.secondCall.args[0] - assert.ok(Array.isArray(innerCtx)) + assert.ok(Array.isArray(innerCtx), `Expected array, got ${inspect(innerCtx)}`) assert.deepStrictEqual(innerCtx[0], { spanId: '123' }) assert.deepStrictEqual(innerCtx[1], { customer: 'acme', region: 'us-east' }) @@ -718,6 +720,7 @@ describe('profilers/native/wall', () => { time: { ...pprof.time, setContext: sinon.stub(), + getContext: sinon.stub(), }, } @@ -736,7 +739,13 @@ describe('profilers/native/wall', () => { function makeWebSpan () { const tags = {} const spanId = {} - const ctx = { _tags: tags, _spanId: spanId, _parentId: null, _trace: { started: [] } } + const ctx = { + _tags: tags, + _spanId: spanId, + _parentId: null, + _trace: { started: [] }, + getTags () { return this._tags }, + } const span = { context: () => ctx } ctx._trace.started.push(span) return { span, tags, spanId } @@ -745,7 +754,13 @@ describe('profilers/native/wall', () => { function makeChildSpan (webSpanId, webSpan) { const tags = { 'span.type': 'router' } const spanId = {} - const ctx = { _tags: tags, _spanId: spanId, _parentId: webSpanId, _trace: { started: [webSpan] } } + const ctx = { + _tags: tags, + _spanId: spanId, + _parentId: webSpanId, + _trace: { started: [webSpan] }, + getTags () { return this._tags }, + } const span = { context: () => ctx } ctx._trace.started.push(span) return { span, tags } @@ -898,5 +913,34 @@ describe('profilers/native/wall', () => { profiler.stop() }) + + it('should skip setContext in ACF mode when current CPED context equals sampleContext', () => { + // Every native setContext in ACF mode allocates a fresh contextHolder + // (Object+Global), so repeated activations of the same span must short- + // circuit when the CPED already holds the cached profilingContext. + const { span: webSpan } = makeWebSpan() + const profiler = new WallProfiler({ + endpointCollectionEnabled: true, + codeHotspotsEnabled: true, + asyncContextFrameEnabled: true, + }) + profiler.start() + + currentStore = { span: webSpan } + enterCh.publish() + sinon.assert.calledOnce(localPprof.time.setContext) + const ctx0 = localPprof.time.setContext.firstCall.args[0] + + // Simulate the CPED now holding ctx0 (which the native side would have + // done in response to the previous setContext call). + localPprof.time.getContext.returns(ctx0) + + // Re-activation with the same span returns the cached ctx0 from + // #getProfilingContext → #enter must skip the native setContext call. + enterCh.publish() + sinon.assert.calledOnce(localPprof.time.setContext) + + profiler.stop() + }) }) }) diff --git a/packages/dd-trace/test/proxy.spec.js b/packages/dd-trace/test/proxy.spec.js index 31c26c4707..8120089461 100644 --- a/packages/dd-trace/test/proxy.spec.js +++ b/packages/dd-trace/test/proxy.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') @@ -787,10 +788,14 @@ describe('TracerProxy', () => { describe('immutability', () => { it('should freeze every store handed out', () => { - assert.ok(Object.isFrozen(proxy.getAllBaggageItems())) - assert.ok(Object.isFrozen(proxy.setBaggageItem('key', 'value'))) - assert.ok(Object.isFrozen(proxy.removeBaggageItem('key'))) - assert.ok(Object.isFrozen(proxy.removeAllBaggageItems())) + const allItems = proxy.getAllBaggageItems() + assert.ok(Object.isFrozen(allItems), `Expected frozen, got ${inspect(allItems)}`) + const setItem = proxy.setBaggageItem('key', 'value') + assert.ok(Object.isFrozen(setItem), `Expected frozen, got ${inspect(setItem)}`) + const removeItem = proxy.removeBaggageItem('key') + assert.ok(Object.isFrozen(removeItem), `Expected frozen, got ${inspect(removeItem)}`) + const removeAll = proxy.removeAllBaggageItems() + assert.ok(Object.isFrozen(removeAll), `Expected frozen, got ${inspect(removeAll)}`) }) it('should refuse mutation through the returned reference', () => { diff --git a/packages/dd-trace/test/remote_config/index.spec.js b/packages/dd-trace/test/remote_config/index.spec.js index e9bc492ad3..c4d79e80b6 100644 --- a/packages/dd-trace/test/remote_config/index.spec.js +++ b/packages/dd-trace/test/remote_config/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach } = require('mocha') const sinon = require('sinon') @@ -142,13 +143,28 @@ describe('RemoteConfig', () => { assert.ok(Array.isArray(clientTracer.process_tags), 'process_tags should be an array') // Verify expected process tag keys are present - assert.ok(clientTracer.process_tags.some(tag => tag.startsWith('entrypoint.basedir:'))) - assert.ok(clientTracer.process_tags.some(tag => tag.startsWith('entrypoint.name:'))) - assert.ok(clientTracer.process_tags.some(tag => tag.startsWith('entrypoint.type:'))) - assert.ok(clientTracer.process_tags.some(tag => tag.startsWith('entrypoint.workdir:'))) + assert.ok( + clientTracer.process_tags.some(tag => tag.startsWith('entrypoint.basedir:')), + `Got: ${inspect(clientTracer.process_tags)}` + ) + assert.ok( + clientTracer.process_tags.some(tag => tag.startsWith('entrypoint.name:')), + `Got: ${inspect(clientTracer.process_tags)}` + ) + assert.ok( + clientTracer.process_tags.some(tag => tag.startsWith('entrypoint.type:')), + `Got: ${inspect(clientTracer.process_tags)}` + ) + assert.ok( + clientTracer.process_tags.some(tag => tag.startsWith('entrypoint.workdir:')), + `Got: ${inspect(clientTracer.process_tags)}` + ) // Verify entrypoint.type has expected value - assert.ok(clientTracer.process_tags.some(tag => tag === 'entrypoint.type:script')) + assert.ok( + clientTracer.process_tags.some(tag => tag === 'entrypoint.type:script'), + `Got: ${inspect(clientTracer.process_tags)}` + ) }) it('should add git metadata to tags if present', () => { diff --git a/packages/dd-trace/test/ritm.spec.js b/packages/dd-trace/test/ritm.spec.js index 81022fc0e6..f20e0b8e67 100644 --- a/packages/dd-trace/test/ritm.spec.js +++ b/packages/dd-trace/test/ritm.spec.js @@ -69,7 +69,7 @@ describe('Ritm', () => { // - we don't recurse infinitely on a CJS cycle // - we observe module-a and module-b as part of the cycle // - start/end counts stay in sync - assert.ok(startListener.callCount >= 2) + assert.ok(startListener.callCount >= 2, `Expected ${startListener.callCount} >= 2`) assert.equal(endListener.callCount, startListener.callCount) const startRequests = new Set() diff --git a/packages/dd-trace/test/runtime_metrics.spec.js b/packages/dd-trace/test/runtime_metrics.spec.js index fe921910cc..195c44c20d 100644 --- a/packages/dd-trace/test/runtime_metrics.spec.js +++ b/packages/dd-trace/test/runtime_metrics.spec.js @@ -414,8 +414,8 @@ function createGarbage (count = 50) { // 1 hour even if a single metric is leaking it would get over // 64980 calls on its own without any other metric. A slightly lower // value is used here to be on the safer side. - assert.ok(client.gauge.callCount < 60000) - assert.ok(client.increment.callCount < 60000) + assert.ok(client.gauge.callCount < 60000, `Expected ${client.gauge.callCount} < 60000`) + assert.ok(client.increment.callCount < 60000, `Expected ${client.increment.callCount} < 60000`) }) it('should handle configuration changes correctly', async () => { @@ -649,7 +649,7 @@ function createGarbage (count = 50) { const heapUsed = heapUsedCalls[0].args[1] const heapTotal = heapTotalCalls[0].args[1] - assert(heapUsed <= heapTotal) + assert(heapUsed <= heapTotal, `Expected ${heapUsed} <= ${heapTotal}`) }) }) diff --git a/packages/dd-trace/test/sampling_rule.spec.js b/packages/dd-trace/test/sampling_rule.spec.js index 1370389fff..87e821f385 100644 --- a/packages/dd-trace/test/sampling_rule.spec.js +++ b/packages/dd-trace/test/sampling_rule.spec.js @@ -52,6 +52,7 @@ function createDummySpans () { }, _name: operation, _tags: {}, + getTag (key) { return this._tags[key] }, } // Give first span a custom service name diff --git a/packages/dd-trace/test/service-naming/source-resolver.spec.js b/packages/dd-trace/test/service-naming/source-resolver.spec.js new file mode 100644 index 0000000000..f196fbc53b --- /dev/null +++ b/packages/dd-trace/test/service-naming/source-resolver.spec.js @@ -0,0 +1,58 @@ +'use strict' + +const assert = require('node:assert/strict') + +const { describe, it } = require('mocha') + +require('../setup/core') +const DatadogSpanContext = require('../../src/opentracing/span_context') +const { + INTEGRATION_SERVICE, + MANUAL, + resolveServiceSource, +} = require('../../src/service-naming/source-resolver') + +const TRACER_SERVICE = 'app' +const SVC_SRC_KEY = '_dd.svc_src' + +function makeSpan (tags = {}, marker) { + const span = { _spanContext: new DatadogSpanContext({ tags: { ...tags } }) } + if (marker !== undefined) span[INTEGRATION_SERVICE] = marker + return span +} + +describe('service-naming/source-resolver', () => { + describe('resolveServiceSource', () => { + it('clears _dd.svc_src when service.name equals the tracer default', () => { + const span = makeSpan({ 'service.name': TRACER_SERVICE, [SVC_SRC_KEY]: 'opt.plugin' }) + + resolveServiceSource(span, TRACER_SERVICE) + + assert.strictEqual(span._spanContext.getTag(SVC_SRC_KEY), undefined) + }) + + it('keeps the integration source when the marker matches current service.name', () => { + const span = makeSpan({ 'service.name': 'kafka-broker', [SVC_SRC_KEY]: 'kafka' }, 'kafka-broker') + + resolveServiceSource(span, TRACER_SERVICE) + + assert.strictEqual(span._spanContext.getTag(SVC_SRC_KEY), 'kafka') + }) + + it('marks manual when user overrides an integration value', () => { + const span = makeSpan({ 'service.name': 'my-app', [SVC_SRC_KEY]: 'kafka' }, 'kafka-broker') + + resolveServiceSource(span, TRACER_SERVICE) + + assert.strictEqual(span._spanContext.getTag(SVC_SRC_KEY), MANUAL) + }) + + it('marks manual for a user-only span with a non-default service', () => { + const span = makeSpan({ 'service.name': 'my-app' }) + + resolveServiceSource(span, TRACER_SERVICE) + + assert.strictEqual(span._spanContext.getTag(SVC_SRC_KEY), MANUAL) + }) + }) +}) diff --git a/packages/dd-trace/test/span_format.spec.js b/packages/dd-trace/test/span_format.spec.js index 746d59421a..ba28a1abfe 100644 --- a/packages/dd-trace/test/span_format.spec.js +++ b/packages/dd-trace/test/span_format.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach } = require('mocha') const sinon = require('sinon') @@ -23,6 +24,7 @@ const SPAN_SAMPLING_MECHANISM = constants.SPAN_SAMPLING_MECHANISM const SPAN_SAMPLING_RULE_RATE = constants.SPAN_SAMPLING_RULE_RATE const SPAN_SAMPLING_MAX_PER_SECOND = constants.SPAN_SAMPLING_MAX_PER_SECOND const SAMPLING_MECHANISM_SPAN = constants.SAMPLING_MECHANISM_SPAN +const TOP_LEVEL_KEY = constants.TOP_LEVEL_KEY const PROCESS_ID = constants.PROCESS_ID const ERROR_MESSAGE = constants.ERROR_MESSAGE const ERROR_STACK = constants.ERROR_STACK @@ -57,6 +59,10 @@ describe('spanFormat', () => { _name: 'operation', toTraceId: sinon.stub().returns(spanId), toSpanId: sinon.stub().returns(spanId), + getTag (key) { return this._tags[key] }, + getTags () { return this._tags }, + setTag (key, value) { this._tags[key] = value }, + hasTag (key) { return key in this._tags }, } span = { @@ -130,6 +136,75 @@ describe('spanFormat', () => { }) }) + it('pins the formatted-span hidden-class shape for a representative HTTP server span', () => { + // Regression guard for the typed-helper inlining: covers every slot + // `formatSpan` / `extractTags` / `extractRootTags` / `extractChunkTags` + // populate for a chunk-root HTTP server span (the Express-profile shape + // that motivated the inlining). The pre-initialised `service`, `type`, + // and `span_events` slots stay in `Object.keys` even when the tag never + // fires, so the hidden class doesn't transition mid-formatting. + spanContext._parentId = null + spanContext._tags = { + 'service.name': 'svc', + 'span.type': 'web', + 'resource.name': 'GET /users/:id', + 'span.kind': 'server', + 'http.method': 'GET', + 'http.url': 'https://example.com/users/42', + 'http.route': '/users/:id', + 'http.useragent': 'Mozilla/5.0', + component: 'express', + 'http.status_code': 200, + 'http.response.content_length': 4096, + } + spanContext._sampling.priority = 1 + spanContext._trace.tags = { + '_dd.p.dm': '-0', + '_dd.p.tid': '671d3c4500000000', + } + spanContext._trace[SAMPLING_RULE_DECISION] = 1 + span._startTime = 1_500_000_000_000.123 + span._duration = 1.234 + + trace = spanFormat(span, true, false) + + assert.deepStrictEqual(trace, { + trace_id: spanContext._traceId, + span_id: spanContext._spanId, + parent_id: id('0'), + name: 'operation', + resource: 'GET /users/:id', + service: 'svc', + type: 'web', + error: 0, + meta: { + '_dd.p.dm': '-0', + '_dd.p.tid': '671d3c4500000000', + 'span.kind': 'server', + 'http.method': 'GET', + 'http.url': 'https://example.com/users/42', + 'http.route': '/users/:id', + 'http.useragent': 'Mozilla/5.0', + component: 'express', + 'http.status_code': '200', + language: 'javascript', + }, + meta_struct: undefined, + metrics: { + [SAMPLING_RULE_DECISION]: 1, + [TOP_LEVEL_KEY]: 1, + [MEASURED]: 1, + 'http.response.content_length': 4096, + [PROCESS_ID]: process.pid, + [SAMPLING_PRIORITY_KEY]: 1, + }, + start: Math.round(1_500_000_000_000.123 * 1e6), + duration: Math.round(1.234 * 1e6), + links: [], + span_events: undefined, + }) + }) + it('should truncate meta and metric keys/values past the agent-side limits', () => { const { MAX_META_KEY_LENGTH, @@ -145,8 +220,9 @@ describe('spanFormat', () => { span.context()._tags[acceptedMetricKey] = 11 // First-rejected lengths (limit + 1) get sliced and gain a `...` suffix. - // Cover all four typed branches in `addTag`: string / number / boolean / - // Buffer (the URL branch shares the boolean/buffer truncation line). + // Cover all four typed branches in `addMixedTag`: string / number / + // boolean / Buffer (the URL branch shares the boolean/buffer truncation + // line). const overlongMetaKey = `${'c'.repeat(MAX_META_KEY_LENGTH)}X` const overlongMetaValue = `${'d'.repeat(MAX_META_VALUE_LENGTH)}Y` const overlongMetricKey = `${'e'.repeat(MAX_METRIC_KEY_LENGTH)}Z` @@ -157,6 +233,11 @@ describe('spanFormat', () => { span.context()._tags[overlongBoolKey] = true span.context()._tags[overlongBufferKey] = Buffer.from('payload') + // `service.name` is dispatched through `addStringTag` (not the + // polymorphic helper); pin its value-truncate branch here too. + const overlongServiceValue = `${'s'.repeat(MAX_META_VALUE_LENGTH)}!` + span.context()._tags['service.name'] = overlongServiceValue + trace = spanFormat(span) const truncatedMetaKey = `${overlongMetaKey.slice(0, MAX_META_KEY_LENGTH)}...` @@ -164,12 +245,50 @@ describe('spanFormat', () => { const truncatedMetricKey = `${overlongMetricKey.slice(0, MAX_METRIC_KEY_LENGTH)}...` const truncatedBoolKey = `${overlongBoolKey.slice(0, MAX_METRIC_KEY_LENGTH)}...` const truncatedBufferKey = `${overlongBufferKey.slice(0, MAX_METRIC_KEY_LENGTH)}...` + const truncatedServiceValue = `${overlongServiceValue.slice(0, MAX_META_VALUE_LENGTH)}...` assert.strictEqual(trace.meta[acceptedMetaKey], acceptedMetaValue) assert.strictEqual(trace.meta[truncatedMetaKey], truncatedMetaValue) assert.strictEqual(trace.metrics[acceptedMetricKey], 11) assert.strictEqual(trace.metrics[truncatedMetricKey], 42) assert.strictEqual(trace.metrics[truncatedBoolKey], 1) assert.strictEqual(trace.metrics[truncatedBufferKey], 'payload') + assert.strictEqual(trace.service, truncatedServiceValue) + }) + + it('truncates overlong Datadog-tag string values to the agent value limit', () => { + const { MAX_META_VALUE_LENGTH } = require('../src/encode/tags-processors') + // `span.type`, `resource.name`, and `http.status_code` each have + // their own inlined truncation branch in the `extractTags` switch + // (the inlining bypasses `addMixedTag`'s polymorphic slow path). + // Pin all three so a refactor that drops one of them surfaces here. + const overlongType = `${'t'.repeat(MAX_META_VALUE_LENGTH)}!` + const overlongResource = `${'r'.repeat(MAX_META_VALUE_LENGTH)}!` + const overlongStatusCode = `${'9'.repeat(MAX_META_VALUE_LENGTH)}!` + spanContext._tags['span.type'] = overlongType + spanContext._tags['resource.name'] = overlongResource + spanContext._tags['http.status_code'] = overlongStatusCode + + trace = spanFormat(span) + + assert.strictEqual(trace.type, `${overlongType.slice(0, MAX_META_VALUE_LENGTH)}...`) + assert.strictEqual(trace.resource, `${overlongResource.slice(0, MAX_META_VALUE_LENGTH)}...`) + assert.strictEqual( + trace.meta['http.status_code'], + `${overlongStatusCode.slice(0, MAX_META_VALUE_LENGTH)}...` + ) + }) + + it('truncates overlong origin and hostname meta values to the agent value limit', () => { + const { MAX_META_VALUE_LENGTH } = require('../src/encode/tags-processors') + const overlongOrigin = `${'o'.repeat(MAX_META_VALUE_LENGTH)}!` + const overlongHostname = `${'h'.repeat(MAX_META_VALUE_LENGTH)}!` + spanContext._trace.origin = overlongOrigin + spanContext._hostname = overlongHostname + + trace = spanFormat(span) + + assert.strictEqual(trace.meta[ORIGIN_KEY], `${overlongOrigin.slice(0, MAX_META_VALUE_LENGTH)}...`) + assert.strictEqual(trace.meta[HOSTNAME_KEY], `${overlongHostname.slice(0, MAX_META_VALUE_LENGTH)}...`) }) it('should truncate the serialized span_links meta value past MAX_META_VALUE_LENGTH', () => { @@ -244,6 +363,7 @@ describe('spanFormat', () => { spanContext._tags['service.name'] = 'service' spanContext._tags['span.type'] = 'type' spanContext._tags['resource.name'] = 'resource' + spanContext._tags['http.status_code'] = 200 trace = spanFormat(span) @@ -251,9 +371,30 @@ describe('spanFormat', () => { service: 'service', type: 'type', resource: 'resource', + meta: { 'http.status_code': '200' }, }) }) + it('should skip non-string values for the string-typed Datadog tag slots', () => { + // `span.type`, `resource.name`, and `http.status_code` are dispatched + // through `addStringTag`. Non-string source values are dropped instead + // of leaking into metrics (the prior throwaway-`{}` pattern hid the + // same skip behind an allocated empty object). + spanContext._tags['span.type'] = false + spanContext._tags['resource.name'] = { foo: 'bar' } + // `value && String(value)` short-circuits on `0`, so the addStringTag + // call receives a non-string and skips writing. + spanContext._tags['http.status_code'] = 0 + + trace = spanFormat(span) + + assert.strictEqual(trace.type, undefined) + // `trace.resource` is initialised by `formatSpan` from the span name + // and must not be overwritten when the source tag is not a string. + assert.strictEqual(trace.resource, spanContext._name) + assert.strictEqual(trace.meta['http.status_code'], undefined) + }) + it('should extract Datadog specific root tags', () => { spanContext._parentId = null spanContext._trace[SAMPLING_AGENT_DECISION] = 0.8 @@ -276,9 +417,29 @@ describe('spanFormat', () => { trace = spanFormat(span) + const sampledKeys = [SAMPLING_AGENT_DECISION, SAMPLING_LIMIT_DECISION, SAMPLING_RULE_DECISION] assert.ok( - !([SAMPLING_AGENT_DECISION, SAMPLING_LIMIT_DECISION, SAMPLING_RULE_DECISION] - .some(k => Object.hasOwn(trace.metrics, k)))) + !sampledKeys.some(k => Object.hasOwn(trace.metrics, k)), + `Expected none of ${inspect(sampledKeys)} in metrics, got keys: ${inspect(Object.keys(trace.metrics))}` + ) + }) + + it('should skip root tag decisions whose source value is undefined', () => { + // The `typeof === 'number'` gate skips any decision the priority + // sampler never set, so partial-decision spans emit only the metric + // they actually own. `Sampler.rate()` / `RateLimiter.effectiveRate()` + // cannot return `NaN` (the `Sampler` constructor throws via + // `BigInt(Math.floor(NaN * MAX_TRACE_ID))` long before the field can + // be assigned), so the `undefined` case is the only one to pin. + spanContext._parentId = null + spanContext._trace[SAMPLING_LIMIT_DECISION] = 0.2 + // SAMPLING_AGENT_DECISION / SAMPLING_RULE_DECISION intentionally unset. + + trace = spanFormat(span) + + assert.strictEqual(trace.metrics[SAMPLING_LIMIT_DECISION], 0.2) + assert.ok(!(SAMPLING_AGENT_DECISION in trace.metrics)) + assert.ok(!(SAMPLING_RULE_DECISION in trace.metrics)) }) it('should always add single span ingestion tags from options if present', () => { @@ -298,9 +459,11 @@ describe('spanFormat', () => { it('should not add single span ingestion tags if options not present', () => { trace = spanFormat(span) + const spanSamplingKeys = [SPAN_SAMPLING_MECHANISM, SPAN_SAMPLING_MAX_PER_SECOND, SPAN_SAMPLING_RULE_RATE] assert.ok( - !([SPAN_SAMPLING_MECHANISM, SPAN_SAMPLING_MAX_PER_SECOND, SPAN_SAMPLING_RULE_RATE] - .some(k => Object.hasOwn(trace.metrics, k)))) + !spanSamplingKeys.some(k => Object.hasOwn(trace.metrics, k)), + `Expected none of ${inspect(spanSamplingKeys)} in metrics, got keys: ${inspect(Object.keys(trace.metrics))}` + ) }) it('should format span links', () => { @@ -363,10 +526,11 @@ describe('spanFormat', () => { count: 1, } - trace = spanFormat(span, true) + trace = spanFormat(span, true, 'process-tag-value') assertObjectContains(trace.meta, { chunk: 'test', + '_dd.tags.process': 'process-tag-value', }) assertObjectContains(trace.metrics, { @@ -374,6 +538,31 @@ describe('spanFormat', () => { }) }) + it('truncates overlong chunk tag keys and values to the agent limit', () => { + const { MAX_META_KEY_LENGTH, MAX_META_VALUE_LENGTH } = require('../src/encode/tags-processors') + const overlongChunkKey = `${'k'.repeat(MAX_META_KEY_LENGTH)}!` + const overlongChunkValue = `${'v'.repeat(MAX_META_VALUE_LENGTH)}!` + // A second tag with a short key and overlong value pins the value + // truncation branch of the inlined `extractChunkTags` for-loop. The + // first tag pairs an overlong key with a short value (key branch); + // `tagForFirstSpanInChunk` pairs an overlong process-tag value with + // its own dedicated truncation branch. + const overlongTraceTagValue = `${'b'.repeat(MAX_META_VALUE_LENGTH)}!` + spanContext._trace.tags = { + [overlongChunkKey]: 'short', + '_dd.p.tid': overlongTraceTagValue, + } + + trace = spanFormat(span, true, overlongChunkValue) + + const truncatedKey = `${overlongChunkKey.slice(0, MAX_META_KEY_LENGTH)}...` + const truncatedValue = `${overlongChunkValue.slice(0, MAX_META_VALUE_LENGTH)}...` + const truncatedTraceTagValue = `${overlongTraceTagValue.slice(0, MAX_META_VALUE_LENGTH)}...` + assert.strictEqual(trace.meta[truncatedKey], 'short') + assert.strictEqual(trace.meta['_dd.tags.process'], truncatedValue) + assert.strictEqual(trace.meta['_dd.p.tid'], truncatedTraceTagValue) + }) + it('should not extract trace chunk tags when not chunk root', () => { spanContext._trace.tags = { chunk: 'test', @@ -430,9 +619,9 @@ describe('spanFormat', () => { 'foo.bar': 'foobar', }, }) - assert.ok(!Object.hasOwn(trace.meta, 'service.name')) - assert.ok(!Object.hasOwn(trace.meta, 'span.type')) - assert.ok(!Object.hasOwn(trace.meta, 'resource.name')) + assert.ok(!Object.hasOwn(trace.meta, 'service.name'), `Available keys: ${inspect(Object.keys(trace.meta))}`) + assert.ok(!Object.hasOwn(trace.meta, 'span.type'), `Available keys: ${inspect(Object.keys(trace.meta))}`) + assert.ok(!Object.hasOwn(trace.meta, 'resource.name'), `Available keys: ${inspect(Object.keys(trace.meta))}`) }) it('should extract numeric tags as metrics', () => { @@ -443,6 +632,27 @@ describe('spanFormat', () => { assert.strictEqual(trace.metrics.metric, 50) }) + it('should extract buffer tags as stringified metrics', () => { + spanContext._tags.payload = Buffer.from('hello') + + trace = spanFormat(span) + + assert.strictEqual(trace.metrics.payload, 'hello') + }) + + it('should extract URL tags as stringified metrics', () => { + // `addMixedTag`'s default branch routes both `Buffer` and `URL` to + // metrics as `value.toString()`. The Buffer half is covered above; + // pin the URL half so a future tightening that drops `isUrl` from + // the helper surfaces here. + const url = new URL('https://example.com/foo?bar=1') + spanContext._tags.endpoint = url + + trace = spanFormat(span) + + assert.strictEqual(trace.metrics.endpoint, url.toString()) + }) + it('should extract boolean tags as metrics', () => { spanContext._tags = { yes: true, no: false } @@ -461,7 +671,9 @@ describe('spanFormat', () => { }) it('should ignore metrics that are not a number', () => { - spanContext._metrics = { metric: NaN } + // Numeric user tags with `NaN` are dropped before they reach metrics + // via `addMixedTag`'s number branch. + spanContext._tags.metric = Number.NaN trace = spanFormat(span) @@ -492,6 +704,86 @@ describe('spanFormat', () => { assert.ok(!(ERROR_STACK in trace.meta)) }) + it('should fall back to error.code when error.message is empty', () => { + const error = new Error('') + error.code = 'E_BOOM' + spanContext._tags.error = error + + trace = spanFormat(span) + + assert.strictEqual(trace.meta[ERROR_MESSAGE], 'E_BOOM') + }) + + it('coerces non-string error tag values to meta strings', () => { + spanContext._tags[ERROR_TYPE] = 42 + spanContext._tags[ERROR_MESSAGE] = { code: 'E_BOOM' } + spanContext._tags[ERROR_STACK] = true + + trace = spanFormat(span) + + assert.strictEqual(trace.meta[ERROR_TYPE], '42') + assert.strictEqual(trace.meta[ERROR_MESSAGE], '[object Object]') + assert.strictEqual(trace.meta[ERROR_STACK], 'true') + assert.ok(!(ERROR_TYPE in trace.metrics)) + assert.ok(!(ERROR_MESSAGE in trace.metrics)) + assert.ok(!(ERROR_STACK in trace.metrics)) + assert.strictEqual(trace.error, 1) + }) + + it('skips null and undefined error tag values without writing meta', () => { + spanContext._tags[ERROR_TYPE] = null + spanContext._tags[ERROR_MESSAGE] = undefined + spanContext._tags[ERROR_STACK] = 'real stack' + + trace = spanFormat(span) + + assert.ok(!(ERROR_TYPE in trace.meta)) + assert.ok(!(ERROR_MESSAGE in trace.meta)) + assert.strictEqual(trace.meta[ERROR_STACK], 'real stack') + // Any of the three present (even null) still flips `error=1` unless + // OTel's `IGNORE_OTEL_ERROR` flag suppresses it. + assert.strictEqual(trace.error, 1) + }) + + it('truncates overlong error tag values to the agent value limit', () => { + const { MAX_META_VALUE_LENGTH } = require('../src/encode/tags-processors') + const overlongStack = `${'s'.repeat(MAX_META_VALUE_LENGTH)}!` + spanContext._tags[ERROR_STACK] = overlongStack + + trace = spanFormat(span) + + assert.strictEqual(trace.meta[ERROR_STACK], `${overlongStack.slice(0, MAX_META_VALUE_LENGTH)}...`) + }) + + it('coerces non-string Error subclass fields to meta strings via extractError', () => { + class WeirdError extends Error {} + const error = new WeirdError() + error.name = Symbol('CustomName') + error.message = 1234 + error.stack = ['frame-0', 'frame-1'] + spanContext._tags.error = error + + trace = spanFormat(span) + + assert.strictEqual(trace.meta[ERROR_TYPE], 'Symbol(CustomName)') + assert.strictEqual(trace.meta[ERROR_MESSAGE], '1234') + assert.strictEqual(trace.meta[ERROR_STACK], 'frame-0,frame-1') + assert.ok(!(ERROR_TYPE in trace.metrics)) + assert.ok(!(ERROR_MESSAGE in trace.metrics)) + assert.ok(!(ERROR_STACK in trace.metrics)) + }) + + it('truncates overlong Error.message via extractError', () => { + const { MAX_META_VALUE_LENGTH } = require('../src/encode/tags-processors') + const overlongMessage = `${'m'.repeat(MAX_META_VALUE_LENGTH)}!` + const error = new Error(overlongMessage) + spanContext._tags.error = error + + trace = spanFormat(span) + + assert.strictEqual(trace.meta[ERROR_MESSAGE], `${overlongMessage.slice(0, MAX_META_VALUE_LENGTH)}...`) + }) + it('should extract the origin', () => { spanContext._trace.origin = 'synthetics' @@ -621,9 +913,53 @@ describe('spanFormat', () => { 'nested.num': '1', }, }) - assert.ok(!Object.hasOwn(trace.meta, 'nested.A')) - assert.ok(!Object.hasOwn(trace.meta, 'nested.A.B')) - assert.ok(!Object.hasOwn(trace.meta, 'nested.A.num')) + assert.ok(!Object.hasOwn(trace.meta, 'nested.A'), `Available keys: ${inspect(Object.keys(trace.meta))}`) + assert.ok(!Object.hasOwn(trace.meta, 'nested.A.B'), `Available keys: ${inspect(Object.keys(trace.meta))}`) + assert.ok(!Object.hasOwn(trace.meta, 'nested.A.num'), `Available keys: ${inspect(Object.keys(trace.meta))}`) + }) + + it('routes nested-object child values of every type through addMixedTag recursion', () => { + const { + MAX_META_KEY_LENGTH, + MAX_META_VALUE_LENGTH, + MAX_METRIC_KEY_LENGTH, + } = require('../src/encode/tags-processors') + // Top-level tags hit the inlined fast paths in `extractTags`. The + // depth-1 recursion in `addMixedTag` is the only place the helper's + // typeof / truncation branches stay reachable, so cover every shape + // (string / number / boolean / NaN / overlong key / overlong value) + // through a single nested-object tag. + const overlongMetaChildKey = 'z'.repeat(MAX_META_KEY_LENGTH) + const overlongStringValue = 'v'.repeat(MAX_META_VALUE_LENGTH + 1) + const overlongMetricChildKey = 'm'.repeat(MAX_METRIC_KEY_LENGTH) + const overlongBoolChildKey = 'b'.repeat(MAX_METRIC_KEY_LENGTH) + spanContext._tags.nested = { + str: 'one', + long_value: overlongStringValue, + [overlongMetaChildKey]: 'short', + num: 2, + [overlongMetricChildKey]: 7, + bool: true, + nope: false, + [overlongBoolChildKey]: false, + nan: Number.NaN, + } + + trace = spanFormat(span) + + const truncatedString = `${overlongStringValue.slice(0, MAX_META_VALUE_LENGTH)}...` + const truncatedMetaKey = `${`nested.${overlongMetaChildKey}`.slice(0, MAX_META_KEY_LENGTH)}...` + const truncatedMetricKey = `${`nested.${overlongMetricChildKey}`.slice(0, MAX_METRIC_KEY_LENGTH)}...` + const truncatedBoolKey = `${`nested.${overlongBoolChildKey}`.slice(0, MAX_METRIC_KEY_LENGTH)}...` + assert.strictEqual(trace.meta['nested.str'], 'one') + assert.strictEqual(trace.meta['nested.long_value'], truncatedString) + assert.strictEqual(trace.meta[truncatedMetaKey], 'short') + assert.strictEqual(trace.metrics['nested.num'], 2) + assert.strictEqual(trace.metrics[truncatedMetricKey], 7) + assert.strictEqual(trace.metrics['nested.bool'], 1) + assert.strictEqual(trace.metrics['nested.nope'], 0) + assert.strictEqual(trace.metrics[truncatedBoolKey], 0) + assert.ok(!('nested.nan' in trace.metrics)) }) it('should accept a boolean for measured', () => { @@ -688,5 +1024,13 @@ describe('spanFormat', () => { assert.strictEqual(trace.metrics['_dd1.sr.eausr'], 1) }) + + it('should map analytics.event false to a zero metric', () => { + spanContext._tags['analytics.event'] = false + + trace = spanFormat(span) + + assert.strictEqual(trace.metrics['_dd1.sr.eausr'], 0) + }) }) }) diff --git a/packages/dd-trace/test/span_processor.spec.js b/packages/dd-trace/test/span_processor.spec.js index 21d68ab9b1..56a5c498bd 100644 --- a/packages/dd-trace/test/span_processor.spec.js +++ b/packages/dd-trace/test/span_processor.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach } = require('mocha') const sinon = require('sinon') @@ -33,12 +34,17 @@ describe('SpanProcessor', () => { finished: [], } + let tags = {} const span = { tracer: sinon.stub().returns(tracer), context: sinon.stub().returns({ _trace: trace, _sampling: {}, - _tags: {}, + getTags: () => tags, + getTag: (key) => tags[key], + setTag: (key, value) => { tags[key] = value }, + hasTag: (key) => key in tags, + clearTags: () => { tags = Object.create(null) }, }), } @@ -93,8 +99,9 @@ describe('SpanProcessor', () => { assert.deepStrictEqual(trace.started, []) assert.ok('finished' in trace) assert.deepStrictEqual(trace.finished, []) - assert.ok('_tags' in finishedSpan.context()) - assert.deepStrictEqual(finishedSpan.context()._tags, {}) + // _erase leaves per-span tag storage intact so callers that retain a + // span ref after finish can still read tags. + assert.deepStrictEqual(finishedSpan.context().getTags(), {}) }) it('should not flush a partial trace below the flushMinSpans threshold', () => { @@ -173,8 +180,7 @@ describe('SpanProcessor', () => { assert.deepStrictEqual(trace.started, []) assert.ok('finished' in trace) assert.deepStrictEqual(trace.finished, []) - assert.ok('_tags' in finishedSpan.context()) - assert.deepStrictEqual(finishedSpan.context()._tags, {}) + assert.deepStrictEqual(finishedSpan.context().getTags(), {}) sinon.assert.notCalled(exporter.export) }) @@ -207,7 +213,12 @@ describe('SpanProcessor', () => { tags.split(',').forEach(tag => { const [key, value] = tag.split(':') if (key !== 'entrypoint.basedir') return - assert.strictEqual(value, 'test') + // The exact basedir varies depending on the test runner location + // (e.g. "test" in source tree vs "bin" when run via node_modules/.bin/mocha). + assert.ok( + typeof value === 'string' && value.length > 0, + `entrypoint.basedir value: ${inspect(value)}` + ) foundATag = true }) assert.ok(foundATag) diff --git a/packages/dd-trace/test/span_sampler.spec.js b/packages/dd-trace/test/span_sampler.spec.js index 655fbbc7df..02b93fbc45 100644 --- a/packages/dd-trace/test/span_sampler.spec.js +++ b/packages/dd-trace/test/span_sampler.spec.js @@ -41,6 +41,7 @@ describe('span sampler', () => { }, _name: 'operation', _tags: {}, + getTag (key) { return this._tags[key] }, } spanContext._trace.started.push({ context: sinon.stub().returns(spanContext), @@ -77,6 +78,7 @@ describe('span sampler', () => { }, _name: 'operation', _tags: {}, + getTag (key) { return this._tags[key] }, } spanContext._trace.started.push({ context: sinon.stub().returns(spanContext), @@ -131,6 +133,7 @@ describe('span sampler', () => { }, _name: 'operation', _tags: {}, + getTag (key) { return this._tags[key] }, } spanContext._trace.started.push({ context: sinon.stub().returns(spanContext), @@ -175,6 +178,7 @@ describe('span sampler', () => { }, _name: 'operation', _tags: {}, + getTag (key) { return this._tags[key] }, } const secondSpanContext = { ...firstSpanContext, @@ -243,6 +247,7 @@ describe('span sampler', () => { }, _name: 'operation', _tags: {}, + getTag (key) { return this._tags[key] }, } const secondSpanContext = { ...firstSpanContext, diff --git a/packages/dd-trace/test/standalone/index.spec.js b/packages/dd-trace/test/standalone/index.spec.js index 9d3e9ad9ab..da9e14a412 100644 --- a/packages/dd-trace/test/standalone/index.spec.js +++ b/packages/dd-trace/test/standalone/index.spec.js @@ -1,6 +1,7 @@ 'use strict' const assert = require('node:assert/strict') +const { inspect } = require('node:util') const { describe, it, beforeEach, afterEach } = require('mocha') const sinon = require('sinon') @@ -135,7 +136,7 @@ describe('Disabled APM Tracing or Standalone', () => { operationName: 'operation', }) - assert.ok(!(APM_TRACING_ENABLED_KEY in span.context()._tags)) + assert.ok(!span.context().hasTag(APM_TRACING_ENABLED_KEY)) }) it('should add _dd.apm.enabled tag when standalone is enabled', () => { @@ -145,7 +146,10 @@ describe('Disabled APM Tracing or Standalone', () => { operationName: 'operation', }) - assert.ok(Object.hasOwn(span.context()._tags, APM_TRACING_ENABLED_KEY)) + assert.ok( + span.context().hasTag(APM_TRACING_ENABLED_KEY), + `Available keys: ${inspect(Object.keys(span.context().getTags()))}` + ) }) it('should not add _dd.apm.enabled tag in child spans with local parent', () => { @@ -155,14 +159,14 @@ describe('Disabled APM Tracing or Standalone', () => { operationName: 'operation', }) - assert.strictEqual(parent.context()._tags[APM_TRACING_ENABLED_KEY], 0) + assert.strictEqual(parent.context().getTag(APM_TRACING_ENABLED_KEY), 0) const child = new DatadogSpan(tracer, processor, prioritySampler, { operationName: 'operation', parent, }) - assert.ok(!(APM_TRACING_ENABLED_KEY in child.context()._tags)) + assert.ok(!child.context().hasTag(APM_TRACING_ENABLED_KEY)) }) it('should add _dd.apm.enabled tag in child spans with remote parent', () => { @@ -179,7 +183,7 @@ describe('Disabled APM Tracing or Standalone', () => { parent, }) - assert.strictEqual(child.context()._tags[APM_TRACING_ENABLED_KEY], 0) + assert.strictEqual(child.context().getTag(APM_TRACING_ENABLED_KEY), 0) }) }) @@ -308,9 +312,12 @@ describe('Disabled APM Tracing or Standalone', () => { const propagator = new TextMapPropagator(config) propagator.inject(span._spanContext, carrier) - assert.ok(Object.hasOwn(carrier, 'x-datadog-trace-id')) - assert.ok(Object.hasOwn(carrier, 'x-datadog-parent-id')) - assert.ok(Object.hasOwn(carrier, 'x-datadog-sampling-priority')) + assert.ok(Object.hasOwn(carrier, 'x-datadog-trace-id'), `Available keys: ${inspect(Object.keys(carrier))}`) + assert.ok(Object.hasOwn(carrier, 'x-datadog-parent-id'), `Available keys: ${inspect(Object.keys(carrier))}`) + assert.ok( + Object.hasOwn(carrier, 'x-datadog-sampling-priority'), + `Available keys: ${inspect(Object.keys(carrier))}` + ) assert.strictEqual(carrier['x-datadog-tags'], '_dd.p.ts=02') }) @@ -331,12 +338,15 @@ describe('Disabled APM Tracing or Standalone', () => { const propagator = new TextMapPropagator(config) propagator.inject(span._spanContext, carrier) - assert.ok(Object.hasOwn(carrier, 'x-datadog-trace-id')) - assert.ok(Object.hasOwn(carrier, 'x-datadog-parent-id')) - assert.ok(Object.hasOwn(carrier, 'x-datadog-sampling-priority')) + assert.ok(Object.hasOwn(carrier, 'x-datadog-trace-id'), `Available keys: ${inspect(Object.keys(carrier))}`) + assert.ok(Object.hasOwn(carrier, 'x-datadog-parent-id'), `Available keys: ${inspect(Object.keys(carrier))}`) + assert.ok( + Object.hasOwn(carrier, 'x-datadog-sampling-priority'), + `Available keys: ${inspect(Object.keys(carrier))}` + ) - assert.ok(Object.hasOwn(carrier, 'x-b3-traceid')) - assert.ok(Object.hasOwn(carrier, 'x-b3-spanid')) + assert.ok(Object.hasOwn(carrier, 'x-b3-traceid'), `Available keys: ${inspect(Object.keys(carrier))}`) + assert.ok(Object.hasOwn(carrier, 'x-b3-spanid'), `Available keys: ${inspect(Object.keys(carrier))}`) }) it('should clear tracestate datadog info', () => { diff --git a/packages/dd-trace/test/standalone/tracesource_priority_sampler.spec.js b/packages/dd-trace/test/standalone/tracesource_priority_sampler.spec.js index ddf40d5562..54a1e157b0 100644 --- a/packages/dd-trace/test/standalone/tracesource_priority_sampler.spec.js +++ b/packages/dd-trace/test/standalone/tracesource_priority_sampler.spec.js @@ -27,10 +27,12 @@ describe('Disabled APM Tracing or Standalone - TraceSourcePrioritySampler', () = root = {} context = { _sampling: {}, + _tags: {}, _trace: { tags: {}, started: [root], }, + getTags () { return this._tags }, } sinon.stub(prioritySampler, '_getContext').returns(context) }) diff --git a/packages/dd-trace/test/tagger.spec.js b/packages/dd-trace/test/tagger.spec.js index 24296298f7..d3ed979c5a 100644 --- a/packages/dd-trace/test/tagger.spec.js +++ b/packages/dd-trace/test/tagger.spec.js @@ -77,10 +77,6 @@ describe('tagger', () => { tagger.add(carrier) }) - it('should handle missing carrier', () => { - tagger.add() - }) - it('should set trace error', () => { tagger.add(carrier, { [ERROR_TYPE]: 'foo', diff --git a/packages/dd-trace/test/tracer.spec.js b/packages/dd-trace/test/tracer.spec.js index 361c59a048..80cb55f5fa 100644 --- a/packages/dd-trace/test/tracer.spec.js +++ b/packages/dd-trace/test/tracer.spec.js @@ -69,8 +69,8 @@ describe('Tracer', () => { tracer.trace('name', options, span => { assert.ok(span instanceof Span) - assertObjectContains(span.context()._tags, options.tags) - assertObjectContains(span.context()._tags, { + assertObjectContains(span.context().getTags(), options.tags) + assertObjectContains(span.context().getTags(), { [SERVICE_NAME]: 'service', [RESOURCE_NAME]: 'resource', [SPAN_TYPE]: 'type', @@ -152,7 +152,7 @@ describe('Tracer', () => { try { tracer.trace('name', {}, _span => { span = _span - tags = span.context()._tags + tags = span.context().getTags() sinon.spy(span, 'finish') throw new Error('boom') }) @@ -192,7 +192,7 @@ describe('Tracer', () => { tracer.trace('name', {}, (_span, _done) => { span = _span - tags = span.context()._tags + tags = span.context().getTags() sinon.spy(span, 'finish') done = _done }) @@ -241,7 +241,7 @@ describe('Tracer', () => { tracer .trace('name', {}, _span => { span = _span - tags = span.context()._tags + tags = span.context().getTags() sinon.spy(span, 'finish') return Promise.reject(new Error('boom')) }) diff --git a/scripts/all-green.mjs b/scripts/all-green.mjs index d541475b22..1a8196e524 100644 --- a/scripts/all-green.mjs +++ b/scripts/all-green.mjs @@ -112,12 +112,6 @@ async function pollUntilDone () { !retriedRunIds.has(r.id) ) - if (toRetry.length > 0) { - await rerunFailedWorkflows(toRetry) - for (const run of toRetry) retriedRunIds.add(run.id) - runsCache = undefined - } - const pending = runs.filter(r => r.status !== 'completed').length if (pending === 0 && toRetry.length === 0) return { runs, done: true } @@ -125,6 +119,12 @@ async function pollUntilDone () { if (RETRIES && retries > RETRIES) return { runs, done: false } + if (toRetry.length > 0) { + await rerunFailedWorkflows(toRetry) + for (const run of toRetry) retriedRunIds.add(run.id) + runsCache = undefined + } + console.log(`Status is still pending, waiting for ${POLLING_INTERVAL} minutes before retrying.`) await setTimeout(POLLING_INTERVAL * 60_000) console.log('Retrying.') @@ -135,9 +135,6 @@ async function rerunFailedWorkflows (workflowRuns) { await Promise.all( workflowRuns.map(workflowRun => { console.log(`Rerunning ${workflowRun.conclusion} workflow run ${workflowRun.id} (${workflowRun.name}).`) - if (workflowRun.conclusion === 'cancelled') { - return octokit.rest.actions.reRunWorkflow({ owner, repo, run_id: workflowRun.id }) - } return octokit.rest.actions.reRunWorkflowFailedJobs({ owner, repo, run_id: workflowRun.id }) }) ) @@ -160,6 +157,18 @@ async function rerunOnStartup () { } } +async function cancelRunningWorkflows (runs) { + const running = runs.filter(r => r.status !== 'completed') + if (running.length === 0) return + console.log(`Cancelling ${running.length} still-running workflow(s).`) + await Promise.all( + running.map(run => { + console.log(`Cancelling workflow run ${run.id} (${run.name}).`) + return octokit.rest.actions.cancelWorkflowRun({ owner, repo, run_id: run.id }) + }) + ) +} + async function checkAllGreen () { await rerunOnStartup() @@ -169,6 +178,7 @@ async function checkAllGreen () { if (!done) { console.log(`State is still pending after ${RETRIES} retries.`) + await cancelRunningWorkflows(runs) process.exitCode = 1 return } diff --git a/scripts/check-proposal-labels.js b/scripts/check-proposal-labels.js index 1f6d702057..3a3e036c37 100644 --- a/scripts/check-proposal-labels.js +++ b/scripts/check-proposal-labels.js @@ -6,19 +6,16 @@ const childProcess = require('child_process') const ORIGIN = 'origin/' let releaseBranch = process.env.GITHUB_BASE_REF // 'origin/v3.x' -let releaseVersion = releaseBranch -if (releaseBranch.startsWith(ORIGIN)) { - releaseVersion = releaseBranch.slice(ORIGIN.length) -} else { +if (!releaseBranch.startsWith(ORIGIN)) { releaseBranch = ORIGIN + releaseBranch } -let currentBranch = process.env.GITHUB_HEAD_REF // 'ugaitz/workflow-to-verify-dont-land-on-v3.x' +let currentBranch = process.env.GITHUB_HEAD_REF if (!currentBranch.startsWith(ORIGIN)) { currentBranch = ORIGIN + currentBranch } -const getHashesCommandWithExclusions = 'branch-diff --user DataDog --repo dd-trace-js --exclude-label=semver-major' + - ` --exclude-label=dont-land-on-${releaseVersion} ${releaseBranch} ${currentBranch}` +const getHashesCommandWithExclusions = + `branch-diff --user DataDog --repo dd-trace-js --exclude-label=only-land-on-next ${releaseBranch} ${currentBranch}` const getHashesCommandWithoutExclusions = `branch-diff --user DataDog --repo dd-trace-js ${releaseBranch} ${currentBranch}` diff --git a/scripts/flakiness.mjs b/scripts/flakiness.mjs index 96855d4b75..3bba1d8057 100644 --- a/scripts/flakiness.mjs +++ b/scripts/flakiness.mjs @@ -167,19 +167,22 @@ await Promise.all(workflows.map(w => checkWorkflowRuns(w))) const dateRange = startDate === endDate ? `on ${endDate}` : `from ${startDate} to ${endDate}` const logString = `jobs with at least ${OCCURRENCES} occurrences seen ${dateRange} (UTC)` -if (Object.keys(flaky).length === 0) { - console.log(`*No flaky ${logString}`) -} else { - const workflowSuccessRate = Number(((1 - flakeCount / totalCount) * 100).toFixed(1)) - const pipelineSuccessRate = Number((((workflowSuccessRate / 100) ** workflows.length) * 100).toFixed(1)) - const pipelineBadge = pipelineSuccessRate >= 85 ? '🟢' : pipelineSuccessRate >= 75 ? '🟡' : '🔴' +const workflowSuccessRate = totalCount > 0 + ? Number(((1 - flakeCount / totalCount) * 100).toFixed(1)) + : 100 +const pipelineSuccessRate = Number((((workflowSuccessRate / 100) ** workflows.length) * 100).toFixed(1)) +const pipelineBadge = pipelineSuccessRate >= 85 ? '🟢' : pipelineSuccessRate >= 75 ? '🟡' : '🔴' - let markdown = '' - let slack = '' +let markdown = '' +let slack = '' - markdown += `**Flaky ${logString}**\n` - slack += String.raw`*Flaky ${logString}*\n` +markdown += `**Flaky ${logString}**\n` +slack += String.raw`*Flaky ${logString}*\n` +if (Object.keys(flaky).length === 0) { + markdown += 'None found.\n' + slack += String.raw`None found.\n` +} else { for (const [workflow, jobs] of Object.entries(flaky).sort()) { if (!reported.has(workflow)) continue @@ -202,33 +205,33 @@ if (Object.keys(flaky).length === 0) { markdown += ` * ${job} (${markdownLinks.join(', ')})${runsBadge}\n` } } +} - markdown += '\n' - markdown += '**Flakiness stats**\n' - markdown += `* Total runs: ${totalCount}\n` - markdown += `* Flaky runs: ${flakeCount}\n` - markdown += `* Workflow success rate: ${workflowSuccessRate}%\n` - markdown += `* Pipeline success rate (approx): ${pipelineSuccessRate}% ${pipelineBadge}` - - if (GITHUB_REPOSITORY && GITHUB_RUN_ID) { - const link = `https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}` - - slack += String.raw`\n` - slack += `View full report with links to failures on <${link}|GitHub>.` - } +if (GITHUB_REPOSITORY && GITHUB_RUN_ID) { + const link = `https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}` slack += String.raw`\n` - slack += String.raw`*Flakiness stats*\n` - slack += String.raw` ● Total runs: ${totalCount}\n` - slack += String.raw` ● Flaky runs: ${flakeCount}\n` - slack += String.raw` ● Workflow success rate: ${workflowSuccessRate}%\n` - slack += ` ● Pipeline success rate (approx): ${pipelineSuccessRate}% ${pipelineBadge}` - - console.log(markdown) - - // TODO: Make this an option instead. - if (CI) { - writeFileSync('flakiness.md', markdown) - writeFileSync('flakiness.txt', slack) - } + slack += `View full report with links to failures on <${link}|GitHub>.` +} + +slack += String.raw`\n` +slack += String.raw`*Flakiness stats*\n` +slack += String.raw` ● Total runs: ${totalCount}\n` +slack += String.raw` ● Flaky runs: ${flakeCount}\n` +slack += String.raw` ● Workflow success rate: ${workflowSuccessRate}%\n` +slack += ` ● Pipeline success rate (approx): ${pipelineSuccessRate}% ${pipelineBadge}` + +markdown += '\n' +markdown += '**Flakiness stats**\n' +markdown += `* Total runs: ${totalCount}\n` +markdown += `* Flaky runs: ${flakeCount}\n` +markdown += `* Workflow success rate: ${workflowSuccessRate}%\n` +markdown += `* Pipeline success rate (approx): ${pipelineSuccessRate}% ${pipelineBadge}` + +console.log(markdown) + +// TODO: Make this an option instead. +if (CI) { + writeFileSync('flakiness.md', markdown) + writeFileSync('flakiness.txt', slack) } diff --git a/scripts/install_plugin_modules.js b/scripts/install_plugin_modules.js index d475c22afd..f09bd4175f 100644 --- a/scripts/install_plugin_modules.js +++ b/scripts/install_plugin_modules.js @@ -67,7 +67,7 @@ async function assertPrerequisites () { for (const name of externalNames) { for (const inst of externals[name]) { // eslint-disable-next-line no-await-in-loop - await assertInstrumentation(inst, true) + await assertInstrumentation(inst, true, name) } } @@ -77,9 +77,13 @@ async function assertPrerequisites () { /** * @param {object} instrumentation * @param {boolean} external + * @param {string} [pluginName] The plugin key the external entry belongs to. Same-name externals (e.g. the aerospike + * externals entry that mirrors the addHook versions) honour `PACKAGE_VERSION_RANGE` so per-major CI matrices do not + * force every major to install on every job. */ -async function assertInstrumentation (instrumentation, external) { - const versions = process.env.PACKAGE_VERSION_RANGE && !external +async function assertInstrumentation (instrumentation, external, pluginName) { + const honourEnvRange = !external || instrumentation.name === pluginName + const versions = process.env.PACKAGE_VERSION_RANGE && honourEnvRange ? [process.env.PACKAGE_VERSION_RANGE] : (instrumentation.versions || []) @@ -140,15 +144,13 @@ async function assertPackage (name, version, dependencyVersionRange, external) { dependencies, } - if (!external) { - if (name === 'aerospike') { - pkg.installConfig = { - hoistingLimits: 'workspaces', - } - } else { - pkg.workspaces = { - nohoist: ['**/**'], - } + if (name === 'aerospike') { + pkg.installConfig = { + hoistingLimits: 'workspaces', + } + } else if (!external) { + pkg.workspaces = { + nohoist: ['**/**'], } } diff --git a/scripts/patch-istanbul-lib-coverage.js b/scripts/patch-istanbul-lib-coverage.js new file mode 100644 index 0000000000..1dfc726a61 --- /dev/null +++ b/scripts/patch-istanbul-lib-coverage.js @@ -0,0 +1,156 @@ +#!/usr/bin/env node + +'use strict' + +/* + * Apply the upstream `FileCoverage.getLineCoverage` fix to the locked + * `istanbul-lib-coverage` install. `getLineCoverage()` walks `statementMap` + * only, so lines that carry an executable token but no statement entry + * (function-declaration lines, `} else {` continuations, inline ternary arms) + * never appear in the returned map. The `lcovonly` reporter emits `DA:` + * records straight from that map, so Codecov's patch view marks those lines + * as missing on every PR until upstream lands the fix. + * + * Idempotent — applies the change once, then no-ops while the sentinel + * comment is present in the file. Fails loudly if the locked upstream code + * shape changes so a future yarn upgrade can't silently leave the patch + * unapplied. + * + * Wired to the `prepare` lifecycle so the script never fires on consumer + * installs of the published tarball — the script itself is not in the + * `files` allowlist. `prepare` also fires under `npm pack`, whose stdout + * is captured by `FILENAME=$(npm pack --silent …)` in CI; all diagnostic + * output therefore goes to stderr so the tarball name on stdout stays clean. + * + * Refs: https://github.com/istanbuljs/istanbuljs/issues/809 + */ + +const fs = require('node:fs') +const path = require('node:path') + +// Inline marker so the script can detect a previous run without parsing the +// whole replacement body. Bump the version suffix when the patch body changes. +const SENTINEL = '// dd-trace-js patch v1: fold fnMap/branchMap into getLineCoverage' + +const ORIGINAL = ` getLineCoverage() { + const statementMap = this.data.statementMap; + const statements = this.data.s; + const lineMap = Object.create(null); + + Object.entries(statements).forEach(([st, count]) => { + /* istanbul ignore if: is this even possible? */ + if (!statementMap[st]) { + return; + } + const { line } = statementMap[st].start; + const prevVal = lineMap[line]; + if (prevVal === undefined || prevVal < count) { + lineMap[line] = count; + } + }); + return lineMap; + }` + +const REPLACEMENT = ` getLineCoverage() { + ${SENTINEL} + const lineMap = Object.create(null); + + const record = (line, count) => { + const prev = lineMap[line]; + if (prev === undefined || prev < count) { + lineMap[line] = count; + } + }; + + const statementMap = this.data.statementMap; + Object.entries(this.data.s).forEach(([st, count]) => { + /* istanbul ignore if: is this even possible? */ + if (!statementMap[st]) return; + record(statementMap[st].start.line, count); + }); + + const fnMap = this.data.fnMap; + Object.entries(this.data.f).forEach(([fn, count]) => { + const entry = fnMap[fn]; + /* istanbul ignore if: is this even possible? */ + if (!entry) return; + const decl = entry.decl || entry.loc; + /* istanbul ignore else: is this even possible? */ + if (decl && decl.start) record(decl.start.line, count); + }); + + const branchMap = this.data.branchMap; + Object.entries(this.data.b).forEach(([br, counts]) => { + const entry = branchMap[br]; + /* istanbul ignore if: is this even possible? */ + if (!entry || !Array.isArray(entry.locations)) return; + entry.locations.forEach((branchLoc, i) => { + /* istanbul ignore else: is this even possible? */ + if (branchLoc && branchLoc.start) { + record(branchLoc.start.line, counts[i] | 0); + } + }); + }); + + return lineMap; + }` + +/** + * @param {string} message + */ +function log (message) { + process.stderr.write(`patch-istanbul-lib-coverage: ${message}\n`) +} + +/** + * @param {string} message + */ +function fail (message) { + process.stderr.write(`patch-istanbul-lib-coverage: ${message}\n`) + process.exitCode = 1 +} + +const repoRoot = path.resolve(__dirname, '..') + +// Belt-and-braces guard against running from somewhere other than the +// dd-trace-js source checkout, in case a future change moves the script +// invocation off the `prepare` lifecycle. +const requiredMarkers = [ + path.join(repoRoot, 'eslint.config.mjs'), + path.join(repoRoot, 'packages', 'datadog-instrumentations'), + path.join(repoRoot, 'integration-tests', 'coverage', 'merge-lcov.js'), +] + +for (const marker of requiredMarkers) { + if (!fs.existsSync(marker)) { + log(`skipping: not running inside the dd-trace-js source checkout (missing ${path.relative(repoRoot, marker)})`) + return + } +} + +let targetFile +try { + targetFile = require.resolve('istanbul-lib-coverage/lib/file-coverage.js', { paths: [repoRoot] }) +} catch { + log('skipping: istanbul-lib-coverage is not installed yet') + return +} + +const source = fs.readFileSync(targetFile, 'utf8') +const relativeTarget = path.relative(repoRoot, targetFile) + +if (source.includes(SENTINEL)) { + log(`already patched at ${relativeTarget}`) + return +} + +if (!source.includes(ORIGINAL)) { + fail( + `refusing to patch ${relativeTarget}: upstream getLineCoverage() shape has changed. ` + + 'Re-verify the patch against the new upstream code before bumping istanbul-lib-coverage.' + ) + return +} + +fs.writeFileSync(targetFile, source.replace(ORIGINAL, REPLACEMENT)) +log(`patched ${relativeTarget}`) diff --git a/scripts/release/proposal.js b/scripts/release/proposal.js index d638c84a41..a395e416d6 100644 --- a/scripts/release/proposal.js +++ b/scripts/release/proposal.js @@ -66,14 +66,26 @@ try { pass(`v${releaseLine}.x`) - const diffCmd = 'branch-diff --user DataDog --repo dd-trace-js --exclude-label=semver-major' + // Notes exclude semver-major (gated behind a flag, not user-visible). + // Cherry-pick includes semver-major; only only-land-on-next is fully excluded. + const notesDiffCmd = 'branch-diff --user DataDog --repo dd-trace-js' + + ' --exclude-label=semver-major --exclude-label=only-land-on-next' + const cherryPickDiffCmd = 'branch-diff --user DataDog --repo dd-trace-js' + + ' --exclude-label=only-land-on-next' start('Determine version increment') const { DD_MAJOR, DD_MINOR, DD_PATCH } = require('../../version') - const lineDiff = capture(`${diffCmd} --markdown=true v${releaseLine}.x ${main}`) - - if (!lineDiff) { + const lineDiff = capture(`${notesDiffCmd} --format=markdown v${releaseLine}.x ${main}`) + const allDiff = capture(`${cherryPickDiffCmd} --format=markdown v${releaseLine}.x ${main}`) + + // Only labeled commits (semver-patch/minor/major) warrant cutting a release; + // unlabeled commits (e.g. docs/chore) ride along but are not enough on their own. + if ( + !allDiff.includes('SEMVER-MINOR') && + !allDiff.includes('SEMVER-PATCH') && + !allDiff.includes('SEMVER-MAJOR') + ) { pass('none (already up to date)') process.exit(0) } @@ -107,12 +119,12 @@ try { // Get the hashes of the last version and the commits to add. const lastCommit = capture('git log -1 --pretty=%B') - const proposalDiff = capture(`${diffCmd} --format=sha --reverse v${newVersion}-proposal ${main}`) + const proposalDiff = capture(`${cherryPickDiffCmd} --format=sha --reverse v${newVersion}-proposal ${main}`) .replaceAll('\n', ' ').trim() if (proposalDiff) { // Get new changes since last commit of the proposal branch. - const newChanges = capture(`${diffCmd} v${newVersion}-proposal ${main}`) + const newChanges = capture(`${cherryPickDiffCmd} v${newVersion}-proposal ${main}`) pass(`\n${newChanges}`) diff --git a/scripts/release/validate.js b/scripts/release/validate.js index febbfcb1b4..617f6d8516 100644 --- a/scripts/release/validate.js +++ b/scripts/release/validate.js @@ -58,7 +58,7 @@ try { pass() - const diffCmd = 'branch-diff --user DataDog --repo dd-trace-js --exclude-label=semver-major' + const diffCmd = 'branch-diff --user DataDog --repo dd-trace-js --exclude-label=only-land-on-next' start('Validate differences between proposal and main branch.') diff --git a/scripts/verify-exercised-tests.js b/scripts/verify-exercised-tests.js index 7aded8422d..720ac8218e 100644 --- a/scripts/verify-exercised-tests.js +++ b/scripts/verify-exercised-tests.js @@ -153,7 +153,15 @@ function normalizeScriptGlob (raw, opts = {}) { // For global analysis we treat env vars as wildcards, but when evaluating a specific CI run // we need to preserve them so they can be expanded with the provided env. - if (!preserveEnv) { + if (preserveEnv) { + // Unwrap extglob constructs that wrap a single env var so the env-aware expansion + // below still sees the variable. Without this, every glob of the form + // `@(${PLUGINS}).spec.js` would degrade to `*.spec.js` and a single-plugin CI job + // (e.g. `PLUGINS=bluebird`) would falsely appear to exercise every spec in the + // same directory. + p = p.replaceAll(/@\((\$\{[^}]+\})\)/g, '$1') + p = p.replaceAll(/@\((\$[A-Za-z_][A-Za-z0-9_]*)\)/g, '$1') + } else { // Replace shell variable expansion with a wildcard for our analysis. // Examples: // - ${PLUGINS} -> * @@ -163,8 +171,8 @@ function normalizeScriptGlob (raw, opts = {}) { p = p.replaceAll(/\$[A-Za-z_][A-Za-z0-9_]*/g, '*') } - // Replace bash extglob constructs with a conservative wildcard to avoid parsing issues. - // Examples: @(...), +(...), ?(...), !(...) + // Replace remaining bash extglob constructs with a conservative wildcard to avoid + // parsing issues. Examples: @(...), +(...), ?(...), !(...). p = p.replaceAll(/[@+?!]\([^)]*\)/g, '*') // Normalize leading './' which appears sometimes in scripts. @@ -749,6 +757,28 @@ function collectWorkflowRuns (repoRoot) { out.push({ workflowFile: wf, jobId, run: e.run, env: e.env }) } } + + // Third-party retry wrappers run their `with.command` like an inline `run:`. + // Without unwrapping it, the joint check below cannot see that `instrumentation-http` + // exercises `test:instrumentations:ci` with `PLUGINS=http`. + if (typeof step.uses === 'string' && /^nick-fields\/retry@/.test(step.uses)) { + const command = isPlainObject(step.with) && typeof step.with.command === 'string' + ? step.with.command + : null + if (command) { + const stepEnv = { ...env } + const exports = parseExportAssignments(command) + for (const [k, v] of Object.entries(exports)) stepEnv[k] = v + const idxYarn = command.indexOf('yarn ') + const idxNpm = command.indexOf('npm ') + const idx = idxYarn === -1 ? idxNpm : (idxNpm === -1 ? idxYarn : Math.min(idxYarn, idxNpm)) + if (idx > 0) { + const assigns = parseInlineAssignments(command.slice(0, idx)) + for (const [k, v] of Object.entries(assigns)) stepEnv[k] = v + } + out.push({ workflowFile: wf, jobId, run: command, env: stepEnv }) + } + } } } } @@ -1040,7 +1070,24 @@ function main () { if (!uniqueErrors.has(msg)) uniqueErrors.add(msg) } + // Transitive closure: a script counts as "invoked" when CI either runs it directly or runs + // another script that calls it via `npm run X` / `yarn X`. Without this, chaining a `:ci` + // script into the body of a parent script (e.g. `lint` -> `npm run lint:codeowners:ci`) + // looks orphaned to the coverage check below even though the parent's CI step exercises it. const invokedScripts = new Set(invoked.map(i => i.script)) + const closureQueue = [...invokedScripts] + while (closureQueue.length) { + const name = closureQueue.shift() + if (name === undefined) continue + const cmd = scripts[name] + if (typeof cmd !== 'string') continue + for (const inv of extractScriptInvocations(cmd, knownScripts)) { + if (!invokedScripts.has(inv.script)) { + invokedScripts.add(inv.script) + closureQueue.push(inv.script) + } + } + } /** * A script counts as "invoked" when either itself or its `:coverage` sibling (or base, if the @@ -1099,6 +1146,13 @@ function main () { // Detect CI steps that will match no tests due to env/script mismatches. const testFileSet = new Set(testFiles) + // Spec files reached by at least one CI invocation. Paired with the per-step + // `matchedTestCount` check below to flag the inverse failure: a spec that is matched + // by some script glob but no workflow ever sets the env (typically PLUGINS) that + // would expand the glob to reach it. Without this, a new `.spec.js` under + // `packages/datadog-instrumentations/test/` looks covered by `test:instrumentations`' + // glob and slips into the tree with no CI job actually running it. + const ciExercisedFiles = new Set() for (const i of invoked) { if (!i.script.startsWith('test:')) continue @@ -1116,7 +1170,10 @@ function main () { if (invokedGlobs.length) { let matchedTestCount = 0 for (const f of files) { - if (testFileSet.has(f)) matchedTestCount++ + if (testFileSet.has(f)) { + matchedTestCount++ + ciExercisedFiles.add(f) + } } if (matchedTestCount === 0) { @@ -1207,6 +1264,21 @@ function main () { } } + // Spec files that pass the "matched by some script glob" check but no CI invocation + // actually expands to reach them. Common cause: a `.spec.js` added under + // `packages/datadog-instrumentations/test/` (or any other PLUGINS-templated location) + // without a matching `PLUGINS=` job in the corresponding workflow. + /** @type {string[]} */ + const ciOrphans = [] + for (const file of testFiles) { + if (!ciExercisedFiles.has(file)) ciOrphans.push(file) + } + if (ciOrphans.length) { + for (const file of ciOrphans) { + pushError(`No CI workflow invocation expands a glob to exercise ${file}`) + } + } + // NOTE: We intentionally do NOT require every datadog-plugin-* package to appear in CI here. // Some plugins are intentionally excluded (platform/service constraints) and are tracked elsewhere. diff --git a/supported_versions_output.json b/supported_versions_output.json index ee7c7f617e..9a6a422595 100644 --- a/supported_versions_output.json +++ b/supported_versions_output.json @@ -3,14 +3,14 @@ "dependency": "@anthropic-ai/sdk", "integration": "anthropic", "minimum_tracer_supported": "0.14.0", - "max_tracer_supported": "0.73.0", + "max_tracer_supported": "0.98.0", "auto-instrumented": "True" }, { "dependency": "@apollo/gateway", "integration": "apollo", "minimum_tracer_supported": "2.3.0", - "max_tracer_supported": "2.12.2", + "max_tracer_supported": "2.14.0", "auto-instrumented": "True" }, { @@ -20,18 +20,25 @@ "max_tracer_supported": "3.374.0", "auto-instrumented": "True" }, + { + "dependency": "@azure/cosmos", + "integration": "azure-cosmos", + "minimum_tracer_supported": "4.4.1", + "max_tracer_supported": "4.9.3", + "auto-instrumented": "True" + }, { "dependency": "@azure/event-hubs", "integration": "azure-event-hubs", "minimum_tracer_supported": "6.0.0", - "max_tracer_supported": "6.0.2", + "max_tracer_supported": "6.0.4", "auto-instrumented": "True" }, { "dependency": "@azure/functions", "integration": "azure-functions", "minimum_tracer_supported": "4.0.0", - "max_tracer_supported": "4.11.0", + "max_tracer_supported": "4.16.0", "auto-instrumented": "True" }, { @@ -45,49 +52,49 @@ "dependency": "@confluentinc/kafka-javascript", "integration": "confluentinc-kafka-javascript", "minimum_tracer_supported": "1.0.0", - "max_tracer_supported": "1.8.0", + "max_tracer_supported": "1.9.0", "auto-instrumented": "True" }, { "dependency": "@cucumber/cucumber", "integration": "cucumber", "minimum_tracer_supported": "7.0.0", - "max_tracer_supported": "12.8.2", + "max_tracer_supported": "12.9.0", "auto-instrumented": "True" }, { "dependency": "@elastic/elasticsearch", "integration": "elasticsearch", "minimum_tracer_supported": "5.6.16", - "max_tracer_supported": "9.3.2", + "max_tracer_supported": "9.4.0", "auto-instrumented": "True" }, { "dependency": "@elastic/transport", "integration": "elasticsearch", "minimum_tracer_supported": "8.0.0", - "max_tracer_supported": "9.3.3", + "max_tracer_supported": "9.3.5", "auto-instrumented": "True" }, { "dependency": "@google-cloud/pubsub", "integration": "google-cloud-pubsub", "minimum_tracer_supported": "1.2.0", - "max_tracer_supported": "5.2.2", + "max_tracer_supported": "5.3.0", "auto-instrumented": "True" }, { "dependency": "@google-cloud/vertexai", "integration": "google-cloud-vertexai", "minimum_tracer_supported": "1.0.0", - "max_tracer_supported": "1.10.0", + "max_tracer_supported": "1.12.0", "auto-instrumented": "True" }, { "dependency": "@google/genai", "integration": "google-genai", "minimum_tracer_supported": "1.19.0", - "max_tracer_supported": "1.37.0", + "max_tracer_supported": "2.6.0", "auto-instrumented": "True" }, { @@ -101,21 +108,21 @@ "dependency": "@hapi/hapi", "integration": "hapi", "minimum_tracer_supported": "17.9.0", - "max_tracer_supported": "21.4.4", + "max_tracer_supported": "21.4.9", "auto-instrumented": "True" }, { "dependency": "@happy-dom/jest-environment", "integration": "jest", "minimum_tracer_supported": "10.0.0", - "max_tracer_supported": "20.3.1", + "max_tracer_supported": "20.9.0", "auto-instrumented": "True" }, { "dependency": "@jest/core", "integration": "jest", "minimum_tracer_supported": "28.0.0", - "max_tracer_supported": "30.4.1", + "max_tracer_supported": "30.4.2", "auto-instrumented": "True" }, { @@ -136,28 +143,42 @@ "dependency": "@koa/router", "integration": "koa", "minimum_tracer_supported": "8.0.0", - "max_tracer_supported": "15.2.0", + "max_tracer_supported": "15.5.0", "auto-instrumented": "True" }, { "dependency": "@langchain/core", "integration": "langchain", "minimum_tracer_supported": "0.1.0", - "max_tracer_supported": "1.1.16", + "max_tracer_supported": "1.1.48", "auto-instrumented": "True" }, { "dependency": "@langchain/langgraph", "integration": "langgraph", "minimum_tracer_supported": "1.1.2", - "max_tracer_supported": "1.1.2", + "max_tracer_supported": "1.3.2", "auto-instrumented": "True" }, { "dependency": "@modelcontextprotocol/sdk", "integration": "modelcontextprotocol-sdk", "minimum_tracer_supported": "1.27.1", - "max_tracer_supported": "1.27.1", + "max_tracer_supported": "1.29.0", + "auto-instrumented": "True" + }, + { + "dependency": "@nats-io/nats-core", + "integration": "nats", + "minimum_tracer_supported": "3.0.0", + "max_tracer_supported": "3.4.0", + "auto-instrumented": "True" + }, + { + "dependency": "@nats-io/transport-node", + "integration": "nats", + "minimum_tracer_supported": "3.0.0", + "max_tracer_supported": "3.4.0", "auto-instrumented": "True" }, { @@ -171,49 +192,56 @@ "dependency": "@opensearch-project/opensearch", "integration": "opensearch", "minimum_tracer_supported": "1.0.0", - "max_tracer_supported": "3.5.1", + "max_tracer_supported": "3.6.0", "auto-instrumented": "True" }, { "dependency": "@prisma/client", "integration": "prisma", "minimum_tracer_supported": "6.1.0", - "max_tracer_supported": "7.2.0", + "max_tracer_supported": "7.8.0", "auto-instrumented": "True" }, { "dependency": "@redis/client", "integration": "redis", "minimum_tracer_supported": "1.1.0", - "max_tracer_supported": "5.10.0", + "max_tracer_supported": "5.12.1", + "auto-instrumented": "True" + }, + { + "dependency": "@smithy/core", + "integration": "aws-sdk", + "minimum_tracer_supported": "3.24.0", + "max_tracer_supported": "3.24.4", "auto-instrumented": "True" }, { "dependency": "@smithy/smithy-client", "integration": "aws-sdk", "minimum_tracer_supported": "1.0.3", - "max_tracer_supported": "4.10.9", + "max_tracer_supported": "4.13.4", "auto-instrumented": "True" }, { "dependency": "@vitest/runner", "integration": "vitest", "minimum_tracer_supported": "1.6.0", - "max_tracer_supported": "4.1.5", + "max_tracer_supported": "4.1.7", "auto-instrumented": "True" }, { "dependency": "aerospike", "integration": "aerospike", "minimum_tracer_supported": "4.0.0", - "max_tracer_supported": "6.5.2", + "max_tracer_supported": "6.7.0", "auto-instrumented": "True" }, { "dependency": "ai", "integration": "ai", "minimum_tracer_supported": "4.0.0", - "max_tracer_supported": "6.0.39", + "max_tracer_supported": "6.0.191", "auto-instrumented": "True" }, { @@ -227,7 +255,7 @@ "dependency": "amqplib", "integration": "amqplib", "minimum_tracer_supported": "0.5.0", - "max_tracer_supported": "0.10.9", + "max_tracer_supported": "2.0.1", "auto-instrumented": "True" }, { @@ -248,7 +276,7 @@ "dependency": "bullmq", "integration": "bullmq", "minimum_tracer_supported": "5.66.0", - "max_tracer_supported": "5.66.5", + "max_tracer_supported": "5.76.10", "auto-instrumented": "True" }, { @@ -262,14 +290,14 @@ "dependency": "cassandra-driver", "integration": "cassandra-driver", "minimum_tracer_supported": "3.0.0", - "max_tracer_supported": "4.8.0", + "max_tracer_supported": "4.9.0", "auto-instrumented": "True" }, { "dependency": "child_process", "integration": "child_process", "minimum_tracer_supported": "18.0.0", - "max_tracer_supported": "25.9.0", + "max_tracer_supported": "26.2.0", "auto-instrumented": "True" }, { @@ -283,28 +311,28 @@ "dependency": "couchbase", "integration": "couchbase", "minimum_tracer_supported": "3.0.7", - "max_tracer_supported": "4.6.0", + "max_tracer_supported": "4.7.0", "auto-instrumented": "True" }, { "dependency": "cypress", "integration": "cypress", "minimum_tracer_supported": "12.0.0", - "max_tracer_supported": "15.14.2", + "max_tracer_supported": "15.16.0", "auto-instrumented": "True" }, { "dependency": "dns", "integration": "dns", "minimum_tracer_supported": "18.0.0", - "max_tracer_supported": "25.9.0", + "max_tracer_supported": "26.2.0", "auto-instrumented": "True" }, { "dependency": "durable-functions", "integration": "azure-durable-functions", "minimum_tracer_supported": "3.0.0", - "max_tracer_supported": "3.3.0", + "max_tracer_supported": "3.3.1", "auto-instrumented": "True" }, { @@ -318,7 +346,7 @@ "dependency": "electron", "integration": "electron", "minimum_tracer_supported": "37.0.0", - "max_tracer_supported": "39.2.4", + "max_tracer_supported": "42.1.0", "auto-instrumented": "True" }, { @@ -332,21 +360,21 @@ "dependency": "fastify", "integration": "fastify", "minimum_tracer_supported": "1.0.0", - "max_tracer_supported": "5.7.1", + "max_tracer_supported": "5.8.5", "auto-instrumented": "True" }, { "dependency": "find-my-way", "integration": "find-my-way", "minimum_tracer_supported": "1.0.0", - "max_tracer_supported": "9.4.0", + "max_tracer_supported": "9.6.0", "auto-instrumented": "True" }, { "dependency": "graphql", "integration": "graphql", "minimum_tracer_supported": "0.10.0", - "max_tracer_supported": "16.12.0", + "max_tracer_supported": "16.14.0", "auto-instrumented": "True" }, { @@ -360,35 +388,35 @@ "dependency": "hono", "integration": "hono", "minimum_tracer_supported": "4.0.0", - "max_tracer_supported": "4.11.7", + "max_tracer_supported": "4.12.19", "auto-instrumented": "True" }, { "dependency": "http", "integration": "http", "minimum_tracer_supported": "18.0.0", - "max_tracer_supported": "25.9.0", + "max_tracer_supported": "26.2.0", "auto-instrumented": "True" }, { "dependency": "http2", "integration": "http2", "minimum_tracer_supported": "18.0.0", - "max_tracer_supported": "25.9.0", + "max_tracer_supported": "26.2.0", "auto-instrumented": "True" }, { "dependency": "https", "integration": "http", "minimum_tracer_supported": "18.0.0", - "max_tracer_supported": "25.9.0", + "max_tracer_supported": "26.2.0", "auto-instrumented": "True" }, { "dependency": "ioredis", "integration": "ioredis", "minimum_tracer_supported": "2.0.0", - "max_tracer_supported": "5.9.2", + "max_tracer_supported": "5.10.1", "auto-instrumented": "True" }, { @@ -402,14 +430,14 @@ "dependency": "jest-circus", "integration": "jest", "minimum_tracer_supported": "28.0.0", - "max_tracer_supported": "30.4.1", + "max_tracer_supported": "30.4.2", "auto-instrumented": "True" }, { "dependency": "jest-config", "integration": "jest", "minimum_tracer_supported": "28.0.0", - "max_tracer_supported": "30.4.1", + "max_tracer_supported": "30.4.2", "auto-instrumented": "True" }, { @@ -430,7 +458,7 @@ "dependency": "jest-runtime", "integration": "jest", "minimum_tracer_supported": "28.0.0", - "max_tracer_supported": "30.4.1", + "max_tracer_supported": "30.4.2", "auto-instrumented": "True" }, { @@ -451,7 +479,7 @@ "dependency": "koa", "integration": "koa", "minimum_tracer_supported": "2.0.0", - "max_tracer_supported": "3.1.1", + "max_tracer_supported": "3.2.0", "auto-instrumented": "True" }, { @@ -486,7 +514,7 @@ "dependency": "mocha", "integration": "mocha", "minimum_tracer_supported": "8.0.0", - "max_tracer_supported": "11.7.5", + "max_tracer_supported": "11.7.6", "auto-instrumented": "True" }, { @@ -500,14 +528,14 @@ "dependency": "moleculer", "integration": "moleculer", "minimum_tracer_supported": "0.14.0", - "max_tracer_supported": "0.14.35", + "max_tracer_supported": "0.15.0", "auto-instrumented": "True" }, { "dependency": "mongodb", "integration": "mongodb-core", "minimum_tracer_supported": "3.3.0", - "max_tracer_supported": "7.0.0", + "max_tracer_supported": "7.2.0", "auto-instrumented": "True" }, { @@ -521,7 +549,7 @@ "dependency": "mongoose", "integration": "mongoose", "minimum_tracer_supported": "4.6.4", - "max_tracer_supported": "9.1.4", + "max_tracer_supported": "9.6.2", "auto-instrumented": "True" }, { @@ -535,70 +563,70 @@ "dependency": "mysql2", "integration": "mysql2", "minimum_tracer_supported": "1.0.0", - "max_tracer_supported": "3.18.2", + "max_tracer_supported": "3.22.3", "auto-instrumented": "True" }, { "dependency": "net", "integration": "net", "minimum_tracer_supported": "18.0.0", - "max_tracer_supported": "25.9.0", + "max_tracer_supported": "26.2.0", "auto-instrumented": "True" }, { "dependency": "next", "integration": "next", "minimum_tracer_supported": "10.2.0", - "max_tracer_supported": "16.1.3", + "max_tracer_supported": "16.2.6", "auto-instrumented": "True" }, { "dependency": "node:dns", "integration": "dns", "minimum_tracer_supported": "18.0.0", - "max_tracer_supported": "25.9.0", + "max_tracer_supported": "26.2.0", "auto-instrumented": "True" }, { "dependency": "node:http", "integration": "http", "minimum_tracer_supported": "18.0.0", - "max_tracer_supported": "25.9.0", + "max_tracer_supported": "26.2.0", "auto-instrumented": "True" }, { "dependency": "node:http2", "integration": "http2", "minimum_tracer_supported": "18.0.0", - "max_tracer_supported": "25.9.0", + "max_tracer_supported": "26.2.0", "auto-instrumented": "True" }, { "dependency": "node:https", "integration": "http", "minimum_tracer_supported": "18.0.0", - "max_tracer_supported": "25.9.0", + "max_tracer_supported": "26.2.0", "auto-instrumented": "True" }, { "dependency": "node:net", "integration": "net", "minimum_tracer_supported": "18.0.0", - "max_tracer_supported": "25.9.0", + "max_tracer_supported": "26.2.0", "auto-instrumented": "True" }, { "dependency": "nyc", "integration": "nyc", "minimum_tracer_supported": "17.0.0", - "max_tracer_supported": "17.1.0", + "max_tracer_supported": "18.0.0", "auto-instrumented": "True" }, { "dependency": "openai", "integration": "openai", "minimum_tracer_supported": "3.0.0", - "max_tracer_supported": "6.18.0", + "max_tracer_supported": "6.39.0", "auto-instrumented": "True" }, { @@ -612,14 +640,14 @@ "dependency": "pg", "integration": "pg", "minimum_tracer_supported": "8.0.3", - "max_tracer_supported": "8.17.1", + "max_tracer_supported": "8.21.0", "auto-instrumented": "True" }, { "dependency": "pino", "integration": "pino", "minimum_tracer_supported": "2.0.0", - "max_tracer_supported": "10.2.0", + "max_tracer_supported": "10.3.1", "auto-instrumented": "True" }, { @@ -633,21 +661,21 @@ "dependency": "playwright", "integration": "playwright", "minimum_tracer_supported": "1.38.0", - "max_tracer_supported": "1.59.1", + "max_tracer_supported": "1.60.0", "auto-instrumented": "True" }, { "dependency": "protobufjs", "integration": "protobufjs", "minimum_tracer_supported": "6.8.0", - "max_tracer_supported": "8.0.0", + "max_tracer_supported": "8.4.0", "auto-instrumented": "True" }, { "dependency": "redis", "integration": "redis", "minimum_tracer_supported": "0.12.0", - "max_tracer_supported": "5.10.0", + "max_tracer_supported": "5.12.1", "auto-instrumented": "True" }, { @@ -661,7 +689,7 @@ "dependency": "rhea", "integration": "rhea", "minimum_tracer_supported": "1.0.0", - "max_tracer_supported": "3.0.4", + "max_tracer_supported": "3.0.5", "auto-instrumented": "True" }, { @@ -675,7 +703,7 @@ "dependency": "selenium-webdriver", "integration": "selenium", "minimum_tracer_supported": "4.11.0", - "max_tracer_supported": "4.39.0", + "max_tracer_supported": "4.44.0", "auto-instrumented": "True" }, { @@ -689,7 +717,7 @@ "dependency": "tedious", "integration": "tedious", "minimum_tracer_supported": "1.0.0", - "max_tracer_supported": "19.2.0", + "max_tracer_supported": "19.2.1", "auto-instrumented": "True" }, { @@ -703,14 +731,14 @@ "dependency": "undici", "integration": "undici", "minimum_tracer_supported": "4.4.1", - "max_tracer_supported": "7.18.2", + "max_tracer_supported": "8.3.0", "auto-instrumented": "True" }, { "dependency": "vitest", "integration": "vitest", "minimum_tracer_supported": "1.6.0", - "max_tracer_supported": "4.1.5", + "max_tracer_supported": "4.1.7", "auto-instrumented": "True" }, { @@ -724,14 +752,14 @@ "dependency": "workerpool", "integration": "mocha", "minimum_tracer_supported": "6.0.0", - "max_tracer_supported": "10.0.1", + "max_tracer_supported": "10.0.2", "auto-instrumented": "True" }, { "dependency": "ws", "integration": "ws", "minimum_tracer_supported": "8.0.0", - "max_tracer_supported": "8.19.0", + "max_tracer_supported": "8.20.1", "auto-instrumented": "True" } ] diff --git a/supported_versions_table.csv b/supported_versions_table.csv index 3d2c32d470..2136ca8796 100644 --- a/supported_versions_table.csv +++ b/supported_versions_table.csv @@ -1,106 +1,110 @@ dependency,integration,minimum_tracer_supported,max_tracer_supported,auto-instrumented -@anthropic-ai/sdk,anthropic,0.14.0,0.73.0,True -@apollo/gateway,apollo,2.3.0,2.12.2,True +@anthropic-ai/sdk,anthropic,0.14.0,0.98.0,True +@apollo/gateway,apollo,2.3.0,2.14.0,True @aws-sdk/smithy-client,aws-sdk,3.0.0,3.374.0,True -@azure/event-hubs,azure-event-hubs,6.0.0,6.0.2,True -@azure/functions,azure-functions,4.0.0,4.11.0,True +@azure/cosmos,azure-cosmos,4.4.1,4.9.3,True +@azure/event-hubs,azure-event-hubs,6.0.0,6.0.4,True +@azure/functions,azure-functions,4.0.0,4.16.0,True @azure/service-bus,azure-service-bus,7.9.2,7.9.5,True -@confluentinc/kafka-javascript,confluentinc-kafka-javascript,1.0.0,1.8.0,True -@cucumber/cucumber,cucumber,7.0.0,12.8.2,True -@elastic/elasticsearch,elasticsearch,5.6.16,9.3.2,True -@elastic/transport,elasticsearch,8.0.0,9.3.3,True -@google-cloud/pubsub,google-cloud-pubsub,1.2.0,5.2.2,True -@google-cloud/vertexai,google-cloud-vertexai,1.0.0,1.10.0,True -@google/genai,google-genai,1.19.0,1.37.0,True +@confluentinc/kafka-javascript,confluentinc-kafka-javascript,1.0.0,1.9.0,True +@cucumber/cucumber,cucumber,7.0.0,12.9.0,True +@elastic/elasticsearch,elasticsearch,5.6.16,9.4.0,True +@elastic/transport,elasticsearch,8.0.0,9.3.5,True +@google-cloud/pubsub,google-cloud-pubsub,1.2.0,5.3.0,True +@google-cloud/vertexai,google-cloud-vertexai,1.0.0,1.12.0,True +@google/genai,google-genai,1.19.0,2.6.0,True @grpc/grpc-js,grpc,1.0.3,1.14.3,True -@hapi/hapi,hapi,17.9.0,21.4.4,True -@happy-dom/jest-environment,jest,10.0.0,20.3.1,True -@jest/core,jest,28.0.0,30.4.1,True +@hapi/hapi,hapi,17.9.0,21.4.9,True +@happy-dom/jest-environment,jest,10.0.0,20.9.0,True +@jest/core,jest,28.0.0,30.4.2,True @jest/test-sequencer,jest,28.0.0,30.4.1,True @jest/transform,jest,28.0.0,30.4.1,True -@koa/router,koa,8.0.0,15.2.0,True -@langchain/core,langchain,0.1.0,1.1.16,True -@langchain/langgraph,langgraph,1.1.2,1.1.2,True -@modelcontextprotocol/sdk,modelcontextprotocol-sdk,1.27.1,1.27.1,True +@koa/router,koa,8.0.0,15.5.0,True +@langchain/core,langchain,0.1.0,1.1.48,True +@langchain/langgraph,langgraph,1.1.2,1.3.2,True +@modelcontextprotocol/sdk,modelcontextprotocol-sdk,1.27.1,1.29.0,True +@nats-io/nats-core,nats,3.0.0,3.4.0,True +@nats-io/transport-node,nats,3.0.0,3.4.0,True @node-redis/client,redis,1.0.0,1.0.6,True -@opensearch-project/opensearch,opensearch,1.0.0,3.5.1,True -@prisma/client,prisma,6.1.0,7.2.0,True -@redis/client,redis,1.1.0,5.10.0,True -@smithy/smithy-client,aws-sdk,1.0.3,4.10.9,True -@vitest/runner,vitest,1.6.0,4.1.5,True -aerospike,aerospike,4.0.0,6.5.2,True -ai,ai,4.0.0,6.0.39,True +@opensearch-project/opensearch,opensearch,1.0.0,3.6.0,True +@prisma/client,prisma,6.1.0,7.8.0,True +@redis/client,redis,1.1.0,5.12.1,True +@smithy/core,aws-sdk,3.24.0,3.24.4,True +@smithy/smithy-client,aws-sdk,1.0.3,4.13.4,True +@vitest/runner,vitest,1.6.0,4.1.7,True +aerospike,aerospike,4.0.0,6.7.0,True +ai,ai,4.0.0,6.0.191,True amqp10,amqp10,3.0.0,3.6.0,True -amqplib,amqplib,0.5.0,0.10.9,True +amqplib,amqplib,0.5.0,2.0.1,True avsc,avsc,5.0.0,5.7.9,True aws-sdk,aws-sdk,2.1.35,2.1693.0,True -bullmq,bullmq,5.66.0,5.66.5,True +bullmq,bullmq,5.66.0,5.76.10,True bunyan,bunyan,1.0.0,2.0.5,True -cassandra-driver,cassandra-driver,3.0.0,4.8.0,True -child_process,child_process,18.0.0,25.9.0,True +cassandra-driver,cassandra-driver,3.0.0,4.9.0,True +child_process,child_process,18.0.0,26.2.0,True connect,connect,2.2.2,3.7.0,True -couchbase,couchbase,3.0.7,4.6.0,True -cypress,cypress,12.0.0,15.14.2,True -dns,dns,18.0.0,25.9.0,True -durable-functions,azure-durable-functions,3.0.0,3.3.0,True +couchbase,couchbase,3.0.7,4.7.0,True +cypress,cypress,12.0.0,15.16.0,True +dns,dns,18.0.0,26.2.0,True +durable-functions,azure-durable-functions,3.0.0,3.3.1,True elasticsearch,elasticsearch,10.0.0,16.7.3,True -electron,electron,37.0.0,39.2.4,True +electron,electron,37.0.0,42.1.0,True express,express,4.0.0,5.2.1,True -fastify,fastify,1.0.0,5.7.1,True -find-my-way,find-my-way,1.0.0,9.4.0,True -graphql,graphql,0.10.0,16.12.0,True +fastify,fastify,1.0.0,5.8.5,True +find-my-way,find-my-way,1.0.0,9.6.0,True +graphql,graphql,0.10.0,16.14.0,True hapi,hapi,16.0.0,18.1.0,True -hono,hono,4.0.0,4.11.7,True -http,http,18.0.0,25.9.0,True -http2,http2,18.0.0,25.9.0,True -https,http,18.0.0,25.9.0,True -ioredis,ioredis,2.0.0,5.9.2,True +hono,hono,4.0.0,4.12.19,True +http,http,18.0.0,26.2.0,True +http2,http2,18.0.0,26.2.0,True +https,http,18.0.0,26.2.0,True +ioredis,ioredis,2.0.0,5.10.1,True iovalkey,iovalkey,0.0.1,0.3.3,True -jest-circus,jest,28.0.0,30.4.1,True -jest-config,jest,28.0.0,30.4.1,True +jest-circus,jest,28.0.0,30.4.2,True +jest-config,jest,28.0.0,30.4.2,True jest-environment-jsdom,jest,28.0.0,30.4.1,True jest-environment-node,jest,28.0.0,30.4.1,True -jest-runtime,jest,28.0.0,30.4.1,True +jest-runtime,jest,28.0.0,30.4.2,True jest-worker,jest,28.0.0,30.4.1,True kafkajs,kafkajs,1.4.0,2.2.4,True -koa,koa,2.0.0,3.1.1,True +koa,koa,2.0.0,3.2.0,True koa-router,koa,7.0.0,14.0.0,True mariadb,mariadb,2.0.4,3.4.5,True memcached,memcached,2.2.0,2.2.2,True microgateway-core,microgateway-core,2.1.0,3.3.7,True -mocha,mocha,8.0.0,11.7.5,True +mocha,mocha,8.0.0,11.7.6,True mocha-each,mocha,2.0.1,2.0.1,True -moleculer,moleculer,0.14.0,0.14.35,True -mongodb,mongodb-core,3.3.0,7.0.0,True +moleculer,moleculer,0.14.0,0.15.0,True +mongodb,mongodb-core,3.3.0,7.2.0,True mongodb-core,mongodb-core,2.0.0,3.2.7,True -mongoose,mongoose,4.6.4,9.1.4,True +mongoose,mongoose,4.6.4,9.6.2,True mysql,mysql,2.0.0,2.18.1,True -mysql2,mysql2,1.0.0,3.18.2,True -net,net,18.0.0,25.9.0,True -next,next,10.2.0,16.1.3,True -node:dns,dns,18.0.0,25.9.0,True -node:http,http,18.0.0,25.9.0,True -node:http2,http2,18.0.0,25.9.0,True -node:https,http,18.0.0,25.9.0,True -node:net,net,18.0.0,25.9.0,True -nyc,nyc,17.0.0,17.1.0,True -openai,openai,3.0.0,6.18.0,True +mysql2,mysql2,1.0.0,3.22.3,True +net,net,18.0.0,26.2.0,True +next,next,10.2.0,16.2.6,True +node:dns,dns,18.0.0,26.2.0,True +node:http,http,18.0.0,26.2.0,True +node:http2,http2,18.0.0,26.2.0,True +node:https,http,18.0.0,26.2.0,True +node:net,net,18.0.0,26.2.0,True +nyc,nyc,17.0.0,18.0.0,True +openai,openai,3.0.0,6.39.0,True oracledb,oracledb,5.0.0,6.10.0,True -pg,pg,8.0.3,8.17.1,True -pino,pino,2.0.0,10.2.0,True +pg,pg,8.0.3,8.21.0,True +pino,pino,2.0.0,10.3.1,True pino-pretty,pino,1.0.0,13.1.3,True -playwright,playwright,1.38.0,1.59.1,True -protobufjs,protobufjs,6.8.0,8.0.0,True -redis,redis,0.12.0,5.10.0,True +playwright,playwright,1.38.0,1.60.0,True +protobufjs,protobufjs,6.8.0,8.4.0,True +redis,redis,0.12.0,5.12.1,True restify,restify,3.0.0,11.1.0,True -rhea,rhea,1.0.0,3.0.4,True +rhea,rhea,1.0.0,3.0.5,True router,router,1.0.0,2.2.0,True -selenium-webdriver,selenium,4.11.0,4.39.0,True +selenium-webdriver,selenium,4.11.0,4.44.0,True sharedb,sharedb,1.0.0,5.2.2,True -tedious,tedious,1.0.0,19.2.0,True +tedious,tedious,1.0.0,19.2.1,True tinypool,vitest,0.8.0,2.1.0,True -undici,undici,4.4.1,7.18.2,True -vitest,vitest,1.6.0,4.1.5,True +undici,undici,4.4.1,8.3.0,True +vitest,vitest,1.6.0,4.1.7,True winston,winston,1.0.0,3.19.0,True -workerpool,mocha,6.0.0,10.0.1,True -ws,ws,8.0.0,8.19.0,True +workerpool,mocha,6.0.0,10.0.2,True +ws,ws,8.0.0,8.20.1,True diff --git a/vendor/package-lock.json b/vendor/package-lock.json index be20dcce0a..8618da500e 100644 --- a/vendor/package-lock.json +++ b/vendor/package-lock.json @@ -7,10 +7,10 @@ "hasInstallScript": true, "license": "(Apache-2.0 OR BSD-3-Clause)", "dependencies": { - "@apm-js-collab/code-transformer": "^0.12.0", + "@apm-js-collab/code-transformer": "^0.13.0", "@datadog/sketches-js": "2.1.1", "@datadog/source-map": "npm:source-map@^0.6.0", - "@isaacs/ttlcache": "^2.1.4", + "@isaacs/ttlcache": "^2.1.5", "@opentelemetry/core": ">=1.14.0 <1.31.0", "@opentelemetry/resources": ">=1.0.0 <1.31.0", "crypto-randomuuid": "^1.0.0", @@ -26,7 +26,7 @@ "module-details-from-path": "^1.0.4", "mutexify": "^1.4.0", "pprof-format": "^2.1.1", - "protobufjs": "^8.0.1", + "protobufjs": "^8.4.2", "retry": "^0.13.1", "rfdc": "^1.4.1", "semifies": "^1.0.0", @@ -41,9 +41,9 @@ } }, "node_modules/@apm-js-collab/code-transformer": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.12.0.tgz", - "integrity": "sha512-5F2ob4cMYezbaUGAk+YltbDvb9BFIghN92ubct9Ho/0MFx4FkChCxYV99NkU6Kx+RAgaqBV6yxKuWreQ6K8SOw==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.13.0.tgz", + "integrity": "sha512-JPUR9mNUJV3SP0l6XQ5xGG/3IMOELzNy86vCq/+GOkIUsxEWC6AMIviAQ5sxrfQQEbQofjIzU3kshx4RQnRq7A==", "license": "Apache-2.0", "dependencies": { "@types/estree": "^1.0.8", @@ -114,9 +114,9 @@ } }, "node_modules/@isaacs/ttlcache": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.4.tgz", - "integrity": "sha512-7kMz0BJpMvgAMkyglums7B2vtrn5g0a0am77JY0GjkZZNetOBCFn7AG7gKCwT0QPiXyxW7YIQSgtARknUEOcxQ==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-2.1.5.tgz", + "integrity": "sha512-VwGZqqjAWPICTmxUZnbpEfO60LhPWzquik+bmyXGY7pYRn6diEvCI5i6Ca+J6o2y4vS73HrpuMTo2dOvUevH8w==", "license": "BlueOak-1.0.0", "engines": { "node": ">=12" @@ -268,70 +268,6 @@ "node": ">=14" } }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, "node_modules/@rspack/binding": { "version": "1.7.11", "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.11.tgz", @@ -538,15 +474,6 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, - "node_modules/@types/node": { - "version": "25.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", - "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, "node_modules/astring": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", @@ -730,24 +657,13 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.1.tgz", - "integrity": "sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.2.tgz", + "integrity": "sha512-64rfNzkWOZAIazXzpBFPWq6F9up6gMvTzjE2oWIzApx2N/dqVUEE7+bCn2+40780dFVtKOUab8QfxJ6KJDWbqA==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -824,12 +740,6 @@ "fast-fifo": "^1.3.2" } }, - "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "license": "MIT" - }, "node_modules/webpack-sources": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", diff --git a/vendor/package.json b/vendor/package.json index 750458d497..4526a1d344 100644 --- a/vendor/package.json +++ b/vendor/package.json @@ -4,10 +4,10 @@ "postinstall": "node rspack" }, "dependencies": { - "@apm-js-collab/code-transformer": "^0.12.0", + "@apm-js-collab/code-transformer": "^0.13.0", "@datadog/sketches-js": "2.1.1", "@datadog/source-map": "npm:source-map@^0.6.0", - "@isaacs/ttlcache": "^2.1.4", + "@isaacs/ttlcache": "^2.1.5", "@opentelemetry/core": ">=1.14.0 <1.31.0", "@opentelemetry/resources": ">=1.0.0 <1.31.0", "crypto-randomuuid": "^1.0.0", @@ -23,7 +23,7 @@ "module-details-from-path": "^1.0.4", "mutexify": "^1.4.0", "pprof-format": "^2.1.1", - "protobufjs": "^8.0.1", + "protobufjs": "^8.4.2", "retry": "^0.13.1", "rfdc": "^1.4.1", "semifies": "^1.0.0", diff --git a/yarn.lock b/yarn.lock index c7b8ffc5cf..ba3636ad0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -190,10 +190,10 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" -"@datadog/flagging-core@0.3.3": - version "0.3.3" - resolved "https://registry.yarnpkg.com/@datadog/flagging-core/-/flagging-core-0.3.3.tgz#cd0553b05a26f924e9d6f8450e4c073eb3d40b96" - integrity sha512-LnkTXMVxaCDGCOF2I+CCACndpbi4E8CP8NIsb1IbMmmATzkQHmYiL1ntFcS4mt5kNGAWXNrKquM02jhoiVc+dA== +"@datadog/flagging-core@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@datadog/flagging-core/-/flagging-core-1.2.1.tgz#1bb2d1ecfd749033ed2570eccc8fb0697b8adfac" + integrity sha512-qeDkki9fFlqyoZBrn7tneT6pZ04EKKvf3xxisYw1a74zbJihvQui/ARUsjXCurRpzpFqGGTJw/oz+HnXaKhcdw== dependencies: spark-md5 "^3.0.2" @@ -209,10 +209,10 @@ dependencies: node-gyp-build "^3.9.0" -"@datadog/native-iast-taint-tracking@4.1.0": - version "4.1.0" - resolved "https://registry.yarnpkg.com/@datadog/native-iast-taint-tracking/-/native-iast-taint-tracking-4.1.0.tgz#133d1b5530f60aec4875fda8d7b07a2fc1a8deb0" - integrity sha512-g9K9Ddx1YQfrQIC2hgtfnYUGuzAFvSvhvt2lPZOAWBPo+bkYoW5KEkMHoY5XykCigTfXBYcQicRV0xB22AMkHw== +"@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== dependencies: node-gyp-build "^3.9.0" @@ -224,19 +224,19 @@ node-addon-api "^6.1.0" node-gyp-build "^3.9.0" -"@datadog/openfeature-node-server@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@datadog/openfeature-node-server/-/openfeature-node-server-1.1.2.tgz#fd5ec4ba80e60464ff2aacae251bb500887956e0" - integrity sha512-fr+zCaKoCSdizX22H7eA9Z3QaZrOMu5qU0yK0M2aWtUxokSAKPlLz3+9HdmqwBWU/ikqI+lbiAK0oNdvmUKeQA== +"@datadog/openfeature-node-server@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@datadog/openfeature-node-server/-/openfeature-node-server-1.2.1.tgz#762b39e486f9d04e0219058b1b9b4202004cf091" + integrity sha512-iY32juuL2w07vOlrTG1Y1U0y3ehyvRxuwzJvaLqjmQE8jj2L3o2SRm2UwgmLnzh6JWzMfNxbfs66KvOmPja7dQ== dependencies: - "@datadog/flagging-core" "0.3.3" + "@datadog/flagging-core" "^1.2.1" -"@datadog/pprof@5.14.1": - version "5.14.1" - resolved "https://registry.yarnpkg.com/@datadog/pprof/-/pprof-5.14.1.tgz#8edc01811600f380c87aa4623d0ece04c3b09088" - integrity sha512-MlODCE9Gltmx7WChOg1BkIm7W2iE4CDW7K72BMwgzCvxFkG9rFWVsuA7/NEZL1nDvJ0qDe2du7DZZdZHTjcVPw== +"@datadog/pprof@5.14.4": + version "5.14.4" + resolved "https://registry.yarnpkg.com/@datadog/pprof/-/pprof-5.14.4.tgz#4a242b6e9c78f66aff836e926b28733749cfa83b" + integrity sha512-egEZDD9v98RBI8ijbHyaWQeY8rW0WEu004As5D7SUkdqSMORhrnh7ZdsM46PUzQgAc85IaEZoukWS9UhMvWn9w== dependencies: - node-gyp-build "<4.0" + node-gyp-build "^4.8.4" pprof-format "^2.2.1" source-map "^0.7.4" @@ -722,174 +722,194 @@ 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.13": - version "1.3.13" - resolved "https://registry.yarnpkg.com/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.13.tgz#b84bad830ea6703b1a1cfc8644e394efcc49ed0b" - integrity sha512-qAS6Hg8Q14ckfBuqJ2Zh7gBQSVSUHeibSq4OFqBTv6DzyJuxYlr0sdYQzmYmnbPxbqobekqUDTa/4XEaqRi7vg== - -"@oven/bun-darwin-x64-baseline@1.3.13": - version "1.3.13" - resolved "https://registry.yarnpkg.com/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.13.tgz#a8352e7431e1903647b97ddbe68fbea40ce63753" - integrity sha512-gMEQayUpmCPYaE9zkNBj9TiQqHupnhjOYcuSzxFjzIjHJBUO4VjNnrpbKVeXNs+rKHFothORDd2QKquu5paSPQ== - -"@oven/bun-darwin-x64@1.3.13": - version "1.3.13" - resolved "https://registry.yarnpkg.com/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.13.tgz#61f1b7d436049a5811c13eff497ae50646d214f9" - integrity sha512-kGePeDD4IN4imo+H4uLjQGZLmvyYQg+nKr2P0nt4ksXXrWA4HE+mb0/TUPHfRI127DocXQpew+fvrHuHR5mpJQ== - -"@oven/bun-linux-aarch64-musl@1.3.13": - version "1.3.13" - resolved "https://registry.yarnpkg.com/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.13.tgz#45ae31cc0b7b235689cbd6fd74ecdfc014f81896" - integrity sha512-UV9EE18VE5aRhWtV2L6MTAGGn3slhJJ2OW/m+FJM15maHm0qf1V7TaZY0FovxhdQRvnklSiQ7Ntv0H5TUX4w0g== - -"@oven/bun-linux-aarch64@1.3.13": - version "1.3.13" - resolved "https://registry.yarnpkg.com/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.13.tgz#3d8dbaa03ad970a0e3757e126ea9cc8f1649e141" - integrity sha512-NbLOJdr+RBFO1vFZ2YUFg4oVJ+2ua6zrwo4ZWRs0jKKcGJWtbY2wY5uz+i0PkwH6b9HYaYDgVTzE4ev06ncYZw== - -"@oven/bun-linux-x64-baseline@1.3.13": - version "1.3.13" - resolved "https://registry.yarnpkg.com/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.13.tgz#c55c0c32dd68e6ab3b6095b17c97fc7a5c208bab" - integrity sha512-fOi4ziKzgJG4UrrNd4AicBs6Fu9GY5xOqg+9tC76nuZNDAdSh6++kzab6TNi1Ck0Yzq6zIBIdGit6/0uSbBn8A== - -"@oven/bun-linux-x64-musl-baseline@1.3.13": - version "1.3.13" - resolved "https://registry.yarnpkg.com/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.13.tgz#e67d4f08958fa7aef0a205765432824558c68798" - integrity sha512-fqBKuiiWLEu2dVkowZaXgKS98xfrvBqivdoxRtRP3eINcpI1dcelGbsOz+Xphn7tbGAuBiE1/0AelvvvdqS9rg== - -"@oven/bun-linux-x64-musl@1.3.13": - version "1.3.13" - resolved "https://registry.yarnpkg.com/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.13.tgz#6fa3b1c9c14d1b09ff7d87874beb3641e75bbfec" - integrity sha512-+VHhE44kEjCXcTFHyc81zfTxL9+vzh9RqIh7gM1iWNhxpctD9kzntbUkP3UTFTwwNjoou1o8VRyxQafvc4OepA== - -"@oven/bun-linux-x64@1.3.13": - version "1.3.13" - resolved "https://registry.yarnpkg.com/@oven/bun-linux-x64/-/bun-linux-x64-1.3.13.tgz#f75bb98a3c82fbbe850038305b8c72faf97adcb4" - integrity sha512-UwttIUXoe9fS+40OcjoaRHgZw+HCPFqBVWEXkXqAJ3W7wA0XPZrWsoMAD9sGh3TaLqrwdiMo5xPogwpXhOtVXA== - -"@oven/bun-windows-aarch64@1.3.13": - version "1.3.13" - resolved "https://registry.yarnpkg.com/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.13.tgz#3e0733d2c09e2c4409de421ac6e18fdcae5c7dca" - integrity sha512-+EvdRWRCRg95Xea4M2lqSJFTjzQBTJDQTMlbG8bmwFkVTN16MdmSH7xhfxVQWUOyZBLEpIwuNFIlBBxVCwSUyQ== - -"@oven/bun-windows-x64-baseline@1.3.13": - version "1.3.13" - resolved "https://registry.yarnpkg.com/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.13.tgz#7ebbcd2396ba34a8318eb8e204084fa4f9a54a05" - integrity sha512-6gy4hhQSjq/T/S9hC9m3NxY0RY+9Ww+XNlB+8koIMTsMSYEjk7Ho+hFHQz1Bn4W61Ub7Vykufg+jgDgPfa2GFA== - -"@oven/bun-windows-x64@1.3.13": - version "1.3.13" - resolved "https://registry.yarnpkg.com/@oven/bun-windows-x64/-/bun-windows-x64-1.3.13.tgz#50c46e195061cd559edf49cc30d91c9e856b8249" - integrity sha512-vqDEFX63ZZQF3YstPSpPD+RxNm5AILPdUuuKpNwsj7ld4NjhdHUYkAmLXDtKNWt9JMRL10bop//W8faY/LV+RQ== - -"@oxc-parser/binding-android-arm-eabi@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.129.0.tgz#fd038240307fa37e42bc6a98a24768aff817bb0b" - integrity sha512-sG37CfXLlYXdDrggAFO/mKcO4w36piwf862xAZXIuf3nzKhWK1FvW4dqie+06++z+mDto2QeOQSvhyzBeK5jsQ== - -"@oxc-parser/binding-android-arm64@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.129.0.tgz#adbb4f9df3348ac3b30fd90452533161c2323676" - integrity sha512-DVyLFN2+S0VOhT6lm5++tFqlu3x2Njiby6y5DhTzjV5uRsZWpifsBn6+yjtwAxl105peEjs5BHE3ToBJuQjLTg== - -"@oxc-parser/binding-darwin-arm64@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.129.0.tgz#7323773e34eb29dc37170733f3df831bf27ec897" - integrity sha512-QeqThtB8qax4IL+NFBWgshudyKkj5c076L8vyd8PCEx7U1wHyIbH49MEQ5J5iURFhUW5jiFmdnLKEwyOo0GAJA== - -"@oxc-parser/binding-darwin-x64@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.129.0.tgz#35182155699eb47093971020180cd706c8310b8f" - integrity sha512-zn5+7nv4DlK4uFgblmhKm6xRV0QUHXOHyIDkjmhxJ53xSA9ahkb3pHNiHesNPXn/nK/VWU+C+Z6JYHdatZBh7g== - -"@oxc-parser/binding-freebsd-x64@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.129.0.tgz#fff993c77bb1b6c1d82749643d133174d77c9676" - integrity sha512-SPTcDBiHWlgRpWFC1jnoi0sBWqCw4DFR+4b8+dV+NAhUu2ONERWyIVIOCfcE9a8BlvZsDCuXf3l/x7wQUs1Rsw== - -"@oxc-parser/binding-linux-arm-gnueabihf@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.129.0.tgz#7ec5e22206fddbb9d8cbdb98a1b5476ca81282a7" - integrity sha512-Rgc9+WNKLbc+chyDTXyyJ7gbgLo+ve27CrRnmIwGgucGflrBZbutge5jdPPegcgf46RrR4dkBbMCp0/x16mdig== - -"@oxc-parser/binding-linux-arm-musleabihf@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.129.0.tgz#dec55e83c690b38ff1392a8980a44b0504cbc3b3" - integrity sha512-YtSsJ51VysXqlO8Cs2mWTyXvxBRemTHj4WDQjXwKl0SAxh+CVrEdXrdH+RnjxLj3JCUMFeYuHs5c+/DImfbKkg== - -"@oxc-parser/binding-linux-arm64-gnu@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.129.0.tgz#f1e7a509ff5c8fc1aa8df64fc7af69382fcd6e61" - integrity sha512-9oK8iQr9KtgI5JhaJ+5IwiQsXEo6NuasFgovtJGrdK/RxbA0bO4YKRvVY7M+8lozUCVz1U7XrFFODv3emIOPRA== - -"@oxc-parser/binding-linux-arm64-musl@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.129.0.tgz#021cfae1197d0210c24dc273ff436d61dd550f8d" - integrity sha512-GghE/bf9ZqgqZFxLacgP0ImVD6UiLKQOpvpgUoIsqjopu2ms/+p1L0d0Dv2Sck+8p0FbKS2WE3IjqmIlLbxJgA== - -"@oxc-parser/binding-linux-ppc64-gnu@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.129.0.tgz#f9638c67c5b97d13f5f387395346b43fe40c79f2" - integrity sha512-A2PW0UbERzKGV6fKX1zoe2Tkc1zVcEJSSPW9IUSKbZAPuPe+M5/5hTA+6fQbWmevabe2B3IDky66a1lFGjpBKA== - -"@oxc-parser/binding-linux-riscv64-gnu@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.129.0.tgz#6b3ed8958b403f75ac68fcb919d9a8447057e70b" - integrity sha512-omwxd9H+jrl1T72RI666k4ho7Eli2iHdELzf+dL0D+uXThNZXYJCbKjm5rK2hrHmDy4O+NWv7+khBrEkorLsgw== - -"@oxc-parser/binding-linux-riscv64-musl@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.129.0.tgz#a8a1be098d37f68ba1321e6cb76830a73200fc26" - integrity sha512-v2hi8id+M8C0uY8uuG2t2a5vr8H9XyHXiHL12yMdMNtgn04nnM/8hlOGuoJuxVc07PhClNiaoSaY2eXehSRa7w== - -"@oxc-parser/binding-linux-s390x-gnu@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.129.0.tgz#8ab51fa869894900f242be4682007dada48d704c" - integrity sha512-UXrdDyLh1Obgj5X+IVVXWoo5/FJbFsU8/uLQ/M9lkVUwBUKpRFxNEhzwBNv21qqdKgAh+pr2CCVD8J1JfRPsIQ== - -"@oxc-parser/binding-linux-x64-gnu@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.129.0.tgz#df221a378d150ca4b168d99d68f1bd6fee120651" - integrity sha512-hsL/3/kdX9FGLqOj8DR3Eki4Y6zO1i3+ZHhiPwX0hDt4n+18abkfUzePCv3h8SShprwCmwdxPnlrebZ5+MZ+cw== - -"@oxc-parser/binding-linux-x64-musl@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.129.0.tgz#4b11eec4ed01d93981965b3ca0a58b2e51b2475a" - integrity sha512-kdXvJ4crOeRld3vWl0J0VU4nmnT4pZ3lKGA5tZ1y0UPWsBtElDYd+jsz4lE36tpAbCiWm0M0PG0laUNBSE+Wlw== - -"@oxc-parser/binding-openharmony-arm64@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.129.0.tgz#d2faff426b739323e734f032d404cfa3db4f0c0b" - integrity sha512-DusJfcK7EGwf9TEakB+z6SXCLdHGvDZ8U8882bzWb4oVrORHpbkFl9npS7cN3YC2axcVKoktbxZevS1nxVCKFw== - -"@oxc-parser/binding-wasm32-wasi@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.129.0.tgz#7c31ffa9902a5d3e0b81bddc88d2db959efe9d56" - integrity sha512-Iie9CcII+ELSinKFnxTR15xhI9qriVivEhbFh3driRNbzms/5ioDAU0fwe8Mf1FEaz3n2FtiUVX0h0nwKLYk0A== +"@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== + +"@oxc-parser/binding-android-arm-eabi@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.132.0.tgz#f88d600252349b5e380e695cadf889cea896f676" + integrity sha512-KrLaPWa5c9Y7LkW+rKkaUE3y7DBDrQtaf7rlsSDfv6KAHUjgzAIRA761Lrrp6//Yd/Rlie/yEOt9YENCoJnOcw== + +"@oxc-parser/binding-android-arm64@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.132.0.tgz#ef91deec0305c54fa6c7b519f82da63d36b49788" + integrity sha512-SThDrSeamB/kG2+NxcJ5/wSLcV6dUqDknrPLqFYQ0ST/55mtBP4M7Q/f3QbubH6aAd11wpzZn/nwbVRSdobOpg== + +"@oxc-parser/binding-darwin-arm64@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.132.0.tgz#033a8f2789c3d09509ddd1a219dcbf2fd516125f" + integrity sha512-Lc0f/TYoKBghE5/2Gsv7bLXk+TJZunx2Tf61X8hG4ARXdc8UYI26dCGccFSd1AyFbK3jfaNXtMnupggDbjPXdQ== + +"@oxc-parser/binding-darwin-x64@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.132.0.tgz#56601549bad307fcee2b3e0756769e36598841f4" + integrity sha512-RG2eJIpf7C21z9HSSXFw1bTArdpKe7Y4fwcJTwRq1yCSe1vSavaN9GA1sm9KqzemTLAGVktQ+7qBTGp0vQeUZg== + +"@oxc-parser/binding-freebsd-x64@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.132.0.tgz#68140dd5670556fca3aa094f0cb7e706854b5967" + integrity sha512-wQIPntPLtJ8NcBpvKPbEv3NqzV6k8eP8tP/jE9Rg8HTg/j7urZGFSsTCPCW5k77Qfw2DM4vRvc9p3I4yq/Shvw== + +"@oxc-parser/binding-linux-arm-gnueabihf@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.132.0.tgz#84ef8af25ffb6172b02b1747bbbef668e09235c1" + integrity sha512-PixKEpeSe3yxQWqNyOCBALRYc72+Tj7ILDofUl3iXo25cVOzLA6jHUhmOINRtWIPh7dbUie3QNeabwaQpZTw6w== + +"@oxc-parser/binding-linux-arm-musleabihf@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.132.0.tgz#ca6a2dffed23143c9bcbefd8250832c71fdfb4d7" + integrity sha512-sCR+DzGHlyHKnbA2z9zWjTUhIo8Sy0enJl4RDsBwPmkxYynPatpwOAWe8W5127SlW0boqUWHGtr1NWn5UwIhXQ== + +"@oxc-parser/binding-linux-arm64-gnu@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.132.0.tgz#ed1a4718c61d05836015c8eac7395ffe74c3f94a" + integrity sha512-sQBix5P2cW+IpzTcCwYxnh9yALrKSIkKJThspBvMGcygSMnbzkSvhN7SfuX1hvBk8y1XEChsdkU3ET0V5DmzUw== + +"@oxc-parser/binding-linux-arm64-musl@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.132.0.tgz#ae32a94bb666604728fa48c568ced5bb270d1819" + integrity sha512-WozHg3Kc//8Sk756HXXgMbEAvqtG+Lzb9JOojwQzIGDtN78Az2dLttkb71akWYUF/8IgYfDSlfKh4Uot8is5Vw== + +"@oxc-parser/binding-linux-ppc64-gnu@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.132.0.tgz#0de7511156b2b5d7d4fc3574ab3badd93a07c1ae" + integrity sha512-CmX/ulNBOEwWTyVRmcpYKAcAizW6+OjtLJgo7fXoL9OqQvjF4VER8tPomv44vwzfSCy1BHbsB0ZlZYzYJNj4cA== + +"@oxc-parser/binding-linux-riscv64-gnu@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.132.0.tgz#9a4a3b3261b6ada598b65adc4521581c45aa1003" + integrity sha512-j9oQS+hM90SdhviNGWbPgT4+Rlq+ac++q/zjgwPD1mVHgxHzATvoRGtDx0sXGmFOQ9J9YkwAhYGb5MAHL6TAsA== + +"@oxc-parser/binding-linux-riscv64-musl@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.132.0.tgz#e55c1d671e41617f27535216483ccc01f1ff4a5e" + integrity sha512-bLz+Xi+Agnfmd7kWPEsSVwCn2k4EyIalZkNBcQ0OGIv9rqn8VgCPLNd03tM9mKX/5TdlvDXalz0q71BIrOPNqg== + +"@oxc-parser/binding-linux-s390x-gnu@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.132.0.tgz#2e4b692103d8ee745990c7ed5fd023387e6c93d9" + integrity sha512-U6t2qbJU0ypTfyj9QV3W1Y6mITDTL8ai/OR6NUn85vyHthOvobKWgXzU4tu0EskSzlpuVFz1g0jFGulDIUKHxQ== + +"@oxc-parser/binding-linux-x64-gnu@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.132.0.tgz#2ba1d08aeaed17247dac4cb5b9a3bc83b7bd7501" + integrity sha512-WcEaSNHFk8yz5YFlQQAlhq6jOFmZBB/RKE7uzhyCIf+pF1Lmv9gUH4221mle2Gd9iHyWT3ySNph8yZgb1xYdWg== + +"@oxc-parser/binding-linux-x64-musl@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.132.0.tgz#677889452adb283e791798faf70af0627bd493ad" + integrity sha512-iQrV4iJzQgRwK3BWRmQl1C3C6g3wYpXN2WLdQdyR+efoUnncdShZAVp9OgcojtlD3MDRbuOMGG3SjxF4fL4nlQ== + +"@oxc-parser/binding-openharmony-arm64@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.132.0.tgz#2928bbd0f815a7bf11a86b1bccfb0f352b92a7b3" + integrity sha512-FWzmUGrZ6GUby4U7WIwcCtab6tdmlTO3xTRRKyb5kjIJVEiaUAT8animUG/nK8ZCA8gkRkPOTId4rl6uTqUmJQ== + +"@oxc-parser/binding-wasm32-wasi@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.132.0.tgz#37df389cce33c8664763a402853a73559b882ce2" + integrity sha512-TlbMppxJI5CjWDes0QaP6G3aneVg1yikBu5QYI+DUShF9WDL66ccgKFNNGmi/Wybtszw6hxwAvv76T4DaPKnHw== dependencies: "@emnapi/core" "1.10.0" "@emnapi/runtime" "1.10.0" "@napi-rs/wasm-runtime" "^1.1.4" -"@oxc-parser/binding-win32-arm64-msvc@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.129.0.tgz#41bc115de494a18557cebe3bee2706b1103def34" - integrity sha512-99kH1udLyrts+wGm+u0VhPbogkb2wxc/6J1XMKOpS6Kx5DjBWGRZZfBjfCGI3xKSInpYbZn4TLWLX1Q1GURYwg== +"@oxc-parser/binding-win32-arm64-msvc@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.132.0.tgz#b1a0913ad2545c30f498ba181c05de3898240976" + integrity sha512-RH/NbFjGKqdUAUi7Oh3LQPxUk2hsWFEEQ38HSnbRQT8QjBZFKqL1fMbmsB3N4jy/KPh9iX94+9dmkEMBBbambw== -"@oxc-parser/binding-win32-ia32-msvc@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.129.0.tgz#d9fee9485c71e99bcf1fa14a80ff6cd79a80ddf2" - integrity sha512-tmSBR1A4yH697qV291xKyDe4OAWFchJ+cXf2wuipx/vK3n5d5Ej9MVLRtXlIcZ38n8qAjsF0/AnskaYgxM151A== +"@oxc-parser/binding-win32-ia32-msvc@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.132.0.tgz#13964f4b59671f7235f4f85866ab3db6e4afd6c5" + integrity sha512-JUr4jQY9jxoIB/YTLXr6XofSi5xikj6p5/Ns1h0VOBDT0j1jKU+kMsv2xxv51RwnETcXpA1Yw/9oUAfcqfaqEA== -"@oxc-parser/binding-win32-x64-msvc@0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.129.0.tgz#5def2348809f09d0e353f6c3d1a3d4840fdd7a2e" - integrity sha512-Z1PbJvkPeLASIUxa3AnrQ5H+vv1K9zC0IGnQqoKfM0ZvsvCSe0d3u5m7d9iuy+HB7GrcElHuwKb0d0qFdtG0iA== +"@oxc-parser/binding-win32-x64-msvc@0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.132.0.tgz#468339fb08809ddb856f3bc51db718790fb51f05" + integrity sha512-2dapgHpA5X8DSXF4AU36hJWYf6zP0tKjMXFRAZFBD62pkevW/uhFDXoFH9Y/3Fd2EtDrw5ByNnR1wVE9X9y0SQ== -"@oxc-project/types@^0.129.0": - version "0.129.0" - resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.129.0.tgz#8e6362388ce6092feafd14f3a73ae6407b1285d9" - integrity sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg== +"@oxc-project/types@^0.132.0": + version "0.132.0" + resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.132.0.tgz#d77243df4fe1a0a1e60e12ac6240fa898d2363ff" + integrity sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ== "@pkgjs/parseargs@^0.11.0": version "0.11.0" @@ -1024,6 +1044,13 @@ acorn@^8.15.0, acorn@^8.16.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -1183,13 +1210,14 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" -axios@^1.16.0: - version "1.16.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.16.0.tgz#f8e5dd931cef2a5f8c32216d5784eda2f8750eb7" - integrity sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w== +axios@^1.16.1: + version "1.16.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.16.1.tgz#517e29291d19d6e8cf919ff264f4fe157261ba12" + integrity sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A== dependencies: follow-redirects "^1.16.0" form-data "^4.0.5" + https-proxy-agent "^5.0.1" proxy-from-env "^2.1.0" balanced-match@^1.0.0: @@ -1293,23 +1321,27 @@ 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.13: - version "1.3.13" - resolved "https://registry.yarnpkg.com/bun/-/bun-1.3.13.tgz#b509c0f82ba805027b6fdc31a9a25c90a314caa4" - integrity sha512-b9T4xZ8KqCHs4+TkHJv540LG1B8OD7noKu0Qaizusx3jFtMDHY6osNqgbaOlwW2B8RB2AKzz+sjzlGKIGxIjZw== +bun@1.3.14: + version "1.3.14" + resolved "https://registry.yarnpkg.com/bun/-/bun-1.3.14.tgz#7ed8c12d8d3a0cb4183738b73798ba3b94b4df7e" + integrity sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg== optionalDependencies: - "@oven/bun-darwin-aarch64" "1.3.13" - "@oven/bun-darwin-x64" "1.3.13" - "@oven/bun-darwin-x64-baseline" "1.3.13" - "@oven/bun-linux-aarch64" "1.3.13" - "@oven/bun-linux-aarch64-musl" "1.3.13" - "@oven/bun-linux-x64" "1.3.13" - "@oven/bun-linux-x64-baseline" "1.3.13" - "@oven/bun-linux-x64-musl" "1.3.13" - "@oven/bun-linux-x64-musl-baseline" "1.3.13" - "@oven/bun-windows-aarch64" "1.3.13" - "@oven/bun-windows-x64" "1.3.13" - "@oven/bun-windows-x64-baseline" "1.3.13" + "@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" busboy@^1.6.0: version "1.6.0" @@ -1586,6 +1618,13 @@ dc-polyfill@^0.1.11: resolved "https://registry.yarnpkg.com/dc-polyfill/-/dc-polyfill-0.1.11.tgz#3efa792147f3b5224b8a9274905b1e98fe82a856" integrity sha512-TyyeGcjx0YeThAI9fTFtgsvj5qd4R+aGfVmXiUhevbgzWFDr7IK4tv4YjE6jaGzLHQTchk4h7DHdr5q4WGgaZw== +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -1593,13 +1632,6 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.0, debug@^4.4.3: - version "4.4.3" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" - integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== - dependencies: - ms "^2.1.3" - decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -1906,10 +1938,10 @@ eslint-plugin-import@^2.32.0: string.prototype.trimend "^1.0.9" tsconfig-paths "^3.15.0" -eslint-plugin-jsdoc@^62.9.0: - version "62.9.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.9.0.tgz#a4902f6978b1e7cc5c5d1a528ecf7d8c7ce716d9" - integrity sha512-PY7/X4jrVgoIDncUmITlUqK546Ltmx/Pd4Hdsu4CvSjryQZJI2mEV4vrdMufyTetMiZ5taNSqvK//BTgVUlNkA== +eslint-plugin-jsdoc@^63.0.0: + version "63.0.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-63.0.0.tgz#0d48ff13ef5ad077b0ae7cd8b200e5450ac9f814" + integrity sha512-eDHuVGyZydr4BKgjXouU7bsn5qaqUlObXBSWRJk3vXcQgXVFnrwWIqpP7uBhRX9NQpk6NIIFyRc6F6omZNi/8g== dependencies: "@es-joy/jsdoccomment" "~0.86.0" "@es-joy/resolve.exports" "1.2.0" @@ -1922,14 +1954,14 @@ eslint-plugin-jsdoc@^62.9.0: html-entities "^2.6.0" object-deep-merge "^2.0.0" parse-imports-exports "^0.2.4" - semver "^7.7.4" + semver "^7.8.0" spdx-expression-parse "^4.0.0" to-valid-identifier "^1.0.0" -eslint-plugin-mocha@^11.2.0: - version "11.2.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-mocha/-/eslint-plugin-mocha-11.2.0.tgz#e9fe4907f180467c210d1548118d05638a601267" - integrity sha512-nMdy3tEXZac8AH5Z/9hwUkSfWu8xHf4XqwB5UEQzyTQGKcNlgFeciRAjLjliIKC3dR1Ex/a2/5sqgQzvYRkkkA== +eslint-plugin-mocha@^11.3.0: + version "11.3.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-mocha/-/eslint-plugin-mocha-11.3.0.tgz#9c8139a9318c54dfa4bfa13afe8c231431abbf12" + integrity sha512-anENwrIwmdvunmmssjMn5a4nTd+mYMkqBlwjksxOECcIThLNhefWJIiTWY7pY/arMQFjNwHQjVOZb6pQ9PrLjg== dependencies: "@eslint-community/eslint-utils" "^4.4.1" globals "^15.14.0" @@ -2528,6 +2560,14 @@ http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.1: statuses "~2.0.2" toidentifier "~1.0.1" +https-proxy-agent@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + husky@^9.1.7: version "9.1.7" resolved "https://registry.yarnpkg.com/husky/-/husky-9.1.7.tgz#d46a38035d101b46a70456a850ff4201344c0b2d" @@ -3217,10 +3257,10 @@ mocha-multi-reporters@^1.5.1: debug "^4.1.1" lodash "^4.17.15" -mocha@^11.6.0: - version "11.7.5" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.5.tgz#58f5bbfa5e0211ce7e5ee6128107cefc2515a627" - integrity sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig== +mocha@^11.7.6: + version "11.7.6" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.6.tgz#ebbe22989d04cbb9424a36307320476624c41a33" + integrity sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA== dependencies: browser-stdout "^1.3.1" chokidar "^4.0.1" @@ -3293,12 +3333,12 @@ node-addon-api@^6.1.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-6.1.0.tgz#ac8470034e58e67d0c6f1204a18ae6995d9c0d76" integrity sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA== -node-gyp-build@<4.0, node-gyp-build@^3.9.0: +node-gyp-build@^3.9.0: version "3.9.0" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-3.9.0.tgz#53a350187dd4d5276750da21605d1cb681d09e25" integrity sha512-zLcTg6P4AbcHPq465ZMFNXx7XpKKJh+7kkN699NiQWisR2uWYOWNWqRHAmbnmKiL4e9aLSlmy5U7rEMUXV59+A== -node-gyp-build@^4.5.0: +node-gyp-build@^4.5.0, node-gyp-build@^4.8.4: version "4.8.4" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.4.tgz#8a70ee85464ae52327772a90d66c6077a900cfc8" integrity sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ== @@ -3466,33 +3506,33 @@ own-keys@^1.0.1: object-keys "^1.1.1" safe-push-apply "^1.0.0" -oxc-parser@^0.129.0: - version "0.129.0" - resolved "https://registry.yarnpkg.com/oxc-parser/-/oxc-parser-0.129.0.tgz#a62b2402c57d4ebfe7960de439b1da24ee53e65d" - integrity sha512-S6eFI+VLkpyA+/Lf8z6qURjDV6Mgo74SLNznNopHTlQW3hedv2MB/z31kBRuBCCTqZN9HHdva0ojljEhPnBKFA== +oxc-parser@^0.132.0: + version "0.132.0" + resolved "https://registry.yarnpkg.com/oxc-parser/-/oxc-parser-0.132.0.tgz#4f0ffad5ccfd0235a8ba79f7e6fc988be6f45476" + integrity sha512-+0LAPHaqtfQlvWdpaAa09SmOaZZgP8C552xosEkGJ4+ruEwP1Vgx+sqBgcBCNfR6KDCmagGOZTde8wmAvcI/Hg== dependencies: - "@oxc-project/types" "^0.129.0" + "@oxc-project/types" "^0.132.0" optionalDependencies: - "@oxc-parser/binding-android-arm-eabi" "0.129.0" - "@oxc-parser/binding-android-arm64" "0.129.0" - "@oxc-parser/binding-darwin-arm64" "0.129.0" - "@oxc-parser/binding-darwin-x64" "0.129.0" - "@oxc-parser/binding-freebsd-x64" "0.129.0" - "@oxc-parser/binding-linux-arm-gnueabihf" "0.129.0" - "@oxc-parser/binding-linux-arm-musleabihf" "0.129.0" - "@oxc-parser/binding-linux-arm64-gnu" "0.129.0" - "@oxc-parser/binding-linux-arm64-musl" "0.129.0" - "@oxc-parser/binding-linux-ppc64-gnu" "0.129.0" - "@oxc-parser/binding-linux-riscv64-gnu" "0.129.0" - "@oxc-parser/binding-linux-riscv64-musl" "0.129.0" - "@oxc-parser/binding-linux-s390x-gnu" "0.129.0" - "@oxc-parser/binding-linux-x64-gnu" "0.129.0" - "@oxc-parser/binding-linux-x64-musl" "0.129.0" - "@oxc-parser/binding-openharmony-arm64" "0.129.0" - "@oxc-parser/binding-wasm32-wasi" "0.129.0" - "@oxc-parser/binding-win32-arm64-msvc" "0.129.0" - "@oxc-parser/binding-win32-ia32-msvc" "0.129.0" - "@oxc-parser/binding-win32-x64-msvc" "0.129.0" + "@oxc-parser/binding-android-arm-eabi" "0.132.0" + "@oxc-parser/binding-android-arm64" "0.132.0" + "@oxc-parser/binding-darwin-arm64" "0.132.0" + "@oxc-parser/binding-darwin-x64" "0.132.0" + "@oxc-parser/binding-freebsd-x64" "0.132.0" + "@oxc-parser/binding-linux-arm-gnueabihf" "0.132.0" + "@oxc-parser/binding-linux-arm-musleabihf" "0.132.0" + "@oxc-parser/binding-linux-arm64-gnu" "0.132.0" + "@oxc-parser/binding-linux-arm64-musl" "0.132.0" + "@oxc-parser/binding-linux-ppc64-gnu" "0.132.0" + "@oxc-parser/binding-linux-riscv64-gnu" "0.132.0" + "@oxc-parser/binding-linux-riscv64-musl" "0.132.0" + "@oxc-parser/binding-linux-s390x-gnu" "0.132.0" + "@oxc-parser/binding-linux-x64-gnu" "0.132.0" + "@oxc-parser/binding-linux-x64-musl" "0.132.0" + "@oxc-parser/binding-openharmony-arm64" "0.132.0" + "@oxc-parser/binding-wasm32-wasi" "0.132.0" + "@oxc-parser/binding-win32-arm64-msvc" "0.132.0" + "@oxc-parser/binding-win32-ia32-msvc" "0.132.0" + "@oxc-parser/binding-win32-x64-msvc" "0.132.0" p-limit@^2.2.0: version "2.3.0" @@ -3708,9 +3748,9 @@ punycode@^2.1.0: integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== qs@^6.14.0, qs@^6.14.1: - version "6.15.0" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.0.tgz#db8fd5d1b1d2d6b5b33adaf87429805f1909e7b3" - integrity sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ== + version "6.15.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.2.tgz#fd55426d710403ddccc45e0f9eab16db7727ece9" + integrity sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw== dependencies: side-channel "^1.1.0" @@ -3948,10 +3988,10 @@ semver@^6.0.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.5.0, semver@^7.5.3, semver@^7.5.4, semver@^7.6.3, semver@^7.7.2, semver@^7.7.4: - version "7.7.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" - integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== +semver@^7.5.0, semver@^7.5.3, semver@^7.5.4, semver@^7.6.3, semver@^7.7.4, semver@^7.8.0, semver@^7.8.1: + version "7.8.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.1.tgz#bf4970b5e70fda0686363cc18bfe8805d5ed957e" + integrity sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg== send@^1.1.0, send@^1.2.0: version "1.2.1" @@ -4691,10 +4731,10 @@ yallist@^3.0.2: resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== -yaml@^2.8.4: - version "2.8.4" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.4.tgz#4b5f411dd25f9544914d8673d4da7f29248e5e2e" - integrity sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog== +yaml@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4" + integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== yargs-parser@^18.1.2: version "18.1.3"