diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dea6b4802dc5..1fc85176963d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -130,7 +130,7 @@ jobs: run: yarn build - name: Upload build artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: build-output path: ${{ env.CACHED_BUILD_PATHS }} @@ -351,7 +351,7 @@ jobs: run: yarn build:tarball - name: Archive artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ github.sha }} retention-days: 90 @@ -588,7 +588,7 @@ jobs: format(' --shard={0}/{1}', matrix.shard, matrix.shards) || '' }} - name: Upload Playwright Traces - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: @@ -654,7 +654,7 @@ jobs: yarn test:loader - name: Upload Playwright Traces - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: playwright-traces-job_browser_loader_tests-${{ matrix.bundle}} @@ -1030,7 +1030,7 @@ jobs: SENTRY_E2E_WORKSPACE_ROOT: ${{ github.workspace }} - name: Upload Playwright Traces - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: playwright-traces-job_e2e_playwright_tests-${{ matrix.test-application}} @@ -1044,7 +1044,7 @@ jobs: node ./scripts/normalize-e2e-test-dump-transaction-events.js - name: Upload E2E Test Event Dumps - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: E2E Test Dump (${{ matrix.label || matrix.test-application }}) @@ -1157,7 +1157,7 @@ jobs: node ./scripts/normalize-e2e-test-dump-transaction-events.js - name: Upload E2E Test Event Dumps - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() with: name: E2E Test Dump (${{ matrix.label || matrix.test-application }}) diff --git a/.github/workflows/create-issue-for-unreferenced-prs.yml b/.github/workflows/create-issue-for-unreferenced-prs.yml deleted file mode 100644 index 0a833715d854..000000000000 --- a/.github/workflows/create-issue-for-unreferenced-prs.yml +++ /dev/null @@ -1,130 +0,0 @@ -# This GitHub Action workflow checks if a new or updated pull request -# references a GitHub issue in its title or body. If no reference is found, -# it automatically creates a new issue. This helps ensure all work is -# tracked, especially when syncing with tools like Linear. - -name: Create issue for unreferenced PR - -# This action triggers on pull request events -on: - pull_request: - types: [opened, edited, reopened, synchronize, ready_for_review] - -# Cancel in progress workflows on pull_requests. -# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - check_for_issue_reference: - runs-on: ubuntu-latest - if: | - (github.event.pull_request.base.ref == 'develop' || github.event.pull_request.base.ref == 'master') - && !contains(github.event.pull_request.labels.*.name, 'Dev: Gitflow') - && !startsWith(github.event.pull_request.head.ref, 'external-contributor/') - && !startsWith(github.event.pull_request.head.ref, 'prepare-release/') - && !startsWith(github.event.pull_request.head.ref, 'dependabot/') - steps: - - name: Check PR Body and Title for Issue Reference - uses: actions/github-script@v8 - with: - script: | - const pr = context.payload.pull_request; - if (!pr) { - core.setFailed('Could not get PR from context.'); - return; - } - - // Don't create an issue for draft PRs - if (pr.draft) { - console.log(`PR #${pr.number} is a draft, skipping issue creation.`); - return; - } - - // Bail if this edit was made by the GitHub Actions bot (this workflow) - // This prevents infinite loops when we update the PR body with the new issue reference - // We check login specifically to not skip edits from other legitimate bots - if (context.payload.sender && context.payload.sender.login === 'github-actions[bot]') { - console.log(`PR #${pr.number} was edited by github-actions[bot] (this workflow), skipping.`); - return; - } - - // Check if the PR is already approved - const reviewsResponse = await github.rest.pulls.listReviews({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - }); - - if (reviewsResponse.data.some(review => review.state === 'APPROVED')) { - console.log(`PR #${pr.number} is already approved, skipping issue creation.`); - return; - } - - const prBody = pr.body || ''; - const prTitle = pr.title || ''; - const prAuthor = pr.user.login; - const prUrl = pr.html_url; - const prNumber = pr.number; - - // Regex for GitHub issue references (e.g., #123, fixes #456) - // https://regex101.com/r/eDiGrQ/1 - const issueRegexGitHub = /(?:(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved):?\s*)?(#\d+|https:\/\/github\.com\/getsentry\/[\w-]+\/issues\/\d+)/i; - - // Regex for Linear issue references (e.g., ENG-123, resolves ENG-456) - const issueRegexLinear = /(?:(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved):?\s*)?[A-Z]+-\d+/i; - - const contentToCheck = `${prTitle} ${prBody}`; - const hasIssueReference = issueRegexGitHub.test(contentToCheck) || issueRegexLinear.test(contentToCheck); - - if (hasIssueReference) { - console.log(`PR #${prNumber} contains a valid issue reference.`); - return; - } - - // Check if there's already an issue created by this automation for this PR - // Search for issues that mention this PR and were created by github-actions bot - const existingIssuesResponse = await github.rest.search.issuesAndPullRequests({ - q: `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open author:app/github-actions "${prUrl}" in:title in:body`, - }); - - if (existingIssuesResponse.data.total_count > 0) { - const existingIssue = existingIssuesResponse.data.items[0]; - console.log(`An issue (#${existingIssue.number}) already exists for PR #${prNumber}, skipping creation.`); - return; - } - - core.warning(`PR #${prNumber} does not have an issue reference. Creating a new issue so it can be tracked in Linear.`); - - // Construct the title and body for the new issue - const issueTitle = `${prTitle}`; - const issueBody = `> [!NOTE] - > The pull request "[${prTitle}](${prUrl})" was created by @${prAuthor} but did not reference an issue. Therefore this issue was created for better visibility in external tools like Linear. - - ${prBody} - `; - - // Create the issue using the GitHub API - const newIssue = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: issueTitle, - body: issueBody, - assignees: [prAuthor] - }); - - const issueID = newIssue.data.number; - console.log(`Created issue #${issueID}.`); - - // Update the PR body to reference the new issue - const updatedPrBody = `${prBody}\n\nCloses #${issueID} (added automatically)`; - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - body: updatedPrBody - }); - - console.log(`Updated PR #${prNumber} to reference newly created issue #${issueID}.`); diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index 6afed7df214b..aa2c33336bd2 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -71,7 +71,7 @@ jobs: TEST_RUN_COUNT: 'AUTO' - name: Upload Playwright Traces - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 if: failure() && steps.test.outcome == 'failure' with: name: playwright-test-results diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml new file mode 100644 index 000000000000..44da67faa43e --- /dev/null +++ b/.github/workflows/validate-pr.yml @@ -0,0 +1,16 @@ +name: Validate PR + +on: + pull_request_target: + types: [opened, reopened] + +jobs: + validate-pr: + runs-on: ubuntu-24.04 + permissions: + pull-requests: write + steps: + - uses: getsentry/github-workflows/validate-pr@0b52fc6a867b744dcbdf5d25c18bc8d1c95710e1 + with: + app-id: ${{ vars.SDK_MAINTAINER_BOT_APP_ID }} + private-key: ${{ secrets.SDK_MAINTAINER_BOT_PRIVATE_KEY }} diff --git a/.size-limit.js b/.size-limit.js index 3e0902c0a57c..fcc455808948 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -248,7 +248,7 @@ module.exports = [ path: createCDNPath('bundle.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '86 KB', + limit: '88 KB', }, { name: 'CDN Bundle (incl. Tracing, Logs, Metrics) - uncompressed', @@ -262,7 +262,7 @@ module.exports = [ path: createCDNPath('bundle.replay.logs.metrics.min.js'), gzip: false, brotli: false, - limit: '210 KB', + limit: '211 KB', }, { name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed', diff --git a/CHANGELOG.md b/CHANGELOG.md index 640448521586..25792ba2ce11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,121 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.47.0 + +### Important Changes + +- **feat(node-core): Add OTLP integration for node-core/light ([#19729](https://github.com/getsentry/sentry-javascript/pull/19729))** + + Added `otlpIntegration` at `@sentry/node-core/light/otlp` for users who manage + their own OpenTelemetry setup and want to send trace data to Sentry without + adopting the full `@sentry/node` SDK. + + ```js + import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; + import * as Sentry from '@sentry/node-core/light'; + import { otlpIntegration } from '@sentry/node-core/light/otlp'; + + const provider = new NodeTracerProvider(); + provider.register(); + + Sentry.init({ + dsn: '__DSN__', + integrations: [ + otlpIntegration({ + // Export OTel spans to Sentry via OTLP (default: true) + setupOtlpTracesExporter: true, + }), + ], + }); + ``` + + The integration links Sentry errors to OTel traces and exports spans to Sentry via OTLP. + +- **feat(node, bun): Add runtime metrics integrations for Node.js and Bun ([#19923](https://github.com/getsentry/sentry-javascript/pull/19923), [#19979](https://github.com/getsentry/sentry-javascript/pull/19979))** + + New `nodeRuntimeMetricsIntegration` and `bunRuntimeMetricsIntegration` automatically collect runtime health metrics and send them to Sentry on a configurable interval (default: 30s). Collected metrics include memory (RSS, heap used/total), CPU utilization, event loop utilization, and process uptime. Node additionally collects event loop delay percentiles (p50, p99). Extra metrics like CPU time and external memory are available as opt-in. + + ```ts + // Node.js + import * as Sentry from '@sentry/node'; + + Sentry.init({ + dsn: '...', + integrations: [Sentry.nodeRuntimeMetricsIntegration()], + }); + + // Bun + import * as Sentry from '@sentry/bun'; + + Sentry.init({ + dsn: '...', + integrations: [Sentry.bunRuntimeMetricsIntegration()], + }); + ``` + +- **feat(core): Support embedding APIs in google-genai ([#19797](https://github.com/getsentry/sentry-javascript/pull/19797))** + + Adds instrumentation for the Google GenAI [`embedContent`](https://ai.google.dev/gemini-api/docs/embeddings) API, creating `gen_ai.embeddings` spans. + +- **feat(browser): Add `elementTimingIntegration` for tracking element render and load times ([#19869](https://github.com/getsentry/sentry-javascript/pull/19869))** + + The new `elementTimingIntegration` captures Element Timing API data as Sentry metrics. It emits `element_timing.render_time` and `element_timing.load_time` distribution metrics for elements annotated with the `elementtiming` HTML attribute. + + ```ts + import * as Sentry from '@sentry/browser'; + + Sentry.init({ + dsn: '__DSN__', + integrations: [Sentry.browserTracingIntegration(), Sentry.elementTimingIntegration()], + }); + ``` + + ```html + + ``` + +### Other Changes + +- feat(nuxt): Add middleware instrumentation compatibility for Nuxt 5 ([#19968](https://github.com/getsentry/sentry-javascript/pull/19968)) +- feat(nuxt): Support parametrized SSR routes in Nuxt 5 ([#19977](https://github.com/getsentry/sentry-javascript/pull/19977)) +- feat(solid): Add route parametrization for Solid Router ([#20031](https://github.com/getsentry/sentry-javascript/pull/20031)) +- fix(core): Guard nullish response in supabase PostgREST handler ([#20033](https://github.com/getsentry/sentry-javascript/pull/20033)) +- fix(node): Deduplicate `sentry-trace` and `baggage` headers on outgoing requests ([#19960](https://github.com/getsentry/sentry-javascript/pull/19960)) +- fix(node): Ensure startNewTrace propagates traceId in OTel environments ([#19963](https://github.com/getsentry/sentry-javascript/pull/19963)) +- fix(nuxt): Use virtual module for Nuxt pages data (SSR route parametrization) ([#20020](https://github.com/getsentry/sentry-javascript/pull/20020)) +- fix(opentelemetry): Convert seconds timestamps in span.end() to milliseconds ([#19958](https://github.com/getsentry/sentry-javascript/pull/19958)) +- fix(profiling): Disable profiling in worker threads ([#20040](https://github.com/getsentry/sentry-javascript/pull/20040)) +- fix(react-router): Disable debug ID injection in Vite plugin to prevent double injection ([#19890](https://github.com/getsentry/sentry-javascript/pull/19890)) +- refactor(browser): Reduce browser package bundle size ([#19856](https://github.com/getsentry/sentry-javascript/pull/19856)) +- feat(deps): Bump OpenTelemetry dependencies ([#20046](https://github.com/getsentry/sentry-javascript/pull/20046)) + +
+ Internal Changes + +- chore: Add shared validate-pr composite action ([#20025](https://github.com/getsentry/sentry-javascript/pull/20025)) +- chore: Update validate-pr action to latest version ([#20027](https://github.com/getsentry/sentry-javascript/pull/20027)) +- chore(deps): Bump @apollo/server from 5.4.0 to 5.5.0 ([#20007](https://github.com/getsentry/sentry-javascript/pull/20007)) +- chore(deps): Bump amqplib from 0.10.7 to 0.10.9 ([#20000](https://github.com/getsentry/sentry-javascript/pull/20000)) +- chore(deps): Bump srvx from 0.11.12 to 0.11.13 ([#20001](https://github.com/getsentry/sentry-javascript/pull/20001)) +- chore(deps-dev): Bump node-forge from 1.3.2 to 1.4.0 ([#20012](https://github.com/getsentry/sentry-javascript/pull/20012)) +- chore(deps-dev): Bump yaml from 2.8.2 to 2.8.3 ([#19985](https://github.com/getsentry/sentry-javascript/pull/19985)) +- ci(deps): Bump actions/upload-artifact from 6 to 7 ([#19569](https://github.com/getsentry/sentry-javascript/pull/19569)) +- docs(release): Update publishing-a-release.md ([#19982](https://github.com/getsentry/sentry-javascript/pull/19982)) +- feat(deps): Bump babel-loader from 10.0.0 to 10.1.1 ([#19997](https://github.com/getsentry/sentry-javascript/pull/19997)) +- feat(deps): Bump handlebars from 4.7.7 to 4.7.9 ([#20008](https://github.com/getsentry/sentry-javascript/pull/20008)) +- fix(browser-tests): Pin axios to 1.13.5 to avoid compromised 1.14.1 ([#20047](https://github.com/getsentry/sentry-javascript/pull/20047)) +- fix(ci): Update validate-pr action to remove draft enforcement ([#20035](https://github.com/getsentry/sentry-javascript/pull/20035)) +- fix(ci): Update validate-pr action to remove draft enforcement ([#20037](https://github.com/getsentry/sentry-javascript/pull/20037)) +- fix(e2e): Pin @opentelemetry/api to 1.9.0 in ts3.8 test app ([#19992](https://github.com/getsentry/sentry-javascript/pull/19992)) +- ref(browser-tests): Add waitForMetricRequest helper ([#20002](https://github.com/getsentry/sentry-javascript/pull/20002)) +- ref(core): Consolidate getOperationName into one shared utility ([#19971](https://github.com/getsentry/sentry-javascript/pull/19971)) +- ref(core): Introduce instrumented method registry for AI integrations ([#19981](https://github.com/getsentry/sentry-javascript/pull/19981)) +- test(deno): Expand Deno E2E test coverage ([#19957](https://github.com/getsentry/sentry-javascript/pull/19957)) +- test(e2e): Add e2e tests for `nodeRuntimeMetricsIntegration` ([#19989](https://github.com/getsentry/sentry-javascript/pull/19989)) + +
+ ## 10.46.0 ### Important Changes @@ -73,6 +188,21 @@ Work in this release was contributed by @roli-lpci. Thank you for your contributions! +### Important Changes + +- **feat(node): Add `nodeRuntimeMetricsIntegration` for automatic Node.js runtime metrics ([#19923](https://github.com/getsentry/sentry-javascript/pull/19923))** + + The new `nodeRuntimeMetricsIntegration` automatically collects Node.js runtime health metrics and sends them to Sentry. Eight metrics are emitted by default every 30 seconds: memory (RSS, heap used/total), CPU utilization, event loop delay (p50, p99), event loop utilization, and process uptime. Additional metrics are available as opt-in. + + ```ts + import * as Sentry from '@sentry/node'; + + Sentry.init({ + dsn: '...', + integrations: [Sentry.nodeRuntimeMetricsIntegration()], + }); + ``` + ## 10.45.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/package.json b/dev-packages/browser-integration-tests/package.json index aebcefb3cd68..ff4db72c90e7 100644 --- a/dev-packages/browser-integration-tests/package.json +++ b/dev-packages/browser-integration-tests/package.json @@ -62,8 +62,8 @@ "@sentry-internal/rrweb": "2.34.0", "@sentry/browser": "10.46.0", "@supabase/supabase-js": "2.49.3", - "axios": "^1.12.2", - "babel-loader": "^10.0.0", + "axios": "1.13.5", + "babel-loader": "^10.1.1", "fflate": "0.8.2", "html-webpack-plugin": "^5.5.0", "webpack": "^5.95.0" diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/mocks.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/mocks.js index 8aab37fb3a1e..d33f5dfbb285 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/mocks.js +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/mocks.js @@ -39,6 +39,24 @@ export class MockGoogleGenAI { }, }; }, + embedContent: async (...args) => { + const params = args[0]; + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + error.status = 404; + throw error; + } + + return { + embeddings: [ + { + values: [0.1, 0.2, 0.3, 0.4, 0.5], + }, + ], + }; + }, generateContentStream: async () => { // Return a promise that resolves to an async generator return (async function* () { diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/subject.js b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/subject.js index 14b95f2b6942..b506ec52195b 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/subject.js +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/subject.js @@ -30,3 +30,11 @@ const response = await chat.sendMessage({ }); console.log('Received response', response); + +// Test embedContent +const embedResponse = await client.models.embedContent({ + model: 'text-embedding-004', + contents: 'Hello world', +}); + +console.log('Received embed response', embedResponse); diff --git a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/test.ts b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/test.ts index 6774129f183e..c5c269d435e3 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/ai-providers/google-genai/test.ts @@ -29,3 +29,26 @@ sentryTest('manual Google GenAI instrumentation sends gen_ai transactions', asyn 'gen_ai.request.model': 'gemini-1.5-pro', }); }); + +sentryTest('manual Google GenAI instrumentation sends embeddings transactions', async ({ getLocalTestUrl, page }) => { + const transactionPromise = waitForTransactionRequest(page, event => { + return !!event.transaction?.includes('text-embedding-004'); + }); + + const url = await getLocalTestUrl({ testDir: __dirname }); + await page.goto(url); + + const req = await transactionPromise; + + const eventData = envelopeRequestParser(req); + + // Verify it's a gen_ai embeddings transaction + expect(eventData.transaction).toBe('embeddings text-embedding-004'); + expect(eventData.contexts?.trace?.op).toBe('gen_ai.embeddings'); + expect(eventData.contexts?.trace?.origin).toBe('auto.ai.google_genai'); + expect(eventData.contexts?.trace?.data).toMatchObject({ + 'gen_ai.operation.name': 'embeddings', + 'gen_ai.system': 'google_genai', + 'gen_ai.request.model': 'text-embedding-004', + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/init.js index 5a4cb2dff8b7..40253c296af1 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/init.js +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/init.js @@ -5,6 +5,6 @@ window.Sentry = Sentry; Sentry.init({ debug: true, dsn: 'https://public@dsn.ingest.sentry.io/1337', - integrations: [Sentry.browserTracingIntegration()], + integrations: [Sentry.browserTracingIntegration(), Sentry.elementTimingIntegration()], tracesSampleRate: 1, }); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts index d5dabb5d0ca5..6f418c79a024 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/element-timing/test.ts @@ -1,173 +1,81 @@ import type { Page, Route } from '@playwright/test'; import { expect } from '@playwright/test'; +import type { SerializedMetric } from '@sentry/core'; import { sentryTest } from '../../../../utils/fixtures'; -import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequest } from '../../../../utils/helpers'; +import { shouldSkipMetricsTest, shouldSkipTracingTest, waitForMetrics } from '../../../../utils/helpers'; + +function getIdentifier(m: SerializedMetric): unknown { + return m.attributes?.['ui.element.identifier']?.value; +} + +function getPaintType(m: SerializedMetric): unknown { + return m.attributes?.['ui.element.paint_type']?.value; +} sentryTest( - 'adds element timing spans to pageload span tree for elements rendered during pageload', + 'emits element timing metrics for elements rendered during pageload', async ({ getLocalTestUrl, page, browserName }) => { - if (shouldSkipTracingTest() || browserName === 'webkit') { + if (shouldSkipTracingTest() || shouldSkipMetricsTest() || browserName === 'webkit') { sentryTest.skip(); } - const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); - serveAssets(page); const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - - const eventData = envelopeRequestParser(await pageloadEventPromise); - - const elementTimingSpans = eventData.spans?.filter(({ op }) => op === 'ui.elementtiming'); - - expect(elementTimingSpans?.length).toEqual(8); - - // Check image-fast span (this is served with a 100ms delay) - const imageFastSpan = elementTimingSpans?.find(({ description }) => description === 'element[image-fast]'); - const imageFastRenderTime = imageFastSpan?.data['element.render_time']; - const imageFastLoadTime = imageFastSpan?.data['element.load_time']; - const duration = imageFastSpan!.timestamp! - imageFastSpan!.start_timestamp; - - expect(imageFastSpan).toBeDefined(); - expect(imageFastSpan?.data).toEqual({ - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'load-time', - 'element.id': 'image-fast-id', - 'element.identifier': 'image-fast', - 'element.type': 'img', - 'element.size': '600x179', - 'element.url': 'https://sentry-test-site.example/path/to/image-fast.png', - 'element.render_time': expect.any(Number), - 'element.load_time': expect.any(Number), - 'element.paint_type': 'image-paint', - 'sentry.transaction_name': '/index.html', - }); - expect(imageFastRenderTime).toBeGreaterThan(90); - expect(imageFastRenderTime).toBeLessThan(400); - expect(imageFastLoadTime).toBeGreaterThan(90); - expect(imageFastLoadTime).toBeLessThan(400); - expect(imageFastRenderTime).toBeGreaterThan(imageFastLoadTime as number); - expect(duration).toBeGreaterThan(0); - expect(duration).toBeLessThan(20); - - // Check text1 span - const text1Span = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'text1'); - const text1RenderTime = text1Span?.data['element.render_time']; - const text1LoadTime = text1Span?.data['element.load_time']; - const text1Duration = text1Span!.timestamp! - text1Span!.start_timestamp; - expect(text1Span).toBeDefined(); - expect(text1Span?.data).toEqual({ - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'render-time', - 'element.id': 'text1-id', - 'element.identifier': 'text1', - 'element.type': 'p', - 'element.render_time': expect.any(Number), - 'element.load_time': expect.any(Number), - 'element.paint_type': 'text-paint', - 'sentry.transaction_name': '/index.html', - }); - expect(text1RenderTime).toBeGreaterThan(0); - expect(text1RenderTime).toBeLessThan(300); - expect(text1LoadTime).toBe(0); - expect(text1RenderTime).toBeGreaterThan(text1LoadTime as number); - expect(text1Duration).toBe(0); - - // Check button1 span (no need for a full assertion) - const button1Span = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'button1'); - expect(button1Span).toBeDefined(); - expect(button1Span?.data).toMatchObject({ - 'element.identifier': 'button1', - 'element.type': 'button', - 'element.paint_type': 'text-paint', - 'sentry.transaction_name': '/index.html', + const expectedIdentifiers = ['image-fast', 'text1', 'button1', 'image-slow', 'lazy-image', 'lazy-text']; + + // Wait for all expected element identifiers to arrive as metrics + const [allMetrics] = await Promise.all([ + waitForMetrics(page, metrics => { + const seen = new Set(metrics.filter(m => m.name === 'ui.element.render_time').map(getIdentifier)); + return expectedIdentifiers.every(id => seen.has(id)); + }), + page.goto(url), + ]); + + const renderTimeMetrics = allMetrics.filter(m => m.name === 'ui.element.render_time'); + const loadTimeMetrics = allMetrics.filter(m => m.name === 'ui.element.load_time'); + + const renderIdentifiers = renderTimeMetrics.map(getIdentifier); + const loadIdentifiers = loadTimeMetrics.map(getIdentifier); + + // All text and image elements should have render_time + expect(renderIdentifiers).toContain('image-fast'); + expect(renderIdentifiers).toContain('text1'); + expect(renderIdentifiers).toContain('button1'); + expect(renderIdentifiers).toContain('image-slow'); + expect(renderIdentifiers).toContain('lazy-image'); + expect(renderIdentifiers).toContain('lazy-text'); + + // Image elements should also have load_time + expect(loadIdentifiers).toContain('image-fast'); + expect(loadIdentifiers).toContain('image-slow'); + expect(loadIdentifiers).toContain('lazy-image'); + + // Text elements should NOT have load_time (loadTime is 0 for text-paint) + expect(loadIdentifiers).not.toContain('text1'); + expect(loadIdentifiers).not.toContain('button1'); + expect(loadIdentifiers).not.toContain('lazy-text'); + + // Validate metric structure for image-fast + const imageFastRender = renderTimeMetrics.find(m => getIdentifier(m) === 'image-fast'); + expect(imageFastRender).toMatchObject({ + name: 'ui.element.render_time', + type: 'distribution', + unit: 'millisecond', + value: expect.any(Number), }); + expect(getPaintType(imageFastRender!)).toBe('image-paint'); - // Check image-slow span - const imageSlowSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'image-slow'); - expect(imageSlowSpan).toBeDefined(); - expect(imageSlowSpan?.data).toEqual({ - 'element.id': '', - 'element.identifier': 'image-slow', - 'element.type': 'img', - 'element.size': '600x179', - 'element.url': 'https://sentry-test-site.example/path/to/image-slow.png', - 'element.paint_type': 'image-paint', - 'element.render_time': expect.any(Number), - 'element.load_time': expect.any(Number), - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'load-time', - 'sentry.transaction_name': '/index.html', - }); - const imageSlowRenderTime = imageSlowSpan?.data['element.render_time']; - const imageSlowLoadTime = imageSlowSpan?.data['element.load_time']; - const imageSlowDuration = imageSlowSpan!.timestamp! - imageSlowSpan!.start_timestamp; - expect(imageSlowRenderTime).toBeGreaterThan(1400); - expect(imageSlowRenderTime).toBeLessThan(2000); - expect(imageSlowLoadTime).toBeGreaterThan(1400); - expect(imageSlowLoadTime).toBeLessThan(2000); - expect(imageSlowDuration).toBeGreaterThan(0); - expect(imageSlowDuration).toBeLessThan(20); - - // Check lazy-image span - const lazyImageSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'lazy-image'); - expect(lazyImageSpan).toBeDefined(); - expect(lazyImageSpan?.data).toEqual({ - 'element.id': '', - 'element.identifier': 'lazy-image', - 'element.type': 'img', - 'element.size': '600x179', - 'element.url': 'https://sentry-test-site.example/path/to/image-lazy.png', - 'element.paint_type': 'image-paint', - 'element.render_time': expect.any(Number), - 'element.load_time': expect.any(Number), - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'load-time', - 'sentry.transaction_name': '/index.html', - }); - const lazyImageRenderTime = lazyImageSpan?.data['element.render_time']; - const lazyImageLoadTime = lazyImageSpan?.data['element.load_time']; - const lazyImageDuration = lazyImageSpan!.timestamp! - lazyImageSpan!.start_timestamp; - expect(lazyImageRenderTime).toBeGreaterThan(1000); - expect(lazyImageRenderTime).toBeLessThan(1500); - expect(lazyImageLoadTime).toBeGreaterThan(1000); - expect(lazyImageLoadTime).toBeLessThan(1500); - expect(lazyImageDuration).toBeGreaterThan(0); - expect(lazyImageDuration).toBeLessThan(20); - - // Check lazy-text span - const lazyTextSpan = elementTimingSpans?.find(({ data }) => data?.['element.identifier'] === 'lazy-text'); - expect(lazyTextSpan?.data).toMatchObject({ - 'element.id': '', - 'element.identifier': 'lazy-text', - 'element.type': 'p', - 'sentry.transaction_name': '/index.html', - }); - const lazyTextRenderTime = lazyTextSpan?.data['element.render_time']; - const lazyTextLoadTime = lazyTextSpan?.data['element.load_time']; - const lazyTextDuration = lazyTextSpan!.timestamp! - lazyTextSpan!.start_timestamp; - expect(lazyTextRenderTime).toBeGreaterThan(1000); - expect(lazyTextRenderTime).toBeLessThan(1500); - expect(lazyTextLoadTime).toBe(0); - expect(lazyTextDuration).toBe(0); - - // the div1 entry does not emit an elementTiming entry because it's neither a text nor an image - expect(elementTimingSpans?.find(({ description }) => description === 'element[div1]')).toBeUndefined(); + // Validate text-paint metric + const text1Render = renderTimeMetrics.find(m => getIdentifier(m) === 'text1'); + expect(getPaintType(text1Render!)).toBe('text-paint'); }, ); -sentryTest('emits element timing spans on navigation', async ({ getLocalTestUrl, page, browserName }) => { - if (shouldSkipTracingTest() || browserName === 'webkit') { +sentryTest('emits element timing metrics after navigation', async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipTracingTest() || shouldSkipMetricsTest() || browserName === 'webkit') { sentryTest.skip(); } @@ -175,44 +83,33 @@ sentryTest('emits element timing spans on navigation', async ({ getLocalTestUrl, const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); + // Start listening before navigation to avoid missing metrics + const pageloadMetricsPromise = waitForMetrics(page, metrics => + metrics.some(m => m.name === 'ui.element.render_time' && getIdentifier(m) === 'image-fast'), + ); - const pageloadEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'pageload'); + await page.goto(url); - const navigationEventPromise = waitForTransactionRequest(page, evt => evt.contexts?.trace?.op === 'navigation'); + // Wait for pageload element timing metrics to arrive before navigating + await pageloadMetricsPromise; - await pageloadEventPromise; + // Start listening before click to avoid missing metrics + const navigationMetricsPromise = waitForMetrics(page, metrics => { + const seen = new Set(metrics.filter(m => m.name === 'ui.element.render_time').map(getIdentifier)); + return seen.has('navigation-image') && seen.has('navigation-text'); + }); + // Trigger navigation await page.locator('#button1').click(); - const navigationTransactionEvent = envelopeRequestParser(await navigationEventPromise); - const pageloadTransactionEvent = envelopeRequestParser(await pageloadEventPromise); + // Wait for navigation element timing metrics + const navigationMetrics = await navigationMetricsPromise; - const navigationElementTimingSpans = navigationTransactionEvent.spans?.filter(({ op }) => op === 'ui.elementtiming'); + const renderTimeMetrics = navigationMetrics.filter(m => m.name === 'ui.element.render_time'); + const renderIdentifiers = renderTimeMetrics.map(getIdentifier); - expect(navigationElementTimingSpans?.length).toEqual(2); - - const navigationStartTime = navigationTransactionEvent.start_timestamp!; - const pageloadStartTime = pageloadTransactionEvent.start_timestamp!; - - const imageSpan = navigationElementTimingSpans?.find( - ({ description }) => description === 'element[navigation-image]', - ); - const textSpan = navigationElementTimingSpans?.find(({ description }) => description === 'element[navigation-text]'); - - // Image started loading after navigation, but render-time and load-time still start from the time origin - // of the pageload. This is somewhat a limitation (though by design according to the ElementTiming spec) - expect((imageSpan!.data['element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( - navigationStartTime, - ); - expect((imageSpan!.data['element.load_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( - navigationStartTime, - ); - - expect(textSpan?.data['element.load_time']).toBe(0); - expect((textSpan!.data['element.render_time']! as number) / 1000 + pageloadStartTime).toBeGreaterThan( - navigationStartTime, - ); + expect(renderIdentifiers).toContain('navigation-image'); + expect(renderIdentifiers).toContain('navigation-text'); }); function serveAssets(page: Page) { diff --git a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/test.ts b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/test.ts index 7c0f6db3483b..dd57b0f6ad84 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/request/fetch-trace-header-merging/test.ts @@ -39,9 +39,10 @@ async function assertRequests({ // No merged sentry trace headers expect(headers['sentry-trace']).not.toContain(','); + expect(headers['sentry-trace']).toBe('12312012123120121231201212312012-1121201211212012-1'); // No multiple baggage entries - expect(headers['baggage'].match(/sentry-release/g) ?? []).toHaveLength(1); + expect(headers['baggage']).toBe('sentry-release=4.2.0'); }); } diff --git a/dev-packages/browser-integration-tests/utils/helpers.ts b/dev-packages/browser-integration-tests/utils/helpers.ts index 50150c6bee20..879e672b6c87 100644 --- a/dev-packages/browser-integration-tests/utils/helpers.ts +++ b/dev-packages/browser-integration-tests/utils/helpers.ts @@ -8,6 +8,8 @@ import type { Event as SentryEvent, EventEnvelope, EventEnvelopeHeaders, + SerializedMetric, + SerializedMetricContainer, SerializedSession, TransactionEvent, } from '@sentry/core'; @@ -283,6 +285,56 @@ export function waitForClientReportRequest(page: Page, callback?: (report: Clien }); } +/** + * Wait for metric requests. Accumulates metrics across all matching requests + * and resolves when the callback returns true for the full set of collected metrics. + * If no callback is provided, resolves on the first request containing metrics. + */ +export function waitForMetrics( + page: Page, + callback?: (metrics: SerializedMetric[]) => boolean, +): Promise { + const collected: SerializedMetric[] = []; + + return page + .waitForRequest(req => { + const postData = req.postData(); + if (!postData) { + return false; + } + + try { + const envelope = properFullEnvelopeRequestParser(req); + const items = envelope[1]; + const metrics: SerializedMetric[] = []; + for (const item of items) { + const [header] = item; + if (header.type === 'trace_metric') { + const payload = item[1] as SerializedMetricContainer; + if (payload.items) { + metrics.push(...payload.items); + } + } + } + + if (metrics.length === 0) { + return false; + } + + collected.push(...metrics); + + if (callback) { + return callback(collected); + } + + return true; + } catch { + return false; + } + }) + .then(() => collected); +} + export async function waitForSession( page: Page, callback?: (session: SerializedSession) => boolean, diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts index 4759ec9a107b..88328768c6f2 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/index.ts @@ -55,7 +55,13 @@ export default Sentry.withSentry( ], }); - return new Response(JSON.stringify({ chatResponse, modelResponse })); + // Test 3: models.embedContent + const embedResponse = await client.models.embedContent({ + model: 'text-embedding-004', + contents: 'Hello world', + }); + + return new Response(JSON.stringify({ chatResponse, modelResponse, embedResponse })); }, }, ); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts index fc475234ef5c..c3eb23d7d5f5 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/mocks.ts @@ -4,6 +4,7 @@ export class MockGoogleGenAI implements GoogleGenAIClient { public models: { generateContent: (...args: unknown[]) => Promise; generateContentStream: (...args: unknown[]) => Promise>; + embedContent: (...args: unknown[]) => Promise<{ embeddings: { values: number[] }[] }>; }; public chats: { create: (...args: unknown[]) => GoogleGenAIChat; @@ -49,6 +50,20 @@ export class MockGoogleGenAI implements GoogleGenAIClient { }, }; }, + embedContent: async (...args: unknown[]) => { + const params = args[0] as { model: string; contents?: unknown }; + await new Promise(resolve => setTimeout(resolve, 10)); + + if (params.model === 'error-model') { + const error = new Error('Model not found'); + (error as unknown as { status: number }).status = 404; + throw error; + } + + return { + embeddings: [{ values: [0.1, 0.2, 0.3, 0.4, 0.5] }], + }; + }, generateContentStream: async () => { // Return a promise that resolves to an async generator return (async function* (): AsyncGenerator { diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts index d2657f55b1ed..7172366a54c5 100644 --- a/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/google-genai/test.ts @@ -78,6 +78,19 @@ it('traces Google GenAI chat creation and message sending', async () => { op: 'gen_ai.generate_content', origin: 'auto.ai.google_genai', }), + // Fourth span - models.embedContent + expect.objectContaining({ + data: expect.objectContaining({ + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'google_genai', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'text-embedding-004', + }), + description: 'embeddings text-embedding-004', + op: 'gen_ai.embeddings', + origin: 'auto.ai.google_genai', + }), ]), ); }) diff --git a/dev-packages/e2e-tests/package.json b/dev-packages/e2e-tests/package.json index 42db5f05ceb2..c8de88c64869 100644 --- a/dev-packages/e2e-tests/package.json +++ b/dev-packages/e2e-tests/package.json @@ -28,7 +28,7 @@ "glob": "^13.0.6", "rimraf": "^6.1.3", "ts-node": "10.9.2", - "yaml": "2.8.2" + "yaml": "2.8.3" }, "volta": { "extends": "../../package.json" diff --git a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json index b74b36c9d314..400ab6144248 100644 --- a/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json +++ b/dev-packages/e2e-tests/test-applications/astro-5-cf-workers/package.json @@ -20,11 +20,6 @@ "devDependencies": { "wrangler": "^4.63.0" }, - "pnpm": { - "overrides": { - "esbuild": "0.24.0" - } - }, "volta": { "extends": "../../package.json" } diff --git a/dev-packages/e2e-tests/test-applications/deno/deno.json b/dev-packages/e2e-tests/test-applications/deno/deno.json index c78a9bccb60a..35242c740171 100644 --- a/dev-packages/e2e-tests/test-applications/deno/deno.json +++ b/dev-packages/e2e-tests/test-applications/deno/deno.json @@ -2,7 +2,10 @@ "imports": { "@sentry/deno": "npm:@sentry/deno", "@sentry/core": "npm:@sentry/core", - "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0" + "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", + "ai": "npm:ai@^3.0.0", + "ai/test": "npm:ai@^3.0.0/test", + "zod": "npm:zod@^3.22.4" }, "nodeModulesDir": "manual" } diff --git a/dev-packages/e2e-tests/test-applications/deno/package.json b/dev-packages/e2e-tests/test-applications/deno/package.json index 8ec92fbd3985..ff30a9304e53 100644 --- a/dev-packages/e2e-tests/test-applications/deno/package.json +++ b/dev-packages/e2e-tests/test-applications/deno/package.json @@ -11,7 +11,9 @@ }, "dependencies": { "@sentry/deno": "latest || *", - "@opentelemetry/api": "^1.9.0" + "@opentelemetry/api": "^1.9.0", + "ai": "^3.0.0", + "zod": "^3.22.4" }, "devDependencies": { "@playwright/test": "~1.56.0", diff --git a/dev-packages/e2e-tests/test-applications/deno/src/app.ts b/dev-packages/e2e-tests/test-applications/deno/src/app.ts index fb34053e29d7..9b19b4ba3ac7 100644 --- a/dev-packages/e2e-tests/test-applications/deno/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/deno/src/app.ts @@ -13,6 +13,9 @@ trace.setGlobalTracerProvider(fakeProvider as any); // Sentry.init() must call trace.disable() to clear the fake provider above import * as Sentry from '@sentry/deno'; +import { generateText } from 'ai'; +import { MockLanguageModelV1 } from 'ai/test'; +import { z } from 'zod'; Sentry.init({ environment: 'qa', @@ -20,11 +23,13 @@ Sentry.init({ debug: !!Deno.env.get('DEBUG'), tunnel: 'http://localhost:3031/', tracesSampleRate: 1, + sendDefaultPii: true, + enableLogs: true, }); const port = 3030; -Deno.serve({ port }, (req: Request) => { +Deno.serve({ port }, async (req: Request) => { const url = new URL(req.url); if (url.pathname === '/test-success') { @@ -84,6 +89,219 @@ Deno.serve({ port }, (req: Request) => { }); } + // Test breadcrumbs: add a breadcrumb then capture an error + if (url.pathname === '/test-breadcrumb') { + Sentry.addBreadcrumb({ + message: 'test-breadcrumb', + category: 'custom', + level: 'info', + }); + const exceptionId = Sentry.captureException(new Error('breadcrumb-test')); + return new Response(JSON.stringify({ exceptionId }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test context: set user, tag, extra then capture an error + if (url.pathname === '/test-context') { + Sentry.setUser({ id: '123', email: 'test@sentry.io' }); + Sentry.setTag('deno-runtime', 'true'); + Sentry.setExtra('detail', { key: 'value' }); + const exceptionId = Sentry.captureException(new Error('context-test')); + return new Response(JSON.stringify({ exceptionId }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test scope isolation: tags inside withScope do not leak + if (url.pathname === '/test-scope-isolation') { + let insideId: string | undefined; + let outsideId: string | undefined; + + Sentry.withScope(scope => { + scope.setTag('isolated', 'yes'); + insideId = Sentry.captureException(new Error('inside-scope')); + }); + + outsideId = Sentry.captureException(new Error('outside-scope')); + + return new Response(JSON.stringify({ insideId, outsideId }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test outbound fetch instrumentation + if (url.pathname === '/test-outgoing-fetch') { + const response = await Sentry.startSpan({ name: 'test-outgoing-fetch' }, async () => { + const res = await fetch('http://localhost:3030/test-success'); + return res.json(); + }); + return new Response(JSON.stringify(response), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test AI: Vercel AI SDK generateText with mock model + if (url.pathname === '/test-ai') { + const results = await Sentry.startSpan({ op: 'function', name: 'ai-test' }, async () => { + // First call - telemetry enabled by default + const result1 = await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'First span here!', + }), + }), + prompt: 'Where is the first span?', + }); + + // Second call - explicitly enabled telemetry + const result2 = await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Second span here!', + }), + }), + prompt: 'Where is the second span?', + }); + + // Third call - with tool calls + const result3 = await generateText({ + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async (args: { location: string }) => { + return `Weather in ${args.location}: Sunny, 72°F`; + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + + // Fourth call - explicitly disabled telemetry, should not be captured + const result4 = await generateText({ + experimental_telemetry: { isEnabled: false }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'stop', + usage: { promptTokens: 10, completionTokens: 20 }, + text: 'Should not be captured!', + }), + }), + prompt: 'Where is the disabled span?', + }); + + return { + result1: result1.text, + result2: result2.text, + result3: result3.text, + result4: result4.text, + }; + }); + + return new Response(JSON.stringify(results), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test AI error: tool call that throws + if (url.pathname === '/test-ai-error') { + try { + await Sentry.startSpan({ op: 'function', name: 'ai-error-test' }, async () => { + await generateText({ + experimental_telemetry: { isEnabled: true }, + model: new MockLanguageModelV1({ + doGenerate: async () => ({ + rawCall: { rawPrompt: null, rawSettings: {} }, + finishReason: 'tool-calls', + usage: { promptTokens: 15, completionTokens: 25 }, + text: 'Tool call completed!', + toolCalls: [ + { + toolCallType: 'function', + toolCallId: 'call-1', + toolName: 'getWeather', + args: '{ "location": "San Francisco" }', + }, + ], + }), + }), + tools: { + getWeather: { + parameters: z.object({ location: z.string() }), + execute: async (_args: { location: string }) => { + throw new Error('Tool call failed'); + }, + }, + }, + prompt: 'What is the weather in San Francisco?', + }); + }); + } catch (e) { + Sentry.captureException(e); + } + + return new Response(JSON.stringify({ status: 'error-handled' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test metrics: emit counter, distribution, and gauge + if (url.pathname === '/test-metrics') { + Sentry.metrics.count('test.deno.count', 1, { + attributes: { + endpoint: '/test-metrics', + 'random.attribute': 'Apples', + }, + }); + Sentry.metrics.distribution('test.deno.distribution', 100, { + attributes: { + endpoint: '/test-metrics', + 'random.attribute': 'Bananas', + }, + }); + Sentry.metrics.gauge('test.deno.gauge', 200, { + attributes: { + endpoint: '/test-metrics', + 'random.attribute': 'Cherries', + }, + }); + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Test logs: emit a debug log via Sentry.logger + if (url.pathname === '/test-log') { + Sentry.logger.debug('Accessed /test-log route'); + return new Response(JSON.stringify({ message: 'Log sent' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response('Not found', { status: 404 }); }); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/ai-error.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/ai-error.test.ts new file mode 100644 index 000000000000..8cf82e56de15 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/ai-error.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; + +test('should link AI errors to the correct trace', async ({ baseURL }) => { + const aiTransactionPromise = waitForTransaction('deno', event => { + return event?.spans?.some(span => span.description === 'ai-error-test') ?? false; + }); + + const errorEventPromise = waitForError('deno', event => { + return event.exception?.values?.[0]?.value?.includes('Tool call failed') ?? false; + }); + + await fetch(`${baseURL}/test-ai-error`); + + const aiTransaction = await aiTransactionPromise; + const errorEvent = await errorEventPromise; + + expect(aiTransaction).toBeDefined(); + + const spans = aiTransaction.spans || []; + + // The parent span wrapping the AI call should exist + expect(spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'ai-error-test', + op: 'function', + }), + ]), + ); + + expect(errorEvent).toBeDefined(); + + // Verify error is linked to the same trace as the transaction + expect(errorEvent?.contexts?.trace?.trace_id).toBe(aiTransaction.contexts?.trace?.trace_id); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts new file mode 100644 index 000000000000..102ef00c6cd1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/ai.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create AI pipeline spans with Vercel AI SDK', async ({ baseURL }) => { + const aiTransactionPromise = waitForTransaction('deno', event => { + return event?.spans?.some(span => span.description === 'ai-test') ?? false; + }); + + await fetch(`${baseURL}/test-ai`); + + const aiTransaction = await aiTransactionPromise; + + expect(aiTransaction).toBeDefined(); + + const spans = aiTransaction.spans || []; + + // The parent span wrapping all AI calls should exist + expect(spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'ai-test', + op: 'function', + }), + ]), + ); + + // Vercel AI SDK emits OTel spans for generateText calls. + // Due to the AI SDK monkey-patching limitation (https://github.com/vercel/ai/pull/6716), + // only explicitly opted-in calls produce telemetry spans. + // The explicitly enabled call (experimental_telemetry: { isEnabled: true }) should produce spans. + const aiSpans = spans.filter( + (span: any) => + span.op === 'gen_ai.invoke_agent' || + span.op === 'gen_ai.generate_text' || + span.op === 'otel.span' || + span.description?.includes('ai.generateText'), + ); + + // We expect at least one AI-related span from the explicitly enabled call + expect(aiSpans.length).toBeGreaterThanOrEqual(1); + + // Verify the disabled call was not captured + const promptsInSpans = spans + .map((span: any) => span.data?.['vercel.ai.prompt']) + .filter((prompt: unknown): prompt is string => prompt !== undefined); + const hasDisabledPrompt = promptsInSpans.some((prompt: string) => prompt.includes('Where is the disabled span?')); + expect(hasDisabledPrompt).toBe(false); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/breadcrumbs.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/breadcrumbs.test.ts new file mode 100644 index 000000000000..b28c57e79ea1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/breadcrumbs.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends error event with breadcrumbs', async ({ baseURL }) => { + const errorEventPromise = waitForError('deno', event => { + return !event.type && event.exception?.values?.[0]?.value === 'breadcrumb-test'; + }); + + await fetch(`${baseURL}/test-breadcrumb`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('breadcrumb-test'); + + expect(errorEvent.breadcrumbs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: 'test-breadcrumb', + category: 'custom', + level: 'info', + }), + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/context.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/context.test.ts new file mode 100644 index 000000000000..79f2043ca223 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/context.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends error event with user, tags, and extras', async ({ baseURL }) => { + const errorEventPromise = waitForError('deno', event => { + return !event.type && event.exception?.values?.[0]?.value === 'context-test'; + }); + + await fetch(`${baseURL}/test-context`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('context-test'); + + expect(errorEvent.user).toEqual( + expect.objectContaining({ + id: '123', + email: 'test@sentry.io', + }), + ); + + expect(errorEvent.tags).toEqual( + expect.objectContaining({ + 'deno-runtime': 'true', + }), + ); + + expect(errorEvent.extra).toEqual( + expect.objectContaining({ + detail: { key: 'value' }, + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/fetch.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/fetch.test.ts new file mode 100644 index 000000000000..7a0dcb30c82e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/fetch.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Outbound fetch inside Sentry span creates transaction', async ({ baseURL }) => { + const transactionPromise = waitForTransaction('deno', event => { + return event?.spans?.some(span => span.description === 'test-outgoing-fetch') ?? false; + }); + + await fetch(`${baseURL}/test-outgoing-fetch`); + + const transaction = await transactionPromise; + + expect(transaction.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + description: 'test-outgoing-fetch', + origin: 'manual', + }), + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/logs.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/logs.test.ts new file mode 100644 index 000000000000..7db4ff68fa9a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/logs.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; +import type { SerializedLogContainer } from '@sentry/core'; + +test('should send logs via Sentry.logger', async ({ baseURL }) => { + const logEnvelopePromise = waitForEnvelopeItem('deno', envelope => { + return envelope[0].type === 'log' && (envelope[1] as SerializedLogContainer).items[0]?.level === 'debug'; + }); + + await fetch(`${baseURL}/test-log`); + + const logEnvelope = await logEnvelopePromise; + const log = (logEnvelope[1] as SerializedLogContainer).items[0]; + expect(log?.level).toBe('debug'); + expect(log?.body).toBe('Accessed /test-log route'); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/metrics.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/metrics.test.ts new file mode 100644 index 000000000000..298f34ccad55 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/metrics.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from '@playwright/test'; +import { waitForMetric } from '@sentry-internal/test-utils'; + +test('Should emit counter, distribution, and gauge metrics', async ({ baseURL }) => { + const countPromise = waitForMetric('deno', metric => { + return metric.name === 'test.deno.count'; + }); + + const distributionPromise = waitForMetric('deno', metric => { + return metric.name === 'test.deno.distribution'; + }); + + const gaugePromise = waitForMetric('deno', metric => { + return metric.name === 'test.deno.gauge'; + }); + + await fetch(`${baseURL}/test-metrics`); + + const count = await countPromise; + const distribution = await distributionPromise; + const gauge = await gaugePromise; + + expect(count).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.deno.count', + type: 'counter', + value: 1, + attributes: { + endpoint: { value: '/test-metrics', type: 'string' }, + 'random.attribute': { value: 'Apples', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.deno', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(distribution).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.deno.distribution', + type: 'distribution', + value: 100, + attributes: { + endpoint: { value: '/test-metrics', type: 'string' }, + 'random.attribute': { value: 'Bananas', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.deno', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); + + expect(gauge).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'test.deno.gauge', + type: 'gauge', + value: 200, + attributes: { + endpoint: { value: '/test-metrics', type: 'string' }, + 'random.attribute': { value: 'Cherries', type: 'string' }, + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.deno', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/deno/tests/scope.test.ts b/dev-packages/e2e-tests/test-applications/deno/tests/scope.test.ts new file mode 100644 index 000000000000..50fc60940113 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/deno/tests/scope.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Scope isolation prevents tag leakage between scopes', async ({ baseURL }) => { + const insideErrorPromise = waitForError('deno', event => { + return !event.type && event.exception?.values?.[0]?.value === 'inside-scope'; + }); + + const outsideErrorPromise = waitForError('deno', event => { + return !event.type && event.exception?.values?.[0]?.value === 'outside-scope'; + }); + + await fetch(`${baseURL}/test-scope-isolation`); + + const insideError = await insideErrorPromise; + const outsideError = await outsideErrorPromise; + + // The error inside withScope should have the isolated tag + expect(insideError.tags).toEqual( + expect.objectContaining({ + isolated: 'yes', + }), + ); + + // The error outside withScope should NOT have the isolated tag + expect(outsideError.tags?.['isolated']).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/generic-ts3.8/package.json b/dev-packages/e2e-tests/test-applications/generic-ts3.8/package.json index fbd40cebcb07..fe0e0f6ec5f0 100644 --- a/dev-packages/e2e-tests/test-applications/generic-ts3.8/package.json +++ b/dev-packages/e2e-tests/test-applications/generic-ts3.8/package.json @@ -20,6 +20,11 @@ "@sentry-internal/replay": "latest || *", "@sentry/wasm": "latest || *" }, + "pnpm": { + "overrides": { + "@opentelemetry/api": "1.9.0" + } + }, "volta": { "extends": "../../package.json" } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts index 8f0b4d0f7800..d7015bce4a30 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/sentry.server.config.ts @@ -8,7 +8,7 @@ Sentry.init({ tracesSampleRate: 1.0, sendDefaultPii: true, // debug: true, - integrations: [Sentry.vercelAIIntegration()], + integrations: [Sentry.vercelAIIntegration(), Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 })], // Verify Log type is available beforeSendLog(log: Log) { return log; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts new file mode 100644 index 000000000000..0efd0d8f7d79 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/node-runtime-metrics.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from '@playwright/test'; +import { waitForMetric } from '@sentry-internal/test-utils'; + +const EXPECTED_ATTRIBUTES = { + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.nextjs', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.origin': { value: 'auto.node.runtime_metrics', type: 'string' }, +}; + +test('Should emit node runtime memory metrics', async ({ request }) => { + const rssPromise = waitForMetric('nextjs-16', metric => { + return metric.name === 'node.runtime.mem.rss'; + }); + + const heapUsedPromise = waitForMetric('nextjs-16', metric => { + return metric.name === 'node.runtime.mem.heap_used'; + }); + + const heapTotalPromise = waitForMetric('nextjs-16', metric => { + return metric.name === 'node.runtime.mem.heap_total'; + }); + + // Trigger a request to ensure the server is running and metrics start being collected + await request.get('/'); + + const rss = await rssPromise; + const heapUsed = await heapUsedPromise; + const heapTotal = await heapTotalPromise; + + expect(rss).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.mem.rss', + type: 'gauge', + unit: 'byte', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(heapUsed).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.mem.heap_used', + type: 'gauge', + unit: 'byte', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(heapTotal).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.mem.heap_total', + type: 'gauge', + unit: 'byte', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); + +test('Should emit node runtime CPU utilization metric', async ({ request }) => { + const cpuUtilPromise = waitForMetric('nextjs-16', metric => { + return metric.name === 'node.runtime.cpu.utilization'; + }); + + await request.get('/'); + + const cpuUtil = await cpuUtilPromise; + + expect(cpuUtil).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.cpu.utilization', + type: 'gauge', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); + +test('Should emit node runtime event loop metrics', async ({ request }) => { + const elDelayP50Promise = waitForMetric('nextjs-16', metric => { + return metric.name === 'node.runtime.event_loop.delay.p50'; + }); + + const elDelayP99Promise = waitForMetric('nextjs-16', metric => { + return metric.name === 'node.runtime.event_loop.delay.p99'; + }); + + const elUtilPromise = waitForMetric('nextjs-16', metric => { + return metric.name === 'node.runtime.event_loop.utilization'; + }); + + await request.get('/'); + + const elDelayP50 = await elDelayP50Promise; + const elDelayP99 = await elDelayP99Promise; + const elUtil = await elUtilPromise; + + expect(elDelayP50).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.event_loop.delay.p50', + type: 'gauge', + unit: 'second', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(elDelayP99).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.event_loop.delay.p99', + type: 'gauge', + unit: 'second', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(elUtil).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.event_loop.utilization', + type: 'gauge', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); + +test('Should emit node runtime uptime counter', async ({ request }) => { + const uptimePromise = waitForMetric('nextjs-16', metric => { + return metric.name === 'node.runtime.process.uptime'; + }); + + await request.get('/'); + + const uptime = await uptimePromise; + + expect(uptime).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.process.uptime', + type: 'counter', + unit: 'second', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json index c78cc950074b..974d0711acc8 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-custom-sampler/package.json @@ -14,8 +14,8 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.6.0", "@opentelemetry/core": "^2.6.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/instrumentation-http": "^0.213.0", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/instrumentation-http": "^0.214.0", "@opentelemetry/resources": "^2.6.0", "@opentelemetry/sdk-trace-node": "^2.6.0", "@opentelemetry/semantic-conventions": "^1.40.0", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json index 749c25696505..00e1ab056be6 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2-sdk-node/package.json @@ -14,13 +14,13 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.6.0", "@opentelemetry/core": "^2.6.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/instrumentation-http": "^0.213.0", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/instrumentation-http": "^0.214.0", "@opentelemetry/resources": "^2.6.0", "@opentelemetry/sdk-trace-node": "^2.6.0", "@opentelemetry/semantic-conventions": "^1.40.0", - "@opentelemetry/sdk-node": "^0.213.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.213.0", + "@opentelemetry/sdk-node": "^0.214.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", "@sentry/node-core": "latest || *", "@sentry/opentelemetry": "latest || *", "@types/express": "4.17.17", diff --git a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json index 4db7c3440bed..77b6006ee947 100644 --- a/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json +++ b/dev-packages/e2e-tests/test-applications/node-core-express-otel-v2/package.json @@ -16,8 +16,8 @@ "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.6.0", "@opentelemetry/core": "^2.6.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/instrumentation-http": "^0.213.0", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/instrumentation-http": "^0.214.0", "@opentelemetry/resources": "^2.6.0", "@opentelemetry/sdk-trace-node": "^2.6.0", "@opentelemetry/semantic-conventions": "^1.40.0", diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.gitignore b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.gitignore new file mode 100644 index 000000000000..f5bd8548c7aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +pnpm-lock.yaml diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.npmrc b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/package.json b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/package.json new file mode 100644 index 000000000000..fcf388cfaa89 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/package.json @@ -0,0 +1,40 @@ +{ + "name": "node-core-light-otlp-app", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", + "@opentelemetry/sdk-trace-base": "^2.5.1", + "@opentelemetry/sdk-trace-node": "^2.5.1", + "@sentry/node-core": "latest || *", + "@types/express": "^4.17.21", + "@types/node": "^22.0.0", + "express": "^4.21.2", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "latest || *" + }, + "volta": { + "node": "22.18.0" + }, + "sentryTest": { + "variants": [ + { + "label": "node 22 (light mode + OTLP integration)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/playwright.config.ts b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/playwright.config.ts new file mode 100644 index 000000000000..604e6d9e6861 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/playwright.config.ts @@ -0,0 +1,34 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig( + { + startCommand: 'pnpm start', + }, + { + webServer: [ + { + command: 'node ./start-event-proxy.mjs', + port: 3031, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'node ./start-otel-proxy.mjs', + port: 3032, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'pnpm start', + port: 3030, + stdout: 'pipe', + stderr: 'pipe', + env: { + PORT: '3030', + }, + }, + ], + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/src/app.ts b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/src/app.ts new file mode 100644 index 000000000000..d8cb48eab19c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/src/app.ts @@ -0,0 +1,90 @@ +import { trace } from '@opentelemetry/api'; +import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import * as Sentry from '@sentry/node-core/light'; +import { otlpIntegration } from '@sentry/node-core/light/otlp'; +import express from 'express'; + +const provider = new NodeTracerProvider({ + spanProcessors: [ + // The user's own exporter (sends to test proxy for verification) + new BatchSpanProcessor( + new OTLPTraceExporter({ + url: 'http://localhost:3032/', + }), + ), + ], +}); + +provider.register(); + +Sentry.init({ + dsn: process.env.E2E_TEST_DSN, + debug: true, + tracesSampleRate: 1.0, + tunnel: 'http://localhost:3031/', // Use event proxy for testing + integrations: [otlpIntegration()], +}); + +const app = express(); +const port = 3030; +const tracer = trace.getTracer('test-app'); + +app.get('/test-error', (_req, res) => { + Sentry.setTag('test', 'error'); + Sentry.captureException(new Error('Test error from light+otel')); + res.status(500).json({ error: 'Error captured' }); +}); + +app.get('/test-otel-span', (_req, res) => { + tracer.startActiveSpan('test-span', span => { + Sentry.captureException(new Error('Error inside OTel span')); + span.end(); + }); + + res.json({ ok: true }); +}); + +app.get('/test-isolation/:userId', async (req, res) => { + const userId = req.params.userId; + + // The light httpIntegration provides request isolation via diagnostics_channel. + // This should still work alongside the OTLP integration. + Sentry.setUser({ id: userId }); + Sentry.setTag('user_id', userId); + + // Simulate async work + await new Promise(resolve => setTimeout(resolve, Math.random() * 200 + 50)); + + const isolationScope = Sentry.getIsolationScope(); + const scopeData = isolationScope.getScopeData(); + + const isIsolated = scopeData.user?.id === userId && scopeData.tags?.user_id === userId; + + res.json({ + userId, + isIsolated, + scope: { + userId: scopeData.user?.id, + userIdTag: scopeData.tags?.user_id, + }, + }); +}); + +app.get('/test-isolation-error/:userId', (req, res) => { + const userId = req.params.userId; + Sentry.setTag('user_id', userId); + Sentry.setUser({ id: userId }); + + Sentry.captureException(new Error(`Error for user ${userId}`)); + res.json({ userId, captured: true }); +}); + +app.get('/health', (_req, res) => { + res.json({ status: 'ok' }); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-event-proxy.mjs new file mode 100644 index 000000000000..3e170b6311bd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-core-light-otlp', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-otel-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-otel-proxy.mjs new file mode 100644 index 000000000000..d3f1d89b1149 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/start-otel-proxy.mjs @@ -0,0 +1,6 @@ +import { startProxyServer } from '@sentry-internal/test-utils'; + +startProxyServer({ + port: 3032, + proxyServerName: 'node-core-light-otlp-otel', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/errors.test.ts new file mode 100644 index 000000000000..9dd6b76a5e15 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/errors.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('should capture errors with correct tags', async ({ request }) => { + const errorEventPromise = waitForError('node-core-light-otlp', event => { + return event?.exception?.values?.[0]?.value === 'Test error from light+otel'; + }); + + const response = await request.get('/test-error'); + expect(response.status()).toBe(500); + + const errorEvent = await errorEventPromise; + expect(errorEvent).toBeDefined(); + expect(errorEvent.exception?.values?.[0]?.value).toBe('Test error from light+otel'); + expect(errorEvent.tags?.test).toBe('error'); +}); + +test('should link error events to the active OTel trace context', async ({ request }) => { + const errorEventPromise = waitForError('node-core-light-otlp', event => { + return event?.exception?.values?.[0]?.value === 'Error inside OTel span'; + }); + + await request.get('/test-otel-span'); + + const errorEvent = await errorEventPromise; + expect(errorEvent).toBeDefined(); + + // The error event should have trace context from the OTel span + expect(errorEvent.contexts?.trace).toBeDefined(); + expect(errorEvent.contexts?.trace?.trace_id).toMatch(/[a-f0-9]{32}/); + expect(errorEvent.contexts?.trace?.span_id).toMatch(/[a-f0-9]{16}/); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/otel-spans.test.ts b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/otel-spans.test.ts new file mode 100644 index 000000000000..b45c09e00b8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/otel-spans.test.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; +import { waitForPlainRequest } from '@sentry-internal/test-utils'; + +test('User OTel exporter still receives spans', async ({ request }) => { + // The user's own OTel exporter sends spans to port 3032 (our test proxy). + // Verify that OTel span export still works alongside the Sentry OTLP integration. + const otelPromise = waitForPlainRequest('node-core-light-otlp-otel', data => { + const json = JSON.parse(data) as { resourceSpans: unknown[] }; + return json.resourceSpans.length > 0; + }); + + await request.get('/test-otel-span'); + + const otelData = await otelPromise; + expect(otelData).toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/request-isolation.test.ts b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/request-isolation.test.ts new file mode 100644 index 000000000000..3510e9f349bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tests/request-isolation.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('should isolate scope data across concurrent requests', async ({ request }) => { + const [response1, response2, response3] = await Promise.all([ + request.get('/test-isolation/user-1'), + request.get('/test-isolation/user-2'), + request.get('/test-isolation/user-3'), + ]); + + const data1 = await response1.json(); + const data2 = await response2.json(); + const data3 = await response3.json(); + + expect(data1.isIsolated).toBe(true); + expect(data1.userId).toBe('user-1'); + expect(data1.scope.userId).toBe('user-1'); + expect(data1.scope.userIdTag).toBe('user-1'); + + expect(data2.isIsolated).toBe(true); + expect(data2.userId).toBe('user-2'); + expect(data2.scope.userId).toBe('user-2'); + expect(data2.scope.userIdTag).toBe('user-2'); + + expect(data3.isIsolated).toBe(true); + expect(data3.userId).toBe('user-3'); + expect(data3.scope.userId).toBe('user-3'); + expect(data3.scope.userIdTag).toBe('user-3'); +}); + +test('should isolate errors across concurrent requests', async ({ request }) => { + const errorPromises = [ + waitForError('node-core-light-otlp', event => { + return event?.exception?.values?.[0]?.value === 'Error for user user-1'; + }), + waitForError('node-core-light-otlp', event => { + return event?.exception?.values?.[0]?.value === 'Error for user user-2'; + }), + waitForError('node-core-light-otlp', event => { + return event?.exception?.values?.[0]?.value === 'Error for user user-3'; + }), + ]; + + await Promise.all([ + request.get('/test-isolation-error/user-1'), + request.get('/test-isolation-error/user-2'), + request.get('/test-isolation-error/user-3'), + ]); + + const [error1, error2, error3] = await Promise.all(errorPromises); + + expect(error1?.user?.id).toBe('user-1'); + expect(error1?.tags?.user_id).toBe('user-1'); + + expect(error2?.user?.id).toBe('user-2'); + expect(error2?.tags?.user_id).toBe('user-2'); + + expect(error3?.user?.id).toBe('user-3'); + expect(error3?.tags?.user_id).toBe('user-3'); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tsconfig.json new file mode 100644 index 000000000000..a2a82225afca --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-core-light-otlp/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index f195206fb5b2..e762909c9173 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -55,6 +55,9 @@ const DEPENDENTS: Dependent[] = [ 'childProcessIntegration', 'systemErrorIntegration', 'pinoIntegration', + // Bun will get its own runtime metrics integration + 'nodeRuntimeMetricsIntegration', + 'NodeRuntimeMetricsOptions', ], }, { diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/src/app.ts index 20dfa5bf84c5..9a7f6f07d8bc 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-v5/src/app.ts +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/src/app.ts @@ -14,6 +14,7 @@ Sentry.init({ tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, enableLogs: true, + integrations: [Sentry.nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 })], }); import { TRPCError, initTRPC } from '@trpc/server'; diff --git a/dev-packages/e2e-tests/test-applications/node-express-v5/tests/node-runtime-metrics.test.ts b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/node-runtime-metrics.test.ts new file mode 100644 index 000000000000..e8e0aef3be17 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-v5/tests/node-runtime-metrics.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from '@playwright/test'; +import { waitForMetric } from '@sentry-internal/test-utils'; + +const EXPECTED_ATTRIBUTES = { + 'sentry.environment': { value: 'qa', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.origin': { value: 'auto.node.runtime_metrics', type: 'string' }, +}; + +test('Should emit node runtime memory metrics', async ({ baseURL }) => { + const rssPromise = waitForMetric('node-express-v5', metric => { + return metric.name === 'node.runtime.mem.rss'; + }); + + const heapUsedPromise = waitForMetric('node-express-v5', metric => { + return metric.name === 'node.runtime.mem.heap_used'; + }); + + const heapTotalPromise = waitForMetric('node-express-v5', metric => { + return metric.name === 'node.runtime.mem.heap_total'; + }); + + // Trigger a request to ensure the server is running and metrics start being collected + await fetch(`${baseURL}/test-success`); + + const rss = await rssPromise; + const heapUsed = await heapUsedPromise; + const heapTotal = await heapTotalPromise; + + expect(rss).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.mem.rss', + type: 'gauge', + unit: 'byte', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(heapUsed).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.mem.heap_used', + type: 'gauge', + unit: 'byte', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(heapTotal).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.mem.heap_total', + type: 'gauge', + unit: 'byte', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); + +test('Should emit node runtime CPU utilization metric', async ({ baseURL }) => { + const cpuUtilPromise = waitForMetric('node-express-v5', metric => { + return metric.name === 'node.runtime.cpu.utilization'; + }); + + await fetch(`${baseURL}/test-success`); + + const cpuUtil = await cpuUtilPromise; + + expect(cpuUtil).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.cpu.utilization', + type: 'gauge', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); + +test('Should emit node runtime event loop metrics', async ({ baseURL }) => { + const elDelayP50Promise = waitForMetric('node-express-v5', metric => { + return metric.name === 'node.runtime.event_loop.delay.p50'; + }); + + const elDelayP99Promise = waitForMetric('node-express-v5', metric => { + return metric.name === 'node.runtime.event_loop.delay.p99'; + }); + + const elUtilPromise = waitForMetric('node-express-v5', metric => { + return metric.name === 'node.runtime.event_loop.utilization'; + }); + + await fetch(`${baseURL}/test-success`); + + const elDelayP50 = await elDelayP50Promise; + const elDelayP99 = await elDelayP99Promise; + const elUtil = await elUtilPromise; + + expect(elDelayP50).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.event_loop.delay.p50', + type: 'gauge', + unit: 'second', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(elDelayP99).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.event_loop.delay.p99', + type: 'gauge', + unit: 'second', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); + + expect(elUtil).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.event_loop.utilization', + type: 'gauge', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); + +test('Should emit node runtime uptime counter', async ({ baseURL }) => { + const uptimePromise = waitForMetric('node-express-v5', metric => { + return metric.name === 'node.runtime.process.uptime'; + }); + + await fetch(`${baseURL}/test-success`); + + const uptime = await uptimePromise; + + expect(uptime).toMatchObject({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name: 'node.runtime.process.uptime', + type: 'counter', + unit: 'second', + value: expect.any(Number), + attributes: expect.objectContaining(EXPECTED_ATTRIBUTES), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json index 59da61d6d7da..8e5563fdb4ec 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json @@ -12,11 +12,11 @@ }, "dependencies": { "@opentelemetry/api": "1.9.0", - "@opentelemetry/sdk-trace-node": "2.6.0", - "@opentelemetry/exporter-trace-otlp-http": "0.213.0", - "@opentelemetry/instrumentation-undici": "0.23.0", - "@opentelemetry/instrumentation-http": "0.213.0", - "@opentelemetry/instrumentation": "0.213.0", + "@opentelemetry/sdk-trace-node": "2.6.1", + "@opentelemetry/exporter-trace-otlp-http": "0.214.0", + "@opentelemetry/instrumentation-undici": "0.24.0", + "@opentelemetry/instrumentation-http": "0.214.0", + "@opentelemetry/instrumentation": "0.214.0", "@sentry/node": "latest || *", "@types/express": "4.17.17", "@types/node": "^18.19.1", diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts index 726cfaba8c10..7ae2b9116713 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/04.hooks.ts @@ -2,16 +2,18 @@ import { defineHandler } from 'nitro'; import { getQuery } from 'nitro/h3'; export default defineHandler({ - onRequest: async event => { - // Set a header to indicate the onRequest hook ran - event.res?.headers.set('x-hooks-onrequest', 'executed'); + middleware: [ + async event => { + // Set a header to indicate the middleware ran + event.res?.headers.set('x-hooks-middleware', 'executed'); - // Check if we should throw an error in onRequest - const query = getQuery(event); - if (query.throwOnRequestError === 'true') { - throw new Error('OnRequest hook error'); - } - }, + // Check if we should throw an error in middleware + const query = getQuery(event); + if (query.throwOnRequestError === 'true') { + throw new Error('OnRequest hook error'); + } + }, + ], handler: async event => { // Set a header to indicate the main handler ran @@ -23,15 +25,4 @@ export default defineHandler({ throw new Error('Handler error'); } }, - - onBeforeResponse: async (event, response) => { - // Set a header to indicate the onBeforeResponse hook ran - event.res?.headers.set('x-hooks-onbeforeresponse', 'executed'); - - // Check if we should throw an error in onBeforeResponse - const query = getQuery(event); - if (query.throwOnBeforeResponseError === 'true') { - throw new Error('OnBeforeResponse hook error'); - } - }, }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts index f0bac6fb3113..cd5a447539c0 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/server/middleware/05.array-hooks.ts @@ -2,10 +2,10 @@ import { defineHandler } from 'nitro'; import { getQuery } from 'nitro/h3'; export default defineHandler({ - // Array of onRequest handlers - onRequest: [ + // Array of middleware handlers (replaces onRequest in h3 v2) + middleware: [ async event => { - event.res?.headers.set('x-array-onrequest-0', 'executed'); + event.res?.headers.set('x-array-middleware-0', 'executed'); const query = getQuery(event); if (query.throwOnRequest0Error === 'true') { @@ -13,7 +13,7 @@ export default defineHandler({ } }, async event => { - event.res?.headers.set('x-array-onrequest-1', 'executed'); + event.res?.headers.set('x-array-middleware-1', 'executed'); const query = getQuery(event); if (query.throwOnRequest1Error === 'true') { @@ -25,24 +25,4 @@ export default defineHandler({ handler: async event => { event.res?.headers.set('x-array-handler', 'executed'); }, - - // Array of onBeforeResponse handlers - onBeforeResponse: [ - async (event, response) => { - event.res?.headers.set('x-array-onbeforeresponse-0', 'executed'); - - const query = getQuery(event); - if (query.throwOnBeforeResponse0Error === 'true') { - throw new Error('OnBeforeResponse[0] hook error'); - } - }, - async (event, response) => { - event.res?.headers.set('x-array-onbeforeresponse-1', 'executed'); - - const query = getQuery(event); - if (query.throwOnBeforeResponse1Error === 'true') { - throw new Error('OnBeforeResponse[1] hook error'); - } - }, - ], }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts index 3c314b80b59c..71ef433b6c07 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/middleware.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; // TODO: Skipped for Nuxt 5 as the SDK is not yet updated for that -test.describe.skip('Server Middleware Instrumentation', () => { +test.describe('Server Middleware Instrumentation', () => { test('should create separate spans for each server middleware', async ({ request }) => { const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; @@ -20,8 +20,8 @@ test.describe.skip('Server Middleware Instrumentation', () => { // Verify that we have spans for each middleware const middlewareSpans = serverTxnEvent.spans?.filter(span => span.op === 'middleware.nuxt') || []; - // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse - expect(middlewareSpans).toHaveLength(11); + // 3 simple + 2 hooks (middleware+handler) + 3 array hooks (2 middleware + 1 handler) + expect(middlewareSpans).toHaveLength(8); // Check for specific middleware spans const firstMiddlewareSpan = middlewareSpans.find(span => span.data?.['nuxt.middleware.name'] === '01.first'); @@ -60,8 +60,8 @@ test.describe.skip('Server Middleware Instrumentation', () => { // Verify spans have different span IDs (each middleware gets its own span) const spanIds = middlewareSpans.map(span => span.span_id); const uniqueSpanIds = new Set(spanIds); - // 3 simple + 3 hooks (onRequest+handler+onBeforeResponse) + 5 array hooks (2 onRequest + 1 handler + 2 onBeforeResponse) - expect(uniqueSpanIds.size).toBe(11); + // 3 simple + 2 hooks (middleware+handler) + 3 array hooks (2 middleware + 1 handler) + expect(uniqueSpanIds.size).toBe(8); // Verify spans share the same trace ID const traceIds = middlewareSpans.map(span => span.trace_id); @@ -128,7 +128,7 @@ test.describe.skip('Server Middleware Instrumentation', () => { ); }); - test('should create spans for onRequest and onBeforeResponse hooks', async ({ request }) => { + test('should create spans for middleware and handler hooks', async ({ request }) => { const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; }); @@ -143,42 +143,35 @@ test.describe.skip('Server Middleware Instrumentation', () => { // Find spans for the hooks middleware const hooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '04.hooks'); - // Should have spans for onRequest, handler, and onBeforeResponse - expect(hooksSpans).toHaveLength(3); + // Should have spans for middleware and handler (h3 v2 no longer has onBeforeResponse) + expect(hooksSpans).toHaveLength(2); // Find specific hook spans - const onRequestSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest'); + const middlewareSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'middleware'); const handlerSpan = hooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); - const onBeforeResponseSpan = hooksSpans.find( - span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', - ); - expect(onRequestSpan).toBeDefined(); + expect(middlewareSpan).toBeDefined(); expect(handlerSpan).toBeDefined(); - expect(onBeforeResponseSpan).toBeDefined(); // Verify span names include hook types - expect(onRequestSpan?.description).toBe('04.hooks.onRequest'); + expect(middlewareSpan?.description).toBe('04.hooks.middleware'); expect(handlerSpan?.description).toBe('04.hooks'); - expect(onBeforeResponseSpan?.description).toBe('04.hooks.onBeforeResponse'); // Verify all spans have correct middleware name (without hook suffix) - [onRequestSpan, handlerSpan, onBeforeResponseSpan].forEach(span => { + [middlewareSpan, handlerSpan].forEach(span => { expect(span?.data?.['nuxt.middleware.name']).toBe('04.hooks'); }); // Verify hook-specific attributes - expect(onRequestSpan?.data?.['nuxt.middleware.hook.name']).toBe('onRequest'); + expect(middlewareSpan?.data?.['nuxt.middleware.hook.name']).toBe('middleware'); expect(handlerSpan?.data?.['nuxt.middleware.hook.name']).toBe('handler'); - expect(onBeforeResponseSpan?.data?.['nuxt.middleware.hook.name']).toBe('onBeforeResponse'); - // Verify no index attributes for single hooks - expect(onRequestSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); + // Verify middleware has index (middleware is always an array in h3 v2) + expect(middlewareSpan?.data?.['nuxt.middleware.hook.index']).toBe(0); expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); - expect(onBeforeResponseSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); }); - test('should create spans with index attributes for array hooks', async ({ request }) => { + test('should create spans with index attributes for array middleware', async ({ request }) => { const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; }); @@ -193,48 +186,35 @@ test.describe.skip('Server Middleware Instrumentation', () => { // Find spans for the array hooks middleware const arrayHooksSpans = middlewareSpans.filter(span => span.data?.['nuxt.middleware.name'] === '05.array-hooks'); - // Should have spans for 2 onRequest + 1 handler + 2 onBeforeResponse = 5 spans - expect(arrayHooksSpans).toHaveLength(5); - - // Find onRequest array spans - const onRequestSpans = arrayHooksSpans.filter(span => span.data?.['nuxt.middleware.hook.name'] === 'onRequest'); - expect(onRequestSpans).toHaveLength(2); + // Should have spans for 2 middleware + 1 handler = 3 spans (h3 v2 no longer has onBeforeResponse) + expect(arrayHooksSpans).toHaveLength(3); - // Find onBeforeResponse array spans - const onBeforeResponseSpans = arrayHooksSpans.filter( - span => span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', + // Find middleware array spans + const middlewareArraySpans = arrayHooksSpans.filter( + span => span.data?.['nuxt.middleware.hook.name'] === 'middleware', ); - expect(onBeforeResponseSpans).toHaveLength(2); + expect(middlewareArraySpans).toHaveLength(2); // Find handler span const handlerSpan = arrayHooksSpans.find(span => span.data?.['nuxt.middleware.hook.name'] === 'handler'); expect(handlerSpan).toBeDefined(); - // Verify index attributes for onRequest array - const onRequest0Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); - const onRequest1Span = onRequestSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); - - expect(onRequest0Span).toBeDefined(); - expect(onRequest1Span).toBeDefined(); + // Verify index attributes for middleware array + const middleware0Span = middlewareArraySpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); + const middleware1Span = middlewareArraySpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); - // Verify index attributes for onBeforeResponse array - const onBeforeResponse0Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 0); - const onBeforeResponse1Span = onBeforeResponseSpans.find(span => span.data?.['nuxt.middleware.hook.index'] === 1); + expect(middleware0Span).toBeDefined(); + expect(middleware1Span).toBeDefined(); - expect(onBeforeResponse0Span).toBeDefined(); - expect(onBeforeResponse1Span).toBeDefined(); - - // Verify span names for array handlers - expect(onRequest0Span?.description).toBe('05.array-hooks.onRequest'); - expect(onRequest1Span?.description).toBe('05.array-hooks.onRequest'); - expect(onBeforeResponse0Span?.description).toBe('05.array-hooks.onBeforeResponse'); - expect(onBeforeResponse1Span?.description).toBe('05.array-hooks.onBeforeResponse'); + // Verify span names for array middleware handlers + expect(middleware0Span?.description).toBe('05.array-hooks.middleware'); + expect(middleware1Span?.description).toBe('05.array-hooks.middleware'); // Verify handler has no index expect(handlerSpan?.data).not.toHaveProperty('nuxt.middleware.hook.index'); }); - test('should handle errors in onRequest hooks', async ({ request }) => { + test('should handle errors in middleware hooks', async ({ request }) => { const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; }); @@ -243,54 +223,26 @@ test.describe.skip('Server Middleware Instrumentation', () => { return errorEvent?.exception?.values?.[0]?.value === 'OnRequest hook error'; }); - // Make request with query param to trigger error in onRequest + // Make request with query param to trigger error in middleware const response = await request.get('/api/middleware-test?throwOnRequestError=true'); expect(response.status()).toBe(500); const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); - // Find the onRequest span that should have error status - const onRequestSpan = serverTxnEvent.spans?.find( + // Find the middleware span that should have error status + const middlewareSpan = serverTxnEvent.spans?.find( span => span.op === 'middleware.nuxt' && span.data?.['nuxt.middleware.name'] === '04.hooks' && - span.data?.['nuxt.middleware.hook.name'] === 'onRequest', + span.data?.['nuxt.middleware.hook.name'] === 'middleware', ); - expect(onRequestSpan).toBeDefined(); - expect(onRequestSpan?.status).toBe('internal_error'); + expect(middlewareSpan).toBeDefined(); + expect(middlewareSpan?.status).toBe('internal_error'); expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest hook error'); }); - test('should handle errors in onBeforeResponse hooks', async ({ request }) => { - const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { - return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; - }); - - const errorEventPromise = waitForError('nuxt-5', errorEvent => { - return errorEvent?.exception?.values?.[0]?.value === 'OnBeforeResponse hook error'; - }); - - // Make request with query param to trigger error in onBeforeResponse - const response = await request.get('/api/middleware-test?throwOnBeforeResponseError=true'); - expect(response.status()).toBe(500); - - const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); - - // Find the onBeforeResponse span that should have error status - const onBeforeResponseSpan = serverTxnEvent.spans?.find( - span => - span.op === 'middleware.nuxt' && - span.data?.['nuxt.middleware.name'] === '04.hooks' && - span.data?.['nuxt.middleware.hook.name'] === 'onBeforeResponse', - ); - - expect(onBeforeResponseSpan).toBeDefined(); - expect(onBeforeResponseSpan?.status).toBe('internal_error'); - expect(errorEvent.exception?.values?.[0]?.value).toBe('OnBeforeResponse hook error'); - }); - - test('should handle errors in array hooks with proper index attribution', async ({ request }) => { + test('should handle errors in array middleware with proper index attribution', async ({ request }) => { const serverTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { return txnEvent.transaction?.includes('GET /api/middleware-test') ?? false; }); @@ -299,35 +251,35 @@ test.describe.skip('Server Middleware Instrumentation', () => { return errorEvent?.exception?.values?.[0]?.value === 'OnRequest[1] hook error'; }); - // Make request with query param to trigger error in second onRequest handler + // Make request with query param to trigger error in second middleware handler const response = await request.get('/api/middleware-test?throwOnRequest1Error=true'); expect(response.status()).toBe(500); const [serverTxnEvent, errorEvent] = await Promise.all([serverTxnEventPromise, errorEventPromise]); - // Find the second onRequest span that should have error status - const onRequest1Span = serverTxnEvent.spans?.find( + // Find the second middleware span that should have error status + const middleware1Span = serverTxnEvent.spans?.find( span => span.op === 'middleware.nuxt' && span.data?.['nuxt.middleware.name'] === '05.array-hooks' && - span.data?.['nuxt.middleware.hook.name'] === 'onRequest' && + span.data?.['nuxt.middleware.hook.name'] === 'middleware' && span.data?.['nuxt.middleware.hook.index'] === 1, ); - expect(onRequest1Span).toBeDefined(); - expect(onRequest1Span?.status).toBe('internal_error'); + expect(middleware1Span).toBeDefined(); + expect(middleware1Span?.status).toBe('internal_error'); expect(errorEvent.exception?.values?.[0]?.value).toBe('OnRequest[1] hook error'); - // Verify the first onRequest handler still executed successfully - const onRequest0Span = serverTxnEvent.spans?.find( + // Verify the first middleware handler still executed successfully + const middleware0Span = serverTxnEvent.spans?.find( span => span.op === 'middleware.nuxt' && span.data?.['nuxt.middleware.name'] === '05.array-hooks' && - span.data?.['nuxt.middleware.hook.name'] === 'onRequest' && + span.data?.['nuxt.middleware.hook.name'] === 'middleware' && span.data?.['nuxt.middleware.hook.index'] === 0, ); - expect(onRequest0Span).toBeDefined(); - expect(onRequest0Span?.status).not.toBe('internal_error'); + expect(middleware0Span).toBeDefined(); + expect(middleware0Span?.status).not.toBe('internal_error'); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts index e136d5635a29..9c456d50d85a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/tracing.test.ts @@ -68,8 +68,7 @@ test.describe('distributed tracing', () => { expect(serverTxnEvent.contexts?.trace?.trace_id).toBe(metaTraceId); }); - // TODO: Make test work with Nuxt 5 - test.skip('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { + test('capture a distributed trace from a client-side API request with parametrized routes', async ({ page }) => { const clientTxnEventPromise = waitForTransaction('nuxt-5', txnEvent => { return txnEvent.transaction === '/test-param/user/:userId()'; }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/tests/performance.client.test.ts index 63f97d519cf8..0cdc2465c065 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-dynamic-import/tests/performance.client.test.ts @@ -18,14 +18,14 @@ test('sends a pageload transaction', async ({ page }) => { }, transaction: '/', transaction_info: { - source: 'url', + source: 'route', }, }); }); -test('sends a navigation transaction', async ({ page }) => { +test('sends a navigation transaction with parametrized route', async ({ page }) => { const transactionPromise = waitForTransaction('solidstart-dynamic-import', async transactionEvent => { - return transactionEvent?.transaction === '/users/5' && transactionEvent.contexts?.trace?.op === 'navigation'; + return transactionEvent?.transaction === '/users/:id' && transactionEvent.contexts?.trace?.op === 'navigation'; }); await page.goto(`/`); @@ -39,9 +39,9 @@ test('sends a navigation transaction', async ({ page }) => { origin: 'auto.navigation.solidstart.solidrouter', }, }, - transaction: '/users/5', + transaction: '/users/:id', transaction_info: { - source: 'url', + source: 'route', }, }); }); @@ -51,7 +51,7 @@ test('updates the transaction when using the back button', async ({ page }) => { // The sentry solidRouterBrowserTracingIntegration tries to update such // transactions with the proper name once the `useLocation` hook triggers. const navigationTxnPromise = waitForTransaction('solidstart-dynamic-import', async transactionEvent => { - return transactionEvent?.transaction === '/users/6' && transactionEvent.contexts?.trace?.op === 'navigation'; + return transactionEvent?.transaction === '/users/:id' && transactionEvent.contexts?.trace?.op === 'navigation'; }); await page.goto(`/back-navigation`); @@ -65,9 +65,9 @@ test('updates the transaction when using the back button', async ({ page }) => { origin: 'auto.navigation.solidstart.solidrouter', }, }, - transaction: '/users/6', + transaction: '/users/:id', transaction_info: { - source: 'url', + source: 'route', }, }); @@ -89,7 +89,7 @@ test('updates the transaction when using the back button', async ({ page }) => { }, transaction: '/back-navigation', transaction_info: { - source: 'url', + source: 'route', }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-spa/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-spa/tests/performance.client.test.ts index c689bca22539..f54318bf171c 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-spa/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-spa/tests/performance.client.test.ts @@ -18,14 +18,14 @@ test('sends a pageload transaction', async ({ page }) => { }, transaction: '/', transaction_info: { - source: 'url', + source: 'route', }, }); }); -test('sends a navigation transaction', async ({ page }) => { +test('sends a navigation transaction with parametrized route', async ({ page }) => { const transactionPromise = waitForTransaction('solidstart-spa', async transactionEvent => { - return transactionEvent?.transaction === '/users/5' && transactionEvent.contexts?.trace?.op === 'navigation'; + return transactionEvent?.transaction === '/users/:id' && transactionEvent.contexts?.trace?.op === 'navigation'; }); await page.goto(`/`); @@ -39,9 +39,9 @@ test('sends a navigation transaction', async ({ page }) => { origin: 'auto.navigation.solidstart.solidrouter', }, }, - transaction: '/users/5', + transaction: '/users/:id', transaction_info: { - source: 'url', + source: 'route', }, }); }); @@ -51,7 +51,7 @@ test('updates the transaction when using the back button', async ({ page }) => { // The sentry solidRouterBrowserTracingIntegration tries to update such // transactions with the proper name once the `useLocation` hook triggers. const navigationTxnPromise = waitForTransaction('solidstart-spa', async transactionEvent => { - return transactionEvent?.transaction === '/users/6' && transactionEvent.contexts?.trace?.op === 'navigation'; + return transactionEvent?.transaction === '/users/:id' && transactionEvent.contexts?.trace?.op === 'navigation'; }); await page.goto(`/back-navigation`); @@ -65,9 +65,9 @@ test('updates the transaction when using the back button', async ({ page }) => { origin: 'auto.navigation.solidstart.solidrouter', }, }, - transaction: '/users/6', + transaction: '/users/:id', transaction_info: { - source: 'url', + source: 'route', }, }); @@ -89,7 +89,7 @@ test('updates the transaction when using the back button', async ({ page }) => { }, transaction: '/back-navigation', transaction_info: { - source: 'url', + source: 'route', }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.client.test.ts index bd5dece39b33..55eeb5a5c757 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart-top-level-import/tests/performance.client.test.ts @@ -18,14 +18,14 @@ test('sends a pageload transaction', async ({ page }) => { }, transaction: '/', transaction_info: { - source: 'url', + source: 'route', }, }); }); -test('sends a navigation transaction', async ({ page }) => { +test('sends a navigation transaction with parametrized route', async ({ page }) => { const transactionPromise = waitForTransaction('solidstart-top-level-import', async transactionEvent => { - return transactionEvent?.transaction === '/users/5' && transactionEvent.contexts?.trace?.op === 'navigation'; + return transactionEvent?.transaction === '/users/:id' && transactionEvent.contexts?.trace?.op === 'navigation'; }); await page.goto(`/`); @@ -39,9 +39,9 @@ test('sends a navigation transaction', async ({ page }) => { origin: 'auto.navigation.solidstart.solidrouter', }, }, - transaction: '/users/5', + transaction: '/users/:id', transaction_info: { - source: 'url', + source: 'route', }, }); }); @@ -51,7 +51,7 @@ test('updates the transaction when using the back button', async ({ page }) => { // The sentry solidRouterBrowserTracingIntegration tries to update such // transactions with the proper name once the `useLocation` hook triggers. const navigationTxnPromise = waitForTransaction('solidstart-top-level-import', async transactionEvent => { - return transactionEvent?.transaction === '/users/6' && transactionEvent.contexts?.trace?.op === 'navigation'; + return transactionEvent?.transaction === '/users/:id' && transactionEvent.contexts?.trace?.op === 'navigation'; }); await page.goto(`/back-navigation`); @@ -65,9 +65,9 @@ test('updates the transaction when using the back button', async ({ page }) => { origin: 'auto.navigation.solidstart.solidrouter', }, }, - transaction: '/users/6', + transaction: '/users/:id', transaction_info: { - source: 'url', + source: 'route', }, }); @@ -89,7 +89,7 @@ test('updates the transaction when using the back button', async ({ page }) => { }, transaction: '/back-navigation', transaction_info: { - source: 'url', + source: 'route', }, }); }); diff --git a/dev-packages/e2e-tests/test-applications/solidstart/tests/performance.client.test.ts b/dev-packages/e2e-tests/test-applications/solidstart/tests/performance.client.test.ts index 52d9cb219401..068fdc9b0cc2 100644 --- a/dev-packages/e2e-tests/test-applications/solidstart/tests/performance.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/solidstart/tests/performance.client.test.ts @@ -18,14 +18,14 @@ test('sends a pageload transaction', async ({ page }) => { }, transaction: '/', transaction_info: { - source: 'url', + source: 'route', }, }); }); -test('sends a navigation transaction', async ({ page }) => { +test('sends a navigation transaction with parametrized route', async ({ page }) => { const transactionPromise = waitForTransaction('solidstart', async transactionEvent => { - return transactionEvent?.transaction === '/users/5' && transactionEvent.contexts?.trace?.op === 'navigation'; + return transactionEvent?.transaction === '/users/:id' && transactionEvent.contexts?.trace?.op === 'navigation'; }); await page.goto(`/`); @@ -39,9 +39,9 @@ test('sends a navigation transaction', async ({ page }) => { origin: 'auto.navigation.solidstart.solidrouter', }, }, - transaction: '/users/5', + transaction: '/users/:id', transaction_info: { - source: 'url', + source: 'route', }, }); }); @@ -51,7 +51,7 @@ test('updates the transaction when using the back button', async ({ page }) => { // The sentry solidRouterBrowserTracingIntegration tries to update such // transactions with the proper name once the `useLocation` hook triggers. const navigationTxnPromise = waitForTransaction('solidstart', async transactionEvent => { - return transactionEvent?.transaction === '/users/6' && transactionEvent.contexts?.trace?.op === 'navigation'; + return transactionEvent?.transaction === '/users/:id' && transactionEvent.contexts?.trace?.op === 'navigation'; }); await page.goto(`/back-navigation`); @@ -65,9 +65,9 @@ test('updates the transaction when using the back button', async ({ page }) => { origin: 'auto.navigation.solidstart.solidrouter', }, }, - transaction: '/users/6', + transaction: '/users/:id', transaction_info: { - source: 'url', + source: 'route', }, }); @@ -89,7 +89,7 @@ test('updates the transaction when using the back button', async ({ page }) => { }, transaction: '/back-navigation', transaction_info: { - source: 'url', + source: 'route', }, }); }); diff --git a/dev-packages/node-core-integration-tests/package.json b/dev-packages/node-core-integration-tests/package.json index 711de7f20519..6ef14031034f 100644 --- a/dev-packages/node-core-integration-tests/package.json +++ b/dev-packages/node-core-integration-tests/package.json @@ -26,13 +26,13 @@ "@nestjs/common": "^11", "@nestjs/core": "^11", "@nestjs/platform-express": "^11", - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.6.0", - "@opentelemetry/core": "^2.6.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/instrumentation-http": "0.213.0", - "@opentelemetry/resources": "^2.6.0", - "@opentelemetry/sdk-trace-base": "^2.6.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.6.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/instrumentation-http": "0.214.0", + "@opentelemetry/resources": "^2.6.1", + "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@sentry/core": "10.46.0", "@sentry/node-core": "10.46.0", diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index 1b46351cec78..1d7abdc225e3 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "0.63.0", - "@apollo/server": "^5.4.0", + "@apollo/server": "^5.5.0", "@aws-sdk/client-s3": "^3.993.0", "@google/genai": "^1.20.0", "@growthbook/growthbook": "^1.6.1", @@ -46,7 +46,7 @@ "@types/mysql": "^2.15.21", "@types/pg": "^8.6.5", "ai": "^4.3.16", - "amqplib": "^0.10.7", + "amqplib": "^0.10.9", "body-parser": "^2.2.2", "connect": "^3.7.0", "consola": "^3.2.3", diff --git a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-all.ts b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-all.ts new file mode 100644 index 000000000000..f82385c4c16e --- /dev/null +++ b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-all.ts @@ -0,0 +1,27 @@ +import * as Sentry from '@sentry/node'; +import { bunRuntimeMetricsIntegration } from '@sentry/bun'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + transport: loggingTransport, + integrations: [ + bunRuntimeMetricsIntegration({ + collectionIntervalMs: 100, + collect: { + cpuTime: true, + memExternal: true, + }, + }), + ], +}); + +async function run(): Promise { + await new Promise(resolve => setTimeout(resolve, 250)); + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts new file mode 100644 index 000000000000..d3aa0f309893 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario-opt-out.ts @@ -0,0 +1,29 @@ +import * as Sentry from '@sentry/node'; +import { bunRuntimeMetricsIntegration } from '@sentry/bun'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + transport: loggingTransport, + integrations: [ + bunRuntimeMetricsIntegration({ + collectionIntervalMs: 100, + collect: { + cpuUtilization: false, + cpuTime: false, + eventLoopUtilization: false, + uptime: false, + }, + }), + ], +}); + +async function run(): Promise { + await new Promise(resolve => setTimeout(resolve, 250)); + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario.ts b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario.ts new file mode 100644 index 000000000000..1948ddfa6c23 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/scenario.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/node'; +import { bunRuntimeMetricsIntegration } from '@sentry/bun'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + transport: loggingTransport, + integrations: [ + bunRuntimeMetricsIntegration({ + collectionIntervalMs: 100, + }), + ], +}); + +async function run(): Promise { + await new Promise(resolve => setTimeout(resolve, 250)); + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/bun-runtime-metrics/test.ts b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/test.ts new file mode 100644 index 000000000000..78638b8b02cb --- /dev/null +++ b/dev-packages/node-integration-tests/suites/bun-runtime-metrics/test.ts @@ -0,0 +1,116 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +const SENTRY_ATTRIBUTES = { + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.origin': { value: 'auto.bun.runtime_metrics', type: 'string' }, +}; + +const gauge = (name: string, unit?: string) => ({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name, + type: 'gauge', + ...(unit ? { unit } : {}), + value: expect.any(Number), + attributes: expect.objectContaining(SENTRY_ATTRIBUTES), +}); + +const counter = (name: string, unit?: string) => ({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name, + type: 'counter', + ...(unit ? { unit } : {}), + value: expect.any(Number), + attributes: expect.objectContaining(SENTRY_ATTRIBUTES), +}); + +describe('bunRuntimeMetricsIntegration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('emits default runtime metrics with correct shape', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + trace_metric: { + items: expect.arrayContaining([ + gauge('bun.runtime.mem.rss', 'byte'), + gauge('bun.runtime.mem.heap_used', 'byte'), + gauge('bun.runtime.mem.heap_total', 'byte'), + gauge('bun.runtime.cpu.utilization'), + gauge('bun.runtime.event_loop.utilization'), + counter('bun.runtime.process.uptime', 'second'), + ]), + }, + }) + .start(); + + await runner.completed(); + }); + + test('does not emit opt-in metrics by default', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + trace_metric: (container: { items: Array<{ name: string }> }) => { + const names = container.items.map(item => item.name); + expect(names).not.toContain('bun.runtime.cpu.user'); + expect(names).not.toContain('bun.runtime.cpu.system'); + expect(names).not.toContain('bun.runtime.mem.external'); + expect(names).not.toContain('bun.runtime.mem.array_buffers'); + }, + }) + .start(); + + await runner.completed(); + }); + + test('emits all metrics when fully opted in', async () => { + const runner = createRunner(__dirname, 'scenario-all.ts') + .expect({ + trace_metric: { + items: expect.arrayContaining([ + gauge('bun.runtime.mem.rss', 'byte'), + gauge('bun.runtime.mem.heap_used', 'byte'), + gauge('bun.runtime.mem.heap_total', 'byte'), + gauge('bun.runtime.mem.external', 'byte'), + gauge('bun.runtime.mem.array_buffers', 'byte'), + gauge('bun.runtime.cpu.user', 'second'), + gauge('bun.runtime.cpu.system', 'second'), + gauge('bun.runtime.cpu.utilization'), + gauge('bun.runtime.event_loop.utilization'), + counter('bun.runtime.process.uptime', 'second'), + ]), + }, + }) + .start(); + + await runner.completed(); + }); + + test('respects opt-out: only memory metrics remain when cpu/event loop/uptime are disabled', async () => { + const runner = createRunner(__dirname, 'scenario-opt-out.ts') + .expect({ + trace_metric: (container: { items: Array<{ name: string }> }) => { + const names = container.items.map(item => item.name); + + // Memory metrics should still be present + expect(names).toContain('bun.runtime.mem.rss'); + expect(names).toContain('bun.runtime.mem.heap_used'); + expect(names).toContain('bun.runtime.mem.heap_total'); + + // Everything else should be absent + expect(names).not.toContain('bun.runtime.cpu.utilization'); + expect(names).not.toContain('bun.runtime.event_loop.utilization'); + expect(names).not.toContain('bun.runtime.process.uptime'); + }, + }) + .start(); + + await runner.completed(); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-other-vendors-with-sentry-entries/test.ts b/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-other-vendors-with-sentry-entries/test.ts index dbdd69ffb45b..eebafa06bfd1 100644 --- a/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-other-vendors-with-sentry-entries/test.ts +++ b/dev-packages/node-integration-tests/suites/express/sentry-trace/baggage-other-vendors-with-sentry-entries/test.ts @@ -1,6 +1,7 @@ import { afterAll, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; import type { TestAPIResponse } from '../server'; +import { extractTraceparentData } from '@sentry/core'; afterAll(() => { cleanupChildProcesses(); @@ -33,7 +34,6 @@ test('should ignore sentry-values in `baggage` header of a third party vendor an 'sentry-environment=myEnv', 'sentry-release=2.1.0', expect.stringMatching(/sentry-sample_rand=\d+/), - 'sentry-sample_rate=0.54', 'third=party', ]); }); @@ -46,6 +46,11 @@ test('should ignore sentry-values in `baggage` header of a third party vendor an expect(response).toBeDefined(); const baggage = response?.test_data.baggage?.split(',').sort(); + const sentryTraceHeader = response?.test_data['sentry-trace']; + + const sentryTrace = extractTraceparentData(sentryTraceHeader); + + expect(sentryTrace?.traceId).toMatch(/^[0-9a-f]{32}$/); expect(response).toMatchObject({ test_data: { @@ -63,7 +68,7 @@ test('should ignore sentry-values in `baggage` header of a third party vendor an expect.stringMatching(/sentry-sample_rand=\d+/), 'sentry-sample_rate=1', 'sentry-sampled=true', - expect.stringMatching(/sentry-trace_id=[\da-f]{32}/), + `sentry-trace_id=${sentryTrace?.traceId}`, 'sentry-transaction=GET%20%2Ftest%2Fexpress', 'third=party', ]); diff --git a/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-all.ts b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-all.ts new file mode 100644 index 000000000000..e995482fafbf --- /dev/null +++ b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-all.ts @@ -0,0 +1,30 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + transport: loggingTransport, + integrations: [ + Sentry.nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 100, + collect: { + cpuTime: true, + memExternal: true, + eventLoopDelayMin: true, + eventLoopDelayMax: true, + eventLoopDelayMean: true, + eventLoopDelayP90: true, + }, + }), + ], +}); + +async function run(): Promise { + await new Promise(resolve => setTimeout(resolve, 250)); + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-opt-out.ts b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-opt-out.ts new file mode 100644 index 000000000000..423e478ed1f8 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario-opt-out.ts @@ -0,0 +1,30 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + transport: loggingTransport, + integrations: [ + Sentry.nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 100, + collect: { + cpuUtilization: false, + cpuTime: false, + eventLoopDelayP50: false, + eventLoopDelayP99: false, + eventLoopUtilization: false, + uptime: false, + }, + }), + ], +}); + +async function run(): Promise { + await new Promise(resolve => setTimeout(resolve, 250)); + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario.ts b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario.ts new file mode 100644 index 000000000000..b862634c719a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/node-runtime-metrics/scenario.ts @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0.0', + environment: 'test', + transport: loggingTransport, + integrations: [ + Sentry.nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 100, + }), + ], +}); + +async function run(): Promise { + // Wait long enough for the collection interval to fire at least once. + await new Promise(resolve => setTimeout(resolve, 250)); + await Sentry.flush(); +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +run(); diff --git a/dev-packages/node-integration-tests/suites/node-runtime-metrics/test.ts b/dev-packages/node-integration-tests/suites/node-runtime-metrics/test.ts new file mode 100644 index 000000000000..42aa075e878c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/node-runtime-metrics/test.ts @@ -0,0 +1,130 @@ +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +const SENTRY_ATTRIBUTES = { + 'sentry.release': { value: '1.0.0', type: 'string' }, + 'sentry.environment': { value: 'test', type: 'string' }, + 'sentry.sdk.name': { value: 'sentry.javascript.node', type: 'string' }, + 'sentry.sdk.version': { value: expect.any(String), type: 'string' }, + 'sentry.origin': { value: 'auto.node.runtime_metrics', type: 'string' }, +}; + +const gauge = (name: string, unit?: string) => ({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name, + type: 'gauge', + ...(unit ? { unit } : {}), + value: expect.any(Number), + attributes: expect.objectContaining(SENTRY_ATTRIBUTES), +}); + +const counter = (name: string, unit?: string) => ({ + timestamp: expect.any(Number), + trace_id: expect.any(String), + name, + type: 'counter', + ...(unit ? { unit } : {}), + value: expect.any(Number), + attributes: expect.objectContaining(SENTRY_ATTRIBUTES), +}); + +describe('nodeRuntimeMetricsIntegration', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + test('emits default runtime metrics with correct shape', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + trace_metric: { + items: expect.arrayContaining([ + gauge('node.runtime.mem.rss', 'byte'), + gauge('node.runtime.mem.heap_used', 'byte'), + gauge('node.runtime.mem.heap_total', 'byte'), + gauge('node.runtime.cpu.utilization'), + gauge('node.runtime.event_loop.delay.p50', 'second'), + gauge('node.runtime.event_loop.delay.p99', 'second'), + gauge('node.runtime.event_loop.utilization'), + counter('node.runtime.process.uptime', 'second'), + ]), + }, + }) + .start(); + + await runner.completed(); + }); + + test('does not emit opt-in metrics by default', async () => { + const runner = createRunner(__dirname, 'scenario.ts') + .expect({ + trace_metric: (container: { items: Array<{ name: string }> }) => { + const names = container.items.map(item => item.name); + expect(names).not.toContain('node.runtime.cpu.user'); + expect(names).not.toContain('node.runtime.cpu.system'); + expect(names).not.toContain('node.runtime.mem.external'); + expect(names).not.toContain('node.runtime.mem.array_buffers'); + expect(names).not.toContain('node.runtime.event_loop.delay.min'); + expect(names).not.toContain('node.runtime.event_loop.delay.max'); + expect(names).not.toContain('node.runtime.event_loop.delay.mean'); + expect(names).not.toContain('node.runtime.event_loop.delay.p90'); + }, + }) + .start(); + + await runner.completed(); + }); + + test('emits all metrics when fully opted in', async () => { + const runner = createRunner(__dirname, 'scenario-all.ts') + .expect({ + trace_metric: { + items: expect.arrayContaining([ + gauge('node.runtime.mem.rss', 'byte'), + gauge('node.runtime.mem.heap_used', 'byte'), + gauge('node.runtime.mem.heap_total', 'byte'), + gauge('node.runtime.mem.external', 'byte'), + gauge('node.runtime.mem.array_buffers', 'byte'), + gauge('node.runtime.cpu.user', 'second'), + gauge('node.runtime.cpu.system', 'second'), + gauge('node.runtime.cpu.utilization'), + gauge('node.runtime.event_loop.delay.min', 'second'), + gauge('node.runtime.event_loop.delay.max', 'second'), + gauge('node.runtime.event_loop.delay.mean', 'second'), + gauge('node.runtime.event_loop.delay.p50', 'second'), + gauge('node.runtime.event_loop.delay.p90', 'second'), + gauge('node.runtime.event_loop.delay.p99', 'second'), + gauge('node.runtime.event_loop.utilization'), + counter('node.runtime.process.uptime', 'second'), + ]), + }, + }) + .start(); + + await runner.completed(); + }); + + test('respects opt-out: only memory metrics remain when cpu/event loop/uptime are disabled', async () => { + const runner = createRunner(__dirname, 'scenario-opt-out.ts') + .expect({ + trace_metric: (container: { items: Array<{ name: string }> }) => { + const names = container.items.map(item => item.name); + + // Memory metrics should still be present + expect(names).toContain('node.runtime.mem.rss'); + expect(names).toContain('node.runtime.mem.heap_used'); + expect(names).toContain('node.runtime.mem.heap_total'); + + // Everything else should be absent + expect(names).not.toContain('node.runtime.cpu.utilization'); + expect(names).not.toContain('node.runtime.event_loop.delay.p50'); + expect(names).not.toContain('node.runtime.event_loop.delay.p99'); + expect(names).not.toContain('node.runtime.event_loop.utilization'); + expect(names).not.toContain('node.runtime.process.uptime'); + }, + }) + .start(); + + await runner.completed(); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/expects.ts b/dev-packages/node-integration-tests/suites/tracing/double-baggage/expects.ts new file mode 100644 index 000000000000..e092f29c1e65 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/expects.ts @@ -0,0 +1,37 @@ +import { expect } from 'vitest'; +import { extractTraceparentData, parseBaggageHeader, TRACEPARENT_REGEXP } from '@sentry/core'; + +export function expectNoDuplicateSentryBaggageKeys(baggage: string | string[] | undefined): void { + expect(baggage).toBeDefined(); + const baggageStr = Array.isArray(baggage) ? baggage.join(',') : (baggage as string); + const sentryEntries = baggageStr.split(',').filter(entry => entry.trim().startsWith('sentry-')); + const sentryKeyNames = sentryEntries.map(entry => entry.trim().split('=')[0]); + const uniqueKeyNames = [...new Set(sentryKeyNames)]; + expect(sentryKeyNames).toEqual(uniqueKeyNames); +} + +export function expectConsistentTraceId(headers: Record): void { + const sentryTrace = headers['sentry-trace']; + expect(sentryTrace).toMatch(TRACEPARENT_REGEXP); + + const sentryTraceData = extractTraceparentData(sentryTrace as string)!; + expect(sentryTraceData.traceId).toMatch(/^[a-f\d]{32}$/); + + const baggage = parseBaggageHeader(headers['baggage']); + + const baggageTraceId = baggage!['sentry-trace_id']; + expect(baggageTraceId).toBeDefined(); + expect(baggageTraceId).toMatch(/^[a-f\d]{32}$/); + + expect(sentryTraceData.traceId).toEqual(baggageTraceId); +} + +export function expectUserSetTraceId(headers: Record): void { + const xSentryTrace = extractTraceparentData(headers['x-tracedata-sentry-trace'] as string); + const sentryTrace = extractTraceparentData(headers['sentry-trace'] as string); + expect(xSentryTrace?.traceId).toBe(sentryTrace?.traceId); + + const xBaggage = parseBaggageHeader(headers['x-tracedata-baggage']); + const baggage = parseBaggageHeader(headers['baggage']); + expect(xBaggage).toEqual(baggage); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/instrument.mjs new file mode 100644 index 000000000000..7acd36926f22 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + // explicitly not setting tracesSampleRate, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/scenario.mjs new file mode 100644 index 000000000000..046e980c5fe2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/scenario.mjs @@ -0,0 +1,45 @@ +import * as Sentry from '@sentry/node'; +import http from 'http'; + +async function run() { + const traceData = Sentry.getTraceData(); + // fetch with manual getTraceData() headers - the core reproduction case from #19158 + await fetch(`${process.env.SERVER_URL}/api/fetch-custom-headers`, { + headers: { + ...traceData, + 'x-tracedata-sentry-trace': traceData['sentry-trace'], + 'x-tracedata-baggage': traceData.baggage, + }, + }).then(res => res.text()); + + // fetch without manual headers (baseline - auto-instrumentation only) + await fetch(`${process.env.SERVER_URL}/api/fetch`).then(res => res.text()); + + // http.request with manual getTraceData() headers + await new Promise((resolve, reject) => { + const url = new URL(`${process.env.SERVER_URL}/api/http-custom-headers`); + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'GET', + headers: { + ...traceData, + 'x-tracedata-sentry-trace': traceData['sentry-trace'], + 'x-tracedata-baggage': traceData.baggage, + }, + }, + res => { + res.on('data', () => {}); + res.on('end', () => resolve()); + }, + ); + req.on('error', reject); + req.end(); + }); + + Sentry.captureException(new Error('done')); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/test.ts b/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/test.ts new file mode 100644 index 000000000000..c517bca0d25b --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/no-spans/test.ts @@ -0,0 +1,47 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { expectConsistentTraceId, expectNoDuplicateSentryBaggageKeys, expectUserSetTraceId } from '../expects'; + +describe('double baggage prevention', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('fetch with manual getTraceData() does not duplicate sentry baggage entries', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/fetch-custom-headers', headers => { + // fetch with manual getTraceData() headers + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^[a-f\d]{32}-[a-f\d]{16}$/)); + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expectConsistentTraceId(headers); + expectUserSetTraceId(headers); + }) + .get('/api/fetch', headers => { + // fetch without manual headers (baseline) + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^[a-f\d]{32}-[a-f\d]{16}$/)); + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expectConsistentTraceId(headers); + }) + .get('/api/http-custom-headers', headers => { + // http.request with manual getTraceData() headers + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^[a-f\d]{32}-[a-f\d]{16}$/)); + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expectConsistentTraceId(headers); + expectUserSetTraceId(headers); + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .ignore('transaction') + .expect({ + event: { + exception: { + values: [{ type: 'Error', value: 'done' }], + }, + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/scenario.mjs new file mode 100644 index 000000000000..dd5841685463 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/scenario.mjs @@ -0,0 +1,45 @@ +import * as Sentry from '@sentry/node'; +import http from 'http'; + +async function run() { + const traceData = Sentry.getTraceData(); + // fetch with manual getTraceData() headers - the core reproduction case from #19158 + await fetch(`${process.env.SERVER_URL}/api/fetch-custom-headers`, { + headers: { + ...traceData, + 'x-tracedata-sentry-trace': traceData['sentry-trace'], + 'x-tracedata-baggage': traceData.baggage, + }, + }).then(res => res.text()); + + // fetch without manual headers (baseline - auto-instrumentation only) + await fetch(`${process.env.SERVER_URL}/api/fetch`, {}).then(res => res.text()); + + // http.request with manual getTraceData() headers + await new Promise((resolve, reject) => { + const url = new URL(`${process.env.SERVER_URL}/api/http-custom-headers`); + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'GET', + headers: { + ...traceData, + 'x-tracedata-sentry-trace': traceData['sentry-trace'], + 'x-tracedata-baggage': traceData.baggage, + }, + }, + res => { + res.on('data', () => {}); + res.on('end', () => resolve()); + }, + ); + req.on('error', reject); + req.end(); + }); + + Sentry.captureException(new Error('done')); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/test.ts b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/test.ts new file mode 100644 index 000000000000..6b5d20cdb5f7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-no-parent/test.ts @@ -0,0 +1,46 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { expectConsistentTraceId, expectNoDuplicateSentryBaggageKeys, expectUserSetTraceId } from '../expects'; + +describe('double baggage prevention', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('fetch with manual getTraceData() does not duplicate sentry baggage entries', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/fetch-custom-headers', headers => { + // fetch with manual getTraceData() headers + expect(headers['sentry-trace']).not.toContain(','); + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expectConsistentTraceId(headers); + expectUserSetTraceId(headers); + }) + .get('/api/fetch', headers => { + // fetch without manual headers (baseline) + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^[a-f\d]{32}-[a-f\d]{16}(-[01])?$/)); + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expectConsistentTraceId(headers); + }) + .get('/api/http-custom-headers', headers => { + // http.request with manual getTraceData() headers + expect(headers['sentry-trace']).not.toContain(','); + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expectConsistentTraceId(headers); + expectUserSetTraceId(headers); + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .expect({ + event: { + exception: { + values: [{ type: 'Error', value: 'done' }], + }, + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/instrument.mjs new file mode 100644 index 000000000000..46a27dd03b74 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/instrument.mjs @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/scenario.mjs new file mode 100644 index 000000000000..a577bad62333 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/scenario.mjs @@ -0,0 +1,50 @@ +import * as Sentry from '@sentry/node'; +import http from 'http'; + +async function run() { + const traceData = Sentry.getTraceData(); + // fetch with manual getTraceData() headers - the core reproduction case from #19158 + await fetch(`${process.env.SERVER_URL}/api/fetch-custom-headers`, { + headers: { + ...traceData, + 'x-tracedata-sentry-trace': traceData['sentry-trace'], + 'x-tracedata-baggage': traceData.baggage, + }, + }).then(res => res.text()); + + // fetch without manual headers (baseline - auto-instrumentation only) + await fetch(`${process.env.SERVER_URL}/api/fetch`, { + headers: { + 'x-tracedata-sentry-trace': traceData['sentry-trace'], + 'x-tracedata-baggage': traceData.baggage, + }, + }).then(res => res.text()); + + // http.request with manual getTraceData() headers + await new Promise((resolve, reject) => { + const url = new URL(`${process.env.SERVER_URL}/api/http-custom-headers`); + const req = http.request( + { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'GET', + headers: { + ...traceData, + 'x-tracedata-sentry-trace': traceData['sentry-trace'], + 'x-tracedata-baggage': traceData.baggage, + }, + }, + res => { + res.on('data', () => {}); + res.on('end', () => resolve()); + }, + ); + req.on('error', reject); + req.end(); + }); + + Sentry.captureException(new Error('done')); +} + +Sentry.startSpan({ name: 'parent_span' }, () => run()); diff --git a/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/test.ts b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/test.ts new file mode 100644 index 000000000000..22de5cb285b3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/double-baggage/spans-parent/test.ts @@ -0,0 +1,92 @@ +import { createTestServer } from '@sentry-internal/test-utils'; +import { describe, expect } from 'vitest'; +import { createEsmAndCjsTests } from '../../../../utils/runner'; +import { extractTraceparentData } from '@sentry/core'; +import { expectConsistentTraceId, expectNoDuplicateSentryBaggageKeys, expectUserSetTraceId } from '../expects'; + +describe('double baggage prevention - http.client spans with parent span', () => { + createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + let transactionTraceId = '000'; + let fetchSpanId = '000'; + let httpCustomHeadersSpanId = '000'; + let fetchCustomHeadersSpanId = '000'; + + test('fetch with manual getTraceData() does not duplicate sentry baggage entries', async () => { + const [SERVER_URL, closeTestServer] = await createTestServer() + .get('/api/fetch-custom-headers', headers => { + // fetch with manual getTraceData() headers — core reproduction case + const sentryTrace = extractTraceparentData(headers['sentry-trace'] as string); + transactionTraceId = sentryTrace!.traceId!; + fetchCustomHeadersSpanId = sentryTrace!.parentSpanId!; + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expect(headers['sentry-trace']).not.toContain(','); + expectConsistentTraceId(headers); + expectUserSetTraceId(headers); + }) + .get('/api/fetch', headers => { + // fetch without manual headers (baseline) + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^[a-f\d]{32}-[a-f\d]{16}(-[01])?$/)); + expectConsistentTraceId(headers); + const sentryTrace = extractTraceparentData(headers['sentry-trace'] as string); + fetchSpanId = sentryTrace!.parentSpanId!; + }) + .get('/api/http-custom-headers', headers => { + // http.request with manual getTraceData() headers + expectNoDuplicateSentryBaggageKeys(headers['baggage']); + expect(headers['sentry-trace']).not.toContain(','); + expectConsistentTraceId(headers); + expectUserSetTraceId(headers); + const sentryTrace = extractTraceparentData(headers['sentry-trace'] as string); + httpCustomHeadersSpanId = sentryTrace!.parentSpanId!; + }) + .start(); + + await createRunner() + .withEnv({ SERVER_URL }) + .ignore('event') + .expect({ + transaction: txn => { + expect(transactionTraceId).toMatch(/^[a-f0-9]{32}$/); + + expect(txn).toMatchObject({ + transaction: 'parent_span', + spans: [ + { + op: 'http.client', + description: expect.stringMatching(/^GET .*\/api\/fetch-custom-headers$/), + data: {}, + // span id is expected to be different since users call getTraceData() before the + // http.client span is created + span_id: expect.not.stringContaining(fetchCustomHeadersSpanId), + start_timestamp: expect.any(Number), + trace_id: transactionTraceId, + }, + { + op: 'http.client', + description: expect.stringMatching(/^GET .*\/api\/fetch$/), + data: {}, + span_id: fetchSpanId, + start_timestamp: expect.any(Number), + trace_id: transactionTraceId, + }, + { + op: 'http.client', + description: expect.stringMatching(/^GET .*\/api\/http-custom-headers$/), + data: {}, + // span id is expected to be different since users call getTraceData() before the + // http.client span is created + span_id: expect.not.stringContaining(httpCustomHeadersSpanId), + start_timestamp: expect.any(Number), + trace_id: transactionTraceId, + }, + ], + }); + }, + }) + .start() + .completed(); + closeTestServer(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-embeddings.mjs b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-embeddings.mjs new file mode 100644 index 000000000000..166e741cf199 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/scenario-embeddings.mjs @@ -0,0 +1,77 @@ +import { GoogleGenAI } from '@google/genai'; +import * as Sentry from '@sentry/node'; +import express from 'express'; + +function startMockGoogleGenAIServer() { + const app = express(); + app.use(express.json()); + + app.post('/v1beta/models/:model\\:batchEmbedContents', (req, res) => { + const model = req.params.model; + + if (model === 'error-model') { + res.status(404).set('x-request-id', 'mock-request-123').end('Model not found'); + return; + } + + res.send({ + embeddings: [ + { + values: [0.1, 0.2, 0.3, 0.4, 0.5], + }, + ], + }); + }); + + return new Promise(resolve => { + const server = app.listen(0, () => { + resolve(server); + }); + }); +} + +async function run() { + const server = await startMockGoogleGenAIServer(); + + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const client = new GoogleGenAI({ + apiKey: 'mock-api-key', + httpOptions: { baseUrl: `http://localhost:${server.address().port}` }, + }); + + // Test 1: Basic embedContent with string contents + await client.models.embedContent({ + model: 'text-embedding-004', + contents: 'What is the capital of France?', + }); + + // Test 2: Error handling + try { + await client.models.embedContent({ + model: 'error-model', + contents: 'This will fail', + }); + } catch { + // Expected error + } + + // Test 3: embedContent with array contents + await client.models.embedContent({ + model: 'text-embedding-004', + contents: [ + { + role: 'user', + parts: [{ text: 'First input text' }], + }, + { + role: 'user', + parts: [{ text: 'Second input text' }], + }, + ], + }); + }); + + server.close(); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts index 993984cc6b3d..91784a2de0e5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/google-genai/test.ts @@ -1,6 +1,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; import { afterAll, describe, expect } from 'vitest'; import { + GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, @@ -601,4 +602,124 @@ describe('Google GenAI integration', () => { }); }, ); + + const EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_EMBEDDINGS = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - embedContent with string contents + expect.objectContaining({ + data: { + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'google_genai', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'text-embedding-004', + }, + description: 'embeddings text-embedding-004', + op: 'gen_ai.embeddings', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Second span - embedContent error model + expect.objectContaining({ + data: { + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'google_genai', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'error-model', + }, + description: 'embeddings error-model', + op: 'gen_ai.embeddings', + origin: 'auto.ai.google_genai', + status: 'internal_error', + }), + // Third span - embedContent with array contents + expect.objectContaining({ + data: { + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'google_genai', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'text-embedding-004', + }, + description: 'embeddings text-embedding-004', + op: 'gen_ai.embeddings', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + ]), + }; + + const EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_EMBEDDINGS = { + transaction: 'main', + spans: expect.arrayContaining([ + // First span - embedContent with PII + expect.objectContaining({ + data: { + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'google_genai', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'text-embedding-004', + [GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE]: 'What is the capital of France?', + }, + description: 'embeddings text-embedding-004', + op: 'gen_ai.embeddings', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + // Second span - embedContent error model with PII + expect.objectContaining({ + data: { + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'google_genai', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'error-model', + [GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE]: 'This will fail', + }, + description: 'embeddings error-model', + op: 'gen_ai.embeddings', + origin: 'auto.ai.google_genai', + status: 'internal_error', + }), + // Third span - embedContent with array contents and PII + expect.objectContaining({ + data: { + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'gen_ai.embeddings', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', + [GEN_AI_SYSTEM_ATTRIBUTE]: 'google_genai', + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: 'text-embedding-004', + [GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE]: + '[{"role":"user","parts":[{"text":"First input text"}]},{"role":"user","parts":[{"text":"Second input text"}]}]', + }, + description: 'embeddings text-embedding-004', + op: 'gen_ai.embeddings', + origin: 'auto.ai.google_genai', + status: 'ok', + }), + ]), + }; + + createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument.mjs', (createRunner, test) => { + test('creates google genai embeddings spans with sendDefaultPii: false', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_FALSE_EMBEDDINGS }) + .start() + .completed(); + }); + }); + + createEsmAndCjsTests(__dirname, 'scenario-embeddings.mjs', 'instrument-with-pii.mjs', (createRunner, test) => { + test('creates google genai embeddings spans with sendDefaultPii: true', async () => { + await createRunner() + .ignore('event') + .expect({ transaction: EXPECTED_TRANSACTION_DEFAULT_PII_TRUE_EMBEDDINGS }) + .start() + .completed(); + }); + }); }); diff --git a/docs/publishing-a-release.md b/docs/publishing-a-release.md index 282c8a6543bd..ae151a90a0af 100644 --- a/docs/publishing-a-release.md +++ b/docs/publishing-a-release.md @@ -2,9 +2,9 @@ _These steps are only relevant to Sentry employees when preparing and publishing a new SDK release._ -These have also been documented via [Cursor Rules](../.cursor/rules/publishing-release.mdc). +These have also been documented as a [skill](../.claude/skills/release/SKILL.md). -You can run a pre-configured command in cursor by just typing `/publish_release` into the chat window to automate the steps below. +You can run the `/release` skill in Claude Code or Cursor to automate the steps below. **If you want to release a new SDK for the first time, be sure to follow the [New SDK Release Checklist](./new-sdk-release-checklist.md)** @@ -20,9 +20,8 @@ You can run a pre-configured command in cursor by just typing `/publish_release` [Auto Prepare Release](https://github.com/getsentry/sentry-javascript/actions/workflows/auto-release.yml) on master. 7. A new issue should appear in https://github.com/getsentry/publish/issues. 8. Wait until the CI check runs have finished successfully (there is a link to them in the issue). -9. Once CI passes successfully, ask a member of the - [@getsentry/releases-approvers](https://github.com/orgs/getsentry/teams/release-approvers) to approve the release. a. - Once the release is completed, a sync from `master` ->` develop` will be automatically triggered +9. Once CI passes successfully, set the `accepted` label on the issue to approve the release. + Once the release is completed, a sync from `master` -> `develop` will be automatically triggered ## Publishing a release for previous majors or prerelease (alpha, beta) versions diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index ac01ff0647a7..f19f82391a5f 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -84,6 +84,8 @@ export { lruMemoizerIntegration, makeNodeTransport, modulesIntegration, + nodeRuntimeMetricsIntegration, + type NodeRuntimeMetricsOptions, mongoIntegration, mongooseIntegration, mysql2Integration, diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 4bb7fd56f5b6..c0afaa7f7c36 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -65,9 +65,9 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/instrumentation-aws-sdk": "0.68.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/instrumentation-aws-sdk": "0.69.0", "@opentelemetry/semantic-conventions": "^1.40.0", "@sentry/core": "10.46.0", "@sentry/node": "10.46.0", diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 1c980e4cae2d..00b14ed59235 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -61,6 +61,8 @@ export { langChainIntegration, langGraphIntegration, modulesIntegration, + nodeRuntimeMetricsIntegration, + type NodeRuntimeMetricsOptions, contextLinesIntegration, nodeContextIntegration, localVariablesIntegration, diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index a4d0960b1ccb..2b2d4b7f9397 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -16,7 +16,7 @@ export { registerInpInteractionListener, } from './metrics/browserMetrics'; -export { startTrackingElementTiming } from './metrics/elementTiming'; +export { elementTimingIntegration, startTrackingElementTiming } from './metrics/elementTiming'; export { extractNetworkProtocol } from './metrics/utils'; diff --git a/packages/browser-utils/src/metrics/elementTiming.ts b/packages/browser-utils/src/metrics/elementTiming.ts index f746b16645af..16aced700844 100644 --- a/packages/browser-utils/src/metrics/elementTiming.ts +++ b/packages/browser-utils/src/metrics/elementTiming.ts @@ -1,18 +1,7 @@ -import type { SpanAttributes } from '@sentry/core'; -import { - browserPerformanceTimeOrigin, - getActiveSpan, - getCurrentScope, - getRootSpan, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - spanToJSON, - startSpan, - timestampInSeconds, -} from '@sentry/core'; +import type { IntegrationFn } from '@sentry/core'; +import { browserPerformanceTimeOrigin, defineIntegration, metrics } from '@sentry/core'; import { addPerformanceInstrumentationHandler } from './instrument'; -import { getBrowserPerformanceAPI, msToSec } from './utils'; +import { getBrowserPerformanceAPI } from './utils'; // ElementTiming interface based on the W3C spec interface PerformanceElementTiming extends PerformanceEntry { @@ -27,95 +16,96 @@ interface PerformanceElementTiming extends PerformanceEntry { url?: string; } +const INTEGRATION_NAME = 'ElementTiming'; + +const _elementTimingIntegration = (() => { + return { + name: INTEGRATION_NAME, + setup() { + const performance = getBrowserPerformanceAPI(); + if (!performance || !browserPerformanceTimeOrigin()) { + return; + } + + addPerformanceInstrumentationHandler('element', ({ entries }) => { + for (const entry of entries) { + const elementEntry = entry as PerformanceElementTiming; + + if (!elementEntry.identifier) { + continue; + } + + const identifier = elementEntry.identifier; + const paintType = elementEntry.name as 'image-paint' | 'text-paint' | undefined; + const renderTime = elementEntry.renderTime; + const loadTime = elementEntry.loadTime; + + const metricAttributes: Record = { + 'sentry.origin': 'auto.ui.browser.element_timing', + 'ui.element.identifier': identifier, + }; + + if (paintType) { + metricAttributes['ui.element.paint_type'] = paintType; + } + + if (elementEntry.id) { + metricAttributes['ui.element.id'] = elementEntry.id; + } + + if (elementEntry.element) { + metricAttributes['ui.element.type'] = elementEntry.element.tagName.toLowerCase(); + } + + if (elementEntry.url) { + metricAttributes['ui.element.url'] = elementEntry.url; + } + + if (elementEntry.naturalWidth) { + metricAttributes['ui.element.width'] = elementEntry.naturalWidth; + } + + if (elementEntry.naturalHeight) { + metricAttributes['ui.element.height'] = elementEntry.naturalHeight; + } + + if (renderTime > 0) { + metrics.distribution(`ui.element.render_time`, renderTime, { + unit: 'millisecond', + attributes: metricAttributes, + }); + } + + if (loadTime > 0) { + metrics.distribution(`ui.element.load_time`, loadTime, { + unit: 'millisecond', + attributes: metricAttributes, + }); + } + } + }); + }, + }; +}) satisfies IntegrationFn; + /** - * Start tracking ElementTiming performance entries. + * Captures [Element Timing API](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming) + * data as Sentry metrics. + * + * To mark an element for tracking, add the `elementtiming` HTML attribute: + * ```html + * + *

Welcome!

+ * ``` + * + * This emits `ui.element.render_time` and `ui.element.load_time` (for images) + * as distribution metrics, tagged with the element's identifier and paint type. */ -export function startTrackingElementTiming(): () => void { - const performance = getBrowserPerformanceAPI(); - if (performance && browserPerformanceTimeOrigin()) { - return addPerformanceInstrumentationHandler('element', _onElementTiming); - } - - return () => undefined; -} +export const elementTimingIntegration = defineIntegration(_elementTimingIntegration); /** - * exported only for testing + * @deprecated Use `elementTimingIntegration` instead. This function is a no-op and will be removed in a future version. */ -export const _onElementTiming = ({ entries }: { entries: PerformanceEntry[] }): void => { - const activeSpan = getActiveSpan(); - const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; - const transactionName = rootSpan - ? spanToJSON(rootSpan).description - : getCurrentScope().getScopeData().transactionName; - - entries.forEach(entry => { - const elementEntry = entry as PerformanceElementTiming; - - // Skip entries without identifier (elementtiming attribute) - if (!elementEntry.identifier) { - return; - } - - // `name` contains the type of the element paint. Can be `'image-paint'` or `'text-paint'`. - // https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming#instance_properties - const paintType = elementEntry.name as 'image-paint' | 'text-paint' | undefined; - - const renderTime = elementEntry.renderTime; - const loadTime = elementEntry.loadTime; - - // starting the span at: - // - `loadTime` if available (should be available for all "image-paint" entries, 0 otherwise) - // - `renderTime` if available (available for all entries, except 3rd party images, but these should be covered by `loadTime`, 0 otherwise) - // - `timestampInSeconds()` as a safeguard - // see https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming/renderTime#cross-origin_image_render_time - const [spanStartTime, spanStartTimeSource] = loadTime - ? [msToSec(loadTime), 'load-time'] - : renderTime - ? [msToSec(renderTime), 'render-time'] - : [timestampInSeconds(), 'entry-emission']; - - const duration = - paintType === 'image-paint' - ? // for image paints, we can acually get a duration because image-paint entries also have a `loadTime` - // and `renderTime`. `loadTime` is the time when the image finished loading and `renderTime` is the - // time when the image finished rendering. - msToSec(Math.max(0, (renderTime ?? 0) - (loadTime ?? 0))) - : // for `'text-paint'` entries, we can't get a duration because the `loadTime` is always zero. - 0; - - const attributes: SpanAttributes = { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.elementtiming', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.elementtiming', - // name must be user-entered, so we can assume low cardinality - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'component', - // recording the source of the span start time, as it varies depending on available data - 'sentry.span_start_time_source': spanStartTimeSource, - 'sentry.transaction_name': transactionName, - 'element.id': elementEntry.id, - 'element.type': elementEntry.element?.tagName?.toLowerCase() || 'unknown', - 'element.size': - elementEntry.naturalWidth && elementEntry.naturalHeight - ? `${elementEntry.naturalWidth}x${elementEntry.naturalHeight}` - : undefined, - 'element.render_time': renderTime, - 'element.load_time': loadTime, - // `url` is `0`(number) for text paints (hence we fall back to undefined) - 'element.url': elementEntry.url || undefined, - 'element.identifier': elementEntry.identifier, - 'element.paint_type': paintType, - }; - - startSpan( - { - name: `element[${elementEntry.identifier}]`, - attributes, - startTime: spanStartTime, - onlyIfParent: true, - }, - span => { - span.end(spanStartTime + duration); - }, - ); - }); -}; +export function startTrackingElementTiming(): () => void { + return () => undefined; +} diff --git a/packages/browser-utils/test/metrics/elementTiming.test.ts b/packages/browser-utils/test/metrics/elementTiming.test.ts index 14431415873b..c58a4faf6d45 100644 --- a/packages/browser-utils/test/metrics/elementTiming.test.ts +++ b/packages/browser-utils/test/metrics/elementTiming.test.ts @@ -1,369 +1,165 @@ import * as sentryCore from '@sentry/core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { _onElementTiming, startTrackingElementTiming } from '../../src/metrics/elementTiming'; +import { elementTimingIntegration, startTrackingElementTiming } from '../../src/metrics/elementTiming'; import * as browserMetricsInstrumentation from '../../src/metrics/instrument'; import * as browserMetricsUtils from '../../src/metrics/utils'; -describe('_onElementTiming', () => { - const spanEndSpy = vi.fn(); - const startSpanSpy = vi.spyOn(sentryCore, 'startSpan').mockImplementation((opts, cb) => { - // @ts-expect-error - only passing a partial span. This is fine for the test. - cb({ - end: spanEndSpy, - }); - }); +describe('elementTimingIntegration', () => { + const distributionSpy = vi.spyOn(sentryCore.metrics, 'distribution'); - beforeEach(() => { - startSpanSpy.mockClear(); - spanEndSpy.mockClear(); - }); + let elementHandler: (data: { entries: PerformanceEntry[] }) => void; - it('does nothing if the ET entry has no identifier', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - renderTime: 100, - } as Partial; + beforeEach(() => { + distributionSpy.mockClear(); - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); + vi.spyOn(browserMetricsUtils, 'getBrowserPerformanceAPI').mockReturnValue({ + getEntriesByType: vi.fn().mockReturnValue([]), + } as unknown as Performance); - expect(startSpanSpy).not.toHaveBeenCalled(); + vi.spyOn(browserMetricsInstrumentation, 'addPerformanceInstrumentationHandler').mockImplementation( + (type, handler) => { + if (type === 'element') { + elementHandler = handler; + } + return () => undefined; + }, + ); }); - describe('span start time', () => { - it('uses the load time as span start time if available', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - renderTime: 100, - loadTime: 50, - identifier: 'test-element', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'element[test-element]', - startTime: 0.05, - attributes: expect.objectContaining({ - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'load-time', - 'element.render_time': 100, - 'element.load_time': 50, - 'element.identifier': 'test-element', - 'element.paint_type': 'image-paint', - }), - }), - expect.any(Function), - ); + function setupIntegration(): void { + const integration = elementTimingIntegration(); + integration?.setup?.({} as sentryCore.Client); + } + + it('skips entries without an identifier', () => { + setupIntegration(); + + elementHandler({ + entries: [ + { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 100, + } as unknown as PerformanceEntry, + ], }); - it('uses the render time as span start time if load time is not available', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - renderTime: 100, - identifier: 'test-element', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); + expect(distributionSpy).not.toHaveBeenCalled(); + }); - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'element[test-element]', - startTime: 0.1, - attributes: expect.objectContaining({ - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'render-time', - 'element.render_time': 100, - 'element.load_time': undefined, - 'element.identifier': 'test-element', - 'element.paint_type': 'image-paint', - }), - }), - expect.any(Function), - ); + it('emits render_time metric for text-paint entries', () => { + setupIntegration(); + + elementHandler({ + entries: [ + { + name: 'text-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 150, + loadTime: 0, + identifier: 'hero-text', + id: 'hero', + element: { tagName: 'P' }, + naturalWidth: 0, + naturalHeight: 0, + } as unknown as PerformanceEntry, + ], }); - it('falls back to the time of handling the entry if load and render time are not available', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - identifier: 'test-element', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'element[test-element]', - startTime: expect.any(Number), - attributes: expect.objectContaining({ - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'entry-emission', - 'element.render_time': undefined, - 'element.load_time': undefined, - 'element.identifier': 'test-element', - 'element.paint_type': 'image-paint', - }), - }), - expect.any(Function), - ); + expect(distributionSpy).toHaveBeenCalledTimes(1); + expect(distributionSpy).toHaveBeenCalledWith('ui.element.render_time', 150, { + unit: 'millisecond', + attributes: { + 'sentry.origin': 'auto.ui.browser.element_timing', + 'ui.element.identifier': 'hero-text', + 'ui.element.paint_type': 'text-paint', + 'ui.element.id': 'hero', + 'ui.element.type': 'p', + }, }); }); - describe('span duration', () => { - it('uses (render-load) time as duration for image paints', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - renderTime: 1505, - loadTime: 1500, - identifier: 'test-element', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'element[test-element]', - startTime: 1.5, - attributes: expect.objectContaining({ - 'element.render_time': 1505, - 'element.load_time': 1500, - 'element.paint_type': 'image-paint', - }), - }), - expect.any(Function), - ); - - expect(spanEndSpy).toHaveBeenCalledWith(1.505); + it('emits both render_time and load_time metrics for image-paint entries', () => { + setupIntegration(); + + elementHandler({ + entries: [ + { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 200, + loadTime: 150, + identifier: 'hero-image', + id: 'img1', + element: { tagName: 'IMG' }, + url: 'https://example.com/hero.jpg', + naturalWidth: 1920, + naturalHeight: 1080, + } as unknown as PerformanceEntry, + ], }); - it('uses 0 as duration for text paints', () => { - const entry = { - name: 'text-paint', - entryType: 'element', - startTime: 0, - duration: 0, - loadTime: 0, - renderTime: 1600, - identifier: 'test-element', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'element[test-element]', - startTime: 1.6, - attributes: expect.objectContaining({ - 'element.paint_type': 'text-paint', - 'element.render_time': 1600, - 'element.load_time': 0, - }), - }), - expect.any(Function), - ); - - expect(spanEndSpy).toHaveBeenCalledWith(1.6); + expect(distributionSpy).toHaveBeenCalledTimes(2); + const expectedAttributes = { + 'sentry.origin': 'auto.ui.browser.element_timing', + 'ui.element.identifier': 'hero-image', + 'ui.element.paint_type': 'image-paint', + 'ui.element.id': 'img1', + 'ui.element.type': 'img', + 'ui.element.url': 'https://example.com/hero.jpg', + 'ui.element.width': 1920, + 'ui.element.height': 1080, + }; + expect(distributionSpy).toHaveBeenCalledWith('ui.element.render_time', 200, { + unit: 'millisecond', + attributes: expectedAttributes, }); - - // per spec, no other kinds are supported but let's make sure we're defensive - it('uses 0 as duration for other kinds of entries', () => { - const entry = { - name: 'somethingelse', - entryType: 'element', - startTime: 0, - duration: 0, - loadTime: 0, - renderTime: 1700, - identifier: 'test-element', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'element[test-element]', - startTime: 1.7, - attributes: expect.objectContaining({ - 'element.paint_type': 'somethingelse', - 'element.render_time': 1700, - 'element.load_time': 0, - }), - }), - expect.any(Function), - ); - - expect(spanEndSpy).toHaveBeenCalledWith(1.7); + expect(distributionSpy).toHaveBeenCalledWith('ui.element.load_time', 150, { + unit: 'millisecond', + attributes: expectedAttributes, }); }); - describe('span attributes', () => { - it('sets element type, identifier, paint type, load and render time', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - renderTime: 100, - identifier: 'my-image', - element: { - tagName: 'IMG', - }, - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: expect.objectContaining({ - 'element.type': 'img', - 'element.identifier': 'my-image', - 'element.paint_type': 'image-paint', - 'element.render_time': 100, - 'element.load_time': undefined, - 'element.size': undefined, - 'element.url': undefined, - }), - }), - expect.any(Function), - ); + it('handles multiple entries in a single batch', () => { + setupIntegration(); + + elementHandler({ + entries: [ + { + name: 'text-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 100, + loadTime: 0, + identifier: 'heading', + } as unknown as PerformanceEntry, + { + name: 'image-paint', + entryType: 'element', + startTime: 0, + duration: 0, + renderTime: 300, + loadTime: 250, + identifier: 'banner', + } as unknown as PerformanceEntry, + ], }); - it('sets element size if available', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - renderTime: 100, - naturalWidth: 512, - naturalHeight: 256, - identifier: 'my-image', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: expect.objectContaining({ - 'element.size': '512x256', - 'element.identifier': 'my-image', - }), - }), - expect.any(Function), - ); - }); - - it('sets element url if available', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - url: 'https://santry.com/image.png', - identifier: 'my-image', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: expect.objectContaining({ - 'element.identifier': 'my-image', - 'element.url': 'https://santry.com/image.png', - }), - }), - expect.any(Function), - ); - }); - - it('sets sentry attributes', () => { - const entry = { - name: 'image-paint', - entryType: 'element', - startTime: 0, - duration: 0, - renderTime: 100, - identifier: 'my-image', - } as Partial; - - // @ts-expect-error - only passing a partial entry. This is fine for the test. - _onElementTiming({ entries: [entry] }); - - expect(startSpanSpy).toHaveBeenCalledWith( - expect.objectContaining({ - attributes: expect.objectContaining({ - 'sentry.op': 'ui.elementtiming', - 'sentry.origin': 'auto.ui.browser.elementtiming', - 'sentry.source': 'component', - 'sentry.span_start_time_source': 'render-time', - 'sentry.transaction_name': undefined, - }), - }), - expect.any(Function), - ); - }); + // heading: 1 render_time, banner: 1 render_time + 1 load_time + expect(distributionSpy).toHaveBeenCalledTimes(3); }); }); describe('startTrackingElementTiming', () => { - const addInstrumentationHandlerSpy = vi.spyOn(browserMetricsInstrumentation, 'addPerformanceInstrumentationHandler'); - - beforeEach(() => { - addInstrumentationHandlerSpy.mockClear(); - }); - - it('returns a function that does nothing if the browser does not support the performance API', () => { - vi.spyOn(browserMetricsUtils, 'getBrowserPerformanceAPI').mockReturnValue(undefined); - expect(typeof startTrackingElementTiming()).toBe('function'); - - expect(addInstrumentationHandlerSpy).not.toHaveBeenCalled(); - }); - - it('adds an instrumentation handler for elementtiming entries, if the browser supports the performance API', () => { - vi.spyOn(browserMetricsUtils, 'getBrowserPerformanceAPI').mockReturnValue({ - getEntriesByType: vi.fn().mockReturnValue([]), - } as unknown as Performance); - - const addInstrumentationHandlerSpy = vi.spyOn( - browserMetricsInstrumentation, - 'addPerformanceInstrumentationHandler', - ); - - const stopTracking = startTrackingElementTiming(); - - expect(typeof stopTracking).toBe('function'); - - expect(addInstrumentationHandlerSpy).toHaveBeenCalledWith('element', expect.any(Function)); + it('is a deprecated no-op that returns a cleanup function', () => { + const cleanup = startTrackingElementTiming(); + expect(typeof cleanup).toBe('function'); }); }); diff --git a/packages/browser/src/eventbuilder.ts b/packages/browser/src/eventbuilder.ts index 798a068b5adf..b430007b552d 100644 --- a/packages/browser/src/eventbuilder.ts +++ b/packages/browser/src/eventbuilder.ts @@ -401,14 +401,5 @@ function getObjectClassName(obj: unknown): string | undefined | void { /** If a plain object has a property that is an `Error`, return this error. */ function getErrorPropertyFromObject(obj: Record): Error | undefined { - for (const prop in obj) { - if (Object.prototype.hasOwnProperty.call(obj, prop)) { - const value = obj[prop]; - if (value instanceof Error) { - return value; - } - } - } - - return undefined; + return Object.values(obj).find((v): v is Error => v instanceof Error); } diff --git a/packages/browser/src/index.bundle.feedback.ts b/packages/browser/src/index.bundle.feedback.ts index 7f8e663bfd0a..f8d2dfd14014 100644 --- a/packages/browser/src/index.bundle.feedback.ts +++ b/packages/browser/src/index.bundle.feedback.ts @@ -1,6 +1,7 @@ import { browserTracingIntegrationShim, consoleLoggingIntegrationShim, + elementTimingIntegrationShim, loggerShim, replayIntegrationShim, } from '@sentry-internal/integration-shims'; @@ -15,6 +16,7 @@ export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; export { browserTracingIntegrationShim as browserTracingIntegration, + elementTimingIntegrationShim as elementTimingIntegration, feedbackAsyncIntegration as feedbackAsyncIntegration, feedbackAsyncIntegration as feedbackIntegration, replayIntegrationShim as replayIntegration, diff --git a/packages/browser/src/index.bundle.logs.metrics.ts b/packages/browser/src/index.bundle.logs.metrics.ts index 461362830e7b..f03371dc40b8 100644 --- a/packages/browser/src/index.bundle.logs.metrics.ts +++ b/packages/browser/src/index.bundle.logs.metrics.ts @@ -9,6 +9,8 @@ export * from './index.bundle.base'; // TODO(v11): Export metrics here once we remove it from the base bundle. export { logger, consoleLoggingIntegration } from '@sentry/core'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; + export { browserTracingIntegrationShim as browserTracingIntegration, feedbackIntegrationShim as feedbackAsyncIntegration, diff --git a/packages/browser/src/index.bundle.replay.feedback.ts b/packages/browser/src/index.bundle.replay.feedback.ts index 60c2a0e2ac4b..da307df3a951 100644 --- a/packages/browser/src/index.bundle.replay.feedback.ts +++ b/packages/browser/src/index.bundle.replay.feedback.ts @@ -1,6 +1,7 @@ import { browserTracingIntegrationShim, consoleLoggingIntegrationShim, + elementTimingIntegrationShim, loggerShim, } from '@sentry-internal/integration-shims'; import { feedbackAsyncIntegration } from './feedbackAsync'; @@ -14,6 +15,7 @@ export { getFeedback, sendFeedback } from '@sentry-internal/feedback'; export { browserTracingIntegrationShim as browserTracingIntegration, + elementTimingIntegrationShim as elementTimingIntegration, feedbackAsyncIntegration as feedbackAsyncIntegration, feedbackAsyncIntegration as feedbackIntegration, }; diff --git a/packages/browser/src/index.bundle.replay.logs.metrics.ts b/packages/browser/src/index.bundle.replay.logs.metrics.ts index ce4f3334e21a..6ceb7623d77f 100644 --- a/packages/browser/src/index.bundle.replay.logs.metrics.ts +++ b/packages/browser/src/index.bundle.replay.logs.metrics.ts @@ -7,6 +7,8 @@ export { logger, consoleLoggingIntegration } from '@sentry/core'; export { replayIntegration, getReplay } from '@sentry-internal/replay'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; + export { browserTracingIntegrationShim as browserTracingIntegration, feedbackIntegrationShim as feedbackAsyncIntegration, diff --git a/packages/browser/src/index.bundle.replay.ts b/packages/browser/src/index.bundle.replay.ts index 9a370ae51b81..e305596f190c 100644 --- a/packages/browser/src/index.bundle.replay.ts +++ b/packages/browser/src/index.bundle.replay.ts @@ -1,6 +1,7 @@ import { browserTracingIntegrationShim, consoleLoggingIntegrationShim, + elementTimingIntegrationShim, feedbackIntegrationShim, loggerShim, } from '@sentry-internal/integration-shims'; @@ -14,6 +15,7 @@ export { replayIntegration, getReplay } from '@sentry-internal/replay'; export { browserTracingIntegrationShim as browserTracingIntegration, + elementTimingIntegrationShim as elementTimingIntegration, feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration, }; diff --git a/packages/browser/src/index.bundle.tracing.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.logs.metrics.ts index ce6a65061385..d10bfea67687 100644 --- a/packages/browser/src/index.bundle.tracing.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.logs.metrics.ts @@ -25,6 +25,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts index 9fb81d9a4750..6caef09459ae 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.logs.metrics.ts @@ -25,6 +25,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; diff --git a/packages/browser/src/index.bundle.tracing.replay.feedback.ts b/packages/browser/src/index.bundle.tracing.replay.feedback.ts index b6b298189aef..dbff7b4dd7b3 100644 --- a/packages/browser/src/index.bundle.tracing.replay.feedback.ts +++ b/packages/browser/src/index.bundle.tracing.replay.feedback.ts @@ -1,5 +1,9 @@ import { registerSpanErrorInstrumentation } from '@sentry/core'; -import { consoleLoggingIntegrationShim, loggerShim } from '@sentry-internal/integration-shims'; +import { + consoleLoggingIntegrationShim, + elementTimingIntegrationShim, + loggerShim, +} from '@sentry-internal/integration-shims'; import { feedbackAsyncIntegration } from './feedbackAsync'; registerSpanErrorInstrumentation(); @@ -26,6 +30,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegrationShim as elementTimingIntegration }; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { reportPageLoaded } from './tracing/reportPageLoaded'; diff --git a/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts b/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts index 6b856e7a37cc..9972cd85ca8a 100644 --- a/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts +++ b/packages/browser/src/index.bundle.tracing.replay.logs.metrics.ts @@ -25,6 +25,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; diff --git a/packages/browser/src/index.bundle.tracing.replay.ts b/packages/browser/src/index.bundle.tracing.replay.ts index a20a7b8388f1..f95e3d6cdcc9 100644 --- a/packages/browser/src/index.bundle.tracing.replay.ts +++ b/packages/browser/src/index.bundle.tracing.replay.ts @@ -1,5 +1,10 @@ import { registerSpanErrorInstrumentation } from '@sentry/core'; -import { consoleLoggingIntegrationShim, feedbackIntegrationShim, loggerShim } from '@sentry-internal/integration-shims'; +import { + consoleLoggingIntegrationShim, + elementTimingIntegrationShim, + feedbackIntegrationShim, + loggerShim, +} from '@sentry-internal/integration-shims'; registerSpanErrorInstrumentation(); @@ -25,6 +30,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegrationShim as elementTimingIntegration }; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; diff --git a/packages/browser/src/index.bundle.tracing.ts b/packages/browser/src/index.bundle.tracing.ts index c3cb0a85cf1d..38186b3aded2 100644 --- a/packages/browser/src/index.bundle.tracing.ts +++ b/packages/browser/src/index.bundle.tracing.ts @@ -1,6 +1,7 @@ import { registerSpanErrorInstrumentation } from '@sentry/core'; import { consoleLoggingIntegrationShim, + elementTimingIntegrationShim, feedbackIntegrationShim, loggerShim, replayIntegrationShim, @@ -30,6 +31,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegrationShim as elementTimingIntegration }; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; export { reportPageLoaded } from './tracing/reportPageLoaded'; diff --git a/packages/browser/src/index.bundle.ts b/packages/browser/src/index.bundle.ts index cd7de6dd80c8..7dfcd30ad2ef 100644 --- a/packages/browser/src/index.bundle.ts +++ b/packages/browser/src/index.bundle.ts @@ -1,6 +1,7 @@ import { browserTracingIntegrationShim, consoleLoggingIntegrationShim, + elementTimingIntegrationShim, feedbackIntegrationShim, loggerShim, replayIntegrationShim, @@ -13,6 +14,7 @@ export { consoleLoggingIntegrationShim as consoleLoggingIntegration, loggerShim export { browserTracingIntegrationShim as browserTracingIntegration, + elementTimingIntegrationShim as elementTimingIntegration, feedbackIntegrationShim as feedbackAsyncIntegration, feedbackIntegrationShim as feedbackIntegration, replayIntegrationShim as replayIntegration, diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 70a6595d07d9..dbf39482e3e2 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -39,6 +39,7 @@ export { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, } from './tracing/browserTracingIntegration'; +export { elementTimingIntegration } from '@sentry-internal/browser-utils'; export { reportPageLoaded } from './tracing/reportPageLoaded'; export { setActiveSpanInBrowser } from './tracing/setActiveSpan'; diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index 79022dc6e31e..de99621bf52f 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -287,13 +287,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe return; } - const breadcrumbData: FetchBreadcrumbData = { - method: handlerData.fetchData.method, - url: handlerData.fetchData.url, - }; - if (handlerData.error) { - const data: FetchBreadcrumbData = handlerData.fetchData; const hint: FetchBreadcrumbHint = { data: handlerData.error, input: handlerData.args, @@ -303,7 +297,7 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe const breadcrumb = { category: 'fetch', - data, + data: handlerData.fetchData, level: 'error', type: 'http', } satisfies Breadcrumb; @@ -318,10 +312,6 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe status_code: response?.status, }; - breadcrumbData.request_body_size = handlerData.fetchData.request_body_size; - breadcrumbData.response_body_size = handlerData.fetchData.response_body_size; - breadcrumbData.status_code = response?.status; - const hint: FetchBreadcrumbHint = { input: handlerData.args, response, diff --git a/packages/browser/src/integrations/browserapierrors.ts b/packages/browser/src/integrations/browserapierrors.ts index 7e94c2bc7167..cd32435fa5b0 100644 --- a/packages/browser/src/integrations/browserapierrors.ts +++ b/packages/browser/src/integrations/browserapierrors.ts @@ -2,39 +2,11 @@ import type { IntegrationFn, WrappedFunction } from '@sentry/core'; import { defineIntegration, fill, getFunctionName, getOriginalFunction } from '@sentry/core'; import { WINDOW, wrap } from '../helpers'; -const DEFAULT_EVENT_TARGET = [ - 'EventTarget', - 'Window', - 'Node', - 'ApplicationCache', - 'AudioTrackList', - 'BroadcastChannel', - 'ChannelMergerNode', - 'CryptoOperation', - 'EventSource', - 'FileReader', - 'HTMLUnknownElement', - 'IDBDatabase', - 'IDBRequest', - 'IDBTransaction', - 'KeyOperation', - 'MediaController', - 'MessagePort', - 'ModalWindow', - 'Notification', - 'SVGElementInstance', - 'Screen', - 'SharedWorker', - 'TextTrack', - 'TextTrackCue', - 'TextTrackList', - 'WebSocket', - 'WebSocketWorker', - 'Worker', - 'XMLHttpRequest', - 'XMLHttpRequestEventTarget', - 'XMLHttpRequestUpload', -]; +// Using a comma-separated string and split for smaller bundle size vs an array literal +const DEFAULT_EVENT_TARGET = + 'EventTarget,Window,Node,ApplicationCache,AudioTrackList,BroadcastChannel,ChannelMergerNode,CryptoOperation,EventSource,FileReader,HTMLUnknownElement,IDBDatabase,IDBRequest,IDBTransaction,KeyOperation,MediaController,MessagePort,ModalWindow,Notification,SVGElementInstance,Screen,SharedWorker,TextTrack,TextTrackCue,TextTrackList,WebSocket,WebSocketWorker,Worker,XMLHttpRequest,XMLHttpRequestEventTarget,XMLHttpRequestUpload'.split( + ',', + ); const INTEGRATION_NAME = 'BrowserApiErrors'; diff --git a/packages/browser/src/integrations/globalhandlers.ts b/packages/browser/src/integrations/globalhandlers.ts index c8cd806d0062..70b3516b63b1 100644 --- a/packages/browser/src/integrations/globalhandlers.ts +++ b/packages/browser/src/integrations/globalhandlers.ts @@ -159,8 +159,8 @@ export function _eventFromRejectionWithPrimitive(reason: Primitive): Event { function _enhanceEventWithInitialFrame( event: Event, url: string | undefined, - line: number | undefined, - column: number | undefined, + lineno: number | undefined, + colno: number | undefined, ): Event { // event.exception const e = (event.exception = event.exception || {}); @@ -173,18 +173,13 @@ function _enhanceEventWithInitialFrame( // event.exception.values[0].stacktrace.frames const ev0sf = (ev0s.frames = ev0s.frames || []); - const colno = column; - const lineno = line; - const filename = getFilenameFromUrl(url) ?? getLocationHref(); - - // event.exception.values[0].stacktrace.frames if (ev0sf.length === 0) { ev0sf.push({ colno, - filename, + lineno, + filename: getFilenameFromUrl(url) ?? getLocationHref(), function: UNKNOWN_FUNCTION, in_app: true, - lineno, }); } diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index b6dc8b2e92b8..7eb87cd1d833 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -11,6 +11,7 @@ import type { import { addNonEnumerableProperty, browserPerformanceTimeOrigin, + consoleSandbox, dateTimestampInSeconds, debug, generateSpanId, @@ -39,7 +40,6 @@ import { addHistoryInstrumentationHandler, addPerformanceEntries, registerInpInteractionListener, - startTrackingElementTiming, startTrackingINP, startTrackingInteractions, startTrackingLongAnimationFrames, @@ -146,12 +146,10 @@ export interface BrowserTracingOptions { enableInp: boolean; /** - * If true, Sentry will capture [element timing](https://developer.mozilla.org/en-US/docs/Web/API/PerformanceElementTiming) - * information and add it to the corresponding transaction. - * - * Default: true + * @deprecated This option is no longer used. Element timing is now tracked via the standalone + * `elementTimingIntegration`. Add it to your `integrations` array to collect element timing metrics. */ - enableElementTiming: boolean; + enableElementTiming?: boolean; /** * Flag to disable patching all together for fetch requests. @@ -337,7 +335,6 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { enableLongTask: true, enableLongAnimationFrame: true, enableInp: true, - enableElementTiming: true, ignoreResourceSpans: [], ignorePerformanceApiSpans: [], detectRedirects: true, @@ -358,6 +355,15 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { * We explicitly export the proper type here, as this has to be extended in some cases. */ export const browserTracingIntegration = ((options: Partial = {}) => { + if ('enableElementTiming' in options) { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn( + '[Sentry] `enableElementTiming` is deprecated and no longer has any effect. Use the standalone `elementTimingIntegration` instead.', + ); + }); + } + const latestRoute: RouteInfo = { name: undefined, source: undefined, @@ -371,7 +377,6 @@ export const browserTracingIntegration = ((options: Partial href.startsWith(`${protocol}://`)); + WINDOW === WINDOW.top && + /^(?:chrome-extension|moz-extension|ms-browser-extension|safari-web-extension):\/\//.test(href); return !isDedicatedExtensionPage; } diff --git a/packages/browser/src/utils/lazyLoadIntegration.ts b/packages/browser/src/utils/lazyLoadIntegration.ts index 8a6688fe5953..ec0b11f099c0 100644 --- a/packages/browser/src/utils/lazyLoadIntegration.ts +++ b/packages/browser/src/utils/lazyLoadIntegration.ts @@ -3,33 +3,48 @@ import { getClient, SDK_VERSION } from '@sentry/core'; import type { BrowserClient } from '../client'; import { WINDOW } from '../helpers'; -// This is a map of integration function method to bundle file name. -const LazyLoadableIntegrations = { - replayIntegration: 'replay', +// Single source of truth: as const array provides both the runtime list and the type. +// Bundle file names are derived: strip 'Integration' suffix, lowercase. +// Exceptions (hyphenated bundle names) are listed in HYPHENATED_BUNDLES. +const LAZY_LOADABLE_NAMES = [ + 'replayIntegration', + 'replayCanvasIntegration', + 'feedbackIntegration', + 'feedbackModalIntegration', + 'feedbackScreenshotIntegration', + 'captureConsoleIntegration', + 'contextLinesIntegration', + 'linkedErrorsIntegration', + 'dedupeIntegration', + 'extraErrorDataIntegration', + 'graphqlClientIntegration', + 'httpClientIntegration', + 'reportingObserverIntegration', + 'rewriteFramesIntegration', + 'browserProfilingIntegration', + 'moduleMetadataIntegration', + 'instrumentAnthropicAiClient', + 'instrumentOpenAiClient', + 'instrumentGoogleGenAIClient', + 'instrumentLangGraph', + 'createLangChainCallbackHandler', +] as const; + +type ElementOf = T[number]; +type LazyLoadableIntegrationName = ElementOf; + +const HYPHENATED_BUNDLES: Partial> = { replayCanvasIntegration: 'replay-canvas', - feedbackIntegration: 'feedback', feedbackModalIntegration: 'feedback-modal', feedbackScreenshotIntegration: 'feedback-screenshot', - captureConsoleIntegration: 'captureconsole', - contextLinesIntegration: 'contextlines', - linkedErrorsIntegration: 'linkederrors', - dedupeIntegration: 'dedupe', - extraErrorDataIntegration: 'extraerrordata', - graphqlClientIntegration: 'graphqlclient', - httpClientIntegration: 'httpclient', - reportingObserverIntegration: 'reportingobserver', - rewriteFramesIntegration: 'rewriteframes', - browserProfilingIntegration: 'browserprofiling', - moduleMetadataIntegration: 'modulemetadata', - instrumentAnthropicAiClient: 'instrumentanthropicaiclient', - instrumentOpenAiClient: 'instrumentopenaiclient', - instrumentGoogleGenAIClient: 'instrumentgooglegenaiclient', - instrumentLangGraph: 'instrumentlanggraph', - createLangChainCallbackHandler: 'createlangchaincallbackhandler', -} as const; +}; + +function getBundleName(name: string): string { + return HYPHENATED_BUNDLES[name as LazyLoadableIntegrationName] || name.replace('Integration', '').toLowerCase(); +} const WindowWithMaybeIntegration = WINDOW as { - Sentry?: Partial>; + Sentry?: Partial>; }; /** @@ -37,10 +52,10 @@ const WindowWithMaybeIntegration = WINDOW as { * Rejects if the integration cannot be loaded. */ export async function lazyLoadIntegration( - name: keyof typeof LazyLoadableIntegrations, + name: LazyLoadableIntegrationName, scriptNonce?: string, ): Promise { - const bundle = LazyLoadableIntegrations[name]; + const bundle = LAZY_LOADABLE_NAMES.includes(name) ? getBundleName(name) : undefined; // `window.Sentry` is only set when using a CDN bundle, but this method can also be used via the NPM package const sentryOnWindow = (WindowWithMaybeIntegration.Sentry = WindowWithMaybeIntegration.Sentry || {}); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index c2990e6262a7..41f5b3cf5c52 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -192,4 +192,5 @@ export type { BunOptions } from './types'; export { BunClient } from './client'; export { getDefaultIntegrations, init } from './sdk'; export { bunServerIntegration } from './integrations/bunserver'; +export { bunRuntimeMetricsIntegration, type BunRuntimeMetricsOptions } from './integrations/bunRuntimeMetrics'; export { makeFetchTransport } from './transports'; diff --git a/packages/bun/src/integrations/bunRuntimeMetrics.ts b/packages/bun/src/integrations/bunRuntimeMetrics.ts new file mode 100644 index 000000000000..7646eb23568b --- /dev/null +++ b/packages/bun/src/integrations/bunRuntimeMetrics.ts @@ -0,0 +1,166 @@ +import { performance } from 'perf_hooks'; +import { _INTERNAL_safeDateNow, _INTERNAL_safeUnref, defineIntegration, metrics } from '@sentry/core'; +import type { NodeRuntimeMetricsOptions } from '@sentry/node'; + +const INTEGRATION_NAME = 'BunRuntimeMetrics'; +const DEFAULT_INTERVAL_MS = 30_000; + +/** + * Which metrics to collect in the Bun runtime metrics integration. + * Explicitly picks the metrics available in Bun from `NodeRuntimeMetricsOptions['collect']`. + * Event loop delay percentiles are excluded because `monitorEventLoopDelay` is unavailable in Bun. + */ +type BunCollectOptions = Pick< + NonNullable, + | 'cpuUtilization' + | 'cpuTime' + | 'memHeapUsed' + | 'memHeapTotal' + | 'memRss' + | 'memExternal' + | 'eventLoopUtilization' + | 'uptime' +>; + +export interface BunRuntimeMetricsOptions { + /** + * Which metrics to collect. + * + * Default on (6 metrics): + * - `cpuUtilization` — CPU utilization ratio + * - `memRss` — Resident Set Size (actual memory footprint) + * - `memHeapUsed` — V8 heap currently in use + * - `memHeapTotal` — total V8 heap allocated + * - `eventLoopUtilization` — fraction of time the event loop was active + * - `uptime` — process uptime (detect restarts/crashes) + * + * Default off (opt-in): + * - `cpuTime` — raw user/system CPU time in seconds + * - `memExternal` — external/ArrayBuffer memory (relevant for native addons) + * + * Note: event loop delay percentiles (p50, p99, etc.) are not available in Bun + * because `monitorEventLoopDelay` from `perf_hooks` is not implemented. + */ + collect?: BunCollectOptions; + /** + * How often to collect metrics, in milliseconds. + * @default 30000 + */ + collectionIntervalMs?: number; +} + +/** + * Automatically collects Bun runtime metrics and emits them to Sentry. + * + * @example + * ```ts + * Sentry.init({ + * integrations: [ + * Sentry.bunRuntimeMetricsIntegration(), + * ], + * }); + * ``` + */ +export const bunRuntimeMetricsIntegration = defineIntegration((options: BunRuntimeMetricsOptions = {}) => { + const collectionIntervalMs = options.collectionIntervalMs ?? DEFAULT_INTERVAL_MS; + const collect = { + // Default on + cpuUtilization: true, + memHeapUsed: true, + memHeapTotal: true, + memRss: true, + eventLoopUtilization: true, + uptime: true, + // Default off + cpuTime: false, + memExternal: false, + ...options.collect, + }; + + const needsCpu = collect.cpuUtilization || collect.cpuTime; + + let intervalId: ReturnType | undefined; + let prevCpuUsage: NodeJS.CpuUsage | undefined; + let prevElu: ReturnType | undefined; + let prevFlushTime: number = 0; + let eluAvailable = false; + + const METRIC_ATTRIBUTES = { attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + const METRIC_ATTRIBUTES_BYTE = { unit: 'byte', attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + const METRIC_ATTRIBUTES_SECOND = { unit: 'second', attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + + function collectMetrics(): void { + const now = _INTERNAL_safeDateNow(); + const elapsed = now - prevFlushTime; + + if (needsCpu && prevCpuUsage !== undefined) { + const delta = process.cpuUsage(prevCpuUsage); + + if (collect.cpuTime) { + metrics.gauge('bun.runtime.cpu.user', delta.user / 1e6, METRIC_ATTRIBUTES_SECOND); + metrics.gauge('bun.runtime.cpu.system', delta.system / 1e6, METRIC_ATTRIBUTES_SECOND); + } + if (collect.cpuUtilization && elapsed > 0) { + metrics.gauge('bun.runtime.cpu.utilization', (delta.user + delta.system) / (elapsed * 1000), METRIC_ATTRIBUTES); + } + + prevCpuUsage = process.cpuUsage(); + } + + if (collect.memRss || collect.memHeapUsed || collect.memHeapTotal || collect.memExternal) { + const mem = process.memoryUsage(); + if (collect.memRss) { + metrics.gauge('bun.runtime.mem.rss', mem.rss, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memHeapUsed) { + metrics.gauge('bun.runtime.mem.heap_used', mem.heapUsed, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memHeapTotal) { + metrics.gauge('bun.runtime.mem.heap_total', mem.heapTotal, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memExternal) { + metrics.gauge('bun.runtime.mem.external', mem.external, METRIC_ATTRIBUTES_BYTE); + metrics.gauge('bun.runtime.mem.array_buffers', mem.arrayBuffers, METRIC_ATTRIBUTES_BYTE); + } + } + + if (collect.eventLoopUtilization && eluAvailable && prevElu !== undefined) { + const currentElu = performance.eventLoopUtilization(); + const delta = performance.eventLoopUtilization(currentElu, prevElu); + metrics.gauge('bun.runtime.event_loop.utilization', delta.utilization, METRIC_ATTRIBUTES); + prevElu = currentElu; + } + + if (collect.uptime && elapsed > 0) { + metrics.count('bun.runtime.process.uptime', elapsed / 1000, METRIC_ATTRIBUTES_SECOND); + } + + prevFlushTime = now; + } + + return { + name: INTEGRATION_NAME, + + setup(): void { + // Prime baselines before the first collection interval. + if (needsCpu) { + prevCpuUsage = process.cpuUsage(); + } + if (collect.eventLoopUtilization) { + try { + prevElu = performance.eventLoopUtilization(); + eluAvailable = true; + } catch { + // Not available in all Bun versions. + } + } + prevFlushTime = _INTERNAL_safeDateNow(); + + // Guard against double setup (e.g. re-init). + if (intervalId) { + clearInterval(intervalId); + } + intervalId = _INTERNAL_safeUnref(setInterval(collectMetrics, collectionIntervalMs)); + }, + }; +}); diff --git a/packages/bun/test/integrations/bunRuntimeMetrics.test.ts b/packages/bun/test/integrations/bunRuntimeMetrics.test.ts new file mode 100644 index 000000000000..6264905db41e --- /dev/null +++ b/packages/bun/test/integrations/bunRuntimeMetrics.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, jest, mock, spyOn } from 'bun:test'; +import { metrics } from '@sentry/core'; + +const mockElu = { idle: 700, active: 300, utilization: 0.3 }; +const mockEluDelta = { idle: 700, active: 300, utilization: 0.3 }; +const mockEventLoopUtilization = jest.fn((curr?: object, _prev?: object) => { + if (curr) return mockEluDelta; + return mockElu; +}); + +mock.module('perf_hooks', () => ({ + performance: { eventLoopUtilization: mockEventLoopUtilization }, +})); + +const { bunRuntimeMetricsIntegration } = await import('../../src/integrations/bunRuntimeMetrics'); + +describe('bunRuntimeMetricsIntegration', () => { + let gaugeSpy: ReturnType; + let countSpy: ReturnType; + + beforeEach(() => { + jest.useFakeTimers(); + gaugeSpy = spyOn(metrics, 'gauge').mockImplementation(() => undefined); + countSpy = spyOn(metrics, 'count').mockImplementation(() => undefined); + + spyOn(process, 'cpuUsage').mockReturnValue({ user: 500_000, system: 200_000 }); + spyOn(process, 'memoryUsage').mockReturnValue({ + rss: 50_000_000, + heapTotal: 30_000_000, + heapUsed: 20_000_000, + external: 1_000_000, + arrayBuffers: 500_000, + }); + + mockEventLoopUtilization.mockClear(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('has the correct name', () => { + const integration = bunRuntimeMetricsIntegration(); + expect(integration.name).toBe('BunRuntimeMetrics'); + }); + + describe('setup', () => { + it('starts a collection interval', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + + expect(gaugeSpy).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1_000); + expect(gaugeSpy).toHaveBeenCalled(); + }); + + it('does not throw if performance.eventLoopUtilization is unavailable', () => { + mockEventLoopUtilization.mockImplementationOnce(() => { + throw new Error('Not implemented'); + }); + + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + expect(() => integration.setup()).not.toThrow(); + }); + }); + + const ORIGIN = { attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + const BYTE = { unit: 'byte', attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + const SECOND = { unit: 'second', attributes: { 'sentry.origin': 'auto.bun.runtime_metrics' } }; + + describe('metric collection — defaults', () => { + it('emits cpu utilization (default on)', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.cpu.utilization', expect.any(Number), ORIGIN); + }); + + it('does not emit cpu.user / cpu.system by default (opt-in)', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.cpu.user', expect.anything(), expect.anything()); + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.cpu.system', expect.anything(), expect.anything()); + }); + + it('emits cpu.user / cpu.system when cpuTime is opted in', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { cpuTime: true }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.cpu.user', expect.any(Number), SECOND); + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.cpu.system', expect.any(Number), SECOND); + }); + + it('emits mem.rss, mem.heap_used, mem.heap_total (default on)', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.mem.rss', 50_000_000, BYTE); + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.mem.heap_used', 20_000_000, BYTE); + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.mem.heap_total', 30_000_000, BYTE); + }); + + it('does not emit mem.external / mem.array_buffers by default (opt-in)', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.mem.external', expect.anything(), expect.anything()); + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.mem.array_buffers', expect.anything(), expect.anything()); + }); + + it('emits mem.external / mem.array_buffers when opted in', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { memExternal: true }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.mem.external', 1_000_000, BYTE); + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.mem.array_buffers', 500_000, BYTE); + }); + + it('emits event loop utilization metric', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('bun.runtime.event_loop.utilization', 0.3, ORIGIN); + }); + + it('does not emit event loop utilization if performance.eventLoopUtilization threw during setup', () => { + mockEventLoopUtilization.mockImplementationOnce(() => { + throw new Error('Not implemented'); + }); + + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith( + 'bun.runtime.event_loop.utilization', + expect.anything(), + expect.anything(), + ); + }); + + it('emits uptime counter', () => { + const integration = bunRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(countSpy).toHaveBeenCalledWith('bun.runtime.process.uptime', expect.any(Number), SECOND); + }); + }); + + describe('opt-out', () => { + it('skips cpu.utilization when cpuUtilization is false', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { cpuUtilization: false }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.cpu.utilization', expect.anything(), expect.anything()); + }); + + it('skips mem.rss when memRss is false', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { memRss: false }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('bun.runtime.mem.rss', expect.anything(), expect.anything()); + }); + + it('skips event loop utilization when eventLoopUtilization is false', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { eventLoopUtilization: false }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith( + 'bun.runtime.event_loop.utilization', + expect.anything(), + expect.anything(), + ); + }); + + it('skips uptime when uptime is false', () => { + const integration = bunRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { uptime: false }, + }); + integration.setup(); + jest.advanceTimersByTime(1_000); + + expect(countSpy).not.toHaveBeenCalledWith('bun.runtime.process.uptime', expect.anything(), expect.anything()); + }); + }); +}); diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index cc4e720d8a45..6e6b4c36cdfd 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -49,7 +49,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api": "^1.9.1", "@sentry/core": "10.46.0" }, "peerDependencies": { diff --git a/packages/core/src/api.ts b/packages/core/src/api.ts index 924c6a8e28ad..2aea96fd825b 100644 --- a/packages/core/src/api.ts +++ b/packages/core/src/api.ts @@ -3,7 +3,7 @@ import type { DsnComponents, DsnLike } from './types-hoist/dsn'; import type { SdkInfo } from './types-hoist/sdkinfo'; import { dsnToString, makeDsn } from './utils/dsn'; -const SENTRY_API_VERSION = '7'; +export const SENTRY_API_VERSION = '7'; /** Returns the prefix to construct Sentry ingestion API endpoints. */ function getBaseApiEndpoint(dsn: DsnComponents): string { diff --git a/packages/core/src/asyncContext/types.ts b/packages/core/src/asyncContext/types.ts index 97af0af1b88a..be1ea92a7736 100644 --- a/packages/core/src/asyncContext/types.ts +++ b/packages/core/src/asyncContext/types.ts @@ -3,6 +3,7 @@ import type { getTraceData } from '../utils/traceData'; import type { continueTrace, startInactiveSpan, + startNewTrace, startSpan, startSpanManual, suppressTracing, @@ -76,4 +77,7 @@ export interface AsyncContextStrategy { * and `` HTML tags. */ continueTrace?: typeof continueTrace; + + /** Start a new trace, ensuring all spans in the callback share the same traceId. */ + startNewTrace?: typeof startNewTrace; } diff --git a/packages/core/src/currentScopes.ts b/packages/core/src/currentScopes.ts index fc40051e56d8..a88aed55c971 100644 --- a/packages/core/src/currentScopes.ts +++ b/packages/core/src/currentScopes.ts @@ -5,6 +5,31 @@ import { Scope } from './scope'; import type { TraceContext } from './types-hoist/context'; import { generateSpanId } from './utils/propagationContext'; +let _externalPropagationContextProvider: (() => { traceId: string; spanId: string } | undefined) | undefined; + +/** + * Register an external propagation context provider function. + * When registered, trace context will be read from the external source (e.g. OpenTelemetry) + * instead of from the Sentry scope's propagation context. + */ +export function registerExternalPropagationContext(fn: () => { traceId: string; spanId: string } | undefined): void { + _externalPropagationContextProvider = fn; +} + +/** + * Get the external propagation context, if a provider has been registered. + */ +export function getExternalPropagationContext(): { traceId: string; spanId: string } | undefined { + return _externalPropagationContextProvider?.(); +} + +/** + * Check if an external propagation context provider has been registered. + */ +export function hasExternalPropagationContext(): boolean { + return _externalPropagationContextProvider !== undefined; +} + /** * Get the currently active scope. */ @@ -125,6 +150,11 @@ export function getClient(): C | undefined { * Get a trace context for the given scope. */ export function getTraceContextFromScope(scope: Scope): TraceContext { + const externalContext = getExternalPropagationContext(); + if (externalContext) { + return { trace_id: externalContext.traceId, span_id: externalContext.spanId }; + } + const propagationContext = scope.getPropagationContext(); const { traceId, parentSpanId, propagationSpanId } = propagationContext; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 61865ea7ba3c..d155da8adf72 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,6 +41,9 @@ export { withIsolationScope, getClient, getTraceContextFromScope, + registerExternalPropagationContext, + getExternalPropagationContext, + hasExternalPropagationContext, } from './currentScopes'; export { getDefaultCurrentScope, getDefaultIsolationScope } from './defaultScopes'; export { setAsyncContextStrategy } from './asyncContext'; @@ -49,7 +52,7 @@ export { makeSession, closeSession, updateSession } from './session'; export { Scope } from './scope'; export type { CaptureContext, ScopeContext, ScopeData } from './scope'; export { notifyEventProcessors } from './eventProcessors'; -export { getEnvelopeEndpointWithUrlEncodedAuth, getReportDialogEndpoint } from './api'; +export { getEnvelopeEndpointWithUrlEncodedAuth, getReportDialogEndpoint, SENTRY_API_VERSION } from './api'; export { Client } from './client'; export { ServerRuntimeClient } from './server-runtime-client'; export { initAndBind, setCurrentClient } from './sdk'; @@ -537,3 +540,4 @@ export { safeMathRandom as _INTERNAL_safeMathRandom, safeDateNow as _INTERNAL_safeDateNow, } from './utils/randomSafeContext'; +export { safeUnref as _INTERNAL_safeUnref } from './utils/timer'; diff --git a/packages/core/src/integrations/supabase.ts b/packages/core/src/integrations/supabase.ts index 1b6f24cc3136..dac7530b46f0 100644 --- a/packages/core/src/integrations/supabase.ts +++ b/packages/core/src/integrations/supabase.ts @@ -403,7 +403,7 @@ function instrumentPostgRESTFilterBuilder(PostgRESTFilterBuilder: PostgRESTFilte span.end(); } - if (res.error) { + if (res?.error) { const err = new Error(res.error.message) as SupabaseError; if (res.error.code) { err.code = res.error.code; diff --git a/packages/core/src/tracing/ai/gen-ai-attributes.ts b/packages/core/src/tracing/ai/gen-ai-attributes.ts index cae9a9353da9..5c740142b4f0 100644 --- a/packages/core/src/tracing/ai/gen-ai-attributes.ts +++ b/packages/core/src/tracing/ai/gen-ai-attributes.ts @@ -312,19 +312,6 @@ export const OPENAI_USAGE_COMPLETION_TOKENS_ATTRIBUTE = 'openai.usage.completion */ export const OPENAI_USAGE_PROMPT_TOKENS_ATTRIBUTE = 'openai.usage.prompt_tokens'; -// ============================================================================= -// OPENAI OPERATIONS -// ============================================================================= - -/** - * OpenAI API operations following OpenTelemetry semantic conventions - * @see https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/#llm-request-spans - */ -export const OPENAI_OPERATIONS = { - CHAT: 'chat', - EMBEDDINGS: 'embeddings', -} as const; - // ============================================================================= // ANTHROPIC AI OPERATIONS // ============================================================================= diff --git a/packages/core/src/tracing/ai/utils.ts b/packages/core/src/tracing/ai/utils.ts index 38e4d831db3f..d9628e3c75e2 100644 --- a/packages/core/src/tracing/ai/utils.ts +++ b/packages/core/src/tracing/ai/utils.ts @@ -17,6 +17,24 @@ export interface AIRecordingOptions { recordOutputs?: boolean; } +/** + * A method registry entry describes a single instrumented method: + * which gen_ai operation it maps to and whether it is intrinsically streaming. + */ +export interface InstrumentedMethodEntry { + /** Operation name (e.g. 'chat', 'embeddings', 'generate_content') */ + operation: string; + /** True if the method itself is always streaming (not param-based) */ + streaming?: boolean; +} + +/** + * Maps method paths to their registry entries. + * Used by proxy-based AI client instrumentations to determine which methods + * to instrument, what operation name to use, and whether they stream. + */ +export type InstrumentedMethodRegistry = Record; + /** * Resolves AI recording options by falling back to the client's `sendDefaultPii` setting. * Precedence: explicit option > sendDefaultPii > false @@ -30,39 +48,6 @@ export function resolveAIRecordingOptions(options? } as T & Required; } -/** - * Maps AI method paths to OpenTelemetry semantic convention operation names - * @see https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/#llm-request-spans - */ -export function getFinalOperationName(methodPath: string): string { - if (methodPath.includes('messages')) { - return 'chat'; - } - if (methodPath.includes('completions')) { - return 'text_completion'; - } - // Google GenAI: models.generateContent* -> generate_content (actually generates AI responses) - if (methodPath.includes('generateContent')) { - return 'generate_content'; - } - // Anthropic: models.get/retrieve -> models (metadata retrieval only) - if (methodPath.includes('models')) { - return 'models'; - } - if (methodPath.includes('chat')) { - return 'chat'; - } - return methodPath.split('.').pop() || 'unknown'; -} - -/** - * Get the span operation for AI methods - * Following Sentry's convention: "gen_ai.{operation_name}" - */ -export function getSpanOperation(methodPath: string): string { - return `gen_ai.${getFinalOperationName(methodPath)}`; -} - /** * Build method path from current traversal */ diff --git a/packages/core/src/tracing/anthropic-ai/constants.ts b/packages/core/src/tracing/anthropic-ai/constants.ts index 7e6c66196a82..4441a32b98ca 100644 --- a/packages/core/src/tracing/anthropic-ai/constants.ts +++ b/packages/core/src/tracing/anthropic-ai/constants.ts @@ -1,13 +1,15 @@ +import type { InstrumentedMethodRegistry } from '../ai/utils'; + export const ANTHROPIC_AI_INTEGRATION_NAME = 'Anthropic_AI'; // https://docs.anthropic.com/en/api/messages // https://docs.anthropic.com/en/api/models-list -export const ANTHROPIC_AI_INSTRUMENTED_METHODS = [ - 'messages.create', - 'messages.stream', - 'messages.countTokens', - 'models.get', - 'completions.create', - 'models.retrieve', - 'beta.messages.create', -] as const; +export const ANTHROPIC_METHOD_REGISTRY = { + 'messages.create': { operation: 'chat' }, + 'messages.stream': { operation: 'chat', streaming: true }, + 'messages.countTokens': { operation: 'chat' }, + 'models.get': { operation: 'models' }, + 'completions.create': { operation: 'chat' }, + 'models.retrieve': { operation: 'models' }, + 'beta.messages.create': { operation: 'chat' }, +} as const satisfies InstrumentedMethodRegistry; diff --git a/packages/core/src/tracing/anthropic-ai/index.ts b/packages/core/src/tracing/anthropic-ai/index.ts index 693ecbd23ff8..226aa8e1d544 100644 --- a/packages/core/src/tracing/anthropic-ai/index.ts +++ b/packages/core/src/tracing/anthropic-ai/index.ts @@ -21,31 +21,25 @@ import { GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, } from '../ai/gen-ai-attributes'; +import type { InstrumentedMethodEntry } from '../ai/utils'; import { buildMethodPath, - getFinalOperationName, - getSpanOperation, resolveAIRecordingOptions, setTokenUsageAttributes, wrapPromiseWithMethods, } from '../ai/utils'; +import { ANTHROPIC_METHOD_REGISTRY } from './constants'; import { instrumentAsyncIterableStream, instrumentMessageStream } from './streaming'; -import type { - AnthropicAiInstrumentedMethod, - AnthropicAiOptions, - AnthropicAiResponse, - AnthropicAiStreamingEvent, - ContentBlock, -} from './types'; -import { handleResponseError, messagesFromParams, setMessagesAttribute, shouldInstrument } from './utils'; +import type { AnthropicAiOptions, AnthropicAiResponse, AnthropicAiStreamingEvent, ContentBlock } from './types'; +import { handleResponseError, messagesFromParams, setMessagesAttribute } from './utils'; /** * Extract request attributes from method arguments */ -function extractRequestAttributes(args: unknown[], methodPath: string): Record { +function extractRequestAttributes(args: unknown[], methodPath: string, operationName: string): Record { const attributes: Record = { [GEN_AI_SYSTEM_ATTRIBUTE]: 'anthropic', - [GEN_AI_OPERATION_NAME_ATTRIBUTE]: getFinalOperationName(methodPath), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: operationName, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.anthropic', }; @@ -212,7 +206,7 @@ function handleStreamingRequest( const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; const spanConfig = { name: `${operationName} ${model}`, - op: getSpanOperation(methodPath), + op: `gen_ai.${operationName}`, attributes: requestAttributes as Record, }; @@ -264,19 +258,20 @@ function handleStreamingRequest( */ function instrumentMethod( originalMethod: (...args: T) => R | Promise, - methodPath: AnthropicAiInstrumentedMethod, + methodPath: string, + instrumentedMethod: InstrumentedMethodEntry, context: unknown, options: AnthropicAiOptions, ): (...args: T) => R | Promise { return new Proxy(originalMethod, { apply(target, thisArg, args: T): R | Promise { - const requestAttributes = extractRequestAttributes(args, methodPath); + const operationName = instrumentedMethod.operation; + const requestAttributes = extractRequestAttributes(args, methodPath, operationName); const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; - const operationName = getFinalOperationName(methodPath); const params = typeof args[0] === 'object' ? (args[0] as Record) : undefined; const isStreamRequested = Boolean(params?.stream); - const isStreamingMethod = methodPath === 'messages.stream'; + const isStreamingMethod = instrumentedMethod.streaming === true; if (isStreamRequested || isStreamingMethod) { return handleStreamingRequest( @@ -299,7 +294,7 @@ function instrumentMethod( const instrumentedPromise = startSpan( { name: `${operationName} ${model}`, - op: getSpanOperation(methodPath), + op: `gen_ai.${operationName}`, attributes: requestAttributes as Record, }, span => { @@ -344,8 +339,15 @@ function createDeepProxy(target: T, currentPath = '', options: const value = (obj as Record)[prop]; const methodPath = buildMethodPath(currentPath, String(prop)); - if (typeof value === 'function' && shouldInstrument(methodPath)) { - return instrumentMethod(value as (...args: unknown[]) => unknown | Promise, methodPath, obj, options); + const instrumentedMethod = ANTHROPIC_METHOD_REGISTRY[methodPath as keyof typeof ANTHROPIC_METHOD_REGISTRY]; + if (typeof value === 'function' && instrumentedMethod) { + return instrumentMethod( + value as (...args: unknown[]) => unknown | Promise, + methodPath, + instrumentedMethod, + obj, + options, + ); } if (typeof value === 'function') { diff --git a/packages/core/src/tracing/anthropic-ai/types.ts b/packages/core/src/tracing/anthropic-ai/types.ts index 124b7c7f73be..ba281ef82a0d 100644 --- a/packages/core/src/tracing/anthropic-ai/types.ts +++ b/packages/core/src/tracing/anthropic-ai/types.ts @@ -1,4 +1,4 @@ -import type { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants'; +import type { ANTHROPIC_METHOD_REGISTRY } from './constants'; export interface AnthropicAiOptions { /** @@ -84,7 +84,10 @@ export interface AnthropicAiIntegration { options: AnthropicAiOptions; } -export type AnthropicAiInstrumentedMethod = (typeof ANTHROPIC_AI_INSTRUMENTED_METHODS)[number]; +/** + * @deprecated This type is no longer used and will be removed in the next major version. + */ +export type AnthropicAiInstrumentedMethod = keyof typeof ANTHROPIC_METHOD_REGISTRY; /** * Message type for Anthropic AI diff --git a/packages/core/src/tracing/anthropic-ai/utils.ts b/packages/core/src/tracing/anthropic-ai/utils.ts index e2cadcec331b..b70d9adcfa67 100644 --- a/packages/core/src/tracing/anthropic-ai/utils.ts +++ b/packages/core/src/tracing/anthropic-ai/utils.ts @@ -8,15 +8,7 @@ import { GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils'; -import { ANTHROPIC_AI_INSTRUMENTED_METHODS } from './constants'; -import type { AnthropicAiInstrumentedMethod, AnthropicAiResponse } from './types'; - -/** - * Check if a method path should be instrumented - */ -export function shouldInstrument(methodPath: string): methodPath is AnthropicAiInstrumentedMethod { - return ANTHROPIC_AI_INSTRUMENTED_METHODS.includes(methodPath as AnthropicAiInstrumentedMethod); -} +import type { AnthropicAiResponse } from './types'; /** * Set the messages and messages original length attributes. diff --git a/packages/core/src/tracing/google-genai/constants.ts b/packages/core/src/tracing/google-genai/constants.ts index b06e46e18755..b23f870f49b7 100644 --- a/packages/core/src/tracing/google-genai/constants.ts +++ b/packages/core/src/tracing/google-genai/constants.ts @@ -1,16 +1,20 @@ +import type { InstrumentedMethodRegistry } from '../ai/utils'; + export const GOOGLE_GENAI_INTEGRATION_NAME = 'Google_GenAI'; // https://ai.google.dev/api/rest/v1/models/generateContent // https://ai.google.dev/api/rest/v1/chats/sendMessage // https://googleapis.github.io/js-genai/release_docs/classes/models.Models.html#generatecontentstream // https://googleapis.github.io/js-genai/release_docs/classes/chats.Chat.html#sendmessagestream -export const GOOGLE_GENAI_INSTRUMENTED_METHODS = [ - 'models.generateContent', - 'models.generateContentStream', - 'chats.create', - 'sendMessage', - 'sendMessageStream', -] as const; +export const GOOGLE_GENAI_METHOD_REGISTRY = { + 'models.generateContent': { operation: 'generate_content' }, + 'models.generateContentStream': { operation: 'generate_content', streaming: true }, + 'models.embedContent': { operation: 'embeddings' }, + 'chats.create': { operation: 'chat' }, + // chat.* paths are built by createDeepProxy when it proxies the chat instance with CHAT_PATH as base + 'chat.sendMessage': { operation: 'chat' }, + 'chat.sendMessageStream': { operation: 'chat', streaming: true }, +} as const satisfies InstrumentedMethodRegistry; // Constants for internal use export const GOOGLE_GENAI_SYSTEM_NAME = 'google_genai'; diff --git a/packages/core/src/tracing/google-genai/index.ts b/packages/core/src/tracing/google-genai/index.ts index e53b320a8503..b754a8c874c6 100644 --- a/packages/core/src/tracing/google-genai/index.ts +++ b/packages/core/src/tracing/google-genai/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { captureException } from '../../exports'; import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import { SPAN_STATUS_ERROR } from '../../tracing'; @@ -5,6 +6,7 @@ import { startSpan, startSpanManual } from '../../tracing/trace'; import type { Span, SpanAttributeValue } from '../../types-hoist/span'; import { handleCallbackErrors } from '../../utils/handleCallbackErrors'; import { + GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_OPERATION_NAME_ATTRIBUTE, @@ -26,24 +28,13 @@ import { GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { truncateGenAiMessages } from '../ai/messageTruncation'; -import { - buildMethodPath, - extractSystemInstructions, - getFinalOperationName, - getSpanOperation, - resolveAIRecordingOptions, -} from '../ai/utils'; -import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; +import type { InstrumentedMethodEntry } from '../ai/utils'; +import { buildMethodPath, extractSystemInstructions, resolveAIRecordingOptions } from '../ai/utils'; +import { CHAT_PATH, CHATS_CREATE_METHOD, GOOGLE_GENAI_METHOD_REGISTRY, GOOGLE_GENAI_SYSTEM_NAME } from './constants'; import { instrumentStream } from './streaming'; -import type { - Candidate, - ContentPart, - GoogleGenAIIstrumentedMethod, - GoogleGenAIOptions, - GoogleGenAIResponse, -} from './types'; +import type { Candidate, ContentPart, GoogleGenAIOptions, GoogleGenAIResponse } from './types'; import type { ContentListUnion, ContentUnion, Message, PartListUnion } from './utils'; -import { contentUnionToMessages, isStreamingMethod, shouldInstrument } from './utils'; +import { contentUnionToMessages } from './utils'; /** * Extract model from parameters or chat context object @@ -105,13 +96,13 @@ function extractConfigAttributes(config: Record): Record, context?: unknown, ): Record { const attributes: Record = { [GEN_AI_SYSTEM_ATTRIBUTE]: GOOGLE_GENAI_SYSTEM_NAME, - [GEN_AI_OPERATION_NAME_ATTRIBUTE]: getFinalOperationName(methodPath), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: operationName, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.google_genai', }; @@ -143,7 +134,18 @@ function extractRequestAttributes( * This is only recorded if recordInputs is true. * Handles different parameter formats for different Google GenAI methods. */ -function addPrivateRequestAttributes(span: Span, params: Record): void { +function addPrivateRequestAttributes(span: Span, params: Record, isEmbeddings: boolean): void { + if (isEmbeddings) { + const contents = params.contents; + if (contents != null) { + span.setAttribute( + GEN_AI_EMBEDDINGS_INPUT_ATTRIBUTE, + typeof contents === 'string' ? contents : JSON.stringify(contents), + ); + } + return; + } + const messages: Message[] = []; // config.systemInstruction: ContentUnion @@ -257,32 +259,34 @@ function addResponseAttributes(span: Span, response: GoogleGenAIResponse, record */ function instrumentMethod( originalMethod: (...args: T) => R | Promise, - methodPath: GoogleGenAIIstrumentedMethod, + methodPath: string, + instrumentedMethod: InstrumentedMethodEntry, context: unknown, options: GoogleGenAIOptions, ): (...args: T) => R | Promise { const isSyncCreate = methodPath === CHATS_CREATE_METHOD; + const isEmbeddings = instrumentedMethod.operation === 'embeddings'; return new Proxy(originalMethod, { apply(target, _, args: T): R | Promise { + const operationName = instrumentedMethod.operation; const params = args[0] as Record | undefined; - const requestAttributes = extractRequestAttributes(methodPath, params, context); + const requestAttributes = extractRequestAttributes(operationName, params, context); const model = requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] ?? 'unknown'; - const operationName = getFinalOperationName(methodPath); // Check if this is a streaming method - if (isStreamingMethod(methodPath)) { + if (instrumentedMethod.streaming) { // Use startSpanManual for streaming methods to control span lifecycle return startSpanManual( { name: `${operationName} ${model}`, - op: getSpanOperation(methodPath), + op: `gen_ai.${operationName}`, attributes: requestAttributes, }, async (span: Span) => { try { if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params); + addPrivateRequestAttributes(span, params, isEmbeddings); } const stream = await target.apply(context, args); return instrumentStream(stream, span, Boolean(options.recordOutputs)) as R; @@ -305,12 +309,12 @@ function instrumentMethod( return startSpan( { name: isSyncCreate ? `${operationName} ${model} create` : `${operationName} ${model}`, - op: getSpanOperation(methodPath), + op: `gen_ai.${operationName}`, attributes: requestAttributes, }, (span: Span) => { if (options.recordInputs && params) { - addPrivateRequestAttributes(span, params); + addPrivateRequestAttributes(span, params, isEmbeddings); } return handleCallbackErrors( @@ -322,8 +326,8 @@ function instrumentMethod( }, () => {}, result => { - // Only add response attributes for content-producing methods, not for chats.create - if (!isSyncCreate) { + // Only add response attributes for content-producing methods, not for chats.create or embeddings + if (!isSyncCreate && !isEmbeddings) { addResponseAttributes(span, result, options.recordOutputs); } }, @@ -344,12 +348,19 @@ function createDeepProxy(target: T, currentPath = '', options: const value = Reflect.get(t, prop, receiver); const methodPath = buildMethodPath(currentPath, String(prop)); - if (typeof value === 'function' && shouldInstrument(methodPath)) { + const instrumentedMethod = GOOGLE_GENAI_METHOD_REGISTRY[methodPath as keyof typeof GOOGLE_GENAI_METHOD_REGISTRY]; + if (typeof value === 'function' && instrumentedMethod) { // Special case: chats.create is synchronous but needs both instrumentation AND result proxying if (methodPath === CHATS_CREATE_METHOD) { - const instrumentedMethod = instrumentMethod(value as (...args: unknown[]) => unknown, methodPath, t, options); + const wrappedMethod = instrumentMethod( + value as (...args: unknown[]) => unknown, + methodPath, + instrumentedMethod, + t, + options, + ); return function instrumentedAndProxiedCreate(...args: unknown[]): unknown { - const result = instrumentedMethod(...args); + const result = wrappedMethod(...args); // If the result is an object (like a chat instance), proxy it too if (result && typeof result === 'object') { return createDeepProxy(result, CHAT_PATH, options); @@ -358,7 +369,13 @@ function createDeepProxy(target: T, currentPath = '', options: }; } - return instrumentMethod(value as (...args: unknown[]) => Promise, methodPath, t, options); + return instrumentMethod( + value as (...args: unknown[]) => Promise, + methodPath, + instrumentedMethod, + t, + options, + ); } if (typeof value === 'function') { diff --git a/packages/core/src/tracing/google-genai/types.ts b/packages/core/src/tracing/google-genai/types.ts index 9a2138a7843d..abfb8141ce31 100644 --- a/packages/core/src/tracing/google-genai/types.ts +++ b/packages/core/src/tracing/google-genai/types.ts @@ -1,4 +1,4 @@ -import type { GOOGLE_GENAI_INSTRUMENTED_METHODS } from './constants'; +import type { GOOGLE_GENAI_METHOD_REGISTRY } from './constants'; export interface GoogleGenAIOptions { /** @@ -163,6 +163,8 @@ export interface GoogleGenAIClient { // https://googleapis.github.io/js-genai/release_docs/classes/models.Models.html#generatecontentstream // eslint-disable-next-line @typescript-eslint/no-explicit-any generateContentStream: (...args: unknown[]) => Promise>; + // https://googleapis.github.io/js-genai/release_docs/classes/models.Models.html#embedcontent + embedContent: (...args: unknown[]) => Promise; }; chats: { create: (...args: unknown[]) => GoogleGenAIChat; @@ -179,7 +181,10 @@ export interface GoogleGenAIChat { sendMessageStream: (...args: unknown[]) => Promise>; } -export type GoogleGenAIIstrumentedMethod = (typeof GOOGLE_GENAI_INSTRUMENTED_METHODS)[number]; +/** + * @deprecated This type is no longer used and will be removed in the next major version. + */ +export type GoogleGenAIIstrumentedMethod = keyof typeof GOOGLE_GENAI_METHOD_REGISTRY; // Export the response type for use in instrumentation export type GoogleGenAIResponse = GenerateContentResponse; diff --git a/packages/core/src/tracing/google-genai/utils.ts b/packages/core/src/tracing/google-genai/utils.ts index 4280957ce43f..9286822fa60d 100644 --- a/packages/core/src/tracing/google-genai/utils.ts +++ b/packages/core/src/tracing/google-genai/utils.ts @@ -1,27 +1,3 @@ -import { GOOGLE_GENAI_INSTRUMENTED_METHODS } from './constants'; -import type { GoogleGenAIIstrumentedMethod } from './types'; - -/** - * Check if a method path should be instrumented - */ -export function shouldInstrument(methodPath: string): methodPath is GoogleGenAIIstrumentedMethod { - // Check for exact matches first (like 'models.generateContent') - if (GOOGLE_GENAI_INSTRUMENTED_METHODS.includes(methodPath as GoogleGenAIIstrumentedMethod)) { - return true; - } - - // Check for method name matches (like 'sendMessage' from chat instances) - const methodName = methodPath.split('.').pop(); - return GOOGLE_GENAI_INSTRUMENTED_METHODS.includes(methodName as GoogleGenAIIstrumentedMethod); -} - -/** - * Check if a method is a streaming method - */ -export function isStreamingMethod(methodPath: string): boolean { - return methodPath.includes('Stream'); -} - // Copied from https://googleapis.github.io/js-genai/release_docs/index.html export type ContentListUnion = Content | Content[] | PartListUnion; export type ContentUnion = Content | PartUnion[] | PartUnion; diff --git a/packages/core/src/tracing/openai/constants.ts b/packages/core/src/tracing/openai/constants.ts index 426cda443680..f28571aae09b 100644 --- a/packages/core/src/tracing/openai/constants.ts +++ b/packages/core/src/tracing/openai/constants.ts @@ -1,16 +1,18 @@ +import type { InstrumentedMethodRegistry } from '../ai/utils'; + export const OPENAI_INTEGRATION_NAME = 'OpenAI'; // https://platform.openai.com/docs/quickstart?api-mode=responses // https://platform.openai.com/docs/quickstart?api-mode=chat // https://platform.openai.com/docs/api-reference/conversations -export const INSTRUMENTED_METHODS = [ - 'responses.create', - 'chat.completions.create', - 'embeddings.create', +export const OPENAI_METHOD_REGISTRY = { + 'responses.create': { operation: 'chat' }, + 'chat.completions.create': { operation: 'chat' }, + 'embeddings.create': { operation: 'embeddings' }, // Conversations API - for conversation state management // https://platform.openai.com/docs/guides/conversation-state - 'conversations.create', -] as const; + 'conversations.create': { operation: 'chat' }, +} as const satisfies InstrumentedMethodRegistry; export const RESPONSES_TOOL_CALL_EVENT_TYPES = [ 'response.output_item.added', 'response.function_call_arguments.delta', diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index d5ee3f53af86..0f83bf2cd3eb 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -15,37 +15,28 @@ import { GEN_AI_RESPONSE_TEXT_ATTRIBUTE, GEN_AI_SYSTEM_ATTRIBUTE, GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, - OPENAI_OPERATIONS, } from '../ai/gen-ai-attributes'; +import type { InstrumentedMethodEntry } from '../ai/utils'; import { + buildMethodPath, extractSystemInstructions, getTruncatedJsonString, resolveAIRecordingOptions, wrapPromiseWithMethods, - buildMethodPath, } from '../ai/utils'; +import { OPENAI_METHOD_REGISTRY } from './constants'; import { instrumentStream } from './streaming'; -import type { - ChatCompletionChunk, - InstrumentedMethod, - OpenAiOptions, - OpenAiResponse, - OpenAIStream, - ResponseStreamingEvent, -} from './types'; +import type { ChatCompletionChunk, OpenAiOptions, OpenAiResponse, OpenAIStream, ResponseStreamingEvent } from './types'; import { addChatCompletionAttributes, addConversationAttributes, addEmbeddingsAttributes, addResponsesApiAttributes, extractRequestParameters, - getOperationName, - getSpanOperation, isChatCompletionResponse, isConversationResponse, isEmbeddingsResponse, isResponsesApiResponse, - shouldInstrument, } from './utils'; /** @@ -74,10 +65,10 @@ function extractAvailableTools(params: Record): string | undefi /** * Extract request attributes from method arguments */ -function extractRequestAttributes(args: unknown[], methodPath: string): Record { +function extractRequestAttributes(args: unknown[], operationName: string): Record { const attributes: Record = { [GEN_AI_SYSTEM_ATTRIBUTE]: 'openai', - [GEN_AI_OPERATION_NAME_ATTRIBUTE]: getOperationName(methodPath), + [GEN_AI_OPERATION_NAME_ATTRIBUTE]: operationName, [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.openai', }; @@ -127,7 +118,7 @@ function addResponseAttributes(span: Span, result: unknown, recordOutputs?: bool // Extract and record AI request inputs, if present. This is intentionally separate from response attributes. function addRequestAttributes(span: Span, params: Record, operationName: string): void { // Store embeddings input on a separate attribute and do not truncate it - if (operationName === OPENAI_OPERATIONS.EMBEDDINGS && 'input' in params) { + if (operationName === 'embeddings' && 'input' in params) { const input = params.input; // No input provided @@ -183,21 +174,22 @@ function addRequestAttributes(span: Span, params: Record, opera */ function instrumentMethod( originalMethod: (...args: T) => Promise, - methodPath: InstrumentedMethod, + methodPath: string, + instrumentedMethod: InstrumentedMethodEntry, context: unknown, options: OpenAiOptions, ): (...args: T) => Promise { - return function instrumentedMethod(...args: T): Promise { - const requestAttributes = extractRequestAttributes(args, methodPath); + return function instrumentedCall(...args: T): Promise { + const operationName = instrumentedMethod.operation; + const requestAttributes = extractRequestAttributes(args, operationName); const model = (requestAttributes[GEN_AI_REQUEST_MODEL_ATTRIBUTE] as string) || 'unknown'; - const operationName = getOperationName(methodPath); const params = args[0] as Record | undefined; const isStreamRequested = params && typeof params === 'object' && params.stream === true; const spanConfig = { name: `${operationName} ${model}`, - op: getSpanOperation(methodPath), + op: `gen_ai.${operationName}`, attributes: requestAttributes as Record, }; @@ -280,8 +272,15 @@ function createDeepProxy(target: T, currentPath = '', options: const value = (obj as Record)[prop]; const methodPath = buildMethodPath(currentPath, String(prop)); - if (typeof value === 'function' && shouldInstrument(methodPath)) { - return instrumentMethod(value as (...args: unknown[]) => Promise, methodPath, obj, options); + const instrumentedMethod = OPENAI_METHOD_REGISTRY[methodPath as keyof typeof OPENAI_METHOD_REGISTRY]; + if (typeof value === 'function' && instrumentedMethod) { + return instrumentMethod( + value as (...args: unknown[]) => Promise, + methodPath, + instrumentedMethod, + obj, + options, + ); } if (typeof value === 'function') { diff --git a/packages/core/src/tracing/openai/types.ts b/packages/core/src/tracing/openai/types.ts index 94809041d94e..dd6872bb691b 100644 --- a/packages/core/src/tracing/openai/types.ts +++ b/packages/core/src/tracing/openai/types.ts @@ -1,4 +1,4 @@ -import type { INSTRUMENTED_METHODS } from './constants'; +import type { OPENAI_METHOD_REGISTRY } from './constants'; /** * Attribute values may be any non-nullish primitive value except an object. @@ -360,4 +360,7 @@ export interface OpenAiIntegration { options: OpenAiOptions; } -export type InstrumentedMethod = (typeof INSTRUMENTED_METHODS)[number]; +/** + * @deprecated This type is no longer used and will be removed in the next major version. + */ +export type InstrumentedMethod = keyof typeof OPENAI_METHOD_REGISTRY; diff --git a/packages/core/src/tracing/openai/utils.ts b/packages/core/src/tracing/openai/utils.ts index f89b786b5a3c..e7ad110e00bb 100644 --- a/packages/core/src/tracing/openai/utils.ts +++ b/packages/core/src/tracing/openai/utils.ts @@ -16,17 +16,14 @@ import { GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, - OPENAI_OPERATIONS, OPENAI_RESPONSE_ID_ATTRIBUTE, OPENAI_RESPONSE_MODEL_ATTRIBUTE, OPENAI_RESPONSE_TIMESTAMP_ATTRIBUTE, OPENAI_USAGE_COMPLETION_TOKENS_ATTRIBUTE, OPENAI_USAGE_PROMPT_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { INSTRUMENTED_METHODS } from './constants'; import type { ChatCompletionChunk, - InstrumentedMethod, OpenAiChatCompletionObject, OpenAIConversationObject, OpenAICreateEmbeddingsObject, @@ -34,41 +31,6 @@ import type { ResponseStreamingEvent, } from './types'; -/** - * Maps OpenAI method paths to OpenTelemetry semantic convention operation names - * @see https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/#llm-request-spans - */ -export function getOperationName(methodPath: string): string { - if (methodPath.includes('chat.completions')) { - return OPENAI_OPERATIONS.CHAT; - } - if (methodPath.includes('responses')) { - return OPENAI_OPERATIONS.CHAT; - } - if (methodPath.includes('embeddings')) { - return OPENAI_OPERATIONS.EMBEDDINGS; - } - if (methodPath.includes('conversations')) { - return OPENAI_OPERATIONS.CHAT; - } - return methodPath.split('.').pop() || 'unknown'; -} - -/** - * Get the span operation for OpenAI methods - * Following Sentry's convention: "gen_ai.{operation_name}" - */ -export function getSpanOperation(methodPath: string): string { - return `gen_ai.${getOperationName(methodPath)}`; -} - -/** - * Check if a method path should be instrumented - */ -export function shouldInstrument(methodPath: string): methodPath is InstrumentedMethod { - return INSTRUMENTED_METHODS.includes(methodPath as InstrumentedMethod); -} - /** * Check if response is a Chat Completion object */ diff --git a/packages/core/src/tracing/trace.ts b/packages/core/src/tracing/trace.ts index 28a5bccd4147..d3043808260f 100644 --- a/packages/core/src/tracing/trace.ts +++ b/packages/core/src/tracing/trace.ts @@ -291,6 +291,11 @@ export function suppressTracing(callback: () => T): T { * or page will automatically create a new trace. */ export function startNewTrace(callback: () => T): T { + const acs = getAcs(); + if (acs.startNewTrace) { + return acs.startNewTrace(callback); + } + return withScope(scope => { scope.setPropagationContext({ traceId: generateTraceId(), diff --git a/packages/core/src/utils/traceData.ts b/packages/core/src/utils/traceData.ts index 9958e2761960..c19b2560b605 100644 --- a/packages/core/src/utils/traceData.ts +++ b/packages/core/src/utils/traceData.ts @@ -1,7 +1,7 @@ import { getAsyncContextStrategy } from '../asyncContext'; import { getMainCarrier } from '../carrier'; import type { Client } from '../client'; -import { getClient, getCurrentScope } from '../currentScopes'; +import { getClient, getCurrentScope, hasExternalPropagationContext } from '../currentScopes'; import { isEnabled } from '../exports'; import type { Scope } from '../scope'; import { getDynamicSamplingContextFromScope, getDynamicSamplingContextFromSpan } from '../tracing'; @@ -20,6 +20,10 @@ import { generateSentryTraceHeader, generateTraceparentHeader, TRACEPARENT_REGEX * This function also applies some validation to the generated sentry-trace and baggage values to ensure that * only valid strings are returned. * + * When an external propagation context is registered (e.g. via the OTLP integration) and there is no active + * Sentry span, this function returns an empty object to defer outgoing request propagation to the external + * propagator (e.g. an OpenTelemetry propagator). + * * If (@param options.propagateTraceparent) is `true`, the function will also generate a `traceparent` value, * following the W3C traceparent header format. * @@ -42,6 +46,13 @@ export function getTraceData( const scope = options.scope || getCurrentScope(); const span = options.span || getActiveSpan(); + + // When no active span and external propagation context is registered (e.g. OTLP integration), + // return empty to let the OTel propagator handle outgoing request propagation. + if (!span && hasExternalPropagationContext()) { + return {}; + } + const sentryTrace = span ? spanToTraceHeader(span) : scopeToTraceHeader(scope); const dsc = span ? getDynamicSamplingContextFromSpan(span) : getDynamicSamplingContextFromScope(client, scope); const baggage = dynamicSamplingContextToSentryBaggageHeader(dsc); diff --git a/packages/core/test/lib/currentScopes.test.ts b/packages/core/test/lib/currentScopes.test.ts new file mode 100644 index 000000000000..2320235ac4b0 --- /dev/null +++ b/packages/core/test/lib/currentScopes.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + getExternalPropagationContext, + getTraceContextFromScope, + hasExternalPropagationContext, + registerExternalPropagationContext, +} from '../../src/currentScopes'; +import { Scope } from '../../src/scope'; + +describe('External Propagation Context', () => { + afterEach(() => { + // Reset by registering a provider that returns undefined + registerExternalPropagationContext(() => undefined); + }); + + describe('registerExternalPropagationContext', () => { + it('registers a provider function', () => { + registerExternalPropagationContext(() => ({ + traceId: 'abc123', + spanId: 'def456', + })); + + expect(hasExternalPropagationContext()).toBe(true); + }); + }); + + describe('getExternalPropagationContext', () => { + it('returns undefined when provider returns undefined', () => { + registerExternalPropagationContext(() => undefined); + expect(getExternalPropagationContext()).toBeUndefined(); + }); + + it('returns trace context from provider', () => { + registerExternalPropagationContext(() => ({ + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + })); + + const result = getExternalPropagationContext(); + expect(result).toEqual({ + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + }); + }); + }); + + describe('hasExternalPropagationContext', () => { + it('returns true after registration', () => { + registerExternalPropagationContext(() => undefined); + expect(hasExternalPropagationContext()).toBe(true); + }); + }); + + describe('getTraceContextFromScope with external propagation context', () => { + it('uses external propagation context when available', () => { + registerExternalPropagationContext(() => ({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', + spanId: 'bbbbbbbbbbbbbb01', + })); + + const scope = new Scope(); + scope.setPropagationContext({ + traceId: 'cccccccccccccccccccccccccccccc01', + sampleRand: 0.5, + }); + + const traceContext = getTraceContextFromScope(scope); + expect(traceContext.trace_id).toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'); + expect(traceContext.span_id).toBe('bbbbbbbbbbbbbb01'); + expect(traceContext.parent_span_id).toBeUndefined(); + }); + + it('falls back to scope propagation context when provider returns undefined', () => { + registerExternalPropagationContext(() => undefined); + + const scope = new Scope(); + scope.setPropagationContext({ + traceId: 'cccccccccccccccccccccccccccccc01', + sampleRand: 0.5, + }); + + const traceContext = getTraceContextFromScope(scope); + expect(traceContext.trace_id).toBe('cccccccccccccccccccccccccccccc01'); + }); + }); +}); diff --git a/packages/core/test/lib/integrations/supabase.test.ts b/packages/core/test/lib/integrations/supabase.test.ts new file mode 100644 index 000000000000..519dda4f06a0 --- /dev/null +++ b/packages/core/test/lib/integrations/supabase.test.ts @@ -0,0 +1,179 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as breadcrumbModule from '../../../src/breadcrumbs'; +import * as exportsModule from '../../../src/exports'; +import { + extractOperation, + instrumentSupabaseClient, + translateFiltersIntoMethods, +} from '../../../src/integrations/supabase'; +import type { PostgRESTQueryBuilder, SupabaseClientInstance } from '../../../src/integrations/supabase'; + +// Mock tracing to avoid needing full SDK setup +vi.mock('../../../src/tracing', () => ({ + startSpan: (_opts: any, cb: (span: any) => any) => { + const mockSpan = { + setStatus: vi.fn(), + end: vi.fn(), + }; + return cb(mockSpan); + }, + setHttpStatus: vi.fn(), + SPAN_STATUS_OK: 1, + SPAN_STATUS_ERROR: 2, +})); + +describe('Supabase Integration', () => { + describe('extractOperation', () => { + it('returns select for GET', () => { + expect(extractOperation('GET')).toBe('select'); + }); + + it('returns insert for POST without resolution header', () => { + expect(extractOperation('POST')).toBe('insert'); + }); + + it('returns upsert for POST with resolution header', () => { + expect(extractOperation('POST', { Prefer: 'resolution=merge-duplicates' })).toBe('upsert'); + }); + + it('returns update for PATCH', () => { + expect(extractOperation('PATCH')).toBe('update'); + }); + + it('returns delete for DELETE', () => { + expect(extractOperation('DELETE')).toBe('delete'); + }); + }); + + describe('translateFiltersIntoMethods', () => { + it('returns select(*) for wildcard', () => { + expect(translateFiltersIntoMethods('select', '*')).toBe('select(*)'); + }); + + it('returns select with columns', () => { + expect(translateFiltersIntoMethods('select', 'id,name')).toBe('select(id,name)'); + }); + + it('translates eq filter', () => { + expect(translateFiltersIntoMethods('id', 'eq.123')).toBe('eq(id, 123)'); + }); + }); + + describe('instrumentPostgRESTFilterBuilder - nullish response handling', () => { + let captureExceptionSpy: ReturnType; + let addBreadcrumbSpy: ReturnType; + + beforeEach(() => { + captureExceptionSpy = vi.spyOn(exportsModule, 'captureException').mockImplementation(() => ''); + addBreadcrumbSpy = vi.spyOn(breadcrumbModule, 'addBreadcrumb').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function createMockSupabaseClient(resolveWith: unknown): unknown { + // Create a PostgRESTFilterBuilder-like class + class MockPostgRESTFilterBuilder { + method = 'GET'; + headers: Record = { 'X-Client-Info': 'supabase-js/2.0.0' }; + url = new URL('https://example.supabase.co/rest/v1/todos'); + schema = 'public'; + body = undefined; + + then(onfulfilled?: (value: any) => any, onrejected?: (reason: any) => any): Promise { + return Promise.resolve(resolveWith).then(onfulfilled, onrejected); + } + } + + class MockPostgRESTQueryBuilder { + select() { + return new MockPostgRESTFilterBuilder(); + } + insert() { + return new MockPostgRESTFilterBuilder(); + } + upsert() { + return new MockPostgRESTFilterBuilder(); + } + update() { + return new MockPostgRESTFilterBuilder(); + } + delete() { + return new MockPostgRESTFilterBuilder(); + } + } + + // Create a mock SupabaseClient constructor + class MockSupabaseClient { + auth = { + admin: {} as any, + } as SupabaseClientInstance['auth']; + + from(_table: string): PostgRESTQueryBuilder { + return new MockPostgRESTQueryBuilder() as unknown as PostgRESTQueryBuilder; + } + } + + return new MockSupabaseClient(); + } + + it('handles undefined response without throwing', async () => { + const client = createMockSupabaseClient(undefined); + instrumentSupabaseClient(client); + + const builder = (client as any).from('todos'); + const result = builder.select('*'); + + // This should not throw even though the response is undefined + const res = await result; + expect(res).toBeUndefined(); + }); + + it('handles null response without throwing', async () => { + const client = createMockSupabaseClient(null); + instrumentSupabaseClient(client); + + const builder = (client as any).from('todos'); + const result = builder.select('*'); + + const res = await result; + expect(res).toBeNull(); + }); + + it('still adds breadcrumb when response is undefined', async () => { + const client = createMockSupabaseClient(undefined); + instrumentSupabaseClient(client); + + const builder = (client as any).from('todos'); + await builder.select('*'); + + expect(addBreadcrumbSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'supabase', + category: 'db.select', + }), + ); + }); + + it('does not capture exception when response is undefined', async () => { + const client = createMockSupabaseClient(undefined); + instrumentSupabaseClient(client); + + const builder = (client as any).from('todos'); + await builder.select('*'); + + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('still captures error when response has error', async () => { + const client = createMockSupabaseClient({ status: 400, error: { message: 'Bad request', code: '400' } }); + instrumentSupabaseClient(client); + + const builder = (client as any).from('todos'); + await builder.select('*'); + + expect(captureExceptionSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/core/test/lib/utils/anthropic-utils.test.ts b/packages/core/test/lib/utils/anthropic-utils.test.ts index b912af40e35e..012ff9e6ccb6 100644 --- a/packages/core/test/lib/utils/anthropic-utils.test.ts +++ b/packages/core/test/lib/utils/anthropic-utils.test.ts @@ -3,7 +3,6 @@ import { mapAnthropicErrorToStatusMessage, messagesFromParams, setMessagesAttribute, - shouldInstrument, } from '../../../src/tracing/anthropic-ai/utils'; import type { Span } from '../../../src/types-hoist/span'; @@ -29,16 +28,6 @@ describe('anthropic-ai-utils', () => { }); }); - describe('shouldInstrument', () => { - it('should instrument known methods', () => { - expect(shouldInstrument('models.get')).toBe(true); - }); - - it('should not instrument unknown methods', () => { - expect(shouldInstrument('models.unknown.thing')).toBe(false); - }); - }); - describe('messagesFromParams', () => { it('includes system message in messages list', () => { expect( diff --git a/packages/core/test/lib/utils/google-genai-utils.test.ts b/packages/core/test/lib/utils/google-genai-utils.test.ts index 7b9c6d80c773..93d7750994f1 100644 --- a/packages/core/test/lib/utils/google-genai-utils.test.ts +++ b/packages/core/test/lib/utils/google-genai-utils.test.ts @@ -1,26 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { ContentListUnion } from '../../../src/tracing/google-genai/utils'; -import { contentUnionToMessages, isStreamingMethod, shouldInstrument } from '../../../src/tracing/google-genai/utils'; - -describe('isStreamingMethod', () => { - it('detects streaming methods', () => { - expect(isStreamingMethod('messageStreamBlah')).toBe(true); - expect(isStreamingMethod('blahblahblah generateContentStream')).toBe(true); - expect(isStreamingMethod('blahblahblah sendMessageStream')).toBe(true); - expect(isStreamingMethod('blahblahblah generateContentStream')).toBe(true); - expect(isStreamingMethod('blahblahblah sendMessageStream')).toBe(true); - expect(isStreamingMethod('blahblahblah generateContent')).toBe(false); - expect(isStreamingMethod('blahblahblah sendMessage')).toBe(false); - }); -}); - -describe('shouldInstrument', () => { - it('detects which methods to instrument', () => { - expect(shouldInstrument('models.generateContent')).toBe(true); - expect(shouldInstrument('some.path.to.sendMessage')).toBe(true); - expect(shouldInstrument('unknown')).toBe(false); - }); -}); +import { contentUnionToMessages } from '../../../src/tracing/google-genai/utils'; describe('convert google-genai messages to consistent message', () => { it('converts strings to messages', () => { diff --git a/packages/core/test/lib/utils/openai-utils.test.ts b/packages/core/test/lib/utils/openai-utils.test.ts index 65a55bcc9ef6..3f8fd0045f2e 100644 --- a/packages/core/test/lib/utils/openai-utils.test.ts +++ b/packages/core/test/lib/utils/openai-utils.test.ts @@ -1,64 +1,14 @@ import { describe, expect, it } from 'vitest'; import { buildMethodPath } from '../../../src/tracing/ai/utils'; import { - getOperationName, - getSpanOperation, isChatCompletionChunk, isChatCompletionResponse, isConversationResponse, isResponsesApiResponse, isResponsesApiStreamEvent, - shouldInstrument, } from '../../../src/tracing/openai/utils'; describe('openai-utils', () => { - describe('getOperationName', () => { - it('should return chat for chat.completions methods', () => { - expect(getOperationName('chat.completions.create')).toBe('chat'); - expect(getOperationName('some.path.chat.completions.method')).toBe('chat'); - }); - - it('should return chat for responses methods', () => { - expect(getOperationName('responses.create')).toBe('chat'); - expect(getOperationName('some.path.responses.method')).toBe('chat'); - }); - - it('should return chat for conversations methods', () => { - expect(getOperationName('conversations.create')).toBe('chat'); - expect(getOperationName('some.path.conversations.method')).toBe('chat'); - }); - - it('should return the last part of path for unknown methods', () => { - expect(getOperationName('some.unknown.method')).toBe('method'); - expect(getOperationName('create')).toBe('create'); - }); - - it('should return unknown for empty path', () => { - expect(getOperationName('')).toBe('unknown'); - }); - }); - - describe('getSpanOperation', () => { - it('should prefix operation with gen_ai', () => { - expect(getSpanOperation('chat.completions.create')).toBe('gen_ai.chat'); - expect(getSpanOperation('responses.create')).toBe('gen_ai.chat'); - expect(getSpanOperation('some.custom.operation')).toBe('gen_ai.operation'); - }); - }); - - describe('shouldInstrument', () => { - it('should return true for instrumented methods', () => { - expect(shouldInstrument('responses.create')).toBe(true); - expect(shouldInstrument('chat.completions.create')).toBe(true); - expect(shouldInstrument('conversations.create')).toBe(true); - }); - - it('should return false for non-instrumented methods', () => { - expect(shouldInstrument('unknown.method')).toBe(false); - expect(shouldInstrument('')).toBe(false); - }); - }); - describe('buildMethodPath', () => { it('should build method path correctly', () => { expect(buildMethodPath('', 'chat')).toBe('chat'); diff --git a/packages/core/test/lib/utils/traceData.test.ts b/packages/core/test/lib/utils/traceData.test.ts index 379103a8a48c..6baf7a9d7a40 100644 --- a/packages/core/test/lib/utils/traceData.test.ts +++ b/packages/core/test/lib/utils/traceData.test.ts @@ -6,6 +6,7 @@ import { getIsolationScope, getMainCarrier, getTraceData, + registerExternalPropagationContext, Scope, SentrySpan, setAsyncContextStrategy, @@ -347,4 +348,47 @@ describe('getTraceData', () => { expect(traceData.traceparent).toBeDefined(); expect(traceData.traceparent).toMatch(/00-12345678901234567890123456789099-[0-9a-f]{16}-00/); }); + + it('returns empty object when no span and external propagation context is registered', () => { + setupClient(); + + registerExternalPropagationContext(() => ({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', + spanId: 'bbbbbbbbbbbbbb01', + })); + + const traceData = getTraceData(); + expect(traceData).toEqual({}); + + // Clean up + registerExternalPropagationContext(() => undefined); + }); + + it('still returns trace data from span even when external propagation context is registered', () => { + setupClient(); + + registerExternalPropagationContext(() => ({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1', + spanId: 'bbbbbbbbbbbbbb01', + })); + + const span = new SentrySpan({ + traceId: '12345678901234567890123456789012', + spanId: '1234567890123456', + sampled: true, + }); + + withActiveSpan(span, () => { + const data = getTraceData(); + + expect(data).toEqual({ + 'sentry-trace': '12345678901234567890123456789012-1234567890123456-1', + baggage: + 'sentry-environment=production,sentry-public_key=123,sentry-trace_id=12345678901234567890123456789012,sentry-sampled=true', + }); + }); + + // Clean up + registerExternalPropagationContext(() => undefined); + }); }); diff --git a/packages/deno/package.json b/packages/deno/package.json index def1e04d26e4..98b039511dfa 100644 --- a/packages/deno/package.json +++ b/packages/deno/package.json @@ -24,7 +24,7 @@ "/build" ], "dependencies": { - "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api": "^1.9.1", "@sentry/core": "10.46.0" }, "scripts": { diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 9478b98f5a58..004c785b6ca5 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -61,6 +61,8 @@ export { langChainIntegration, langGraphIntegration, modulesIntegration, + nodeRuntimeMetricsIntegration, + type NodeRuntimeMetricsOptions, contextLinesIntegration, nodeContextIntegration, localVariablesIntegration, diff --git a/packages/hono/package.json b/packages/hono/package.json index 3b30a6583577..59fda0a959a0 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -65,7 +65,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api": "^1.9.1", "@sentry/cloudflare": "10.46.0", "@sentry/core": "10.46.0", "@sentry/node": "10.46.0" diff --git a/packages/integration-shims/src/ElementTiming.ts b/packages/integration-shims/src/ElementTiming.ts new file mode 100644 index 000000000000..8a521163f7e1 --- /dev/null +++ b/packages/integration-shims/src/ElementTiming.ts @@ -0,0 +1,17 @@ +import { consoleSandbox, defineIntegration } from '@sentry/core'; + +/** + * This is a shim for the ElementTiming integration. + * It is needed in order for the CDN bundles to continue working when users add/remove metrics + * from it, without changing their config. This is necessary for the loader mechanism. + */ +export const elementTimingIntegrationShim = defineIntegration(() => { + consoleSandbox(() => { + // eslint-disable-next-line no-console + console.warn('You are using elementTimingIntegration() even though this bundle does not include element timing.'); + }); + + return { + name: 'ElementTiming', + }; +}); diff --git a/packages/integration-shims/src/index.ts b/packages/integration-shims/src/index.ts index 1d535b6da35d..4cabb8a5e36f 100644 --- a/packages/integration-shims/src/index.ts +++ b/packages/integration-shims/src/index.ts @@ -2,4 +2,5 @@ export { feedbackIntegrationShim } from './Feedback'; export { replayIntegrationShim } from './Replay'; export { browserTracingIntegrationShim } from './BrowserTracing'; export { launchDarklyIntegrationShim, buildLaunchDarklyFlagUsedHandlerShim } from './launchDarkly'; +export { elementTimingIntegrationShim } from './ElementTiming'; export { loggerShim, consoleLoggingIntegrationShim } from './logs'; diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 6da89cb2ed8a..50105e65486d 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -44,10 +44,10 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^2.6.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/instrumentation-nestjs-core": "0.59.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/instrumentation-nestjs-core": "0.60.0", "@opentelemetry/semantic-conventions": "^1.40.0", "@sentry/core": "10.46.0", "@sentry/node": "10.46.0" diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index f79bee2c9dc5..cae2bf083f20 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -76,7 +76,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api": "^1.9.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@rollup/plugin-commonjs": "28.0.1", "@sentry-internal/browser-utils": "10.46.0", diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 8a3c8dfaa927..f8e24ab7b5d2 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -54,6 +54,16 @@ "require": { "default": "./build/cjs/init.js" } + }, + "./light/otlp": { + "import": { + "types": "./build/types/light/integrations/otlpIntegration.d.ts", + "default": "./build/esm/light/integrations/otlpIntegration.js" + }, + "require": { + "types": "./build/types/light/integrations/otlpIntegration.d.ts", + "default": "./build/cjs/light/integrations/otlpIntegration.js" + } } }, "typesVersions": { @@ -73,7 +83,8 @@ "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/resources": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", - "@opentelemetry/semantic-conventions": "^1.39.0" + "@opentelemetry/semantic-conventions": "^1.39.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1" }, "peerDependenciesMeta": { "@opentelemetry/api": { @@ -96,6 +107,9 @@ }, "@opentelemetry/semantic-conventions": { "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true } }, "dependencies": { @@ -104,12 +118,13 @@ "import-in-the-middle": "^3.0.0" }, "devDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.6.0", - "@opentelemetry/core": "^2.6.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/resources": "^2.6.0", - "@opentelemetry/sdk-trace-base": "^2.6.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.6.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/resources": "^2.6.1", + "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@types/node": "^18.19.1" }, diff --git a/packages/node-core/rollup.npm.config.mjs b/packages/node-core/rollup.npm.config.mjs index 9bae67fd2dd8..9fa0a1fb19b9 100644 --- a/packages/node-core/rollup.npm.config.mjs +++ b/packages/node-core/rollup.npm.config.mjs @@ -19,7 +19,7 @@ export default [ localVariablesWorkerConfig, ...makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/init.ts', 'src/light/index.ts'], + entrypoints: ['src/index.ts', 'src/init.ts', 'src/light/index.ts', 'src/light/integrations/otlpIntegration.ts'], packageSpecificConfig: { output: { // set exports to 'named' or 'auto' so that rollup doesn't warn diff --git a/packages/node-core/src/common-exports.ts b/packages/node-core/src/common-exports.ts index 3fff4100b352..d6d1e070ef85 100644 --- a/packages/node-core/src/common-exports.ts +++ b/packages/node-core/src/common-exports.ts @@ -12,6 +12,7 @@ import * as logger from './logs/exports'; // Node-core integrations (not OTel-dependent) export { nodeContextIntegration } from './integrations/context'; +export { nodeRuntimeMetricsIntegration, type NodeRuntimeMetricsOptions } from './integrations/nodeRuntimeMetrics'; export { contextLinesIntegration } from './integrations/contextlines'; export { localVariablesIntegration } from './integrations/local-variables'; export { modulesIntegration } from './integrations/modules'; diff --git a/packages/node-core/src/integrations/nodeRuntimeMetrics.ts b/packages/node-core/src/integrations/nodeRuntimeMetrics.ts new file mode 100644 index 000000000000..c2ae72f04f77 --- /dev/null +++ b/packages/node-core/src/integrations/nodeRuntimeMetrics.ts @@ -0,0 +1,243 @@ +import { monitorEventLoopDelay, performance } from 'perf_hooks'; +import { _INTERNAL_safeDateNow, _INTERNAL_safeUnref, defineIntegration, metrics } from '@sentry/core'; + +const INTEGRATION_NAME = 'NodeRuntimeMetrics'; +const DEFAULT_INTERVAL_MS = 30_000; +const EVENT_LOOP_DELAY_RESOLUTION_MS = 10; + +export interface NodeRuntimeMetricsOptions { + /** + * Which metrics to collect. + * + * Default on (8 metrics): + * - `cpuUtilization` — CPU utilization ratio + * - `memRss` — Resident Set Size (actual memory footprint) + * - `memHeapUsed` — V8 heap currently in use + * - `memHeapTotal` — total V8 heap allocated (headroom paired with `memHeapUsed`) + * - `eventLoopDelayP50` — median event loop delay (baseline latency) + * - `eventLoopDelayP99` — 99th percentile event loop delay (tail latency / spikes) + * - `eventLoopUtilization` — fraction of time the event loop was active + * - `uptime` — process uptime (detect restarts/crashes) + * + * Default off (opt-in): + * - `cpuTime` — raw user/system CPU time in seconds + * - `memExternal` — external/ArrayBuffer memory (relevant for native addons) + * - `eventLoopDelayMin` / `eventLoopDelayMax` / `eventLoopDelayMean` / `eventLoopDelayP90` + */ + collect?: { + // Default on + cpuUtilization?: boolean; + memHeapUsed?: boolean; + memRss?: boolean; + eventLoopDelayP99?: boolean; + eventLoopUtilization?: boolean; + uptime?: boolean; + // Default off + cpuTime?: boolean; + memHeapTotal?: boolean; + memExternal?: boolean; + eventLoopDelayMin?: boolean; + eventLoopDelayMax?: boolean; + eventLoopDelayMean?: boolean; + eventLoopDelayP50?: boolean; + eventLoopDelayP90?: boolean; + }; + /** + * How often to collect metrics, in milliseconds. + * @default 30000 + */ + collectionIntervalMs?: number; +} + +/** + * Automatically collects Node.js runtime metrics and emits them to Sentry. + * + * @example + * ```ts + * Sentry.init({ + * integrations: [ + * Sentry.nodeRuntimeMetricsIntegration(), + * ], + * }); + * ``` + */ +export const nodeRuntimeMetricsIntegration = defineIntegration((options: NodeRuntimeMetricsOptions = {}) => { + const collectionIntervalMs = options.collectionIntervalMs ?? DEFAULT_INTERVAL_MS; + const collect = { + // Default on + cpuUtilization: true, + memHeapUsed: true, + memHeapTotal: true, + memRss: true, + eventLoopDelayP50: true, + eventLoopDelayP99: true, + eventLoopUtilization: true, + uptime: true, + // Default off + cpuTime: false, + memExternal: false, + eventLoopDelayMin: false, + eventLoopDelayMax: false, + eventLoopDelayMean: false, + eventLoopDelayP90: false, + ...options.collect, + }; + + const needsEventLoopDelay = + collect.eventLoopDelayP99 || + collect.eventLoopDelayMin || + collect.eventLoopDelayMax || + collect.eventLoopDelayMean || + collect.eventLoopDelayP50 || + collect.eventLoopDelayP90; + + const needsCpu = collect.cpuUtilization || collect.cpuTime; + + let intervalId: ReturnType | undefined; + let prevCpuUsage: NodeJS.CpuUsage | undefined; + let prevElu: ReturnType | undefined; + let prevFlushTime: number = 0; + let eventLoopDelayHistogram: ReturnType | undefined; + + const resolutionNs = EVENT_LOOP_DELAY_RESOLUTION_MS * 1e6; + const nsToS = (ns: number): number => Math.max(0, (ns - resolutionNs) / 1e9); + + const METRIC_ATTRIBUTES = { attributes: { 'sentry.origin': 'auto.node.runtime_metrics' } }; + const METRIC_ATTRIBUTES_BYTE = { unit: 'byte', attributes: { 'sentry.origin': 'auto.node.runtime_metrics' } }; + const METRIC_ATTRIBUTES_SECOND = { unit: 'second', attributes: { 'sentry.origin': 'auto.node.runtime_metrics' } }; + + function collectMetrics(): void { + const now = _INTERNAL_safeDateNow(); + const elapsed = now - prevFlushTime; + + if (needsCpu && prevCpuUsage !== undefined) { + const delta = process.cpuUsage(prevCpuUsage); + + if (collect.cpuTime) { + metrics.gauge('node.runtime.cpu.user', delta.user / 1e6, METRIC_ATTRIBUTES_SECOND); + metrics.gauge('node.runtime.cpu.system', delta.system / 1e6, METRIC_ATTRIBUTES_SECOND); + } + if (collect.cpuUtilization && elapsed > 0) { + // Ratio of CPU time to wall-clock time. Can exceed 1.0 on multi-core systems. + // TODO: In cluster mode, add a runtime_id/process_id attribute to disambiguate per-worker metrics. + metrics.gauge( + 'node.runtime.cpu.utilization', + (delta.user + delta.system) / (elapsed * 1000), + METRIC_ATTRIBUTES, + ); + } + + prevCpuUsage = process.cpuUsage(); + } + + if (collect.memRss || collect.memHeapUsed || collect.memHeapTotal || collect.memExternal) { + const mem = process.memoryUsage(); + if (collect.memRss) { + metrics.gauge('node.runtime.mem.rss', mem.rss, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memHeapUsed) { + metrics.gauge('node.runtime.mem.heap_used', mem.heapUsed, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memHeapTotal) { + metrics.gauge('node.runtime.mem.heap_total', mem.heapTotal, METRIC_ATTRIBUTES_BYTE); + } + if (collect.memExternal) { + metrics.gauge('node.runtime.mem.external', mem.external, METRIC_ATTRIBUTES_BYTE); + metrics.gauge('node.runtime.mem.array_buffers', mem.arrayBuffers, METRIC_ATTRIBUTES_BYTE); + } + } + + if (needsEventLoopDelay && eventLoopDelayHistogram) { + if (collect.eventLoopDelayMin) { + metrics.gauge( + 'node.runtime.event_loop.delay.min', + nsToS(eventLoopDelayHistogram.min), + METRIC_ATTRIBUTES_SECOND, + ); + } + if (collect.eventLoopDelayMax) { + metrics.gauge( + 'node.runtime.event_loop.delay.max', + nsToS(eventLoopDelayHistogram.max), + METRIC_ATTRIBUTES_SECOND, + ); + } + if (collect.eventLoopDelayMean) { + metrics.gauge( + 'node.runtime.event_loop.delay.mean', + nsToS(eventLoopDelayHistogram.mean), + METRIC_ATTRIBUTES_SECOND, + ); + } + if (collect.eventLoopDelayP50) { + metrics.gauge( + 'node.runtime.event_loop.delay.p50', + nsToS(eventLoopDelayHistogram.percentile(50)), + METRIC_ATTRIBUTES_SECOND, + ); + } + if (collect.eventLoopDelayP90) { + metrics.gauge( + 'node.runtime.event_loop.delay.p90', + nsToS(eventLoopDelayHistogram.percentile(90)), + METRIC_ATTRIBUTES_SECOND, + ); + } + if (collect.eventLoopDelayP99) { + metrics.gauge( + 'node.runtime.event_loop.delay.p99', + nsToS(eventLoopDelayHistogram.percentile(99)), + METRIC_ATTRIBUTES_SECOND, + ); + } + + eventLoopDelayHistogram.reset(); + } + + if (collect.eventLoopUtilization && prevElu !== undefined) { + const currentElu = performance.eventLoopUtilization(); + const delta = performance.eventLoopUtilization(currentElu, prevElu); + metrics.gauge('node.runtime.event_loop.utilization', delta.utilization, METRIC_ATTRIBUTES); + prevElu = currentElu; + } + + if (collect.uptime && elapsed > 0) { + metrics.count('node.runtime.process.uptime', elapsed / 1000, METRIC_ATTRIBUTES_SECOND); + } + + prevFlushTime = now; + } + + return { + name: INTEGRATION_NAME, + + setup(): void { + if (needsEventLoopDelay) { + // Disable any previous histogram before overwriting (prevents native resource leak on re-init). + eventLoopDelayHistogram?.disable(); + try { + eventLoopDelayHistogram = monitorEventLoopDelay({ resolution: EVENT_LOOP_DELAY_RESOLUTION_MS }); + eventLoopDelayHistogram.enable(); + } catch { + // Not available in all runtimes (e.g. Bun throws NotImplementedError). + eventLoopDelayHistogram = undefined; + } + } + + // Prime baselines before the first collection interval. + if (needsCpu) { + prevCpuUsage = process.cpuUsage(); + } + if (collect.eventLoopUtilization) { + prevElu = performance.eventLoopUtilization(); + } + prevFlushTime = _INTERNAL_safeDateNow(); + + // Guard against double setup (e.g. re-init). + if (intervalId) { + clearInterval(intervalId); + } + intervalId = _INTERNAL_safeUnref(setInterval(collectMetrics, collectionIntervalMs)); + }, + }; +}); diff --git a/packages/node-core/src/light/integrations/otlpIntegration.ts b/packages/node-core/src/light/integrations/otlpIntegration.ts new file mode 100644 index 000000000000..3f4507813525 --- /dev/null +++ b/packages/node-core/src/light/integrations/otlpIntegration.ts @@ -0,0 +1,142 @@ +import { trace } from '@opentelemetry/api'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import type { SpanExporter } from '@opentelemetry/sdk-trace-base'; +import { BasicTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; +import type { Client, IntegrationFn } from '@sentry/core'; +import { debug, defineIntegration, registerExternalPropagationContext, SENTRY_API_VERSION } from '@sentry/core'; + +interface OtlpIntegrationOptions { + /** + * Whether to set up the OTLP traces exporter that sends spans to Sentry. + * Default: true + */ + setupOtlpTracesExporter?: boolean; + + /** + * URL of your own OpenTelemetry collector. + * When set, the exporter will send traces to this URL instead of the Sentry OTLP endpoint derived from the DSN. + * Default: undefined (uses DSN-derived endpoint) + */ + collectorUrl?: string; +} + +const INTEGRATION_NAME = 'OtlpIntegration'; + +const _otlpIntegration = ((userOptions: OtlpIntegrationOptions = {}) => { + const options = { + setupOtlpTracesExporter: userOptions.setupOtlpTracesExporter ?? true, + collectorUrl: userOptions.collectorUrl, + }; + + let _spanProcessor: BatchSpanProcessor | undefined; + let _tracerProvider: BasicTracerProvider | undefined; + + return { + name: INTEGRATION_NAME, + + setup(_client: Client): void { + // Always register external propagation context so that Sentry error/log events + // are linked to the active OTel trace context. + registerExternalPropagationContext(() => { + const activeSpan = trace.getActiveSpan(); + if (!activeSpan) { + return undefined; + } + const spanContext = activeSpan.spanContext(); + return { traceId: spanContext.traceId, spanId: spanContext.spanId }; + }); + + debug.log(`[${INTEGRATION_NAME}] External propagation context registered.`); + }, + + afterAllSetup(client: Client): void { + if (options.setupOtlpTracesExporter) { + setupTracesExporter(client); + } + }, + }; + + function setupTracesExporter(client: Client): void { + let endpoint: string; + let headers: Record | undefined; + + if (options.collectorUrl) { + endpoint = options.collectorUrl; + debug.log(`[${INTEGRATION_NAME}] Sending traces to collector at ${endpoint}`); + } else { + const dsn = client.getDsn(); + if (!dsn) { + debug.warn(`[${INTEGRATION_NAME}] No DSN found. OTLP exporter not set up.`); + return; + } + + const { protocol, host, port, path, projectId, publicKey } = dsn; + + const basePath = path ? `/${path}` : ''; + const portStr = port ? `:${port}` : ''; + endpoint = `${protocol}://${host}${portStr}${basePath}/api/${projectId}/integration/otlp/v1/traces/`; + + const sdkInfo = client.getSdkMetadata()?.sdk; + const sentryClient = sdkInfo ? `, sentry_client=${sdkInfo.name}/${sdkInfo.version}` : ''; + headers = { + 'X-Sentry-Auth': `Sentry sentry_version=${SENTRY_API_VERSION}, sentry_key=${publicKey}${sentryClient}`, + }; + } + + let exporter: SpanExporter; + try { + exporter = new OTLPTraceExporter({ + url: endpoint, + headers, + }); + } catch (e) { + debug.warn(`[${INTEGRATION_NAME}] Failed to create OTLPTraceExporter:`, e); + return; + } + + _spanProcessor = new BatchSpanProcessor(exporter); + + // Add span processor to existing global tracer provider. + // trace.getTracerProvider() returns a ProxyTracerProvider; unwrap it to get the real provider. + const globalProvider = trace.getTracerProvider(); + const delegate = + 'getDelegate' in globalProvider + ? (globalProvider as unknown as { getDelegate(): unknown }).getDelegate() + : globalProvider; + + // In OTel v2, addSpanProcessor was removed. We push into the internal _spanProcessors + // array on the MultiSpanProcessor, which is how OTel's own forceFlush() accesses it. + const activeProcessor = (delegate as Record)?._activeSpanProcessor as + | { _spanProcessors?: unknown[] } + | undefined; + if (activeProcessor?._spanProcessors) { + activeProcessor._spanProcessors.push(_spanProcessor); + debug.log(`[${INTEGRATION_NAME}] Added span processor to existing TracerProvider.`); + } else { + // No user-configured provider; create a minimal one and set it as global + _tracerProvider = new BasicTracerProvider({ + spanProcessors: [_spanProcessor], + }); + trace.setGlobalTracerProvider(_tracerProvider); + debug.log(`[${INTEGRATION_NAME}] Created new TracerProvider with OTLP span processor.`); + } + + client.on('flush', () => { + void _spanProcessor?.forceFlush(); + }); + + client.on('close', () => { + void _spanProcessor?.shutdown(); + void _tracerProvider?.shutdown(); + }); + } +}) satisfies IntegrationFn; + +/** + * OTLP integration for the Sentry light SDK. + * + * Bridges an existing OpenTelemetry setup with Sentry by: + * 1. Linking Sentry error/log events to the active OTel trace context + * 2. Exporting OTel spans to Sentry via OTLP (or to a custom collector) + */ +export const otlpIntegration = defineIntegration(_otlpIntegration); diff --git a/packages/node-core/src/utils/baggage.ts b/packages/node-core/src/utils/baggage.ts index d236851559db..496c834d5c23 100644 --- a/packages/node-core/src/utils/baggage.ts +++ b/packages/node-core/src/utils/baggage.ts @@ -1,4 +1,4 @@ -import { objectToBaggageHeader, parseBaggageHeader } from '@sentry/core'; +import { objectToBaggageHeader, parseBaggageHeader, SENTRY_BAGGAGE_KEY_PREFIX } from '@sentry/core'; /** * Merge two baggage headers into one. @@ -24,15 +24,39 @@ export function mergeBaggageHeaders { - // Sentry-specific keys always take precedence from new baggage - // Non-Sentry keys only added if not already present - if (key.startsWith('sentry-') || !mergedBaggageEntries[key]) { + // Single pass over new entries to partition sentry vs non-sentry + const newSentryEntries: Record = {}; + const newNonSentryEntries: Record = {}; + for (const [key, value] of Object.entries(newBaggageEntries)) { + if (key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { + newSentryEntries[key] = value; + } else { + newNonSentryEntries[key] = value; + } + } + + const hasNewSentryEntries = Object.keys(newSentryEntries).length > 0; + + // If new baggage contains at least one sentry- value, we remove all old sentry- values + // otherwise, we keep old sentry- values. If we don't remove old sentry- values, we end + // up with an inconsistent dynamic sampling context propagation. + const mergedBaggageEntries: Record = {}; + if (existingBaggageEntries) { + for (const [key, value] of Object.entries(existingBaggageEntries)) { + if (hasNewSentryEntries && key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { + continue; + } mergedBaggageEntries[key] = value; } - }); + } + + // Sentry entries from new baggage always overwrite; non-sentry only if not already present + Object.assign(mergedBaggageEntries, newSentryEntries); + for (const [key, value] of Object.entries(newNonSentryEntries)) { + if (!mergedBaggageEntries[key]) { + mergedBaggageEntries[key] = value; + } + } return objectToBaggageHeader(mergedBaggageEntries); } diff --git a/packages/node-core/src/utils/outgoingFetchRequest.ts b/packages/node-core/src/utils/outgoingFetchRequest.ts index ce04a26db1e0..cad20496e478 100644 --- a/packages/node-core/src/utils/outgoingFetchRequest.ts +++ b/packages/node-core/src/utils/outgoingFetchRequest.ts @@ -10,13 +10,10 @@ import { } from '@sentry/core'; import type { UndiciRequest, UndiciResponse } from '../integrations/node-fetch/types'; import { mergeBaggageHeaders } from './baggage'; - +import { debug } from '@sentry/core'; const SENTRY_TRACE_HEADER = 'sentry-trace'; const SENTRY_BAGGAGE_HEADER = 'baggage'; - -// For baggage, we make sure to merge this into a possibly existing header -const BAGGAGE_HEADER_REGEX = /baggage: (.*)\r\n/; - +const W3C_TRACEPARENT_HEADER = 'traceparent'; /** * Add trace propagation headers to an outgoing fetch/undici request. * @@ -45,55 +42,137 @@ export function addTracePropagationHeadersToFetchRequest( const { 'sentry-trace': sentryTrace, baggage, traceparent } = addedHeaders; + const requestHeaders = Array.isArray(request.headers) ? request.headers : stringToArrayHeaders(request.headers); + + // OTel's UndiciInstrumentation calls propagation.inject() which unconditionally + // appends headers to the request. When the user also sets headers via getTraceData(), + // this results in duplicate sentry-trace and baggage (and optionally traceparent) entries. + // We clean these up before applying our own logic. + _deduplicateArrayHeader(requestHeaders, SENTRY_TRACE_HEADER); + _deduplicateArrayHeader(requestHeaders, SENTRY_BAGGAGE_HEADER); + if (propagateTraceparent) { + _deduplicateArrayHeader(requestHeaders, W3C_TRACEPARENT_HEADER); + } + // We do not want to overwrite existing headers here // If the core UndiciInstrumentation is registered, it will already have set the headers // We do not want to add any then - if (Array.isArray(request.headers)) { - const requestHeaders = request.headers; + const hasExistingSentryTraceHeader = _findExistingHeaderIndex(requestHeaders, SENTRY_TRACE_HEADER) !== -1; - // We do not want to overwrite existing header here, if it was already set - if (sentryTrace && !requestHeaders.includes(SENTRY_TRACE_HEADER)) { + // We do not want to set any headers if we already have an existing sentry-trace header. + // sentry-trace is still the source of truth, otherwise we risk mixing up baggage and sentry-trace values. + if (!hasExistingSentryTraceHeader) { + if (sentryTrace) { requestHeaders.push(SENTRY_TRACE_HEADER, sentryTrace); } - if (traceparent && !requestHeaders.includes('traceparent')) { + if (traceparent && _findExistingHeaderIndex(requestHeaders, 'traceparent') === -1) { requestHeaders.push('traceparent', traceparent); } // For baggage, we make sure to merge this into a possibly existing header - const existingBaggagePos = requestHeaders.findIndex(header => header === SENTRY_BAGGAGE_HEADER); - if (baggage && existingBaggagePos === -1) { + const existingBaggageIndex = _findExistingHeaderIndex(requestHeaders, SENTRY_BAGGAGE_HEADER); + if (baggage && existingBaggageIndex === -1) { requestHeaders.push(SENTRY_BAGGAGE_HEADER, baggage); } else if (baggage) { - const existingBaggage = requestHeaders[existingBaggagePos + 1]; - const merged = mergeBaggageHeaders(existingBaggage, baggage); + // headers in format [key_0, value_0, key_1, value_1, ...], hence the +1 here + const existingBaggageValue = requestHeaders[existingBaggageIndex + 1]; + const merged = mergeBaggageHeaders(existingBaggageValue, baggage); if (merged) { - requestHeaders[existingBaggagePos + 1] = merged; + requestHeaders[existingBaggageIndex + 1] = merged; + } + } + } + + if (!Array.isArray(request.headers)) { + // For original string request headers, we need to write them back to the request + request.headers = arrayToStringHeaders(requestHeaders); + } +} + +function stringToArrayHeaders(requestHeaders: string): string[] { + const headersArray = requestHeaders.split('\r\n'); + const headers: string[] = []; + for (const header of headersArray) { + try { + const colonIndex = header.indexOf(':'); + if (colonIndex === -1) { + continue; + } + const key = header.slice(0, colonIndex).trim(); + const value = header.slice(colonIndex + 1).trim(); + if (key) { + headers.push(key, value); } + } catch { + debug.warn(`Failed to convert string request header to array header: ${header}`); + } + } + return headers; +} + +function arrayToStringHeaders(headers: string[]): string { + const headerPairs: string[] = []; + + for (let i = 0; i < headers.length; i += 2) { + const key = headers[i]; + const value = headers[i + 1]; + if (!key || value == null) { + // skip falsy keys but only null/undefined values + continue; } - } else { - const requestHeaders = request.headers; - // We do not want to overwrite existing header here, if it was already set - if (sentryTrace && !requestHeaders.includes(`${SENTRY_TRACE_HEADER}:`)) { - request.headers += `${SENTRY_TRACE_HEADER}: ${sentryTrace}\r\n`; + headerPairs.push(`${key}: ${value}`); + } + + if (!headerPairs.length) { + return ''; + } + + return headerPairs.join('\r\n').concat('\r\n'); +} + +/** + * For a given header name, if there are multiple entries in the [key, value, key, value, ...] array, + * keep the first entry and remove the rest. + * For baggage, values are merged to preserve all entries but to dedupe sentry- values, and always + * keep the first occurrence of them + */ +function _deduplicateArrayHeader(headers: string[], headerName: string): void { + let firstIndex = -1; + for (let i = 0; i < headers.length; i += 2) { + if (headers[i] !== headerName) { + continue; } - if (traceparent && !requestHeaders.includes('traceparent:')) { - request.headers += `traceparent: ${traceparent}\r\n`; + if (firstIndex === -1) { + firstIndex = i; + continue; } - const existingBaggage = request.headers.match(BAGGAGE_HEADER_REGEX)?.[1]; - if (baggage && !existingBaggage) { - request.headers += `${SENTRY_BAGGAGE_HEADER}: ${baggage}\r\n`; - } else if (baggage) { - const merged = mergeBaggageHeaders(existingBaggage, baggage); + const firstHeaderValue = headers[firstIndex + 1]; + if (headerName === SENTRY_BAGGAGE_HEADER && firstHeaderValue) { + // mergeBaggageHeaders always takes sentry- values from the new baggage (2nd param) and merges + // it with the existing one (1st param). Here, we want to keep the first header's existing + // sentry- values in favor of the new ones. Hence we swap the parameters. + const merged = mergeBaggageHeaders(headers[i + 1], firstHeaderValue); if (merged) { - request.headers = request.headers.replace(BAGGAGE_HEADER_REGEX, `baggage: ${merged}\r\n`); + headers[firstIndex + 1] = merged; } } + headers.splice(i, 2); + i -= 2; } } +/** + * Find the index of an existing header in an array of headers. + * Only take even indices, because headers are in format [key_0, value_0, key_1, value_1, ...] + * otherwise we could match a header _value_ with @param name + */ +function _findExistingHeaderIndex(headers: string[], name: string): number { + return headers.findIndex((header, i) => i % 2 === 0 && header === name); +} + /** Add a breadcrumb for an outgoing fetch/undici request. */ export function addFetchRequestBreadcrumb(request: UndiciRequest, response: UndiciResponse): void { const data = getBreadcrumbData(request); diff --git a/packages/node-core/src/utils/outgoingHttpRequest.ts b/packages/node-core/src/utils/outgoingHttpRequest.ts index 7eafa941286a..34624900b472 100644 --- a/packages/node-core/src/utils/outgoingHttpRequest.ts +++ b/packages/node-core/src/utils/outgoingHttpRequest.ts @@ -63,7 +63,13 @@ export function addTracePropagationHeadersToOutgoingRequest( const { 'sentry-trace': sentryTrace, baggage, traceparent } = headersToAdd; - if (sentryTrace && !request.getHeader('sentry-trace')) { + const hasExistingSentryTraceHeader = !!request.getHeader('sentry-trace'); + + if (hasExistingSentryTraceHeader) { + return; + } + + if (sentryTrace) { try { request.setHeader('sentry-trace', sentryTrace); DEBUG_BUILD && debug.log(LOG_PREFIX, 'Added sentry-trace header to outgoing request'); @@ -92,7 +98,8 @@ export function addTracePropagationHeadersToOutgoingRequest( } if (baggage) { - const newBaggage = mergeBaggageHeaders(request.getHeader('baggage'), baggage); + const existingBaggage = request.getHeader('baggage'); + const newBaggage = mergeBaggageHeaders(existingBaggage, baggage); if (newBaggage) { try { request.setHeader('baggage', newBaggage); diff --git a/packages/node-core/test/integrations/nodeRuntimeMetrics.test.ts b/packages/node-core/test/integrations/nodeRuntimeMetrics.test.ts new file mode 100644 index 000000000000..fe1de568304a --- /dev/null +++ b/packages/node-core/test/integrations/nodeRuntimeMetrics.test.ts @@ -0,0 +1,333 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { metrics } from '@sentry/core'; +import { nodeRuntimeMetricsIntegration } from '../../src/integrations/nodeRuntimeMetrics'; + +const { mockHistogram, mockMonitorEventLoopDelay, mockPerformance } = vi.hoisted(() => { + const mockHistogram = { + min: 2_000_000, + max: 20_000_000, + mean: 10_000_000, + percentile: vi.fn((p: number) => { + if (p === 50) return 8_000_000; + if (p === 90) return 15_000_000; + if (p === 99) return 19_000_000; + return 0; + }), + enable: vi.fn(), + reset: vi.fn(), + disable: vi.fn(), + }; + + const mockMonitorEventLoopDelay = vi.fn(() => mockHistogram); + const mockElu = { idle: 700, active: 300, utilization: 0.3 }; + const mockEluDelta = { idle: 700, active: 300, utilization: 0.3 }; + const mockPerformance = { + eventLoopUtilization: vi.fn((curr?: object, _prev?: object) => { + if (curr) return mockEluDelta; + return mockElu; + }), + }; + + return { mockHistogram, mockMonitorEventLoopDelay, mockPerformance }; +}); + +vi.mock('perf_hooks', () => ({ + monitorEventLoopDelay: mockMonitorEventLoopDelay, + performance: mockPerformance, +})); + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { ...actual }; +}); + +describe('nodeRuntimeMetricsIntegration', () => { + let gaugeSpy: ReturnType; + let countSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + gaugeSpy = vi.spyOn(metrics, 'gauge'); + countSpy = vi.spyOn(metrics, 'count'); + + vi.spyOn(process, 'cpuUsage').mockReturnValue({ user: 500_000, system: 200_000 }); + vi.spyOn(process, 'memoryUsage').mockReturnValue({ + rss: 50_000_000, + heapTotal: 30_000_000, + heapUsed: 20_000_000, + external: 1_000_000, + arrayBuffers: 500_000, + }); + + mockHistogram.percentile.mockClear(); + mockHistogram.enable.mockClear(); + mockHistogram.reset.mockClear(); + mockMonitorEventLoopDelay.mockClear(); + mockPerformance.eventLoopUtilization.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('has the correct name', () => { + const integration = nodeRuntimeMetricsIntegration(); + expect(integration.name).toBe('NodeRuntimeMetrics'); + }); + + describe('setup', () => { + it('initializes event loop delay histogram with resolution 10', () => { + const integration = nodeRuntimeMetricsIntegration(); + integration.setup(); + + expect(mockMonitorEventLoopDelay).toHaveBeenCalledWith({ resolution: 10 }); + expect(mockHistogram.enable).toHaveBeenCalledOnce(); + }); + + it('does not throw if monitorEventLoopDelay is unavailable (e.g. Bun)', () => { + mockMonitorEventLoopDelay.mockImplementationOnce(() => { + throw new Error('NotImplementedError'); + }); + + const integration = nodeRuntimeMetricsIntegration(); + expect(() => integration.setup()).not.toThrow(); + }); + + it('starts a collection interval', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + + expect(gaugeSpy).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1_000); + expect(gaugeSpy).toHaveBeenCalled(); + }); + }); + + const ORIGIN = { attributes: { 'sentry.origin': 'auto.node.runtime_metrics' } }; + const BYTE = { unit: 'byte', attributes: { 'sentry.origin': 'auto.node.runtime_metrics' } }; + const SECOND = { unit: 'second', attributes: { 'sentry.origin': 'auto.node.runtime_metrics' } }; + + describe('metric collection — defaults', () => { + it('emits cpu utilization (default on)', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.cpu.utilization', expect.any(Number), ORIGIN); + }); + + it('does not emit cpu.user / cpu.system by default (opt-in)', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('node.runtime.cpu.user', expect.anything(), expect.anything()); + expect(gaugeSpy).not.toHaveBeenCalledWith('node.runtime.cpu.system', expect.anything(), expect.anything()); + }); + + it('emits cpu.user / cpu.system when cpuTime is opted in', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { cpuTime: true }, + }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.cpu.user', expect.any(Number), SECOND); + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.cpu.system', expect.any(Number), SECOND); + }); + + it('emits mem.rss, mem.heap_used, mem.heap_total (default on)', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.mem.rss', 50_000_000, BYTE); + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.mem.heap_used', 20_000_000, BYTE); + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.mem.heap_total', 30_000_000, BYTE); + }); + + it('does not emit mem.external / mem.array_buffers by default (opt-in)', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('node.runtime.mem.external', expect.anything(), expect.anything()); + expect(gaugeSpy).not.toHaveBeenCalledWith('node.runtime.mem.array_buffers', expect.anything(), expect.anything()); + }); + + it('emits mem.external / mem.array_buffers when opted in', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { memExternal: true }, + }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.mem.external', 1_000_000, BYTE); + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.mem.array_buffers', 500_000, BYTE); + }); + + it('emits event_loop.delay.p50 and p99 (default on) and resets histogram', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.event_loop.delay.p50', expect.any(Number), SECOND); + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.event_loop.delay.p99', expect.any(Number), SECOND); + expect(mockHistogram.reset).toHaveBeenCalledOnce(); + }); + + it('does not emit min/max/mean/p90 event loop delay by default (opt-in)', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + for (const suffix of ['min', 'max', 'mean', 'p90']) { + expect(gaugeSpy).not.toHaveBeenCalledWith( + `node.runtime.event_loop.delay.${suffix}`, + expect.anything(), + expect.anything(), + ); + } + }); + + it('emits all opt-in event loop delay percentiles when enabled', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { + eventLoopDelayMin: true, + eventLoopDelayMax: true, + eventLoopDelayMean: true, + eventLoopDelayP90: true, + }, + }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + // min: (2_000_000 - 10_000_000) clamped to 0 → 0s + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.event_loop.delay.min', 0, SECOND); + // max: (20_000_000 - 10_000_000) / 1e9 → 0.01s + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.event_loop.delay.max', 0.01, SECOND); + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.event_loop.delay.mean', 0, SECOND); + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.event_loop.delay.p90', expect.any(Number), SECOND); + }); + + it('emits event loop utilization metric', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.event_loop.utilization', 0.3, ORIGIN); + }); + + it('emits uptime counter', () => { + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(countSpy).toHaveBeenCalledWith('node.runtime.process.uptime', expect.any(Number), SECOND); + }); + + it('does not emit event loop delay metrics if monitorEventLoopDelay threw', () => { + mockMonitorEventLoopDelay.mockImplementationOnce(() => { + throw new Error('NotImplementedError'); + }); + + const integration = nodeRuntimeMetricsIntegration({ collectionIntervalMs: 1_000 }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith( + 'node.runtime.event_loop.delay.p99', + expect.anything(), + expect.anything(), + ); + }); + }); + + describe('opt-out', () => { + it('skips cpu.utilization when cpuUtilization is false', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { cpuUtilization: false }, + }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('node.runtime.cpu.utilization', expect.anything(), expect.anything()); + }); + + it('skips mem.rss when memRss is false', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { memRss: false }, + }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith('node.runtime.mem.rss', expect.anything(), expect.anything()); + }); + + it('skips event loop delay metrics when all delay flags are false', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { eventLoopDelayP50: false, eventLoopDelayP99: false }, + }); + integration.setup(); + + expect(mockMonitorEventLoopDelay).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1_000); + for (const suffix of ['min', 'max', 'mean', 'p50', 'p90', 'p99']) { + expect(gaugeSpy).not.toHaveBeenCalledWith( + `node.runtime.event_loop.delay.${suffix}`, + expect.anything(), + expect.anything(), + ); + } + }); + + it('skips only p99 but still emits p50 when eventLoopDelayP99 is false', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { eventLoopDelayP99: false }, + }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith( + 'node.runtime.event_loop.delay.p99', + expect.anything(), + expect.anything(), + ); + expect(gaugeSpy).toHaveBeenCalledWith('node.runtime.event_loop.delay.p50', expect.any(Number), SECOND); + }); + + it('skips event loop utilization when eventLoopUtilization is false', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { eventLoopUtilization: false }, + }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(gaugeSpy).not.toHaveBeenCalledWith( + 'node.runtime.event_loop.utilization', + expect.anything(), + expect.anything(), + ); + }); + + it('skips uptime when uptime is false', () => { + const integration = nodeRuntimeMetricsIntegration({ + collectionIntervalMs: 1_000, + collect: { uptime: false }, + }); + integration.setup(); + vi.advanceTimersByTime(1_000); + + expect(countSpy).not.toHaveBeenCalledWith('node.runtime.process.uptime', expect.anything(), expect.anything()); + }); + }); +}); diff --git a/packages/node-core/test/light/integrations/otlpIntegration.test.ts b/packages/node-core/test/light/integrations/otlpIntegration.test.ts new file mode 100644 index 000000000000..8d40bfad18cf --- /dev/null +++ b/packages/node-core/test/light/integrations/otlpIntegration.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { otlpIntegration } from '../../../src/light/integrations/otlpIntegration'; +import { cleanupLightSdk, mockLightSdkInit } from '../../helpers/mockLightSdkInit'; + +describe('Light Mode | otlpIntegration', () => { + afterEach(() => { + cleanupLightSdk(); + }); + + it('has correct integration name', () => { + const integration = otlpIntegration(); + expect(integration.name).toBe('OtlpIntegration'); + }); + + it('accepts empty options', () => { + const integration = otlpIntegration(); + expect(integration.name).toBe('OtlpIntegration'); + }); + + it('accepts all options', () => { + const integration = otlpIntegration({ + setupOtlpTracesExporter: false, + collectorUrl: 'https://my-collector.example.com/v1/traces', + }); + expect(integration.name).toBe('OtlpIntegration'); + }); + + describe('endpoint construction', () => { + it('constructs correct endpoint from DSN', () => { + const client = mockLightSdkInit({ + integrations: [otlpIntegration()], + }); + + const dsn = client?.getDsn(); + expect(dsn).toBeDefined(); + expect(dsn?.host).toBe('domain'); + expect(dsn?.projectId).toBe('123'); + }); + + it('handles DSN with port and path', () => { + const client = mockLightSdkInit({ + dsn: 'https://key@sentry.example.com:9000/mypath/456', + integrations: [otlpIntegration()], + }); + + const dsn = client?.getDsn(); + expect(dsn?.host).toBe('sentry.example.com'); + expect(dsn?.port).toBe('9000'); + expect(dsn?.path).toBe('mypath'); + expect(dsn?.projectId).toBe('456'); + }); + }); + + describe('auth header', () => { + it('constructs correct X-Sentry-Auth header format with sentry_client', () => { + const client = mockLightSdkInit({ + integrations: [otlpIntegration()], + }); + + const dsn = client?.getDsn(); + expect(dsn?.publicKey).toBe('username'); + + const sdkInfo = client?.getSdkMetadata()?.sdk; + expect(sdkInfo?.name).toBe('sentry.javascript.node-light'); + expect(sdkInfo?.version).toBeDefined(); + + const expectedAuth = `Sentry sentry_version=7, sentry_key=${dsn?.publicKey}, sentry_client=${sdkInfo?.name}/${sdkInfo?.version}`; + expect(expectedAuth).toMatch( + /^Sentry sentry_version=7, sentry_key=username, sentry_client=sentry\.javascript\.node-light\/.+$/, + ); + }); + }); +}); diff --git a/packages/node-core/test/utils/baggage.test.ts b/packages/node-core/test/utils/baggage.test.ts index aae5c48d6068..0d7ff5f757d5 100644 --- a/packages/node-core/test/utils/baggage.test.ts +++ b/packages/node-core/test/utils/baggage.test.ts @@ -69,7 +69,6 @@ describe('mergeBaggageHeaders', () => { expect(entries).toContain('third=party'); expect(entries).toContain('sentry-environment=myEnv'); expect(entries).toContain('sentry-release=2.1.0'); - expect(entries).toContain('sentry-sample_rate=0.54'); expect(entries).not.toContain('sentry-environment=staging'); expect(entries).not.toContain('sentry-release=9.9.9'); }); @@ -87,7 +86,7 @@ describe('mergeBaggageHeaders', () => { it('handles array-type existing baggage', () => { const result = mergeBaggageHeaders(['foo=bar', 'other=vendor'], 'sentry-release=1.0.0'); - const entries = result?.split(','); + const entries = (result as string)?.split(','); expect(entries).toContain('foo=bar'); expect(entries).toContain('other=vendor'); expect(entries).toContain('sentry-release=1.0.0'); @@ -115,7 +114,7 @@ describe('mergeBaggageHeaders', () => { expect(entries).not.toContain('sentry-environment=old'); }); - it('matches OTEL propagation.inject() behavior for Sentry keys', () => { + it('overwrites existing Sentry entries with new SDK values', () => { const result = mergeBaggageHeaders( 'sentry-trace_id=abc123,sentry-sampled=false,non-sentry=keep', 'sentry-trace_id=xyz789,sentry-sampled=true', @@ -128,4 +127,41 @@ describe('mergeBaggageHeaders', () => { expect(entries).not.toContain('sentry-trace_id=abc123'); expect(entries).not.toContain('sentry-sampled=false'); }); + + it('merges non-conflicting baggage entries', () => { + const existing = 'custom-key=value'; + const newBaggage = 'sentry-environment=production'; + const result = mergeBaggageHeaders(existing, newBaggage); + expect(result).toBe('custom-key=value,sentry-environment=production'); + }); + + it('overwrites existing Sentry entries when keys conflict', () => { + const existing = 'sentry-environment=staging'; + const newBaggage = 'sentry-environment=production'; + const result = mergeBaggageHeaders(existing, newBaggage); + expect(result).toBe('sentry-environment=production'); + }); + + it('handles multiple entries with Sentry conflicts', () => { + const existing = 'custom-key=value1,sentry-environment=staging'; + const newBaggage = 'sentry-environment=production,sentry-trace_id=123'; + const result = mergeBaggageHeaders(existing, newBaggage); + expect(result).toContain('custom-key=value1'); + expect(result).toContain('sentry-environment=production'); + expect(result).toContain('sentry-trace_id=123'); + expect(result).not.toContain('sentry-environment=staging'); + }); + + it('removes all sentry- values from old baggage and only adds new ones (if at least one new sentry- value is present)', () => { + const existing = 'sentry-trace_id=old,sentry-sampled=false,non-sentry=keep'; + const newBaggage = 'sentry-trace_id=new,sentry-environment=new'; + const result = mergeBaggageHeaders(existing, newBaggage); + expect(result).toBe('non-sentry=keep,sentry-trace_id=new,sentry-environment=new'); + }); + + it('preserves existing sentry entries when new baggage has no sentry entries', () => { + const result = mergeBaggageHeaders('sentry-release=1.0.0,foo=bar', 'baz=qux'); + + expect(result).toBe('sentry-release=1.0.0,foo=bar,baz=qux'); + }); }); diff --git a/packages/node-core/test/utils/outgoingFetchRequest.test.ts b/packages/node-core/test/utils/outgoingFetchRequest.test.ts new file mode 100644 index 000000000000..f023c2cab268 --- /dev/null +++ b/packages/node-core/test/utils/outgoingFetchRequest.test.ts @@ -0,0 +1,478 @@ +import type { MockedFunction } from 'vitest'; +import { describe, beforeEach, vi, expect, it } from 'vitest'; +import type { UndiciRequest } from '../../src/integrations/node-fetch/types'; +import { addTracePropagationHeadersToFetchRequest } from '../../src/utils/outgoingFetchRequest'; +import { LRUMap } from '@sentry/core'; +import * as SentryCore from '@sentry/core'; + +const mockedGetTraceData: MockedFunction<() => ReturnType> = vi.hoisted(() => + vi.fn(() => ({ + 'sentry-trace': 'trace_id_1-span_id_1-1', + baggage: 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + })), +); + +const mockedClientGetOptions: MockedFunction<() => Partial> = vi.hoisted(() => + vi.fn(() => ({ + tracePropagationTargets: ['https://example.com'], + propagateTraceparent: true, + })), +); + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + getClient: vi.fn(() => ({ + getOptions: mockedClientGetOptions, + })), + shouldPropagateTraceForUrl: () => true, + getTraceData: mockedGetTraceData, + }; +}); + +describe('addTracePropagationHeadersToFetchRequest', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("doesn't add headers if shouldPropagateTraceForUrl returns false", () => { + vi.spyOn(SentryCore, 'shouldPropagateTraceForUrl').mockReturnValueOnce(false); + + const request = { + headers: [] as string[], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([]); + }); + + describe('when headers are an array', () => { + it('adds sentry-trace and baggage headers to request', () => { + const request = { + headers: [] as string[], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'sentry-trace', + 'trace_id_1-span_id_1-1', + 'baggage', + 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + ]); + }); + + it('adds sentry-trace, baggage and traceparent headers to request', () => { + mockedGetTraceData.mockReturnValueOnce({ + 'sentry-trace': 'trace_id_1-span_id_1-1', + baggage: 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + traceparent: '00-trace_id_1-span_id_1-01', + }); + + const request = { + headers: [] as string[], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'sentry-trace', + 'trace_id_1-span_id_1-1', + 'traceparent', + '00-trace_id_1-span_id_1-01', + 'baggage', + 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + ]); + }); + + it('preserves non-sentry entries in existing baggage header', () => { + const request = { + headers: ['baggage', 'other=entry,not=sentry'], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'baggage', + 'other=entry,not=sentry,sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + 'sentry-trace', + 'trace_id_1-span_id_1-1', + ]); + }); + + it('preserves pre-existing traceparent header', () => { + mockedGetTraceData.mockReturnValueOnce({ + 'sentry-trace': 'trace_id_1-span_id_1-1', + baggage: 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + traceparent: '00-trace_id_1-span_id_1-01', + }); + + const request = { + headers: ['traceparent', '00-some-other-trace_id-span_id_x-01'], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'traceparent', + '00-some-other-trace_id-span_id_x-01', + 'sentry-trace', + 'trace_id_1-span_id_1-1', + 'baggage', + 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + ]); + }); + + describe('when sentry-trace is already set', () => { + it("preserves original sentry-trace header doesn't add baggage", () => { + const request = { + headers: ['sentry-trace', 'trace_id_2-span_id_2-1'], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual(['sentry-trace', 'trace_id_2-span_id_2-1']); + }); + + it('preserves original baggage header', () => { + const request = { + headers: [ + 'sentry-trace', + 'trace_id_2-span_id_2-1', + 'baggage', + 'sentry-trace_id=trace_id_2,sentry-sampled=true,sentry-environment=staging', + ], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'sentry-trace', + 'trace_id_2-span_id_2-1', + 'baggage', + 'sentry-trace_id=trace_id_2,sentry-sampled=true,sentry-environment=staging', + ]); + }); + + it("doesn't add traceparent header even if propagateTraceparent is true", () => { + mockedGetTraceData.mockReturnValueOnce({ + 'sentry-trace': 'trace_id_2-span_id_2-1', + baggage: 'sentry-trace_id=trace_id_2,sentry-sampled=true,sentry-environment=staging', + traceparent: '00-trace_id_2-span_id_2-01', + }); + + const request = { + headers: ['sentry-trace', 'trace_id_2-span_id_2-1'], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual(['sentry-trace', 'trace_id_2-span_id_2-1']); + }); + }); + + describe('pre-existing header deduplication', () => { + it('deduplicates sentry-trace and baggage headers', () => { + const request = { + headers: [ + 'sentry-trace', + 'user-trace_id-xyz-1', + 'baggage', + 'sentry-trace_id=user-trace_id-xyz-1,sentry-sampled=true,sentry-environment=user', + 'sentry-trace', + 'undici-trace_id-abc-1', + 'baggage', + 'sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici', + ], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'sentry-trace', + 'user-trace_id-xyz-1', + 'baggage', + 'sentry-trace_id=user-trace_id-xyz-1,sentry-sampled=true,sentry-environment=user', + ]); + }); + + it('deduplicates traceparent headers if propagateTraceparent is true', () => { + mockedClientGetOptions.mockReturnValueOnce({ + tracePropagationTargets: ['https://example.com'], + propagateTraceparent: true, + }); + + const request = { + headers: [ + 'sentry-trace', + 'user-trace_id-xyz-1', + 'baggage', + 'sentry-trace_id=user-trace_id-xyz-1,sentry-sampled=true,sentry-environment=user', + 'traceparent', + '00-user-trace_id-xyz-1-01', + 'sentry-trace', + 'undici-trace_id-abc-1', + 'baggage', + 'sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici', + 'traceparent', + '00-undici-trace_id-abc-1-01', + ], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'sentry-trace', + 'user-trace_id-xyz-1', + 'baggage', + 'sentry-trace_id=user-trace_id-xyz-1,sentry-sampled=true,sentry-environment=user', + 'traceparent', + '00-user-trace_id-xyz-1-01', + ]); + }); + + // admittedly an unrealistic edge case but doesn't hurt to test it + it("doesn't crash with incomplete headers array", () => { + const request = { + headers: [ + 'sentry-trace', + 'user-trace_id-xyz-1', + 'baggage', + 'sentry-trace_id=user-trace_id-xyz-1,sentry-sampled=true,sentry-environment=user', + 'sentry-trace', + 'undici-trace_id-abc-1', + 'baggage', + 'sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici', + 'baggage', // only the key, no value + ], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'sentry-trace', + 'user-trace_id-xyz-1', + 'baggage', + 'sentry-trace_id=user-trace_id-xyz-1,sentry-sampled=true,sentry-environment=user', + ]); + }); + + it('dedupes multiple baggage headers with sentry- values keeps non-sentry values around', () => { + const request = { + headers: [ + 'sentry-trace', + 'user-trace_id-xyz-1', + 'baggage', + 'user-added=value,another=one', + 'baggage', + 'yet-another=value,another=two', + 'sentry-trace', + 'undici-trace_id-abc-1', + 'baggage', + 'sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici,', + ], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'sentry-trace', + 'user-trace_id-xyz-1', + 'baggage', + 'sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici,yet-another=value,another=two,user-added=value', + ]); + }); + + it('dedupes multiple baggage headers keeps non-sentry values around', () => { + const request = { + headers: ['baggage', 'user-added=value,another=one', 'baggage', 'yet-another=value,another=two'], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'baggage', + 'yet-another=value,another=two,user-added=value,sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + 'sentry-trace', + 'trace_id_1-span_id_1-1', + ]); + }); + }); + + it('doesn\'t mistake a header value with "sentry-trace" for a sentry-trace header', () => { + const request = { + headers: ['x-allow-header', 'sentry-trace'], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'x-allow-header', + 'sentry-trace', + 'sentry-trace', + 'trace_id_1-span_id_1-1', + 'baggage', + 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + ]); + }); + + it('doesn\'t mistake a header value with "baggage" for a sentry-trace header', () => { + const request = { + headers: ['x-allow-header', 'baggage'], + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toEqual([ + 'x-allow-header', + 'baggage', + 'sentry-trace', + 'trace_id_1-span_id_1-1', + 'baggage', + 'sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging', + ]); + }); + }); + + describe('when headers are a string', () => { + it('adds sentry-trace and baggage headers to request', () => { + const request = { + headers: '', + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toBe( + 'sentry-trace: trace_id_1-span_id_1-1\r\n' + + 'baggage: sentry-trace_id=trace_id_1,sentry-sampled=true,sentry-environment=staging\r\n', + ); + }); + + describe('when sentry-trace is already set', () => { + it("preserves original sentry-trace header doesn't add baggage", () => { + const request = { + headers: 'sentry-trace: trace_id_2-span_id_2-1\r\n', + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toBe('sentry-trace: trace_id_2-span_id_2-1\r\n'); + }); + + it('preserves the original baggage header', () => { + const request = { + headers: + 'sentry-trace: trace_id_2-span_id_2-1\r\n' + + 'baggage: sentry-trace_id=trace_id_2,sentry-sampled=true,sentry-environment=staging\r\n', + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toBe( + 'sentry-trace: trace_id_2-span_id_2-1\r\n' + + 'baggage: sentry-trace_id=trace_id_2,sentry-sampled=true,sentry-environment=staging\r\n', + ); + }); + }); + + describe('pre-existing header deduplication', () => { + it('deduplicates sentry-trace and baggage headers', () => { + const request = { + headers: + 'sentry-trace: user-trace_id-xyz-1\r\n' + + 'baggage: sentry-trace_id=user-trace_id,sentry-sampled=true,sentry-environment=user\r\n' + + 'sentry-trace: undici-trace_id-abc-1\r\n' + + 'baggage: sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici\r\n', + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toBe( + 'sentry-trace: user-trace_id-xyz-1\r\n' + + 'baggage: sentry-trace_id=user-trace_id,sentry-sampled=true,sentry-environment=user\r\n', + ); + }); + + it("doesn't crash with incomplete headers string", () => { + const request = { + headers: + 'sentry-trace: user-trace_id-xyz-1\r\n' + + 'baggage: sentry-trace_id=user-trace_id,sentry-sampled=true,sentry-environment=user\r\n' + + 'sentry-trace: undici-trace_id-abc-1\r\n' + + 'baggage: sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici\r\n' + + 'baggage: \r\n', + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toBe( + 'sentry-trace: user-trace_id-xyz-1\r\n' + + 'baggage: sentry-trace_id=user-trace_id,sentry-sampled=true,sentry-environment=user\r\n', + ); + }); + }); + + it("doesn't dedupe nearly-sentry-tracing headers", () => { + const request = { + headers: + 'sentry-trace: user-trace_id-xyz-1\r\n' + + 'baggage: sentry-trace_id=user-trace_id,sentry-sampled=true,sentry-environment=user\r\n' + + 'x-sentry-trace: custom-trace_id-abc-1\r\n' + + 'x-baggage: sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici\r\n', + origin: 'https://some-service.com', + path: '/api/test', + } as UndiciRequest; + + addTracePropagationHeadersToFetchRequest(request, new LRUMap(100)); + + expect(request.headers).toBe( + 'sentry-trace: user-trace_id-xyz-1\r\n' + + 'baggage: sentry-trace_id=user-trace_id,sentry-sampled=true,sentry-environment=user\r\n' + + 'x-sentry-trace: custom-trace_id-abc-1\r\n' + + 'x-baggage: sentry-trace_id=undici-trace_id-abc-1,sentry-sampled=true,sentry-environment=undici\r\n', + ); + }); + }); +}); diff --git a/packages/node/package.json b/packages/node/package.json index 83a4214a6829..273f2adca754 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -65,37 +65,37 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.6.0", - "@opentelemetry/core": "^2.6.0", - "@opentelemetry/instrumentation": "^0.213.0", - "@opentelemetry/instrumentation-amqplib": "0.60.0", - "@opentelemetry/instrumentation-connect": "0.56.0", - "@opentelemetry/instrumentation-dataloader": "0.30.0", - "@opentelemetry/instrumentation-express": "0.61.0", - "@opentelemetry/instrumentation-fs": "0.32.0", - "@opentelemetry/instrumentation-generic-pool": "0.56.0", - "@opentelemetry/instrumentation-graphql": "0.61.0", - "@opentelemetry/instrumentation-hapi": "0.59.0", - "@opentelemetry/instrumentation-http": "0.213.0", - "@opentelemetry/instrumentation-ioredis": "0.61.0", - "@opentelemetry/instrumentation-kafkajs": "0.22.0", - "@opentelemetry/instrumentation-knex": "0.57.0", - "@opentelemetry/instrumentation-koa": "0.61.0", - "@opentelemetry/instrumentation-lru-memoizer": "0.57.0", - "@opentelemetry/instrumentation-mongodb": "0.66.0", - "@opentelemetry/instrumentation-mongoose": "0.59.0", - "@opentelemetry/instrumentation-mysql": "0.59.0", - "@opentelemetry/instrumentation-mysql2": "0.59.0", - "@opentelemetry/instrumentation-pg": "0.65.0", - "@opentelemetry/instrumentation-redis": "0.61.0", - "@opentelemetry/instrumentation-tedious": "0.32.0", - "@opentelemetry/instrumentation-undici": "0.23.0", - "@opentelemetry/resources": "^2.6.0", - "@opentelemetry/sdk-trace-base": "^2.6.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.6.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/instrumentation-amqplib": "0.61.0", + "@opentelemetry/instrumentation-connect": "0.57.0", + "@opentelemetry/instrumentation-dataloader": "0.31.0", + "@opentelemetry/instrumentation-express": "0.62.0", + "@opentelemetry/instrumentation-fs": "0.33.0", + "@opentelemetry/instrumentation-generic-pool": "0.57.0", + "@opentelemetry/instrumentation-graphql": "0.62.0", + "@opentelemetry/instrumentation-hapi": "0.60.0", + "@opentelemetry/instrumentation-http": "0.214.0", + "@opentelemetry/instrumentation-ioredis": "0.62.0", + "@opentelemetry/instrumentation-kafkajs": "0.23.0", + "@opentelemetry/instrumentation-knex": "0.58.0", + "@opentelemetry/instrumentation-koa": "0.62.0", + "@opentelemetry/instrumentation-lru-memoizer": "0.58.0", + "@opentelemetry/instrumentation-mongodb": "0.67.0", + "@opentelemetry/instrumentation-mongoose": "0.60.0", + "@opentelemetry/instrumentation-mysql": "0.60.0", + "@opentelemetry/instrumentation-mysql2": "0.60.0", + "@opentelemetry/instrumentation-pg": "0.66.0", + "@opentelemetry/instrumentation-redis": "0.62.0", + "@opentelemetry/instrumentation-tedious": "0.33.0", + "@opentelemetry/instrumentation-undici": "0.24.0", + "@opentelemetry/resources": "^2.6.1", + "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", - "@prisma/instrumentation": "7.4.2", - "@fastify/otel": "0.17.1", + "@prisma/instrumentation": "7.6.0", + "@fastify/otel": "0.18.0", "@sentry/core": "10.46.0", "@sentry/node-core": "10.46.0", "@sentry/opentelemetry": "10.46.0", diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 8458dee5f6a7..67fe97e59300 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -188,6 +188,8 @@ export { spotlightIntegration, childProcessIntegration, processSessionIntegration, + nodeRuntimeMetricsIntegration, + type NodeRuntimeMetricsOptions, pinoIntegration, createSentryWinstonTransport, SentryContextManager, diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 0c1e43031742..bccba280b9ce 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -18,6 +18,7 @@ import { addStorageInstrumentation } from './vite/storageConfig'; import { addOTelCommonJSImportAlias, findDefaultSdkInitFile, getNitroMajorVersion } from './vite/utils'; export type ModuleOptions = SentryNuxtModuleOptions; +type NuxtPageSubset = { file?: string; path: string }; export default defineNuxtModule({ meta: { @@ -79,20 +80,25 @@ export default defineNuxtModule({ const serverConfigFile = findDefaultSdkInitFile('server', nuxt); const isNitroV3 = (await getNitroMajorVersion()) >= 3; + const nuxtMajor = parseInt((nuxt as unknown as { _version: string })._version?.split('.')[0] ?? '3', 10); + const isMinNuxtV4 = nuxtMajor >= 4; if (serverConfigFile) { if (isNitroV3) { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler.server')); + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name.server')); } else { addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/handler-legacy.server')); + addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/update-route-name-legacy.server')); } addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); - addPlugin({ - src: moduleDirResolver.resolve('./runtime/plugins/route-detector.server'), - mode: 'server', - }); + if (isMinNuxtV4) { + addPlugin({ src: moduleDirResolver.resolve('./runtime/plugins/route-detector.server'), mode: 'server' }); + } else { + addPlugin({ src: moduleDirResolver.resolve('./runtime/plugins/route-detector-legacy.server'), mode: 'server' }); + } // Preps the middleware instrumentation module. addMiddlewareImports(); @@ -106,26 +112,35 @@ export default defineNuxtModule({ addOTelCommonJSImportAlias(nuxt, isNitroV3); - const pagesDataTemplate = addTemplate({ - filename: 'sentry--nuxt-pages-data.mjs', - // Initial empty array (later filled in pages:extend hook) - // Template needs to be created in the root-level of the module to work - getContents: () => 'export default [];', - }); + let pagesData: NuxtPageSubset[] = []; nuxt.hooks.hook('pages:extend', pages => { - pagesDataTemplate.getContents = () => { - const pagesSubset = pages - .map(page => ({ file: page.file, path: page.path })) - .filter(page => { - // Check for dynamic parameter (e.g., :userId or [userId]) - return page.path.includes(':') || page?.file?.includes('['); - }); - - return `export default ${JSON.stringify(pagesSubset, null, 2)};`; - }; + pagesData = pages + .map(page => ({ file: page.file, path: page.path })) + .filter(page => { + // Check for dynamic parameter (e.g., :userId or [userId]) + return page.path.includes(':') || page?.file?.includes('['); + }); }); + if (isMinNuxtV4) { + const pagesDataVirtualModuleId = '#sentry/nuxt-pages-data.mjs'; + + // Vite virtual plugin (for the Vite SSR build, where addPlugin mode:'server' plugins are bundled) + addVitePlugin({ + name: 'sentry-nuxt-pages-data-virtual', + resolveId: id => (id === pagesDataVirtualModuleId ? `\0${pagesDataVirtualModuleId}` : null), + load: id => + id === `\0${pagesDataVirtualModuleId}` ? `export default ${JSON.stringify(pagesData, null, 2)};` : undefined, + }); + } else { + // Nuxt v3: register as a build template (accessible via #build/) + addTemplate({ + filename: 'sentry--nuxt-pages-data.mjs', + getContents: () => `export default ${JSON.stringify(pagesData, null, 2)};`, + }); + } + // Add the sentry config file to the include array nuxt.hook('prepare:types', options => { const tsConfig = options.tsConfig as { include?: string[] }; @@ -151,7 +166,7 @@ export default defineNuxtModule({ return; } - if (serverConfigFile && !isNitroV3) { + if (serverConfigFile) { addMiddlewareInstrumentation(nitro); } diff --git a/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts b/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts index 9b0f8f4d05fe..becb367e178b 100644 --- a/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts +++ b/packages/nuxt/src/runtime/hooks/updateRouteBeforeResponse.ts @@ -1,19 +1,29 @@ import { debug, getActiveSpan, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; import type { H3Event } from 'h3'; +type MatchedRoute = { path?: string; route?: string }; +type EventWithMatchedRoute = Pick & Partial>; + +function getMatchedRoutePath(event: EventWithMatchedRoute): string | undefined { + const matchedRoute = (event.context as { matchedRoute?: MatchedRoute }).matchedRoute; + // Nuxt 4 with h3 v1 uses `path`, Nuxt 5 with h3 v2 uses `route` + return matchedRoute?.path ?? matchedRoute?.route; +} + /** * Update the root span (transaction) name for routes with parameters based on the matched route. */ -export function updateRouteBeforeResponse(event: H3Event): void { +export function updateRouteBeforeResponse(event: EventWithMatchedRoute): void { if (!event.context.matchedRoute) { return; } - const matchedRoutePath = event.context.matchedRoute.path; + const matchedRoutePath = getMatchedRoutePath(event); + const requestPath = event.path ?? event._path; // If the matched route path is defined and differs from the event's path, it indicates a parametrized route // Example: Matched route is "/users/:id" and the event's path is "/users/123", - if (matchedRoutePath && matchedRoutePath !== event._path) { + if (matchedRoutePath && matchedRoutePath !== requestPath) { if (matchedRoutePath === '/**') { // If page is server-side rendered, the whole path gets transformed to `/**` (Example : `/users/123` becomes `/**` instead of `/users/:id`). return; // Skip if the matched route is a catch-all route (handled in `route-detector.server.ts`) diff --git a/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts b/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts index b257d70b72d7..c0e79c902b93 100644 --- a/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts +++ b/packages/nuxt/src/runtime/hooks/wrapMiddlewareHandler.ts @@ -21,6 +21,19 @@ import type { H3Event, } from 'h3'; +type RequestMiddleware = (event: H3Event) => void | Promise; +type HookName = 'onRequest' | 'onBeforeResponse' | 'middleware'; + +// Broader handler object type covering both h3 v1 and h3 v2 shapes. +type EventHandlerObjectH3 = EventHandlerObject & { + // h3 v1 (Nitro v2): onRequest, onBeforeResponse, handler (required) + onRequest?: RequestMiddleware | RequestMiddleware[]; + onBeforeResponse?: ResponseMiddleware | ResponseMiddleware[]; + + // h3 v2 (Nitro v3): middleware[], handler (optional), fetch, meta + middleware?: EventHandler[]; +}; + /** * Wraps a middleware handler with Sentry instrumentation. * @@ -35,36 +48,41 @@ export function wrapMiddlewareHandlerWithSentry - wrapEventHandler(h, fileName, 'onRequest', index), + // h3 v1 (Nitro v2): onRequest and response hooks + if (result.onRequest) { + result.onRequest = normalizeHandlers(result.onRequest, (h, index) => + wrapEventHandler(h as EventHandler, fileName, 'onRequest', index), ); } - if (handlerObj.onBeforeResponse) { - handlerObj.onBeforeResponse = normalizeHandlers(handlerObj.onBeforeResponse, (h, index) => + if (result.onBeforeResponse) { + result.onBeforeResponse = normalizeHandlers(result.onBeforeResponse, (h, index) => wrapResponseHandler(h, fileName, index), ); } - return handlerObj; + // h3 v2 (Nitro v3): middleware array replaces onRequest/onBeforeResponse + if (result.middleware?.length) { + result.middleware = result.middleware.map((h, index) => wrapEventHandler(h, fileName, 'middleware', index)); + } + + return result as THandler; } /** * Wraps a callable event handler with Sentry instrumentation. - * - * @param handler The event handler. - * @param handlerName The name of the event handler to be used for the span name and logging. */ function wrapEventHandler( handler: EventHandler, middlewareName: string, - hookName?: 'onRequest', + hookName?: HookName, index?: number, ): EventHandler { return async (event: H3Event) => { @@ -77,7 +95,7 @@ function wrapEventHandler( } /** - * Wraps a middleware response handler with Sentry instrumentation. + * Wraps a middleware response handler with Sentry instrumentation (h3 v1 only). */ function wrapResponseHandler(handler: ResponseMiddleware, middlewareName: string, index?: number): ResponseMiddleware { return async (event: H3Event, response: EventHandlerResponse) => { @@ -96,7 +114,7 @@ function withSpan( handler: () => TResult | Promise, attributes: SpanAttributes, middlewareName: string, - hookName?: 'handler' | 'onRequest' | 'onBeforeResponse', + hookName?: HookName | 'handler', ): Promise { const spanName = hookName && hookName !== 'handler' ? `${middlewareName}.${hookName}` : middlewareName; @@ -132,10 +150,7 @@ function withSpan( /** * Takes a list of handlers and wraps them with the normalizer function. */ -function normalizeHandlers( - handlers: T | T[], - normalizer: (h: T, index?: number) => T, -): T | T[] { +function normalizeHandlers(handlers: T | T[], normalizer: (h: T, index?: number) => T): T | T[] { return Array.isArray(handlers) ? handlers.map((handler, index) => normalizer(handler, index)) : normalizer(handlers); } @@ -145,7 +160,7 @@ function normalizeHandlers( function getSpanAttributes( event: H3Event, middlewareName: string, - hookName?: 'handler' | 'onRequest' | 'onBeforeResponse', + hookName?: HookName | 'handler', index?: number, ): SpanAttributes { const attributes: SpanAttributes = { @@ -161,18 +176,31 @@ function getSpanAttributes( attributes['nuxt.middleware.hook.index'] = index; } - // Add HTTP method - if (event.method) { - attributes['http.request.method'] = event.method; + // oxlint-disable-next-line typescript/no-explicit-any + const eventH3v2 = event as any; + // oxlint-disable-next-line @typescript-oxlint/no-unsafe-member-access + const method = event.method ?? eventH3v2?.req?.method; + // oxlint-disable-next-line @typescript-oxlint/no-unsafe-member-access + const path = event.path ?? eventH3v2?.url?.pathname; + + if (method) { + attributes['http.request.method'] = method; } - // Add route information - if (event.path) { - attributes['http.route'] = event.path; + if (path) { + attributes['http.route'] = path; + } + + // h3 v1 (Nuxt 4): headers are on event.node.req.headers + // h3 v2 (Nuxt 5): headers are on event.req.headers + let headers: Record = event.node?.req?.headers || {}; + + // oxlint-disable-next-line @typescript-oxlint/no-unsafe-member-access + if (!Object.keys(headers).length && eventH3v2?.req?.headers instanceof Headers) { + // oxlint-disable-next-line @typescript-oxlint/no-unsafe-member-access + headers = Object.fromEntries(eventH3v2?.req.headers.entries()); } - // Get headers from the Node.js request object - const headers = event.node?.req?.headers || {}; const headerAttributes = httpHeadersToSpanAttributes(headers, getClient()?.getOptions().sendDefaultPii ?? false); // Merge header attributes with existing attributes @@ -182,7 +210,7 @@ function getSpanAttributes( } /** - * Checks if the handler is an event handler, util for type narrowing. + * Checks if the handler is an event handler object, util for type narrowing. */ function isEventHandlerObject(handler: EventHandler | EventHandlerObject): handler is EventHandlerObject { return typeof handler !== 'function'; diff --git a/packages/nuxt/src/runtime/plugins/route-detector-legacy.server.ts b/packages/nuxt/src/runtime/plugins/route-detector-legacy.server.ts new file mode 100644 index 000000000000..d67102576158 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/route-detector-legacy.server.ts @@ -0,0 +1,49 @@ +import { debug, getActiveSpan, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '@sentry/core'; +import { defineNuxtPlugin } from 'nuxt/app'; +import type { NuxtPageSubset } from '../utils/route-extraction'; +import { extractParametrizedRouteFromContext } from '../utils/route-extraction'; + +export default defineNuxtPlugin(nuxtApp => { + nuxtApp.hooks.hook('app:rendered', async renderContext => { + let buildTimePagesData: NuxtPageSubset[]; + try { + // This is a common Nuxt pattern to import build-time generated data (until Nuxt v3): https://nuxt.com/docs/4.x/api/kit/templates#creating-a-virtual-file-for-runtime-plugin + // @ts-expect-error This import is dynamically resolved at build time (`addTemplate` in module.ts) + const { default: importedPagesData } = await import('#build/sentry--nuxt-pages-data.mjs'); + buildTimePagesData = importedPagesData || []; + debug.log('Imported build-time pages data:', buildTimePagesData); + } catch (error) { + buildTimePagesData = []; + debug.warn('Failed to import build-time pages data:', error); + } + + const ssrContext = renderContext.ssrContext; + + const routeInfo = extractParametrizedRouteFromContext( + ssrContext?.modules, + ssrContext?.url || ssrContext?.event._path, + buildTimePagesData, + ); + + if (routeInfo === null) { + return; + } + + const activeSpan = getActiveSpan(); // In development mode, getActiveSpan() is always undefined + + if (activeSpan && routeInfo.parametrizedRoute) { + const rootSpan = getRootSpan(activeSpan); + + if (!rootSpan) { + return; + } + + debug.log('Matched parametrized server route:', routeInfo.parametrizedRoute); + + rootSpan.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': routeInfo.parametrizedRoute, + }); + } + }); +}); diff --git a/packages/nuxt/src/runtime/plugins/route-detector.server.ts b/packages/nuxt/src/runtime/plugins/route-detector.server.ts index 37c6bc17a4b5..1ed3a2f1d36b 100644 --- a/packages/nuxt/src/runtime/plugins/route-detector.server.ts +++ b/packages/nuxt/src/runtime/plugins/route-detector.server.ts @@ -7,9 +7,9 @@ export default defineNuxtPlugin(nuxtApp => { nuxtApp.hooks.hook('app:rendered', async renderContext => { let buildTimePagesData: NuxtPageSubset[]; try { - // This is a common Nuxt pattern to import build-time generated data: https://nuxt.com/docs/4.x/api/kit/templates#creating-a-virtual-file-for-runtime-plugin - // @ts-expect-error This import is dynamically resolved at build time (`addTemplate` in module.ts) - const { default: importedPagesData } = await import('#build/sentry--nuxt-pages-data.mjs'); + // Virtual module registered via addServerTemplate in module.ts (Nuxt v4+) + // @ts-expect-error - This is a virtual module + const { default: importedPagesData } = await import('#sentry/nuxt-pages-data.mjs'); buildTimePagesData = importedPagesData || []; debug.log('Imported build-time pages data:', buildTimePagesData); } catch (error) { diff --git a/packages/nuxt/src/runtime/plugins/sentry.server.ts b/packages/nuxt/src/runtime/plugins/sentry.server.ts index 7ea91e36cf25..63b4f6c107be 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.server.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.server.ts @@ -3,12 +3,9 @@ import type { H3Event } from 'h3'; import type { NitroAppPlugin } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; import { sentryCaptureErrorHook } from '../hooks/captureErrorHook'; -import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; import { addSentryTracingMetaTags } from '../utils'; export default (nitroApp => { - nitroApp.hooks.hook('beforeResponse', updateRouteBeforeResponse); - nitroApp.hooks.hook('error', sentryCaptureErrorHook); // @ts-expect-error - 'render:html' is a valid hook name in the Nuxt context diff --git a/packages/nuxt/src/runtime/plugins/update-route-name-legacy.server.ts b/packages/nuxt/src/runtime/plugins/update-route-name-legacy.server.ts new file mode 100644 index 000000000000..417c83062cb8 --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/update-route-name-legacy.server.ts @@ -0,0 +1,6 @@ +import type { NitroAppPlugin } from 'nitropack'; +import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; + +export default (nitroApp => { + nitroApp.hooks.hook('beforeResponse', updateRouteBeforeResponse); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/runtime/plugins/update-route-name.server.ts b/packages/nuxt/src/runtime/plugins/update-route-name.server.ts new file mode 100644 index 000000000000..72e3d9452e7e --- /dev/null +++ b/packages/nuxt/src/runtime/plugins/update-route-name.server.ts @@ -0,0 +1,8 @@ +import type { NitroAppPlugin } from 'nitro/types'; +import { updateRouteBeforeResponse } from '../hooks/updateRouteBeforeResponse'; +import type { H3Event } from 'h3'; + +export default (nitroApp => { + // @ts-expect-error Hook in Nuxt 5 (Nitro 3) is called 'response' https://nitro.build/docs/plugins#available-hooks + nitroApp.hooks.hook('response', (_response, event: H3Event) => updateRouteBeforeResponse(event)); +}) satisfies NitroAppPlugin; diff --git a/packages/nuxt/src/vite/middlewareConfig.ts b/packages/nuxt/src/vite/middlewareConfig.ts index d851345172d8..aa8f5a41ea23 100644 --- a/packages/nuxt/src/vite/middlewareConfig.ts +++ b/packages/nuxt/src/vite/middlewareConfig.ts @@ -90,8 +90,13 @@ function instrumentedEventHandler(handlerOrObject) { return eventHandler(wrapMiddlewareHandlerWithSentry(handlerOrObject, '${cleanFileName}')); } +function defineInstrumentedHandler(handlerOrObject) { + return defineHandler(wrapMiddlewareHandlerWithSentry(handlerOrObject, '${cleanFileName}')); +} + ${originalCode .replace(/defineEventHandler\(/g, 'defineInstrumentedEventHandler(') - .replace(/eventHandler\(/g, 'instrumentedEventHandler(')} + .replace(/eventHandler\(/g, 'instrumentedEventHandler(') + .replace(/defineHandler\(/g, 'defineInstrumentedHandler(')} `; } diff --git a/packages/nuxt/test/runtime/hooks/updateRouteBeforeResponse.test.ts b/packages/nuxt/test/runtime/hooks/updateRouteBeforeResponse.test.ts new file mode 100644 index 000000000000..d311ee253121 --- /dev/null +++ b/packages/nuxt/test/runtime/hooks/updateRouteBeforeResponse.test.ts @@ -0,0 +1,87 @@ +import { + debug, + getActiveSpan, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + type Span, + type SpanAttributes, +} from '@sentry/core'; +import { afterEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { updateRouteBeforeResponse } from '../../../src/runtime/hooks/updateRouteBeforeResponse'; + +vi.mock(import('@sentry/core'), async importOriginal => { + const mod = await importOriginal(); + + return { + ...mod, + debug: { + ...mod.debug, + log: vi.fn(), + }, + getActiveSpan: vi.fn(), + getRootSpan: vi.fn(), + }; +}); + +describe('updateRouteBeforeResponse', () => { + const mockRootSpan = { + setAttributes: vi.fn(), + } as unknown as Pick; + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('updates the transaction name for Nitro v2 matched routes', () => { + (getActiveSpan as Mock).mockReturnValue({} as Span); + (getRootSpan as Mock).mockReturnValue(mockRootSpan); + + updateRouteBeforeResponse({ + _path: '/users/123', + context: { + matchedRoute: { + path: '/users/:id', + }, + params: { + id: '123', + }, + }, + } as never); + + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': '/users/:id', + } satisfies SpanAttributes); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + 'params.id': '123', + 'url.path.parameter.id': '123', + } satisfies SpanAttributes); + expect(debug.log).toHaveBeenCalledWith('Updated transaction name for parametrized route: /users/:id'); + }); + + it('updates the transaction name for Nitro v3 matched routes', () => { + (getActiveSpan as Mock).mockReturnValue({} as Span); + (getRootSpan as Mock).mockReturnValue(mockRootSpan); + + updateRouteBeforeResponse({ + path: '/users/123', + context: { + matchedRoute: { + route: '/users/:id', + }, + params: { + id: '123', + }, + }, + } as never); + + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + 'http.route': '/users/:id', + } satisfies SpanAttributes); + expect(mockRootSpan.setAttributes).toHaveBeenCalledWith({ + 'params.id': '123', + 'url.path.parameter.id': '123', + } satisfies SpanAttributes); + }); +}); diff --git a/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts b/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts index c1f73cd858fa..b340ab875ec0 100644 --- a/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts +++ b/packages/nuxt/test/runtime/hooks/wrapMiddlewareHandler.test.ts @@ -468,6 +468,99 @@ describe('wrapMiddlewareHandlerWithSentry', () => { }); }); + describe('h3 v2 (Nitro v3) middleware array wrapping', () => { + it('should wrap middleware array handlers correctly', async () => { + const baseHandler: EventHandler = vi.fn().mockResolvedValue('handler-result'); + const middlewareHandler1 = vi.fn().mockResolvedValue(undefined); + const middlewareHandler2 = vi.fn().mockResolvedValue(undefined); + const handlerObject = { + handler: baseHandler, + middleware: [middlewareHandler1, middlewareHandler2], + }; + + const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject as any, 'v2-middleware'); + + expect(wrapped).toHaveProperty('middleware'); + expect(Array.isArray((wrapped as any).middleware)).toBe(true); + expect((wrapped as any).middleware).toHaveLength(2); + + await (wrapped as any).middleware[0](mockEvent); + await (wrapped as any).middleware[1](mockEvent); + + expect(middlewareHandler1).toHaveBeenCalledWith(mockEvent); + expect(middlewareHandler2).toHaveBeenCalledWith(mockEvent); + + expect(SentryCore.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'v2-middleware.middleware', + attributes: expect.objectContaining({ + [SentryCore.SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nuxt', + 'nuxt.middleware.name': 'v2-middleware', + 'nuxt.middleware.hook.name': 'middleware', + 'nuxt.middleware.hook.index': 0, + }), + }), + expect.any(Function), + ); + expect(SentryCore.startSpan).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'v2-middleware.middleware', + attributes: expect.objectContaining({ + 'nuxt.middleware.hook.name': 'middleware', + 'nuxt.middleware.hook.index': 1, + }), + }), + expect.any(Function), + ); + }); + + it('should wrap single-element middleware array with index', async () => { + const baseHandler: EventHandler = vi.fn().mockResolvedValue('handler-result'); + const middlewareHandler = vi.fn().mockResolvedValue(undefined); + const handlerObject = { + handler: baseHandler, + middleware: [middlewareHandler], + }; + + const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject as any, 'v2-single-middleware'); + + await (wrapped as any).middleware[0](mockEvent); + + expect(middlewareHandler).toHaveBeenCalledWith(mockEvent); + + const spanCall = (SentryCore.startSpan as any).mock.calls.find( + (call: any) => call[0]?.attributes?.['nuxt.middleware.hook.name'] === 'middleware', + ); + expect(spanCall[0].attributes['nuxt.middleware.hook.index']).toBe(0); + }); + + it('should handle h3 v2 object without middleware property', async () => { + const baseHandler: EventHandler = vi.fn().mockResolvedValue('v2-result'); + const handlerObject = { handler: baseHandler }; + + const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject as any, 'v2-handler-only'); + + const result = await (wrapped as any).handler(mockEvent); + expect(result).toBe('v2-result'); + expect(baseHandler).toHaveBeenCalledWith(mockEvent); + }); + + it('should propagate errors from middleware array handlers', async () => { + const baseHandler: EventHandler = vi.fn().mockResolvedValue('success'); + const error = new Error('Middleware error'); + const failingMiddleware = vi.fn().mockRejectedValue(error); + const handlerObject = { + handler: baseHandler, + middleware: [failingMiddleware], + }; + + const wrapped = wrapMiddlewareHandlerWithSentry(handlerObject as any, 'failing-v2-middleware'); + + await expect((wrapped as any).middleware[0](mockEvent)).rejects.toThrow('Middleware error'); + expect(SentryCore.captureException).toHaveBeenCalledWith(error, expect.any(Object)); + }); + }); + describe('Sentry API integration', () => { it('should call Sentry APIs with correct parameters', async () => { const userHandler: EventHandler = vi.fn().mockResolvedValue('api-test-result'); diff --git a/packages/opentelemetry/package.json b/packages/opentelemetry/package.json index d512d57afa8f..f4192101577d 100644 --- a/packages/opentelemetry/package.json +++ b/packages/opentelemetry/package.json @@ -49,10 +49,10 @@ "@opentelemetry/semantic-conventions": "^1.39.0" }, "devDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^2.6.0", - "@opentelemetry/core": "^2.6.0", - "@opentelemetry/sdk-trace-base": "^2.6.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/context-async-hooks": "^2.6.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0" }, "scripts": { diff --git a/packages/opentelemetry/src/asyncContextStrategy.ts b/packages/opentelemetry/src/asyncContextStrategy.ts index 9f7b38d0b43d..7cb8dc0f54eb 100644 --- a/packages/opentelemetry/src/asyncContextStrategy.ts +++ b/packages/opentelemetry/src/asyncContextStrategy.ts @@ -6,7 +6,7 @@ import { SENTRY_FORK_SET_ISOLATION_SCOPE_CONTEXT_KEY, SENTRY_FORK_SET_SCOPE_CONTEXT_KEY, } from './constants'; -import { continueTrace, startInactiveSpan, startSpan, startSpanManual, withActiveSpan } from './trace'; +import { continueTrace, startInactiveSpan, startNewTrace, startSpan, startSpanManual, withActiveSpan } from './trace'; import type { CurrentScopes } from './types'; import { getContextFromScope, getScopesFromContext } from './utils/contextData'; import { getActiveSpan } from './utils/getActiveSpan'; @@ -104,6 +104,7 @@ export function setOpenTelemetryContextAsyncContextStrategy(): void { suppressTracing, getTraceData, continueTrace, + startNewTrace, // The types here don't fully align, because our own `Span` type is narrower // than the OTEL one - but this is OK for here, as we now we'll only have OTEL spans passed around withActiveSpan: withActiveSpan as typeof defaultWithActiveSpan, diff --git a/packages/opentelemetry/src/propagator.ts b/packages/opentelemetry/src/propagator.ts index 2b5873658706..b8582b37d964 100644 --- a/packages/opentelemetry/src/propagator.ts +++ b/packages/opentelemetry/src/propagator.ts @@ -63,6 +63,8 @@ export class SentryPropagator extends W3CBaggagePropagator { } const existingBaggageHeader = getExistingBaggage(carrier); + const existingSentryTraceHeader = getExistingSentryTrace(carrier); + let baggage = propagation.getBaggage(context) || propagation.createBaggage({}); const { dynamicSamplingContext, traceId, spanId, sampled } = getInjectionData(context); @@ -72,12 +74,18 @@ export class SentryPropagator extends W3CBaggagePropagator { if (baggageEntries) { Object.entries(baggageEntries).forEach(([key, value]) => { + if (!existingSentryTraceHeader && key.startsWith(SENTRY_BAGGAGE_KEY_PREFIX)) { + // Edge case: A baggage header with sentry- keys was added previously but no + // sentry-trace header. In this case we remove the old sentry-keys and add new + // ones below. + return; + } baggage = baggage.setEntry(key, { value }); }); } } - if (dynamicSamplingContext) { + if (!existingSentryTraceHeader && dynamicSamplingContext) { baggage = Object.entries(dynamicSamplingContext).reduce((b, [dscKey, dscValue]) => { if (dscValue) { return b.setEntry(`${SENTRY_BAGGAGE_KEY_PREFIX}${dscKey}`, { value: dscValue }); @@ -87,7 +95,7 @@ export class SentryPropagator extends W3CBaggagePropagator { } // We also want to avoid setting the default OTEL trace ID, if we get that for whatever reason - if (traceId && traceId !== INVALID_TRACEID) { + if (!existingSentryTraceHeader && traceId && traceId !== INVALID_TRACEID) { setter.set(carrier, SENTRY_TRACE_HEADER, generateSentryTraceHeader(traceId, spanId, sampled)); if (propagateTraceparent) { @@ -248,6 +256,14 @@ function getExistingBaggage(carrier: unknown): string | undefined { } } +function getExistingSentryTrace(carrier: unknown): string | string[] | undefined { + try { + return (carrier as Record)[SENTRY_TRACE_HEADER]; + } catch { + return undefined; + } +} + /** * It is pretty tricky to get access to the outgoing request URL of a request in the propagator. * As we only have access to the context of the span to be sent and the carrier (=headers), diff --git a/packages/opentelemetry/src/trace.ts b/packages/opentelemetry/src/trace.ts index a640841cd3d4..7c9d09a169b9 100644 --- a/packages/opentelemetry/src/trace.ts +++ b/packages/opentelemetry/src/trace.ts @@ -1,4 +1,4 @@ -import type { Context, Span, SpanContext, SpanOptions, Tracer } from '@opentelemetry/api'; +import type { Context, Span, SpanContext, SpanOptions, TimeInput, Tracer } from '@opentelemetry/api'; import { context, SpanStatusCode, trace, TraceFlags } from '@opentelemetry/api'; import { isTracingSuppressed, suppressTracing } from '@opentelemetry/core'; import type { @@ -10,6 +10,9 @@ import type { TraceContext, } from '@sentry/core'; import { + _INTERNAL_safeMathRandom, + generateSpanId, + generateTraceId, getClient, getCurrentScope, getDynamicSamplingContextFromScope, @@ -60,6 +63,7 @@ function _startSpan(options: OpenTelemetrySpanContext, callback: (span: Span) return context.with(suppressedCtx, () => { return tracer.startActiveSpan(name, spanOptions, suppressedCtx, span => { + patchSpanEnd(span); // Restore the original unsuppressed context for the callback execution // so that custom OpenTelemetry spans maintain the correct context. // We use activeCtx (not ctx) because ctx may be suppressed when onlyIfParent is true @@ -82,6 +86,7 @@ function _startSpan(options: OpenTelemetrySpanContext, callback: (span: Span) } return tracer.startActiveSpan(name, spanOptions, ctx, span => { + patchSpanEnd(span); return handleCallbackErrors( () => callback(span), () => { @@ -155,7 +160,9 @@ export function startInactiveSpan(options: OpenTelemetrySpanContext): Span { ctx = isTracingSuppressed(ctx) ? ctx : suppressTracing(ctx); } - return tracer.startSpan(name, spanOptions, ctx); + const span = tracer.startSpan(name, spanOptions, ctx); + patchSpanEnd(span); + return span; }); } @@ -202,6 +209,17 @@ function ensureTimestampInMilliseconds(timestamp: number): number { return isMs ? timestamp * 1000 : timestamp; } +/** + * Wraps the span's `end()` method so that numeric timestamps passed in seconds + * are converted to milliseconds before reaching OTel's native `Span.end()`. + */ +function patchSpanEnd(span: Span): void { + const originalEnd = span.end.bind(span); + span.end = (endTime?: TimeInput) => { + return originalEnd(typeof endTime === 'number' ? ensureTimestampInMilliseconds(endTime) : endTime); + }; +} + function getContext(scope: Scope | undefined, forceTransaction: boolean | undefined): Context { const ctx = getContextForScope(scope); const parentSpan = trace.getSpan(ctx); @@ -276,6 +294,36 @@ export function continueTrace(options: Parameters[0 return continueTraceAsRemoteSpan(context.active(), options, callback); } +/** + * Start a new trace with a unique traceId, ensuring all spans created within the callback + * share the same traceId. + * + * This is a custom version of `startNewTrace` for OTEL-powered environments. + * It injects the new traceId as a remote span context into the OTEL context, so that + * `startInactiveSpan` and `startSpan` pick it up correctly. + */ +export function startNewTrace(callback: () => T): T { + const traceId = generateTraceId(); + const spanId = generateSpanId(); + + const spanContext: SpanContext = { + traceId, + spanId, + isRemote: true, + traceFlags: TraceFlags.NONE, + }; + + const ctxWithTrace = trace.setSpanContext(context.active(), spanContext); + + return context.with(ctxWithTrace, () => { + getCurrentScope().setPropagationContext({ + traceId, + sampleRand: _INTERNAL_safeMathRandom(), + }); + return callback(); + }); +} + /** * Get the trace context for a given scope. * We have a custom implementation here because we need an OTEL-specific way to get the span from a scope. diff --git a/packages/opentelemetry/test/propagator.test.ts b/packages/opentelemetry/test/propagator.test.ts index ef8223b5974a..7610f040a72d 100644 --- a/packages/opentelemetry/test/propagator.test.ts +++ b/packages/opentelemetry/test/propagator.test.ts @@ -500,14 +500,17 @@ describe('SentryPropagator', () => { ); }); - it('should overwrite existing sentry baggage header', () => { + it('overwrites existing sentry baggage values and add sentry-trace header if sentry-trace is not set yet', () => { + // This is an edeg case where someone set a baggage header with existing sentry- values but no sentry-trace header. + // There's no evidence this occurs in real-life but if it does, we can assume that this must be some kind of error + // Hence, we overwrite the existing sentry- values with our new ones but keep all other non-sentry values. const spanContext = { traceId: 'd4cda95b652f4a1592b449d5929fda1b', spanId: '6e0c63257de34c92', traceFlags: TraceFlags.SAMPLED, }; - const carrier = { + const carrier: Record = { baggage: 'foo=bar,other=yes,sentry-release=9.9.9,sentry-other=yes', }; const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); @@ -520,11 +523,11 @@ describe('SentryPropagator', () => { 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', 'sentry-public_key=abc', 'sentry-environment=production', - 'sentry-other=yes', 'sentry-release=1.0.0', 'sentry-sampled=true', ].sort(), ); + expect(carrier[SENTRY_TRACE_HEADER]).toBe('d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-1'); }); it('should create baggage without propagation context', () => { @@ -537,6 +540,7 @@ describe('SentryPropagator', () => { expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe( `foo=bar,sentry-environment=production,sentry-release=1.0.0,sentry-public_key=abc,sentry-trace_id=${traceId}`, ); + expect(carrier[SENTRY_TRACE_HEADER]).toBeDefined(); // whenever we set baggage, we must also set sentry-trace }); it('should NOT set baggage and sentry-trace header if instrumentation is suppressed', () => { @@ -551,6 +555,86 @@ describe('SentryPropagator', () => { expect(carrier[SENTRY_TRACE_HEADER]).toBe(undefined); expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe(undefined); }); + + it("doesn't set baggage header if sentry-trace header is already set", () => { + const carrier: Record = { + [SENTRY_TRACE_HEADER]: 'abcdef-xyz-1', + }; + propagator.inject(ROOT_CONTEXT, carrier, defaultTextMapSetter); + + expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe(undefined); + expect(carrier[SENTRY_TRACE_HEADER]).toBe('abcdef-xyz-1'); + }); + + describe('traceparent header', () => { + it("doesn't change baggage header if sentry-trace header is already set", () => { + const carrier: Record = { + [SENTRY_TRACE_HEADER]: 'abcdef-xyz-1', + [SENTRY_BAGGAGE_HEADER]: 'foo=bar,other=yes,sentry-release=9.9.9', + }; + propagator.inject(ROOT_CONTEXT, carrier, defaultTextMapSetter); + + expect(carrier[SENTRY_BAGGAGE_HEADER]).toBe('foo=bar,other=yes,sentry-release=9.9.9'); + expect(carrier[SENTRY_TRACE_HEADER]).toBe('abcdef-xyz-1'); + }); + + it("doesn't set traceparent header if sentry-trace header is already set", () => { + mockSdkInit({ propagateTraceparent: true }); + const carrier: Record = { + [SENTRY_TRACE_HEADER]: 'abcdef-xyz-1', + }; + propagator.inject(ROOT_CONTEXT, carrier, defaultTextMapSetter); + + expect(carrier['traceparent']).toBe(undefined); + expect(carrier[SENTRY_TRACE_HEADER]).toBe('abcdef-xyz-1'); + }); + + it('sets traceparent header if propagateTraceparent is true', () => { + mockSdkInit({ + environment: 'production', + release: '1.0.0', + tracesSampleRate: 1, + dsn: 'https://abc@domain/123', + propagateTraceparent: true, + }); + + const spanContext = { + traceId: 'd4cda95b652f4a1592b449d5929fda1b', + spanId: '6e0c63257de34c92', + traceFlags: TraceFlags.SAMPLED, + }; + const context = trace.setSpanContext(ROOT_CONTEXT, spanContext); + const baggage = propagation.createBaggage({ foo: { value: 'bar' } }); + propagator.inject(propagation.setBaggage(context, baggage), carrier, defaultTextMapSetter); + + expect(baggageToArray(carrier[SENTRY_BAGGAGE_HEADER])).toEqual( + [ + 'foo=bar', + 'sentry-trace_id=d4cda95b652f4a1592b449d5929fda1b', + 'sentry-public_key=abc', + 'sentry-environment=production', + 'sentry-release=1.0.0', + 'sentry-sampled=true', + ].sort(), + ); + expect(carrier['traceparent']).toBe('00-d4cda95b652f4a1592b449d5929fda1b-6e0c63257de34c92-01'); + }); + + it("doesn't set traceparent header if propagateTraceparent is false", () => { + mockSdkInit({ + environment: 'production', + release: '1.0.0', + tracesSampleRate: 1, + dsn: 'https://abc@domain/123', + propagateTraceparent: false, + }); + const carrier: Record = {}; + propagator.inject(ROOT_CONTEXT, carrier, defaultTextMapSetter); + + expect(carrier['traceparent']).toBe(undefined); + expect(carrier[SENTRY_TRACE_HEADER]).toBeDefined(); + }); + }); }); describe('extract', () => { diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index 1bb1f2634839..a6a7f35ab76a 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -21,7 +21,7 @@ import { } from '@sentry/core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getParentSpanId } from '../../../packages/opentelemetry/src/utils/getParentSpanId'; -import { continueTrace, startInactiveSpan, startSpan, startSpanManual } from '../src/trace'; +import { continueTrace, startInactiveSpan, startNewTrace, startSpan, startSpanManual } from '../src/trace'; import type { AbstractSpan } from '../src/types'; import { getActiveSpan } from '../src/utils/getActiveSpan'; import { getSamplingDecision } from '../src/utils/getSamplingDecision'; @@ -2005,6 +2005,187 @@ describe('suppressTracing', () => { }); }); +describe('span.end() timestamp conversion', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('converts seconds to milliseconds for startInactiveSpan', () => { + // Use a timestamp in seconds that is after the span start (i.e. in the future) + // OTel resets endTime to startTime if endTime < startTime + const nowSec = Math.floor(Date.now() / 1000) + 1; + const span = startInactiveSpan({ name: 'test' }); + span.end(nowSec); + + const endTime = getSpanEndTime(span); + // ensureTimestampInMilliseconds converts seconds (< 9999999999) to ms by * 1000 + // OTel then converts ms to HrTime [seconds, nanoseconds] + expect(endTime![0]).toBe(nowSec); + expect(endTime![1]).toBe(0); + }); + + it('keeps milliseconds as-is for startInactiveSpan', () => { + // Timestamp already in milliseconds (> 9999999999 threshold) + const nowMs = Date.now() + 1000; + const nowSec = Math.floor(nowMs / 1000); + const span = startInactiveSpan({ name: 'test' }); + span.end(nowMs); + + const endTime = getSpanEndTime(span); + expect(endTime![0]).toBe(nowSec); + }); + + it('handles Date input for startInactiveSpan', () => { + const nowMs = Date.now() + 1000; + const nowSec = Math.floor(nowMs / 1000); + const span = startInactiveSpan({ name: 'test' }); + span.end(new Date(nowMs)); + + const endTime = getSpanEndTime(span); + expect(endTime![0]).toBe(nowSec); + }); + + it('handles no-arg end for startInactiveSpan', () => { + const span = startInactiveSpan({ name: 'test' }); + span.end(); + + const endTime = getSpanEndTime(span); + expect(endTime).toBeDefined(); + expect(endTime![0]).not.toBe(0); + }); + + it('handles HrTime input for startInactiveSpan', () => { + const nowSec = Math.floor(Date.now() / 1000) + 1; + const span = startInactiveSpan({ name: 'test' }); + span.end([nowSec, 500000000] as [number, number]); + + const endTime = getSpanEndTime(span); + expect(endTime![0]).toBe(nowSec); + expect(endTime![1]).toBe(500000000); + }); + + it('converts seconds to milliseconds for startSpanManual callback span', () => { + const nowSec = Math.floor(Date.now() / 1000) + 1; + startSpanManual({ name: 'test' }, span => { + span.end(nowSec); + + const endTime = getSpanEndTime(span); + expect(endTime![0]).toBe(nowSec); + expect(endTime![1]).toBe(0); + }); + }); + + it('converts seconds to milliseconds for startSpan child span', () => { + const nowSec = Math.floor(Date.now() / 1000) + 1; + let capturedEndTime: [number, number] | undefined; + startSpan({ name: 'outer' }, () => { + const innerSpan = startInactiveSpan({ name: 'inner' }); + innerSpan.end(nowSec); + capturedEndTime = getSpanEndTime(innerSpan); + }); + + expect(capturedEndTime![0]).toBe(nowSec); + expect(capturedEndTime![1]).toBe(0); + }); +}); + +describe('startNewTrace', () => { + beforeEach(() => { + mockSdkInit({ tracesSampleRate: 1 }); + }); + + afterEach(async () => { + await cleanupOtel(); + }); + + it('sequential startInactiveSpan calls share the same traceId', () => { + startNewTrace(() => { + const propagationContext = getCurrentScope().getPropagationContext(); + + const span1 = startInactiveSpan({ name: 'span-1' }); + const span2 = startInactiveSpan({ name: 'span-2' }); + const span3 = startInactiveSpan({ name: 'span-3' }); + + const traceId1 = span1.spanContext().traceId; + const traceId2 = span2.spanContext().traceId; + const traceId3 = span3.spanContext().traceId; + + expect(traceId1).toBe(propagationContext.traceId); + expect(traceId2).toBe(propagationContext.traceId); + expect(traceId3).toBe(propagationContext.traceId); + + span1.end(); + span2.end(); + span3.end(); + }); + }); + + it('startSpan inside startNewTrace uses the correct traceId', () => { + startNewTrace(() => { + const propagationContext = getCurrentScope().getPropagationContext(); + + startSpan({ name: 'parent-span' }, parentSpan => { + const parentTraceId = parentSpan.spanContext().traceId; + expect(parentTraceId).toBe(propagationContext.traceId); + + const child = startInactiveSpan({ name: 'child-span' }); + expect(child.spanContext().traceId).toBe(propagationContext.traceId); + child.end(); + }); + }); + }); + + it('generates a different traceId than the outer trace', () => { + startSpan({ name: 'outer-span' }, outerSpan => { + const outerTraceId = outerSpan.spanContext().traceId; + + startNewTrace(() => { + const innerSpan = startInactiveSpan({ name: 'inner-span' }); + const innerTraceId = innerSpan.spanContext().traceId; + + expect(innerTraceId).not.toBe(outerTraceId); + + const propagationContext = getCurrentScope().getPropagationContext(); + expect(innerTraceId).toBe(propagationContext.traceId); + + innerSpan.end(); + }); + }); + }); + + it('allows spans to be sampled based on tracesSampleRate', () => { + startNewTrace(() => { + const span = startInactiveSpan({ name: 'sampled-span' }); + // tracesSampleRate is 1 in mockSdkInit, so spans should be sampled + // This verifies that TraceFlags.NONE on the remote span context does not + // cause the sampler to inherit a "not sampled" decision from the parent + expect(spanIsSampled(span)).toBe(true); + span.end(); + }); + }); + + it('does not leak the new traceId to the outer scope', () => { + const outerScope = getCurrentScope(); + const outerTraceId = outerScope.getPropagationContext().traceId; + + startNewTrace(() => { + // Manually set a known traceId on the inner scope to verify it doesn't leak + getCurrentScope().setPropagationContext({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + sampleRand: 0.5, + }); + }); + + const afterTraceId = outerScope.getPropagationContext().traceId; + expect(afterTraceId).toBe(outerTraceId); + expect(afterTraceId).not.toBe('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + }); +}); + function getSpanName(span: AbstractSpan): string | undefined { return spanHasName(span) ? span.name : undefined; } diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts index 9b4fd1601420..4cb51ac540b5 100644 --- a/packages/profiling-node/src/integration.ts +++ b/packages/profiling-node/src/integration.ts @@ -14,6 +14,7 @@ import { } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { CpuProfilerBindings, ProfileFormat, type RawThreadCpuProfile } from '@sentry-internal/node-cpu-profiler'; +import { isMainThread } from 'worker_threads'; import { DEBUG_BUILD } from './debug-build'; import { NODE_MAJOR } from './nodeVersion'; import { MAX_PROFILE_DURATION_MS, maybeProfileSpan, stopSpanProfile } from './spanProfileUtils'; @@ -62,6 +63,14 @@ class ContinuousProfiler { * @param client */ public initialize(client: NodeClient): void { + if (!isMainThread) { + DEBUG_BUILD && + debug.warn( + '[Profiling] nodeProfilingIntegration() does not support worker threads — profiling will be disabled for this thread.', + ); + return; + } + this._client = client; const options = client.getOptions(); diff --git a/packages/profiling-node/src/spanProfileUtils.ts b/packages/profiling-node/src/spanProfileUtils.ts index bf1be3a0bf44..436e741ff7a2 100644 --- a/packages/profiling-node/src/spanProfileUtils.ts +++ b/packages/profiling-node/src/spanProfileUtils.ts @@ -3,6 +3,7 @@ import type { CustomSamplingContext, Span } from '@sentry/core'; import { debug, spanIsSampled, spanToJSON, uuid4 } from '@sentry/core'; import type { NodeClient } from '@sentry/node'; import { CpuProfilerBindings, type RawThreadCpuProfile } from '@sentry-internal/node-cpu-profiler'; +import { isMainThread } from 'worker_threads'; import { DEBUG_BUILD } from './debug-build'; import { isValidSampleRate } from './utils'; @@ -17,6 +18,13 @@ export function maybeProfileSpan( span: Span, customSamplingContext?: CustomSamplingContext, ): string | undefined { + // Profiling is not supported in worker threads as the native CPU profiler's + // sampling thread can race with V8's GC across isolates, causing heap corruption. + if (!isMainThread) { + DEBUG_BUILD && debug.log('[Profiling] Skipping span profiling in worker thread.'); + return; + } + // profilesSampleRate is multiplied with tracesSampleRate to get the final sampling rate. We dont perform // the actual multiplication to get the final rate, but we discard the profile if the span was sampled, // so anything after this block from here is based on the span sampling. diff --git a/packages/profiling-node/test/integration.worker.test.ts b/packages/profiling-node/test/integration.worker.test.ts index 1d34c03b33cb..fea3c1eb4c4f 100644 --- a/packages/profiling-node/test/integration.worker.test.ts +++ b/packages/profiling-node/test/integration.worker.test.ts @@ -1,6 +1,7 @@ import type { ProfilingIntegration, Transport } from '@sentry/core'; import * as Sentry from '@sentry/node'; -import { expect, it, vi } from 'vitest'; +import { CpuProfilerBindings } from '@sentry-internal/node-cpu-profiler'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { _nodeProfilingIntegration } from '../src/integration'; // Mock the modules before the import, so that the value is initialized before the module is loaded @@ -12,7 +13,7 @@ vi.mock('worker_threads', () => { }); vi.setConfig({ testTimeout: 10_000 }); -function makeContinuousProfilingClient(): [Sentry.NodeClient, Transport] { +function makeClient(options: Partial = {}): [Sentry.NodeClient, Transport] { const integration = _nodeProfilingIntegration(); const client = new Sentry.NodeClient({ stackParser: Sentry.defaultStackParser, @@ -28,48 +29,69 @@ function makeContinuousProfilingClient(): [Sentry.NodeClient, Transport] { return undefined; }, }), + ...options, }); return [client, client.getTransport() as Transport]; } -it('worker threads context', () => { - const [client, transport] = makeContinuousProfilingClient(); - Sentry.setCurrentClient(client); - client.init(); +describe('worker threads', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not start continuous profiling in worker threads', () => { + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeClient(); + Sentry.setCurrentClient(client); + client.init(); + + const integration = client.getIntegrationByName>('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } - const transportSpy = vi.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); + // Calling start should be a no-op in a worker thread + integration._profiler.start(); - const nonProfiledTransaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); - nonProfiledTransaction.end(); + const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + transaction.end(); - expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[0]?.[1]).not.toMatchObject({ - contexts: { - profile: {}, - }, + // The native profiler should never have been called + expect(startProfilingSpy).not.toHaveBeenCalled(); + + integration._profiler.stop(); }); - const integration = client.getIntegrationByName>('ProfilingIntegration'); - if (!integration) { - throw new Error('Profiling integration not found'); - } - - integration._profiler.start(); - const profiledTransaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); - profiledTransaction.end(); - integration._profiler.stop(); - - expect(transportSpy.mock.calls?.[1]?.[0]?.[1]?.[0]?.[1]).toMatchObject({ - contexts: { - trace: { - data: expect.objectContaining({ - ['thread.id']: '9999', - ['thread.name']: 'worker', - }), - }, - profile: { - profiler_id: expect.any(String), - }, - }, + it('does not start span profiling in worker threads', () => { + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeClient({ profilesSampleRate: 1 }); + Sentry.setCurrentClient(client); + client.init(); + + const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + transaction.end(); + + // The native profiler should never have been called even with profilesSampleRate set + expect(startProfilingSpy).not.toHaveBeenCalled(); + }); + + it('does not start trace lifecycle profiling in worker threads', () => { + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeClient({ + profileSessionSampleRate: 1.0, + profileLifecycle: 'trace', + }); + Sentry.setCurrentClient(client); + client.init(); + + const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + transaction.end(); + + // The native profiler should never have been called + expect(startProfilingSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/react-router/package.json b/packages/react-router/package.json index ddb4d65e28f7..86646107c846 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -45,9 +45,9 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^2.6.0", - "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.40.0", "@sentry/browser": "10.46.0", "@sentry/cli": "^2.58.5", diff --git a/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts b/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts index 1863a7b66f9f..fcec109c6baa 100644 --- a/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts +++ b/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts @@ -42,7 +42,7 @@ export async function makeCustomSentryVitePlugins(options: SentryReactRouterBuil }, // will be handled in buildEnd hook sourcemaps: { - disable: 'disable-upload', + disable: true, ...unstable_sentryVitePluginOptions?.sourcemaps, }, ...unstable_sentryVitePluginOptions, diff --git a/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts b/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts index 98dc6d8ba662..b98a6ebfb80d 100644 --- a/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts +++ b/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts @@ -54,13 +54,13 @@ describe('makeCustomSentryVitePlugins', () => { expect(plugins?.[0]?.name).toBe('sentry-vite-plugin'); }); - it('should disable sourcemap upload with "disable-upload" by default', async () => { + it('should disable sourcemap upload by default', async () => { await makeCustomSentryVitePlugins({}); expect(sentryVitePlugin).toHaveBeenCalledWith( expect.objectContaining({ sourcemaps: expect.objectContaining({ - disable: 'disable-upload', + disable: true, }), }), ); diff --git a/packages/remix/package.json b/packages/remix/package.json index 6e4a0d2da5a8..88ad022db3a8 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -64,8 +64,8 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/instrumentation": "^0.213.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.40.0", "@remix-run/router": "^1.23.2", "@sentry/cli": "^2.58.5", diff --git a/packages/solid/src/solidrouter.ts b/packages/solid/src/solidrouter.ts index 40af69caa2bc..0424183aea18 100644 --- a/packages/solid/src/solidrouter.ts +++ b/packages/solid/src/solidrouter.ts @@ -20,7 +20,7 @@ import type { RouteSectionProps, StaticRouter, } from '@solidjs/router'; -import { useBeforeLeave, useLocation } from '@solidjs/router'; +import { useBeforeLeave, useCurrentMatches, useLocation } from '@solidjs/router'; import type { Component, JSX, ParentProps } from 'solid-js'; import { createEffect, mergeProps, splitProps } from 'solid-js'; import { createComponent } from 'solid-js/web'; @@ -66,31 +66,60 @@ function SentryDefaultRoot(props: ParentProps): JSX.Element { */ function withSentryRouterRoot(Root: Component): Component { const SentryRouterRoot = (props: RouteSectionProps): JSX.Element => { - // TODO: This is a rudimentary first version of handling navigation spans - // It does not - // - use query params - // - parameterize the route + // Tracks the target of a pending navigation, so the effect can skip + // stale updates during redirects where the location signal + // hasn't caught up to the navigation span yet. + let pendingNavigationTarget: string | undefined; useBeforeLeave(({ to }: BeforeLeaveEventArgs) => { - // `to` could be `-1` if the browser back-button was used - handleNavigation(to.toString()); + const target = to.toString(); + pendingNavigationTarget = target; + handleNavigation(target); }); const location = useLocation(); + const matches = useCurrentMatches(); + createEffect(() => { const name = location.pathname; const rootSpan = getActiveRootSpan(); + if (!rootSpan) { + return; + } - if (rootSpan) { + // During redirects, the effect can fire before the router + // transition completes. In that case, location.pathname still points + // to the old route while the active span is already the navigation span. + // Skip the update to avoid overwriting the span with stale route data. + // `-1` is solid router's representation of a browser back-button + // navigation, where we don't know the target URL upfront. + if (pendingNavigationTarget && pendingNavigationTarget !== '-1' && name !== pendingNavigationTarget) { + return; + } + pendingNavigationTarget = undefined; + + const currentMatches = matches(); + const lastMatch = currentMatches[currentMatches.length - 1]; + + if (lastMatch) { + const parametrizedRoute = lastMatch.route.pattern || name; + rootSpan.updateName(parametrizedRoute); + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + + const params = lastMatch.params; + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + rootSpan.setAttribute(`url.path.parameter.${key}`, value); + rootSpan.setAttribute(`params.${key}`, value); + } + } + } else { + // No matched route - update back-button navigations and set source to url const { op, description } = spanToJSON(rootSpan); - - // We only need to update navigation spans that have been created by - // a browser back-button navigation (stored as `-1` by solid router) - // everything else was already instrumented correctly in `useBeforeLeave` if (op === 'navigation' && description === '-1') { rootSpan.updateName(name); - rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); } + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); } }); diff --git a/packages/solid/test/solidrouter.test.tsx b/packages/solid/test/solidrouter.test.tsx index 5a5ab77e9f2c..f33b7e72daf4 100644 --- a/packages/solid/test/solidrouter.test.tsx +++ b/packages/solid/test/solidrouter.test.tsx @@ -1,4 +1,5 @@ import { spanToJSON } from '@sentry/browser'; +import type { Span } from '@sentry/core'; import { createTransport, getCurrentScope, @@ -9,7 +10,7 @@ import { } from '@sentry/core'; import type { MemoryHistory } from '@solidjs/router'; import { createMemoryHistory, MemoryRouter, Navigate, Route } from '@solidjs/router'; -import { render } from '@solidjs/testing-library'; +import { render, waitFor } from '@solidjs/testing-library'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserClient } from '../src'; import { solidRouterBrowserTracingIntegration, withSentryRouterRouting } from '../src/solidrouter'; @@ -114,39 +115,54 @@ describe('solidRouterBrowserTracingIntegration', () => { }); it.each([ - ['', '/navigate-to-about', '/about'], - ['for nested navigation', '/navigate-to-about-us', '/about/us'], - ['for navigation with param', '/navigate-to-user', '/user/5'], - ['for nested navigation with params', '/navigate-to-user-post', '/user/5/post/12'], - ])('starts a navigation span %s', (_itDescription, navigationPath, path) => { - const spanStartMock = vi.fn(); - - const client = createMockBrowserClient(); - setCurrentClient(client); - - client.on('spanStart', span => { - spanStartMock(spanToJSON(span)); - }); - client.addIntegration(solidRouterBrowserTracingIntegration()); - const SentryRouter = withSentryRouterRouting(MemoryRouter); - - const history = createMemoryHistory(); - history.set({ value: navigationPath }); - - renderRouter(SentryRouter, history); - - expect(spanStartMock).toHaveBeenCalledWith( - expect.objectContaining({ - op: 'navigation', - description: path, - data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + ['', '/navigate-to-about', '/about', {}], + ['for nested navigation', '/navigate-to-about-us', '/about/us', {}], + ['for navigation with param', '/navigate-to-user', '/user/:id', { id: '5' }], + [ + 'for nested navigation with params', + '/navigate-to-user-post', + '/user/:id/post/:postId', + { id: '5', postId: '12' }, + ], + ])( + 'starts a parametrized navigation span %s', + async (_itDescription, navigationPath, parametrizedRoute, expectedParams) => { + const spans: Span[] = []; + + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.on('spanStart', span => { + spans.push(span); + }); + client.addIntegration(solidRouterBrowserTracingIntegration()); + const SentryRouter = withSentryRouterRouting(MemoryRouter); + + const history = createMemoryHistory(); + history.set({ value: navigationPath }); + + renderRouter(SentryRouter, history); + + // Wait for the router transition to complete (Navigate redirects are async) + await waitFor(() => { + const navSpan = spans.find(s => spanToJSON(s).op === 'navigation'); + expect(navSpan).toBeDefined(); + + const span = spanToJSON(navSpan!); + expect(span.description).toBe(parametrizedRoute); + expect(span.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solid.solidrouter', - }), - }), - ); - }); + }); + + for (const [key, value] of Object.entries(expectedParams as Record)) { + expect(span.data![`url.path.parameter.${key}`]).toBe(value); + expect(span.data![`params.${key}`]).toBe(value); + } + }); + }, + ); it('skips navigation span, with `instrumentNavigation: false`', () => { const spanStartMock = vi.fn(); @@ -172,7 +188,7 @@ describe('solidRouterBrowserTracingIntegration', () => { op: 'navigation', description: '/about', data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solid.solidrouter', }), diff --git a/packages/solidstart/test/client/solidrouter.test.tsx b/packages/solidstart/test/client/solidrouter.test.tsx index 143a7340456e..1b9623cabc13 100644 --- a/packages/solidstart/test/client/solidrouter.test.tsx +++ b/packages/solidstart/test/client/solidrouter.test.tsx @@ -1,4 +1,5 @@ import { spanToJSON } from '@sentry/browser'; +import type { Span } from '@sentry/core'; import { createTransport, getCurrentScope, @@ -9,7 +10,7 @@ import { } from '@sentry/core'; import type { MemoryHistory } from '@solidjs/router'; import { createMemoryHistory, MemoryRouter, Navigate, Route } from '@solidjs/router'; -import { render } from '@solidjs/testing-library'; +import { render, waitFor } from '@solidjs/testing-library'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BrowserClient } from '../../src/client'; import { solidRouterBrowserTracingIntegration, withSentryRouterRouting } from '../../src/client/solidrouter'; @@ -114,39 +115,54 @@ describe('solidRouterBrowserTracingIntegration', () => { }); it.each([ - ['', '/navigate-to-about', '/about'], - ['for nested navigation', '/navigate-to-about-us', '/about/us'], - ['for navigation with param', '/navigate-to-user', '/user/5'], - ['for nested navigation with params', '/navigate-to-user-post', '/user/5/post/12'], - ])('starts a navigation span %s', (_itDescription, navigationPath, path) => { - const spanStartMock = vi.fn(); - - const client = createMockBrowserClient(); - setCurrentClient(client); - - client.on('spanStart', span => { - spanStartMock(spanToJSON(span)); - }); - client.addIntegration(solidRouterBrowserTracingIntegration()); - const SentryRouter = withSentryRouterRouting(MemoryRouter); - - const history = createMemoryHistory(); - history.set({ value: navigationPath }); - - renderRouter(SentryRouter, history); - - expect(spanStartMock).toHaveBeenCalledWith( - expect.objectContaining({ - op: 'navigation', - description: path, - data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + ['', '/navigate-to-about', '/about', {}], + ['for nested navigation', '/navigate-to-about-us', '/about/us', {}], + ['for navigation with param', '/navigate-to-user', '/user/:id', { id: '5' }], + [ + 'for nested navigation with params', + '/navigate-to-user-post', + '/user/:id/post/:postId', + { id: '5', postId: '12' }, + ], + ])( + 'starts a parametrized navigation span %s', + async (_itDescription, navigationPath, parametrizedRoute, expectedParams) => { + const spans: Span[] = []; + + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.on('spanStart', span => { + spans.push(span); + }); + client.addIntegration(solidRouterBrowserTracingIntegration()); + const SentryRouter = withSentryRouterRouting(MemoryRouter); + + const history = createMemoryHistory(); + history.set({ value: navigationPath }); + + renderRouter(SentryRouter, history); + + // Wait for the router transition to complete (Navigate redirects are async) + await waitFor(() => { + const navSpan = spans.find(s => spanToJSON(s).op === 'navigation'); + expect(navSpan).toBeDefined(); + + const span = spanToJSON(navSpan!); + expect(span.description).toBe(parametrizedRoute); + expect(span.data).toMatchObject({ + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solidstart.solidrouter', - }), - }), - ); - }); + }); + + for (const [key, value] of Object.entries(expectedParams as Record)) { + expect(span.data![`url.path.parameter.${key}`]).toBe(value); + expect(span.data![`params.${key}`]).toBe(value); + } + }); + }, + ); it('skips navigation span, with `instrumentNavigation: false`', () => { const spanStartMock = vi.fn(); @@ -172,7 +188,7 @@ describe('solidRouterBrowserTracingIntegration', () => { op: 'navigation', description: '/about', data: expect.objectContaining({ - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.solidstart.solidrouter', }), diff --git a/packages/tanstackstart-react/package.json b/packages/tanstackstart-react/package.json index 12863fd29e10..a0895dc3b178 100644 --- a/packages/tanstackstart-react/package.json +++ b/packages/tanstackstart-react/package.json @@ -63,7 +63,7 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", + "@opentelemetry/api": "^1.9.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@sentry-internal/browser-utils": "10.46.0", "@sentry/core": "10.46.0", diff --git a/packages/vercel-edge/package.json b/packages/vercel-edge/package.json index 265edf1ad023..45fda6487a51 100644 --- a/packages/vercel-edge/package.json +++ b/packages/vercel-edge/package.json @@ -39,14 +39,14 @@ "access": "public" }, "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/resources": "^2.6.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/resources": "^2.6.1", "@sentry/core": "10.46.0" }, "devDependencies": { "@edge-runtime/types": "4.0.0", - "@opentelemetry/core": "^2.6.0", - "@opentelemetry/sdk-trace-base": "^2.6.0", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/sdk-trace-base": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", "@sentry/opentelemetry": "10.46.0" }, diff --git a/yarn.lock b/yarn.lock index b4340f8ab7d3..9991a4549bca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -464,10 +464,10 @@ "@apollo/utils.keyvaluecache" "^4.0.0" "@apollo/utils.logger" "^3.0.0" -"@apollo/server@^5.4.0": - version "5.4.0" - resolved "https://registry.yarnpkg.com/@apollo/server/-/server-5.4.0.tgz#ad161a6e8b14f5227027205e0970a91667351e49" - integrity sha512-E0/2C5Rqp7bWCjaDh4NzYuEPDZ+dltTf2c0FI6GCKJA6GBetVferX3h1//1rS4+NxD36wrJsGGJK+xyT/M3ysg== +"@apollo/server@^5.5.0": + version "5.5.0" + resolved "https://registry.yarnpkg.com/@apollo/server/-/server-5.5.0.tgz#7918e45af53879b11baea04772fc2968fe64492c" + integrity sha512-vWtodBOK/SZwBTJzItECOmLfL8E8pn/IdvP7pnxN5g2tny9iW4+9sxdajE798wV1H2+PYp/rRcl/soSHIBKMPw== dependencies: "@apollo/cache-control-types" "^1.0.3" "@apollo/server-gateway-interface" "^2.0.0" @@ -4417,10 +4417,10 @@ resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.0.0.tgz#f22824caff3ae506b18207bad4126dbc6ccdb6b8" integrity sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ== -"@fastify/otel@0.17.1": - version "0.17.1" - resolved "https://registry.yarnpkg.com/@fastify/otel/-/otel-0.17.1.tgz#a7f13edc40dbc2e0c2a59d54e388f11e4d2235ce" - integrity sha512-K4wyxfUZx2ux5o+b6BtTqouYFVILohLZmSbA2tKUueJstNcBnoGPVhllCaOvbQ3ZrXdUxUC/fyrSWSCqHhdOPg== +"@fastify/otel@0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@fastify/otel/-/otel-0.18.0.tgz#d21814af7c97579856698e03aae0581beb3e734b" + integrity sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA== dependencies: "@opentelemetry/core" "^2.0.0" "@opentelemetry/instrumentation" "^0.212.0" @@ -6201,244 +6201,260 @@ dependencies: "@opentelemetry/api" "^1.3.0" -"@opentelemetry/api-logs@0.213.0": - version "0.213.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.213.0.tgz#c7abc7d3c4586cfbfd737c0a2fcfb2323a9def75" - integrity sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw== +"@opentelemetry/api-logs@0.214.0": + version "0.214.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz#74a54ad7b166c6fa30a0df811954c0f5a435deee" + integrity sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA== dependencies: "@opentelemetry/api" "^1.3.0" -"@opentelemetry/api@1.9.0", "@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.9.0": +"@opentelemetry/api@1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== -"@opentelemetry/context-async-hooks@^2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.0.tgz#6c824e900630b378233c1a78ca7f0dc5a3b460b2" - integrity sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q== +"@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.9.1": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.1.tgz#c1b0346de336ba55af2d5a7970882037baedec05" + integrity sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q== -"@opentelemetry/core@2.6.0", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.6.0.tgz#719c829ed98bd7af808a2d2c83374df1fd1f3c66" - integrity sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg== +"@opentelemetry/context-async-hooks@^2.6.1": + version "2.6.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.1.tgz#06e60d5b3fba992a832af7f034758574e951bba3" + integrity sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ== + +"@opentelemetry/core@2.6.1", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.6.1": + version "2.6.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/core/-/core-2.6.1.tgz#a59d22a9ae3be80bb41b280bbbe1fe9fbdb6c2a5" + integrity sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g== dependencies: "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/instrumentation-amqplib@0.60.0": - version "0.60.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.60.0.tgz#a2b2abe3cf433bea166c18a703c8ddf6accf83da" - integrity sha512-q/B2IvoVXRm1M00MvhnzpMN6rKYOszPXVsALi6u0ss4AYHe+TidZEtLW9N1ZhrobI1dSriHnBqqtAOZVAv07sg== +"@opentelemetry/exporter-trace-otlp-http@^0.214.0": + version "0.214.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.214.0.tgz#2a140d0bafa8690f29ed7f76bf27e3daa607da92" + integrity sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw== + dependencies: + "@opentelemetry/core" "2.6.1" + "@opentelemetry/otlp-exporter-base" "0.214.0" + "@opentelemetry/otlp-transformer" "0.214.0" + "@opentelemetry/resources" "2.6.1" + "@opentelemetry/sdk-trace-base" "2.6.1" + +"@opentelemetry/instrumentation-amqplib@0.61.0": + version "0.61.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.61.0.tgz#e9d52f56dfc4cb8a26837f31c1832af18859f1f2" + integrity sha512-mCKoyTGfRNisge4br0NpOFSy2Z1NnEW8hbCJdUDdJFHrPqVzc4IIBPA/vX0U+LUcQqrQvJX+HMIU0dbDRe0i0Q== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.33.0" -"@opentelemetry/instrumentation-aws-sdk@0.68.0": - version "0.68.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.68.0.tgz#436353e94d32c7cdb5b6bb4ed28bdd16bd4f39a4" - integrity sha512-nHXSRX3iYSE9MaiPE+jIovuNA8dTmleeg0vdLHkk5nvWCYFf/I9kMdqA3KcfKCPonVc5+NtSTft6OVtuGtawIA== +"@opentelemetry/instrumentation-aws-sdk@0.69.0": + version "0.69.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-sdk/-/instrumentation-aws-sdk-0.69.0.tgz#461de2337b1931c195f0d284760206a657bdee06" + integrity sha512-JfSp3anFL5Lx/ysQSa4MnKxvSsXSnYpgQ831Y+yNs5wJZcJC4tB+YpnKH+bU5oFdKEF59FpI6Gn5Wg2vjVpR2A== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.34.0" -"@opentelemetry/instrumentation-connect@0.56.0": - version "0.56.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.56.0.tgz#8d846d2f7cf1f6b2723e5b0ff5595e8d31cb7446" - integrity sha512-PKp+sSZ7AfzMvGgO3VCyo1inwNu+q7A1k9X88WK4PQ+S6Hp7eFk8pie+sWHDTaARovmqq5V2osav3lQej2B0nw== +"@opentelemetry/instrumentation-connect@0.57.0": + version "0.57.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.57.0.tgz#66b58af135ef6d52ad546cb440b808a149118296" + integrity sha512-FMEBChnI4FLN5TE9DHwfH7QpNir1JzXno1uz/TAucVdLCyrG0jTrKIcNHt/i30A0M2AunNBCkcd8Ei26dIPKdg== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.27.0" "@types/connect" "3.4.38" -"@opentelemetry/instrumentation-dataloader@0.30.0": - version "0.30.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.30.0.tgz#7fbea57b27165324092639abf090ca3697eb7a80" - integrity sha512-MXHP2Q38cd2OhzEBKAIXUi9uBlPEYzF6BNJbyjUXBQ6kLaf93kRC41vNMIz0Nl5mnuwK7fDvKT+/lpx7BXRwdg== +"@opentelemetry/instrumentation-dataloader@0.31.0": + version "0.31.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.31.0.tgz#43bfbe09f99e84eb0d8b6e9f914c2e51a45e6d95" + integrity sha512-f654tZFQXS5YeLDNb9KySrwtg7SnqZN119FauD7acBoTzuLduaiGTNz88ixcVSOOMGZ+EjJu/RFtx5klObC95g== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" -"@opentelemetry/instrumentation-express@0.61.0": - version "0.61.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.61.0.tgz#49b4d144ab6e9d6e035941a51f5e573e84e3647f" - integrity sha512-Xdmqo9RZuZlL29Flg8QdwrrX7eW1CZ7wFQPKHyXljNymgKhN1MCsYuqQ/7uxavhSKwAl7WxkTzKhnqpUApLMvQ== +"@opentelemetry/instrumentation-express@0.62.0": + version "0.62.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.62.0.tgz#c03e353caf04b7074004ce899faf759dec210b8d" + integrity sha512-Tvx+vgAZKEQxU3Rx+xWLiR0mLxHwmk69/8ya04+VsV9WYh8w6Lhx5hm5yAMvo1wy0KqWgFKBLwSeo3sHCwdOww== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-fs@0.32.0": - version "0.32.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.32.0.tgz#2010d86da8ab3d543f8e44c8fff81b94f904d91d" - integrity sha512-koR6apx0g0wX6RRiPpjA4AFQUQUbXrK16kq4/SZjVp7u5cffJhNkY4TnITxcGA4acGSPYAfx3NHRIv4Khn1axQ== +"@opentelemetry/instrumentation-fs@0.33.0": + version "0.33.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.33.0.tgz#75f2ccf653b772801b398cc2ad0974e8785f2e3d" + integrity sha512-sCZWXGalQ01wr3tAhSR9ucqFJ0phidpAle6/17HVjD6gN8FLmZMK/8sKxdXYHy3PbnlV1P4zeiSVFNKpbFMNLA== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" -"@opentelemetry/instrumentation-generic-pool@0.56.0": - version "0.56.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.56.0.tgz#01560f52d5bac6fb6312a1f0bc74bf0939119894" - integrity sha512-fg+Jffs6fqrf0uQS0hom7qBFKsbtpBiBl8+Vkc63Gx8xh6pVh+FhagmiO6oM0m3vyb683t1lP7yGYq22SiDnqg== +"@opentelemetry/instrumentation-generic-pool@0.57.0": + version "0.57.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.57.0.tgz#4220a2fc1974b40a989171a9b5f3d1eeab92683f" + integrity sha512-orhmlaK+ZIW9hKU+nHTbXrCSXZcH83AescTqmpamHRobRmYSQwRbD0a1odc0yAzuzOtxYiHiXAnpnIpaSSY7Ow== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" -"@opentelemetry/instrumentation-graphql@0.61.0": - version "0.61.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.61.0.tgz#d1f896095a891c9576967645e7fcba935da82a94" - integrity sha512-pUiVASv6nh2XrerTvlbVHh7vKFzscpgwiQ/xvnZuAIzQ5lRjWVdRPUuXbvZJ/Yq79QsE81TZdJ7z9YsXiss1ew== +"@opentelemetry/instrumentation-graphql@0.62.0": + version "0.62.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.62.0.tgz#dc2fc92c6be331c4f95b62a40983c8aedb8f9bf9" + integrity sha512-3YNuLVPUxafXkH1jBAbGsKNsP3XVzcFDhCDCE3OqBwCwShlqQbLMRMFh1T/d5jaVZiGVmSsfof+ICKD2iOV8xg== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" -"@opentelemetry/instrumentation-hapi@0.59.0": - version "0.59.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.59.0.tgz#412ea19e97ead684c5737e1f1aaa19ff940512d3" - integrity sha512-33wa4mEr+9+ztwdgLor1SeBu4Opz4IsmpcLETXAd3VmBrOjez8uQtrsOhPCa5Vhbm5gzDlMYTgFRLQzf8/YHFA== +"@opentelemetry/instrumentation-hapi@0.60.0": + version "0.60.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.60.0.tgz#ad1ba65a32347351c310ac0f194fe66b8e9d9e7d" + integrity sha512-aNljZKYrEa7obLAxd1bCEDxF7kzCLGXTuTJZ8lMR9rIVEjmuKBXN1gfqpm/OB//Zc2zP4iIve1jBp7sr3mQV6w== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-http@0.213.0": - version "0.213.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.213.0.tgz#b379d6bcbae43a7d6d54070f3794527021f176c9" - integrity sha512-B978Xsm5XEPGhm1P07grDoaOFLHapJPkOG9h016cJsyWWxmiLnPu2M/4Nrm7UCkHSiLnkXgC+zVGUAIahy8EEA== +"@opentelemetry/instrumentation-http@0.214.0": + version "0.214.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-http/-/instrumentation-http-0.214.0.tgz#d4a31a638b798e191f4f556c257a4d3c97d65ba0" + integrity sha512-FlkDhZDRjDJDcO2LcSCtjRpkal1NJ8y0fBqBhTvfAR3JSYY2jAIj1kSS5IjmEBt4c3aWv+u/lqLuoCDrrKCSKg== dependencies: - "@opentelemetry/core" "2.6.0" - "@opentelemetry/instrumentation" "0.213.0" + "@opentelemetry/core" "2.6.1" + "@opentelemetry/instrumentation" "0.214.0" "@opentelemetry/semantic-conventions" "^1.29.0" forwarded-parse "2.1.2" -"@opentelemetry/instrumentation-ioredis@0.61.0": - version "0.61.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.61.0.tgz#e862540cbf188d0ca368d3a75020d165cb8beefb" - integrity sha512-hsHDadUtAFbws1YSDc1XW0svGFKiUbqv2td1Cby+UAiwvojm1NyBo/taifH0t8CuFZ0x/2SDm0iuTwrM5pnVOg== +"@opentelemetry/instrumentation-ioredis@0.62.0": + version "0.62.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.62.0.tgz#4fd1775577132de5d92165caee6bbc0ae16a8c8a" + integrity sha512-ZYt//zcPve8qklaZX+5Z4MkU7UpEkFRrxsf2cnaKYBitqDnsCN69CPAuuMOX6NYdW2rG9sFy7V/QWtBlP5XiNQ== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/redis-common" "^0.38.2" "@opentelemetry/semantic-conventions" "^1.33.0" -"@opentelemetry/instrumentation-kafkajs@0.22.0": - version "0.22.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.22.0.tgz#a3cf7aca003f96211e514a348b7568799efdfba1" - integrity sha512-wJU4IBQMUikdJAcTChLFqK5lo+flo7pahqd8DSLv7uMxsdOdAHj6RzKYAm8pPfUS6ItKYutYyuicwKaFwQKsoA== +"@opentelemetry/instrumentation-kafkajs@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.23.0.tgz#6b7d449d88d674ddc295a0d0cf2156f0f7d5889f" + integrity sha512-4K+nVo+zI+aDz0Z85SObwbdixIbzS9moIuKJaYsdlzcHYnKOPtB7ya8r8Ezivy/GVIBHiKJVq4tv+BEkgOMLaQ== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.30.0" -"@opentelemetry/instrumentation-knex@0.57.0": - version "0.57.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.57.0.tgz#d46622a3f82f3df2ba29c64498d6ef828a40457e" - integrity sha512-vMCSh8kolEm5rRsc+FZeTZymWmIJwc40hjIKnXH4O0Dv/gAkJJIRXCsPX5cPbe0c0j/34+PsENd0HqKruwhVYw== +"@opentelemetry/instrumentation-knex@0.58.0": + version "0.58.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.58.0.tgz#48878fe40bc48834d6b4c4148433c84524a2558a" + integrity sha512-Hc/o8fSsaWxZ8r1Yw4rNDLwTpUopTf4X32y4W6UhlHmW8Wizz8wfhgOKIelSeqFVTKBBPIDUOsQWuIMxBmu8Bw== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.33.1" -"@opentelemetry/instrumentation-koa@0.61.0": - version "0.61.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.61.0.tgz#c12f57b023834afb1c142c11746d560bcc288b5b" - integrity sha512-lvrfWe9ShK/D2X4brmx8ZqqeWPfRl8xekU0FCn7C1dHm5k6+rTOOi36+4fnaHAP8lig9Ux6XQ1D4RNIpPCt1WQ== +"@opentelemetry/instrumentation-koa@0.62.0": + version "0.62.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.62.0.tgz#65fdf96c1b1ffb382167cd3b7a244631afd0cc1f" + integrity sha512-uVip0VuGUQXZ+vFxkKxAUNq8qNl+VFlyHDh/U6IQ8COOEDfbEchdaHnpFrMYF3psZRUuoSIgb7xOeXj00RdwDA== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.36.0" -"@opentelemetry/instrumentation-lru-memoizer@0.57.0": - version "0.57.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.57.0.tgz#4da92ecd1bc5d5e9c7de28ea14ed57c9f29cfefd" - integrity sha512-cEqpUocSKJfwDtLYTTJehRLWzkZ2eoePCxfVIgGkGkb83fMB71O+y4MvRHJPbeV2bdoWdOVrl8uO0+EynWhTEA== +"@opentelemetry/instrumentation-lru-memoizer@0.58.0": + version "0.58.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.58.0.tgz#7c730a0cb963e8ac5f3d11023518050e5f124a6a" + integrity sha512-6grM3TdMyHzlGY1cUA+mwoPueB1F3dYKgKtZIH6jOFXqfHAByyLTc+6PFjGM9tKh52CFBJaDwodNlL/Td39z7Q== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" -"@opentelemetry/instrumentation-mongodb@0.66.0": - version "0.66.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.66.0.tgz#990bf4571382d3b02a9584927411c92c375d2fd4" - integrity sha512-d7m9QnAY+4TCWI4q1QRkfrc6fo/92VwssaB1DzQfXNRvu51b78P+HJlWP7Qg6N6nkwdb9faMZNBCZJfftmszkw== +"@opentelemetry/instrumentation-mongodb@0.67.0": + version "0.67.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.67.0.tgz#ac45611586e363e2d96c735d50f97556dd33c37e" + integrity sha512-1WJp5N1lYfHq2IhECOTewFs5Tf2NfUOwQRqs/rZdXKTezArMlucxgzAaqcgp3A3YREXopXTpXHsxZTGHjNhMdQ== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.33.0" -"@opentelemetry/instrumentation-mongoose@0.59.0": - version "0.59.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.59.0.tgz#8446ece86df59f09c630e7df6d794c8cd08f58d8" - integrity sha512-6/jWU+c1NgznkVLDU/2y0bXV2nJo3o9FWZ9mZ9nN6T/JBNRoMnVXZl2FdBmgH+a5MwaWLs5kmRJTP5oUVGIkPw== +"@opentelemetry/instrumentation-mongoose@0.60.0": + version "0.60.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.60.0.tgz#9481a90d3f75d66244d7f63709529cb7f2823103" + integrity sha512-8BahAZpKsOoc+lrZGb7Ofn4g3z8qtp5IxDfvAVpKXsEheQN7ONMH5djT5ihy6yf8yyeQJGS0gXFfpEAEeEHqQg== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.33.0" -"@opentelemetry/instrumentation-mysql2@0.59.0": - version "0.59.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.59.0.tgz#938cd4a294b7e4a6e8c3855b8cfe267c8d2e5493" - integrity sha512-n9/xrVCRBfG9egVbffnlU1uhr+HX0vF4GgtAB/Bvm48wpFgRidqD8msBMiym1kRYzmpWvJqTxNT47u1MkgBEdw== +"@opentelemetry/instrumentation-mysql2@0.60.0": + version "0.60.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.60.0.tgz#10eddc3f933a80f11e334ae31c67e9d1156373ca" + integrity sha512-m/5d3bxQALllCzezYDk/6vajh0tj5OijMMvOZGr+qN1NMXm1dzMNwyJ0gNZW7Fo3YFRyj/jJMxIw+W7d525dlw== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.33.0" "@opentelemetry/sql-common" "^0.41.2" -"@opentelemetry/instrumentation-mysql@0.59.0": - version "0.59.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.59.0.tgz#bf43cafbac5928236ea53704a52c718349c22e38" - integrity sha512-r+V/Fh0sm7Ga8/zk/TI5H5FQRAjwr0RrpfPf8kNIehlsKf12XnvIaZi8ViZkpX0gyPEpLXqzqWD6QHlgObgzZw== +"@opentelemetry/instrumentation-mysql@0.60.0": + version "0.60.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.60.0.tgz#e8e13b60f8d8fe8d0f4941f200ae3e4a4e5e4a3c" + integrity sha512-08pO8GFPEIz2zquKDGteBZDNmwketdgH8hTe9rVYgW9kCJXq1Psj3wPQGx+VaX4ZJKCfPeoLMYup9+cxHvZyVQ== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.33.0" "@types/mysql" "2.15.27" -"@opentelemetry/instrumentation-nestjs-core@0.59.0": - version "0.59.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.59.0.tgz#858e7514e0842ceec1356cb0ba55cb3c60dbace6" - integrity sha512-tt2cFTENV8XB3D3xjhOz0q4hLc1eqkMZS5UyT9nnHF5FfYH94S2vAGdssvsMv+pFtA6/PmhPUZd4onUN1O7STg== +"@opentelemetry/instrumentation-nestjs-core@0.60.0": + version "0.60.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.60.0.tgz#60a34a8a3af7e3ab4cd7e46c783c99ff2430f2fb" + integrity sha512-BZqFAoD+frnwjpb0/T4kEEQMhl2YykZch4n2MMLKAVTzTehTBBV2hZxvFF629ipS+WOGBKjCjz1dycU9QNIckQ== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.30.0" -"@opentelemetry/instrumentation-pg@0.65.0": - version "0.65.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.65.0.tgz#f1f76f8c57c5c6fec68c77ce6ee104fee5de13e1" - integrity sha512-W0zpHEIEuyZ8zvb3njaX9AAbHgPYOsSWVOoWmv1sjVRSF6ZpBqtlxBWbU+6hhq1TFWBeWJOXZ8nZS/PUFpLJYQ== +"@opentelemetry/instrumentation-pg@0.66.0": + version "0.66.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.66.0.tgz#78d16b50dc4c5d851015823611a46243d63a88fb" + integrity sha512-KxfLGXBb7k2ueaPJfq2GXBDXBly8P+SpR/4Mj410hhNgmQF3sCqwXvUBQxZQkDAmsdBAoenM+yV1LhtsMRamcA== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.34.0" "@opentelemetry/sql-common" "^0.41.2" "@types/pg" "8.15.6" "@types/pg-pool" "2.0.7" -"@opentelemetry/instrumentation-redis@0.61.0": - version "0.61.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.61.0.tgz#b43b9c3b5d0b124f2e60b055e4529a3a4b55dbc4" - integrity sha512-JnPexA034/0UJRsvH96B0erQoNOqKJZjE2ZRSw9hiTSC23LzE0nJE/u6D+xqOhgUhRnhhcPHq4MdYtmUdYTF+Q== +"@opentelemetry/instrumentation-redis@0.62.0": + version "0.62.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.62.0.tgz#ecde90337fa49fec8d243bcbb8d470ce1a9ee7a1" + integrity sha512-y3pPpot7WzR/8JtHcYlTYsyY8g+pbFhAqbwAuG5bLPnR6v6pt1rQc0DpH0OlGP/9CZbWBP+Zhwp9yFoygf/ZXQ== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/redis-common" "^0.38.2" "@opentelemetry/semantic-conventions" "^1.27.0" -"@opentelemetry/instrumentation-tedious@0.32.0": - version "0.32.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.32.0.tgz#8204a14adb71adcbf7d72705244d606bb69e428a" - integrity sha512-BQS6gG8RJ1foEqfEZ+wxoqlwfCAzb1ZVG0ad8Gfe4x8T658HJCLGLd4E4NaoQd8EvPfLqOXgzGaE/2U4ytDSWA== +"@opentelemetry/instrumentation-tedious@0.33.0": + version "0.33.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.33.0.tgz#00f6698f8afae1b350bf0c463a59eeae3c8d25d7" + integrity sha512-Q6WQwAD01MMTub31GlejoiFACYNw26J426wyjvU7by7fDIr2nZXNW4vhTGs7i7F0TnXBO3xN688g1tdUgYwJ5w== dependencies: - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.33.0" "@types/tedious" "^4.0.14" -"@opentelemetry/instrumentation-undici@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.23.0.tgz#e328bf6e53847ba7baa2a345d02221cc62917cec" - integrity sha512-LL0VySzKVR2cJSFVZaTYpZl1XTpBGnfzoQPe2W7McS2267ldsaEIqtQY6VXs2KCXN0poFjze5110PIpxHDaDGg== +"@opentelemetry/instrumentation-undici@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.24.0.tgz#6ad41245012742899294edf65aa79fd190369094" + integrity sha512-oKzZ3uvqP17sV0EsoQcJgjEfIp0kiZRbYu/eD8p13Cbahumf8lb/xpYeNr/hfAJ4owzEtIDcGIjprfLcYbIKBQ== dependencies: "@opentelemetry/core" "^2.0.0" - "@opentelemetry/instrumentation" "^0.213.0" + "@opentelemetry/instrumentation" "^0.214.0" "@opentelemetry/semantic-conventions" "^1.24.0" -"@opentelemetry/instrumentation@0.213.0", "@opentelemetry/instrumentation@^0.213.0": - version "0.213.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.213.0.tgz#55362569efd0cba00aab9921a78dd20dfddf70b6" - integrity sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w== +"@opentelemetry/instrumentation@0.214.0", "@opentelemetry/instrumentation@^0.214.0": + version "0.214.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz#2649e8a29a8c4748bc583d35281c80632f046e25" + integrity sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w== dependencies: - "@opentelemetry/api-logs" "0.213.0" + "@opentelemetry/api-logs" "0.214.0" import-in-the-middle "^3.0.0" require-in-the-middle "^8.0.0" @@ -6460,26 +6476,65 @@ import-in-the-middle "^2.0.6" require-in-the-middle "^8.0.0" +"@opentelemetry/otlp-exporter-base@0.214.0": + version "0.214.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.214.0.tgz#97d63666d56e92391e6a9840959ff68c5c5a90f6" + integrity sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg== + dependencies: + "@opentelemetry/core" "2.6.1" + "@opentelemetry/otlp-transformer" "0.214.0" + +"@opentelemetry/otlp-transformer@0.214.0": + version "0.214.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/otlp-transformer/-/otlp-transformer-0.214.0.tgz#c3dca1101364cb819090356f51979f503e6c5330" + integrity sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w== + dependencies: + "@opentelemetry/api-logs" "0.214.0" + "@opentelemetry/core" "2.6.1" + "@opentelemetry/resources" "2.6.1" + "@opentelemetry/sdk-logs" "0.214.0" + "@opentelemetry/sdk-metrics" "2.6.1" + "@opentelemetry/sdk-trace-base" "2.6.1" + protobufjs "^7.0.0" + "@opentelemetry/redis-common@^0.38.2": version "0.38.2" resolved "https://registry.yarnpkg.com/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz#cefa4f3e79db1cd54f19e233b7dfb56621143955" integrity sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA== -"@opentelemetry/resources@2.6.0", "@opentelemetry/resources@^2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.6.0.tgz#1a945dbb8986043d8b593c358d5d8e3de6becf5a" - integrity sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ== +"@opentelemetry/resources@2.6.1", "@opentelemetry/resources@^2.6.1": + version "2.6.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/resources/-/resources-2.6.1.tgz#e1b02772c5f65c0e074d59e4743188f7575e97c7" + integrity sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA== dependencies: - "@opentelemetry/core" "2.6.0" + "@opentelemetry/core" "2.6.1" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/sdk-trace-base@^2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz#d7e752a0906f2bcae3c1261e224aef3e3b3746f9" - integrity sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ== +"@opentelemetry/sdk-logs@0.214.0": + version "0.214.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz#3d887ef93d8d65f1230a68900209b8a9e8e03c76" + integrity sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA== dependencies: - "@opentelemetry/core" "2.6.0" - "@opentelemetry/resources" "2.6.0" + "@opentelemetry/api-logs" "0.214.0" + "@opentelemetry/core" "2.6.1" + "@opentelemetry/resources" "2.6.1" + "@opentelemetry/semantic-conventions" "^1.29.0" + +"@opentelemetry/sdk-metrics@2.6.1": + version "2.6.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.1.tgz#9cdc9e636ec31399f228f23d9663beda5e63ee56" + integrity sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ== + dependencies: + "@opentelemetry/core" "2.6.1" + "@opentelemetry/resources" "2.6.1" + +"@opentelemetry/sdk-trace-base@2.6.1", "@opentelemetry/sdk-trace-base@^2.6.1": + version "2.6.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz#ed353062be4c28a0649247ad369654020c29bfce" + integrity sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw== + dependencies: + "@opentelemetry/core" "2.6.1" + "@opentelemetry/resources" "2.6.1" "@opentelemetry/semantic-conventions" "^1.29.0" "@opentelemetry/semantic-conventions@^1.24.0", "@opentelemetry/semantic-conventions@^1.27.0", "@opentelemetry/semantic-conventions@^1.28.0", "@opentelemetry/semantic-conventions@^1.29.0", "@opentelemetry/semantic-conventions@^1.30.0", "@opentelemetry/semantic-conventions@^1.33.0", "@opentelemetry/semantic-conventions@^1.33.1", "@opentelemetry/semantic-conventions@^1.34.0", "@opentelemetry/semantic-conventions@^1.36.0", "@opentelemetry/semantic-conventions@^1.40.0": @@ -7018,10 +7073,10 @@ dependencies: "@prisma/debug" "6.15.0" -"@prisma/instrumentation@7.4.2": - version "7.4.2" - resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-7.4.2.tgz#b05e814d0647343febd26a8ccb039d27ccc69eca" - integrity sha512-r9JfchJF1Ae6yAxcaLu/V1TGqBhAuSDe3mRNOssBfx1rMzfZ4fdNvrgUBwyb/TNTGXFxlH9AZix5P257x07nrg== +"@prisma/instrumentation@7.6.0": + version "7.6.0" + resolved "https://registry.yarnpkg.com/@prisma/instrumentation/-/instrumentation-7.6.0.tgz#22a4ea3e9d8cdc57cbaa0e26ccf10cb8db854549" + integrity sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ== dependencies: "@opentelemetry/instrumentation" "^0.207.0" @@ -9749,10 +9804,10 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=18": - version "25.4.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-25.4.0.tgz#f25d8467984d6667cc4c1be1e2f79593834aaedb" - integrity sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw== +"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=13.7.0", "@types/node@>=18": + version "25.3.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.3.3.tgz#605862544ee7ffd7a936bcbf0135a14012f1e549" + integrity sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ== dependencies: undici-types "~7.18.0" @@ -11153,10 +11208,10 @@ amdefine@>=0.0.4: resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= -amqplib@^0.10.7: - version "0.10.7" - resolved "https://registry.yarnpkg.com/amqplib/-/amqplib-0.10.7.tgz#d28586805169bedb03a2efe6e09a3e43148eaa0f" - integrity sha512-7xPSYKSX2kj/bT6iHZ3MlctzxdCW1Ds9xyN0EmuRi2DZxHztwwoG1YkZrgmLyuPNjfxlRiMdWJPQscmoa3Vgdg== +amqplib@^0.10.9: + version "0.10.9" + resolved "https://registry.yarnpkg.com/amqplib/-/amqplib-0.10.9.tgz#5b744c21d624f9307d0399e4d339b7354675831c" + integrity sha512-jwSftI4QjS3mizvnSnOrPGYiUnm1vI2OP1iXeOUz5pb74Ua0nbf6nPyyTzuiCLEE3fMpaJORXh2K/TQ08H5xGA== dependencies: buffer-more-ints "~1.0.0" url-parse "~1.5.10" @@ -11818,7 +11873,7 @@ aws-ssl-profiles@^1.1.2: resolved "https://registry.yarnpkg.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz#157dd77e9f19b1d123678e93f120e6f193022641" integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g== -axios@^1.12.0, axios@^1.12.2: +axios@1.13.5, axios@^1.12.0: version "1.13.5" resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.5.tgz#5e464688fa127e11a660a2c49441c009f6567a43" integrity sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q== @@ -11886,10 +11941,10 @@ babel-loader@8.2.5, babel-loader@^8.0.6: make-dir "^3.1.0" schema-utils "^2.6.5" -babel-loader@^10.0.0: - version "10.0.0" - resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-10.0.0.tgz#b9743714c0e1e084b3e4adef3cd5faee33089977" - integrity sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA== +babel-loader@^10.1.1: + version "10.1.1" + resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-10.1.1.tgz#ce9748e85b7071eb88006e3cfa9e6cf14eeb97c5" + integrity sha512-JwKSzk2kjIe7mgPK+/lyZ2QAaJcpahNAdM+hgR2HI8D0OJVkdj8Rl6J3kaLYki9pwF7P2iWnD8qVv80Lq1ABtg== dependencies: find-up "^5.0.0" @@ -18549,12 +18604,12 @@ handle-thing@^2.0.0: integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg== handlebars@^4.0.4, handlebars@^4.3.1, handlebars@^4.7.3: - version "4.7.7" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" - integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA== + version "4.7.9" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.9.tgz#6f139082ab58dc4e5a0e51efe7db5ae890d56a0f" + integrity sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ== dependencies: minimist "^1.2.5" - neo-async "^2.6.0" + neo-async "^2.6.2" source-map "^0.6.1" wordwrap "^1.0.0" optionalDependencies: @@ -21187,7 +21242,7 @@ long@^4.0.0: resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28" integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA== -long@^5.3.2: +long@^5.0.0, long@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== @@ -22767,7 +22822,7 @@ negotiator@^1.0.0: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== -neo-async@^2.6.0, neo-async@^2.6.2: +neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== @@ -23014,9 +23069,9 @@ node-fetch@^2.3.0, node-fetch@^2.6.1, node-fetch@^2.6.7, node-fetch@^2.6.9: whatwg-url "^5.0.0" node-forge@^1, node-forge@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.2.tgz#d0d2659a26eef778bf84d73e7f55c08144ee7750" - integrity sha512-6xKiQ+cph9KImrRh0VsjH2d8/GXA4FIMlgU4B757iI1ApvcyA9VlouP0yZJha01V+huImO+kKMU7ih+2+E14fw== + version "1.4.0" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.4.0.tgz#1c7b7d8bdc2d078739f58287d589d903a11b2fc2" + integrity sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ== node-gyp-build@^4.2.2: version "4.6.0" @@ -25600,6 +25655,24 @@ property-information@^7.0.0: resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ== +protobufjs@^7.0.0: + version "7.5.4" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.4.tgz#885d31fe9c4b37f25d1bb600da30b1c5b37d286a" + integrity sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg== + 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" + proxy-addr@^2.0.7, proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" @@ -28054,9 +28127,9 @@ sqlstring@2.3.1: integrity sha1-R1OT/56RR5rqYtyvDKPRSYOn+0A= srvx@^0.11.12, srvx@^0.11.2, srvx@^0.11.9: - version "0.11.12" - resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.11.12.tgz#ed59866cd0cec580b119e161ead3fecd2a546fee" - integrity sha512-AQfrGqntqVPXgP03pvBDN1KyevHC+KmYVqb8vVf4N+aomQqdhaZxjvoVp+AOm4u6x+GgNQY3MVzAUIn+TqwkOA== + version "0.11.13" + resolved "https://registry.yarnpkg.com/srvx/-/srvx-0.11.13.tgz#cc77a98cb9a459c34f75ee4345bd0eef9f613a54" + integrity sha512-oknN6qduuMPafxKtHucUeG32Q963pjriA5g3/Bl05cwEsUe5VVbIU4qR9LrALHbipSCyBe+VmfDGGydqazDRkw== ssri@^9.0.0: version "9.0.1" @@ -31297,10 +31370,10 @@ yam@^1.0.0: fs-extra "^4.0.2" lodash.merge "^4.6.0" -yaml@2.8.2, yaml@^2.6.0, yaml@^2.8.0: - version "2.8.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.2.tgz#5694f25eca0ce9c3e7a9d9e00ce0ddabbd9e35c5" - integrity sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A== +yaml@2.8.3, yaml@^2.6.0, yaml@^2.8.0: + version "2.8.3" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.3.tgz#a0d6bd2efb3dd03c59370223701834e60409bd7d" + integrity sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg== yaml@^1.10.0: version "1.10.2"