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"