diff --git a/.size-limit.js b/.size-limit.js
index 0c03c0ff1b8b..012fea839bda 100644
--- a/.size-limit.js
+++ b/.size-limit.js
@@ -8,7 +8,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init'),
gzip: true,
- limit: '24 KB',
+ limit: '25 KB',
},
{
name: '@sentry/browser - with treeshaking flags',
@@ -52,7 +52,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration', 'replayIntegration'),
gzip: true,
- limit: '70.1 KB',
+ limit: '71 KB',
modifyWebpackConfig: function (config) {
const webpack = require('webpack');
@@ -206,7 +206,7 @@ module.exports = [
import: createImport('init'),
ignore: ['next/router', 'next/constants'],
gzip: true,
- limit: '42 KB',
+ limit: '42.5 KB',
},
// SvelteKit SDK (ESM)
{
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78064f48920a..ebfcb504f813 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,26 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
+## 9.20.0
+
+### Important changes
+
+- **feat(browser): Track measure detail as span attributes ([#16240](https://github.com/getsentry/sentry-javascript/pull/16240))**
+
+The SDK now automatically collects details passed to `performance.measure` options.
+
+### Other changes
+
+- feat(node): Add `maxIncomingRequestBodySize` ([#16225](https://github.com/getsentry/sentry-javascript/pull/16225))
+- feat(react-router): Add server action instrumentation ([#16292](https://github.com/getsentry/sentry-javascript/pull/16292))
+- feat(react-router): Filter manifest requests ([#16294](https://github.com/getsentry/sentry-javascript/pull/16294))
+- feat(replay): Extend default list for masking with `aria-label` ([#16192](https://github.com/getsentry/sentry-javascript/pull/16192))
+- fix(browser): Ensure pageload & navigation spans have correct data ([#16279](https://github.com/getsentry/sentry-javascript/pull/16279))
+- fix(cloudflare): Account for static fields in wrapper type ([#16303](https://github.com/getsentry/sentry-javascript/pull/16303))
+- fix(nextjs): Preserve `next.route` attribute on root spans ([#16297](https://github.com/getsentry/sentry-javascript/pull/16297))
+- feat(node): Fork isolation scope in tRPC middleware ([#16296](https://github.com/getsentry/sentry-javascript/pull/16296))
+- feat(core): Add `orgId` option to `init` and DSC (`sentry-org_id` in baggage) ([#16305](https://github.com/getsentry/sentry-javascript/pull/16305))
+
## 9.19.0
- feat(react-router): Add otel instrumentation for server requests ([#16147](https://github.com/getsentry/sentry-javascript/pull/16147))
diff --git a/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/history/navigation/test.ts b/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/history/navigation/test.ts
index c03dedd417bd..1eb7f55b60cd 100644
--- a/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/history/navigation/test.ts
+++ b/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/history/navigation/test.ts
@@ -29,14 +29,14 @@ sentryTest('should record history changes as navigation breadcrumbs', async ({ g
category: 'navigation',
data: {
from: '/bar?a=1#fragment',
- to: '[object Object]',
+ to: '/[object%20Object]',
},
timestamp: expect.any(Number),
},
{
category: 'navigation',
data: {
- from: '[object Object]',
+ from: '/[object%20Object]',
to: '/bar?a=1#fragment',
},
timestamp: expect.any(Number),
diff --git a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts
index 1d5eda8697f1..336e09a331e1 100644
--- a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts
+++ b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts
@@ -104,7 +104,7 @@ sentryTest(
nodeId: expect.any(Number),
node: {
attributes: {
- 'aria-label': 'An Error in aria-label',
+ 'aria-label': '** ***** ** **********',
class: 'btn btn-error',
id: 'error',
role: 'button',
diff --git a/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts b/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts
index ac046c74d337..2c059bb226f4 100644
--- a/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts
+++ b/dev-packages/browser-integration-tests/suites/replay/multiple-pages/test.ts
@@ -210,7 +210,7 @@ sentryTest(
expect(replayEvent6).toEqual(
getExpectedReplayEvent({
segment_id: 6,
- urls: ['/spa'],
+ urls: [`${TEST_HOST}/spa`],
request: {
url: `${TEST_HOST}/spa`,
headers: {
diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-chromium.json b/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-chromium.json
index a3c9c494b0b5..4ac06ffeb444 100644
--- a/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-chromium.json
+++ b/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-chromium.json
@@ -62,7 +62,7 @@
"type": 2,
"tagName": "button",
"attributes": {
- "aria-label": "Click me",
+ "aria-label": "***** **",
"onclick": "console.log('Test log')"
},
"childNodes": [
diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-firefox.json b/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-firefox.json
index a3c9c494b0b5..4ac06ffeb444 100644
--- a/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-firefox.json
+++ b/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-firefox.json
@@ -62,7 +62,7 @@
"type": 2,
"tagName": "button",
"attributes": {
- "aria-label": "Click me",
+ "aria-label": "***** **",
"onclick": "console.log('Test log')"
},
"childNodes": [
diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-webkit.json b/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-webkit.json
index a3c9c494b0b5..4ac06ffeb444 100644
--- a/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-webkit.json
+++ b/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy-webkit.json
@@ -62,7 +62,7 @@
"type": 2,
"tagName": "button",
"attributes": {
- "aria-label": "Click me",
+ "aria-label": "***** **",
"onclick": "console.log('Test log')"
},
"childNodes": [
diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy.json b/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy.json
index a3c9c494b0b5..4ac06ffeb444 100644
--- a/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy.json
+++ b/dev-packages/browser-integration-tests/suites/replay/privacyBlock/test.ts-snapshots/privacy.json
@@ -62,7 +62,7 @@
"type": 2,
"tagName": "button",
"attributes": {
- "aria-label": "Click me",
+ "aria-label": "***** **",
"onclick": "console.log('Test log')"
},
"childNodes": [
diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-chromium.json b/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-chromium.json
index e04944384bbd..d27e1dc96634 100644
--- a/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-chromium.json
+++ b/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-chromium.json
@@ -62,7 +62,7 @@
"type": 2,
"tagName": "button",
"attributes": {
- "aria-label": "Click me",
+ "aria-label": "***** **",
"onclick": "console.log('Test log')"
},
"childNodes": [
diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-firefox.json b/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-firefox.json
index a57a8507fda9..14f3d7989f57 100644
--- a/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-firefox.json
+++ b/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-firefox.json
@@ -62,7 +62,7 @@
"type": 2,
"tagName": "button",
"attributes": {
- "aria-label": "Click me",
+ "aria-label": "***** **",
"onclick": "console.log('Test log')"
},
"childNodes": [
diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-webkit.json b/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-webkit.json
index e04944384bbd..d27e1dc96634 100644
--- a/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-webkit.json
+++ b/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy-webkit.json
@@ -62,7 +62,7 @@
"type": 2,
"tagName": "button",
"attributes": {
- "aria-label": "Click me",
+ "aria-label": "***** **",
"onclick": "console.log('Test log')"
},
"childNodes": [
diff --git a/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy.json b/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy.json
index 16c4caf2ed69..dd5cc92a7723 100644
--- a/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy.json
+++ b/dev-packages/browser-integration-tests/suites/replay/privacyDefault/test.ts-snapshots/privacy.json
@@ -62,7 +62,7 @@
"type": 2,
"tagName": "button",
"attributes": {
- "aria-label": "Click me",
+ "aria-label": "***** **",
"onclick": "console.log('Test log')"
},
"childNodes": [
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js
index c0424a9b743f..8fb188a75278 100644
--- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/init.js
@@ -9,4 +9,4 @@ Sentry.init({
});
// Immediately navigate to a new page to abort the pageload
-window.location.href = '#foo';
+window.history.pushState({}, '', '/sub-page');
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/test.ts
index ad224aa6d1d9..b68d1903a0db 100644
--- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/test.ts
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation-aborting-pageload/test.ts
@@ -40,6 +40,9 @@ sentryTest(
expect(navigationTraceId).toBeDefined();
expect(pageloadTraceId).not.toEqual(navigationTraceId);
+ expect(pageloadRequest.transaction).toEqual('/index.html');
+ expect(navigationRequest.transaction).toEqual('/sub-page');
+
expect(pageloadRequest.contexts?.trace?.data).toMatchObject({
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser',
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
@@ -54,5 +57,17 @@ sentryTest(
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
['sentry.idle_span_finish_reason']: 'idleTimeout',
});
+ expect(pageloadRequest.request).toEqual({
+ headers: {
+ 'User-Agent': expect.any(String),
+ },
+ url: 'http://sentry-test.io/index.html',
+ });
+ expect(navigationRequest.request).toEqual({
+ headers: {
+ 'User-Agent': expect.any(String),
+ },
+ url: 'http://sentry-test.io/sub-page',
+ });
},
);
diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation/test.ts
index 503aa73ba4ff..cd80a2e3fa8e 100644
--- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation/test.ts
+++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/navigation/test.ts
@@ -7,7 +7,12 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
import { sentryTest } from '../../../../utils/fixtures';
-import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';
+import {
+ envelopeRequestParser,
+ getFirstSentryEnvelopeRequest,
+ shouldSkipTracingTest,
+ waitForTransactionRequest,
+} from '../../../../utils/helpers';
sentryTest('should create a navigation transaction on page navigation', async ({ getLocalTestUrl, page }) => {
if (shouldSkipTracingTest()) {
@@ -31,6 +36,10 @@ sentryTest('should create a navigation transaction on page navigation', async ({
expect(navigationTraceId).toBeDefined();
expect(pageloadTraceId).not.toEqual(navigationTraceId);
+ expect(pageloadRequest.transaction).toEqual('/index.html');
+ // Fragment is not in transaction name
+ expect(navigationRequest.transaction).toEqual('/index.html');
+
expect(pageloadRequest.contexts?.trace?.data).toMatchObject({
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.browser',
[SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
@@ -45,6 +54,18 @@ sentryTest('should create a navigation transaction on page navigation', async ({
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
['sentry.idle_span_finish_reason']: 'idleTimeout',
});
+ expect(pageloadRequest.request).toEqual({
+ headers: {
+ 'User-Agent': expect.any(String),
+ },
+ url: 'http://sentry-test.io/index.html',
+ });
+ expect(navigationRequest.request).toEqual({
+ headers: {
+ 'User-Agent': expect.any(String),
+ },
+ url: 'http://sentry-test.io/index.html#foo',
+ });
const pageloadSpans = pageloadRequest.spans;
const navigationSpans = navigationRequest.spans;
@@ -69,3 +90,65 @@ sentryTest('should create a navigation transaction on page navigation', async ({
expect(pageloadSpanId).not.toEqual(navigationSpanId);
});
+
+//
+sentryTest('should handle pushState with full URL', async ({ getLocalTestUrl, page }) => {
+ if (shouldSkipTracingTest()) {
+ sentryTest.skip();
+ }
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ const pageloadRequestPromise = waitForTransactionRequest(page, event => event.contexts?.trace?.op === 'pageload');
+ const navigationRequestPromise = waitForTransactionRequest(
+ page,
+ event => event.contexts?.trace?.op === 'navigation' && event.transaction === '/sub-page',
+ );
+ const navigationRequestPromise2 = waitForTransactionRequest(
+ page,
+ event => event.contexts?.trace?.op === 'navigation' && event.transaction === '/sub-page-2',
+ );
+
+ await page.goto(url);
+ await pageloadRequestPromise;
+
+ await page.evaluate("window.history.pushState({}, '', `${window.location.origin}/sub-page`);");
+
+ const navigationRequest = envelopeRequestParser(await navigationRequestPromise);
+
+ expect(navigationRequest.transaction).toEqual('/sub-page');
+
+ expect(navigationRequest.contexts?.trace?.data).toMatchObject({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
+ ['sentry.idle_span_finish_reason']: 'idleTimeout',
+ });
+ expect(navigationRequest.request).toEqual({
+ headers: {
+ 'User-Agent': expect.any(String),
+ },
+ url: 'http://sentry-test.io/sub-page',
+ });
+
+ await page.evaluate("window.history.pushState({}, '', `${window.location.origin}/sub-page-2`);");
+
+ const navigationRequest2 = envelopeRequestParser(await navigationRequestPromise2);
+
+ expect(navigationRequest2.transaction).toEqual('/sub-page-2');
+
+ expect(navigationRequest2.contexts?.trace?.data).toMatchObject({
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.browser',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE]: 1,
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
+ ['sentry.idle_span_finish_reason']: 'idleTimeout',
+ });
+ expect(navigationRequest2.request).toEqual({
+ headers: {
+ 'User-Agent': expect.any(String),
+ },
+ url: 'http://sentry-test.io/sub-page-2',
+ });
+});
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts
index 4f564f2f462d..498c9b969ed9 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/server-components.test.ts
@@ -25,6 +25,7 @@ test('Sends a transaction for a request to app router', async ({ page }) => {
'http.status_code': 200,
'http.target': '/server-component/parameter/1337/42',
'otel.kind': 'SERVER',
+ 'next.route': '/server-component/parameter/[...parameters]',
}),
op: 'http.server',
origin: 'auto',
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts
index c1aacf4e5ce2..b412893def52 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes.ts
@@ -16,5 +16,6 @@ export default [
route('with/:param', 'routes/performance/dynamic-param.tsx'),
route('static', 'routes/performance/static.tsx'),
route('server-loader', 'routes/performance/server-loader.tsx'),
+ route('server-action', 'routes/performance/server-action.tsx'),
]),
] satisfies RouteConfig;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/server-action.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/server-action.tsx
new file mode 100644
index 000000000000..462fc6fbf54c
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/routes/performance/server-action.tsx
@@ -0,0 +1,24 @@
+import { Form } from 'react-router';
+import type { Route } from './+types/server-action';
+
+export async function action({ request }: Route.ActionArgs) {
+ let formData = await request.formData();
+ let name = formData.get('name');
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ return {
+ greeting: `Hola ${name}`,
+ };
+}
+
+export default function Project({ actionData }: Route.ComponentProps) {
+ return (
+
+
Server action page
+
+ {actionData ?
{actionData.greeting}
: null}
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts
index 36e37f1ff288..b747719b5ff2 100644
--- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts
+++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/performance/performance.server.test.ts
@@ -132,4 +132,31 @@ test.describe('servery - performance', () => {
origin: 'auto.http.react-router',
});
});
+
+ test('should automatically instrument server action', async ({ page }) => {
+ const txPromise = waitForTransaction(APP_NAME, async transactionEvent => {
+ return transactionEvent.transaction === 'POST /performance/server-action.data';
+ });
+
+ await page.goto(`/performance/server-action`);
+ await page.getByRole('button', { name: 'Submit' }).click(); // this will trigger a .data request
+
+ const transaction = await txPromise;
+
+ expect(transaction?.spans?.[transaction.spans?.length - 1]).toMatchObject({
+ span_id: expect.any(String),
+ trace_id: expect.any(String),
+ data: {
+ 'sentry.origin': 'auto.http.react-router',
+ 'sentry.op': 'function.react-router.action',
+ },
+ description: 'Executing Server Action',
+ parent_span_id: expect.any(String),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ op: 'function.react-router.action',
+ origin: 'auto.http.react-router',
+ });
+ });
});
diff --git a/dev-packages/node-integration-tests/suites/express/with-http/instrument.mjs b/dev-packages/node-integration-tests/suites/express/with-http/base/instrument.mjs
similarity index 100%
rename from dev-packages/node-integration-tests/suites/express/with-http/instrument.mjs
rename to dev-packages/node-integration-tests/suites/express/with-http/base/instrument.mjs
diff --git a/dev-packages/node-integration-tests/suites/express/with-http/scenario.mjs b/dev-packages/node-integration-tests/suites/express/with-http/base/scenario.mjs
similarity index 100%
rename from dev-packages/node-integration-tests/suites/express/with-http/scenario.mjs
rename to dev-packages/node-integration-tests/suites/express/with-http/base/scenario.mjs
diff --git a/dev-packages/node-integration-tests/suites/express/with-http/test.ts b/dev-packages/node-integration-tests/suites/express/with-http/base/test.ts
similarity index 97%
rename from dev-packages/node-integration-tests/suites/express/with-http/test.ts
rename to dev-packages/node-integration-tests/suites/express/with-http/base/test.ts
index 10dbefa74a9a..40c74a3d8888 100644
--- a/dev-packages/node-integration-tests/suites/express/with-http/test.ts
+++ b/dev-packages/node-integration-tests/suites/express/with-http/base/test.ts
@@ -1,5 +1,5 @@
import { afterAll, describe } from 'vitest';
-import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';
+import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
describe('express with http import', () => {
afterAll(() => {
diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/generatePayload.ts b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/generatePayload.ts
new file mode 100644
index 000000000000..7b85c82f9ab9
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/generatePayload.ts
@@ -0,0 +1,35 @@
+// Payload for requests
+export function generatePayload(sizeInBytes: number): { data: string } {
+ const baseSize = JSON.stringify({ data: '' }).length;
+ const contentLength = sizeInBytes - baseSize;
+
+ return { data: 'x'.repeat(contentLength) };
+}
+
+// Generate the "expected" body string
+export function generatePayloadString(dataLength: number, truncate?: boolean): string {
+ const prefix = '{"data":"';
+ const suffix = truncate ? '...' : '"}';
+
+ const baseStructuralLength = prefix.length + suffix.length;
+ const dataContent = 'x'.repeat(dataLength - baseStructuralLength);
+
+ return `${prefix}${dataContent}${suffix}`;
+}
+
+// Functions for non-ASCII payloads (e.g. emojis)
+export function generateEmojiPayload(sizeInBytes: number): { data: string } {
+ const baseSize = JSON.stringify({ data: '' }).length;
+ const contentLength = sizeInBytes - baseSize;
+
+ return { data: '👍'.repeat(contentLength) };
+}
+export function generateEmojiPayloadString(dataLength: number, truncate?: boolean): string {
+ const prefix = '{"data":"';
+ const suffix = truncate ? '...' : '"}';
+
+ const baseStructuralLength = suffix.length;
+ const dataContent = '👍'.repeat(dataLength - baseStructuralLength);
+
+ return `${prefix}${dataContent}${suffix}`;
+}
diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-always.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-always.mjs
new file mode 100644
index 000000000000..9f26662334fb
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-always.mjs
@@ -0,0 +1,10 @@
+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,
+ integrations: [Sentry.httpIntegration({ maxIncomingRequestBodySize: 'always' })],
+});
diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-default.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-default.mjs
new file mode 100644
index 000000000000..46a27dd03b74
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-default.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/express/with-http/maxIncomingRequestBodySize/instrument-medium.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-medium.mjs
new file mode 100644
index 000000000000..92ed3d0d5d35
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-medium.mjs
@@ -0,0 +1,10 @@
+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,
+ integrations: [Sentry.httpIntegration({ maxIncomingRequestBodySize: 'medium' })],
+});
diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-none.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-none.mjs
new file mode 100644
index 000000000000..609863666ee4
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-none.mjs
@@ -0,0 +1,15 @@
+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,
+ integrations: [
+ Sentry.httpIntegration({
+ maxIncomingRequestBodySize: 'none',
+ ignoreIncomingRequestBody: url => url.includes('/ignore-request-body'),
+ }),
+ ],
+});
diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-small.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-small.mjs
new file mode 100644
index 000000000000..fc13fbe20d31
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/instrument-small.mjs
@@ -0,0 +1,10 @@
+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,
+ integrations: [Sentry.httpIntegration({ maxIncomingRequestBodySize: 'small' })],
+});
diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/scenario.mjs b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/scenario.mjs
new file mode 100644
index 000000000000..c198c8056fea
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/scenario.mjs
@@ -0,0 +1,32 @@
+import * as Sentry from '@sentry/node';
+import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
+import bodyParser from 'body-parser';
+import express from 'express';
+
+const app = express();
+
+// Increase limit for JSON parsing
+app.use(bodyParser.json({ limit: '3mb' }));
+app.use(express.json({ limit: '3mb' }));
+
+app.post('/test-body-size', (req, res) => {
+ const receivedSize = JSON.stringify(req.body).length;
+ res.json({
+ success: true,
+ receivedSize,
+ message: 'Payload processed successfully',
+ });
+});
+
+app.post('/ignore-request-body', (req, res) => {
+ const receivedSize = JSON.stringify(req.body).length;
+ res.json({
+ success: true,
+ receivedSize,
+ message: 'Payload processed successfully',
+ });
+});
+
+Sentry.setupExpressErrorHandler(app);
+
+startExpressServerAndSendPortToRunner(app);
diff --git a/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/test.ts b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/test.ts
new file mode 100644
index 000000000000..5ae6b4e2bacc
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/express/with-http/maxIncomingRequestBodySize/test.ts
@@ -0,0 +1,311 @@
+import { afterAll, describe, expect } from 'vitest';
+import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../../utils/runner';
+import {
+ generateEmojiPayload,
+ generateEmojiPayloadString,
+ generatePayload,
+ generatePayloadString,
+} from './generatePayload';
+
+// Value of MAX_BODY_BYTE_LENGTH in SentryHttpIntegration
+const MAX_GENERAL = 1024 * 1024; // 1MB
+const MAX_MEDIUM = 10_000;
+const MAX_SMALL = 1000;
+
+describe('express with httpIntegration and not defined maxIncomingRequestBodySize', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument-default.mjs', (createRunner, test) => {
+ test('captures medium request bodies with default setting (medium)', async () => {
+ const runner = createRunner()
+ .expect({
+ transaction: {
+ transaction: 'POST /test-body-size',
+ request: {
+ data: JSON.stringify(generatePayload(MAX_MEDIUM)),
+ },
+ },
+ })
+ .start();
+
+ await runner.makeRequest('post', '/test-body-size', {
+ headers: { 'Content-Type': 'application/json' },
+ data: JSON.stringify(generatePayload(MAX_MEDIUM)),
+ });
+
+ await runner.completed();
+ });
+
+ test('truncates large request bodies with default setting (medium)', async () => {
+ const runner = createRunner()
+ .expect({
+ transaction: {
+ transaction: 'POST /test-body-size',
+ request: {
+ data: generatePayloadString(MAX_MEDIUM, true),
+ },
+ },
+ })
+ .start();
+
+ await runner.makeRequest('post', '/test-body-size', {
+ headers: { 'Content-Type': 'application/json' },
+ data: JSON.stringify(generatePayload(MAX_MEDIUM + 1)),
+ });
+
+ await runner.completed();
+ });
+ });
+});
+
+describe('express with httpIntegration and maxIncomingRequestBodySize: "none"', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario.mjs',
+ 'instrument-none.mjs',
+ (createRunner, test) => {
+ test('does not capture any request bodies with "none" setting', async () => {
+ const runner = createRunner()
+ .expect({
+ transaction: {
+ transaction: 'POST /test-body-size',
+ request: expect.not.objectContaining({
+ data: expect.any(String),
+ }),
+ },
+ })
+ .start();
+
+ await runner.makeRequest('post', '/test-body-size', {
+ headers: { 'Content-Type': 'application/json' },
+ data: JSON.stringify(generatePayload(500)),
+ });
+
+ await runner.completed();
+ });
+
+ test('does not capture any request bodies with "none" setting and "ignoreIncomingRequestBody"', async () => {
+ const runner = createRunner()
+ .expect({
+ transaction: {
+ transaction: 'POST /test-body-size',
+ request: expect.not.objectContaining({
+ data: expect.any(String),
+ }),
+ },
+ })
+ .expect({
+ transaction: {
+ transaction: 'POST /ignore-request-body',
+ request: expect.not.objectContaining({
+ data: expect.any(String),
+ }),
+ },
+ })
+ .start();
+
+ await runner.makeRequest('post', '/test-body-size', {
+ headers: { 'Content-Type': 'application/json' },
+ data: JSON.stringify(generatePayload(500)),
+ });
+
+ await runner.makeRequest('post', '/ignore-request-body', {
+ headers: { 'Content-Type': 'application/json' },
+ data: JSON.stringify(generatePayload(500)),
+ });
+
+ await runner.completed();
+ });
+ },
+ { failsOnEsm: false },
+ );
+});
+
+describe('express with httpIntegration and maxIncomingRequestBodySize: "always"', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario.mjs',
+ 'instrument-always.mjs',
+ (createRunner, test) => {
+ test('captures maximum allowed request body length with "always" setting', async () => {
+ const runner = createRunner()
+ .expect({
+ transaction: {
+ transaction: 'POST /test-body-size',
+ request: {
+ data: JSON.stringify(generatePayload(MAX_GENERAL)),
+ },
+ },
+ })
+ .start();
+
+ await runner.makeRequest('post', '/test-body-size', {
+ headers: { 'Content-Type': 'application/json' },
+ data: JSON.stringify(generatePayload(MAX_GENERAL)),
+ });
+
+ await runner.completed();
+ });
+
+ test('captures large request bodies with "always" setting but respects maximum size limit', async () => {
+ const runner = createRunner()
+ .expect({
+ transaction: {
+ transaction: 'POST /test-body-size',
+ request: {
+ data: generatePayloadString(MAX_GENERAL, true),
+ },
+ },
+ })
+ .start();
+
+ await runner.makeRequest('post', '/test-body-size', {
+ headers: { 'Content-Type': 'application/json' },
+ data: JSON.stringify(generatePayload(MAX_GENERAL + 1)),
+ });
+
+ await runner.completed();
+ });
+ },
+ { failsOnEsm: false },
+ );
+});
+
+describe('express with httpIntegration and maxIncomingRequestBodySize: "small"', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario.mjs',
+ 'instrument-small.mjs',
+ (createRunner, test) => {
+ test('keeps small request bodies with "small" setting', async () => {
+ const runner = createRunner()
+ .expect({
+ transaction: {
+ transaction: 'POST /test-body-size',
+ request: {
+ data: JSON.stringify(generatePayload(MAX_SMALL)),
+ },
+ },
+ })
+ .start();
+
+ await runner.makeRequest('post', '/test-body-size', {
+ headers: { 'Content-Type': 'application/json' },
+ data: JSON.stringify(generatePayload(MAX_SMALL)),
+ });
+
+ await runner.completed();
+ });
+
+ test('truncates too large request bodies with "small" setting', async () => {
+ const runner = createRunner()
+ .expect({
+ transaction: {
+ transaction: 'POST /test-body-size',
+ request: {
+ data: generatePayloadString(MAX_SMALL, true),
+ },
+ },
+ })
+ .start();
+
+ await runner.makeRequest('post', '/test-body-size', {
+ headers: { 'Content-Type': 'application/json' },
+ data: JSON.stringify(generatePayload(MAX_SMALL + 1)),
+ });
+
+ await runner.completed();
+ });
+
+ test('truncates too large non-ASCII request bodies with "small" setting', async () => {
+ const runner = createRunner()
+ .expect({
+ transaction: {
+ transaction: 'POST /test-body-size',
+ request: {
+ // 250 emojis, each 4 bytes in UTF-8 (resulting in 1000 bytes --> MAX_SMALL)
+ data: generateEmojiPayloadString(250, true),
+ },
+ },
+ })
+ .start();
+
+ await runner.makeRequest('post', '/test-body-size', {
+ headers: { 'Content-Type': 'application/json' },
+ data: JSON.stringify(generateEmojiPayload(MAX_SMALL + 1)),
+ });
+
+ await runner.completed();
+ });
+ },
+ { failsOnEsm: false },
+ );
+});
+
+describe('express with httpIntegration and maxIncomingRequestBodySize: "medium"', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ createEsmAndCjsTests(
+ __dirname,
+ 'scenario.mjs',
+ 'instrument-medium.mjs',
+ (createRunner, test) => {
+ test('keeps medium request bodies with "medium" setting', async () => {
+ const runner = createRunner()
+ .expect({
+ transaction: {
+ transaction: 'POST /test-body-size',
+ request: {
+ data: JSON.stringify(generatePayload(MAX_MEDIUM)),
+ },
+ },
+ })
+ .start();
+
+ await runner.makeRequest('post', '/test-body-size', {
+ headers: { 'Content-Type': 'application/json' },
+ data: JSON.stringify(generatePayload(MAX_MEDIUM)),
+ });
+
+ await runner.completed();
+ });
+
+ test('truncates large request bodies with "medium" setting', async () => {
+ const runner = createRunner()
+ .expect({
+ transaction: {
+ transaction: 'POST /test-body-size',
+ request: {
+ data: generatePayloadString(MAX_MEDIUM, true),
+ },
+ },
+ })
+ .start();
+
+ await runner.makeRequest('post', '/test-body-size', {
+ headers: { 'Content-Type': 'application/json' },
+ data: JSON.stringify(generatePayload(MAX_MEDIUM + 1)),
+ });
+
+ await runner.completed();
+ });
+ },
+ { failsOnEsm: false },
+ );
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-explicit-org-id.ts b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-explicit-org-id.ts
new file mode 100644
index 000000000000..1b3afae252d7
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-explicit-org-id.ts
@@ -0,0 +1,34 @@
+import * as Sentry from '@sentry/node';
+import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
+
+export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } };
+
+Sentry.init({
+ dsn: 'https://public@o01234987.ingest.sentry.io/1337',
+ release: '1.0',
+ environment: 'prod',
+ tracesSampleRate: 1.0,
+ transport: loggingTransport,
+});
+
+import cors from 'cors';
+import express from 'express';
+import * as http from 'http';
+
+const app = express();
+
+app.use(cors());
+
+app.get('/test/express', (_req, res) => {
+ const headers = http
+ .get({
+ hostname: 'example.com',
+ })
+ .getHeaders();
+
+ res.send({ test_data: headers });
+});
+
+Sentry.setupExpressErrorHandler(app);
+
+startExpressServerAndSendPortToRunner(app);
diff --git a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-org-id.ts b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-org-id.ts
new file mode 100644
index 000000000000..5fe73e5451a9
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server-no-org-id.ts
@@ -0,0 +1,34 @@
+import * as Sentry from '@sentry/node';
+import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
+
+export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } };
+
+Sentry.init({
+ dsn: 'https://public@public.ingest.sentry.io/1337',
+ release: '1.0',
+ environment: 'prod',
+ tracesSampleRate: 1.0,
+ transport: loggingTransport,
+});
+
+import cors from 'cors';
+import express from 'express';
+import * as http from 'http';
+
+const app = express();
+
+app.use(cors());
+
+app.get('/test/express', (_req, res) => {
+ const headers = http
+ .get({
+ hostname: 'example.com',
+ })
+ .getHeaders();
+
+ res.send({ test_data: headers });
+});
+
+Sentry.setupExpressErrorHandler(app);
+
+startExpressServerAndSendPortToRunner(app);
diff --git a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server.ts b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server.ts
new file mode 100644
index 000000000000..a149f74370f6
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/server.ts
@@ -0,0 +1,35 @@
+import * as Sentry from '@sentry/node';
+import { loggingTransport, startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
+
+export type TestAPIResponse = { test_data: { host: string; 'sentry-trace': string; baggage: string } };
+
+Sentry.init({
+ dsn: 'https://public@o0000987.ingest.sentry.io/1337',
+ release: '1.0',
+ environment: 'prod',
+ orgId: '01234987',
+ tracesSampleRate: 1.0,
+ transport: loggingTransport,
+});
+
+import cors from 'cors';
+import express from 'express';
+import * as http from 'http';
+
+const app = express();
+
+app.use(cors());
+
+app.get('/test/express', (_req, res) => {
+ const headers = http
+ .get({
+ hostname: 'example.com',
+ })
+ .getHeaders();
+
+ res.send({ test_data: headers });
+});
+
+Sentry.setupExpressErrorHandler(app);
+
+startExpressServerAndSendPortToRunner(app);
diff --git a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/test.ts b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/test.ts
new file mode 100644
index 000000000000..732473ac4880
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/baggage-org-id/test.ts
@@ -0,0 +1,37 @@
+import { afterAll, expect, test } from 'vitest';
+import { cleanupChildProcesses, createRunner } from '../../../../utils/runner';
+import type { TestAPIResponse } from './server';
+
+afterAll(() => {
+ cleanupChildProcesses();
+});
+
+test('should include explicitly set org_id in the baggage header', async () => {
+ const runner = createRunner(__dirname, 'server.ts').start();
+
+ const response = await runner.makeRequest('get', '/test/express');
+ expect(response).toBeDefined();
+
+ const baggage = response?.test_data.baggage;
+ expect(baggage).toContain('sentry-org_id=01234987');
+});
+
+test('should extract org_id from DSN host when not explicitly set', async () => {
+ const runner = createRunner(__dirname, 'server-no-explicit-org-id.ts').start();
+
+ const response = await runner.makeRequest('get', '/test/express');
+ expect(response).toBeDefined();
+
+ const baggage = response?.test_data.baggage;
+ expect(baggage).toContain('sentry-org_id=01234987');
+});
+
+test('should set undefined org_id when it cannot be extracted', async () => {
+ const runner = createRunner(__dirname, 'server-no-org-id.ts').start();
+
+ const response = await runner.makeRequest('get', '/test/express');
+ expect(response).toBeDefined();
+
+ const baggage = response?.test_data.baggage;
+ expect(baggage).not.toContain('sentry-org_id');
+});
diff --git a/packages/browser-utils/src/instrument/history.ts b/packages/browser-utils/src/instrument/history.ts
index 60ee888aae24..76bf43f7b398 100644
--- a/packages/browser-utils/src/instrument/history.ts
+++ b/packages/browser-utils/src/instrument/history.ts
@@ -47,9 +47,15 @@ export function instrumentHistory(): void {
return function (this: History, ...args: unknown[]): void {
const url = args.length > 2 ? args[2] : undefined;
if (url) {
- // coerce to string (this is what pushState does)
const from = lastHref;
- const to = String(url);
+
+ // Ensure the URL is absolute
+ // this can be either a path, then it is relative to the current origin
+ // or a full URL of the current origin - other origins are not allowed
+ // See: https://developer.mozilla.org/en-US/docs/Web/API/History/pushState#url
+ // coerce to string (this is what pushState does)
+ const to = getAbsoluteUrl(String(url));
+
// keep track of the current URL state, as we always receive only the updated state
lastHref = to;
@@ -67,3 +73,13 @@ export function instrumentHistory(): void {
fill(WINDOW.history, 'pushState', historyReplacementFunction);
fill(WINDOW.history, 'replaceState', historyReplacementFunction);
}
+
+function getAbsoluteUrl(urlOrPath: string): string {
+ try {
+ const url = new URL(urlOrPath, WINDOW.location.origin);
+ return url.toString();
+ } catch {
+ // fallback, just do nothing
+ return urlOrPath;
+ }
+}
diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts
index 71470a0d8706..646d73ef29c3 100644
--- a/packages/browser-utils/src/metrics/browserMetrics.ts
+++ b/packages/browser-utils/src/metrics/browserMetrics.ts
@@ -1,10 +1,11 @@
/* eslint-disable max-lines */
-import type { Measurements, Span, SpanAttributes, StartSpanOptions } from '@sentry/core';
+import type { Measurements, Span, SpanAttributes, SpanAttributeValue, StartSpanOptions } from '@sentry/core';
import {
browserPerformanceTimeOrigin,
getActiveSpan,
getComponentName,
htmlTreeAsString,
+ isPrimitive,
parseUrl,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
setMeasurement,
@@ -339,7 +340,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries
case 'mark':
case 'paint':
case 'measure': {
- _addMeasureSpans(span, entry, startTime, duration, timeOrigin);
+ _addMeasureSpans(span, entry as PerformanceMeasure, startTime, duration, timeOrigin);
// capture web vitals
const firstHidden = getVisibilityWatcher();
@@ -421,7 +422,7 @@ export function addPerformanceEntries(span: Span, options: AddPerformanceEntries
*/
export function _addMeasureSpans(
span: Span,
- entry: PerformanceEntry,
+ entry: PerformanceMeasure,
startTime: number,
duration: number,
timeOrigin: number,
@@ -450,6 +451,34 @@ export function _addMeasureSpans(
attributes['sentry.browser.measure_start_time'] = measureStartTimestamp;
}
+ // https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure#detail
+ if (entry.detail) {
+ // Handle detail as an object
+ if (typeof entry.detail === 'object') {
+ for (const [key, value] of Object.entries(entry.detail)) {
+ if (value && isPrimitive(value)) {
+ attributes[`sentry.browser.measure.detail.${key}`] = value as SpanAttributeValue;
+ } else {
+ try {
+ // This is user defined so we can't guarantee it's serializable
+ attributes[`sentry.browser.measure.detail.${key}`] = JSON.stringify(value);
+ } catch {
+ // skip
+ }
+ }
+ }
+ } else if (isPrimitive(entry.detail)) {
+ attributes['sentry.browser.measure.detail'] = entry.detail as SpanAttributeValue;
+ } else {
+ // This is user defined so we can't guarantee it's serializable
+ try {
+ attributes['sentry.browser.measure.detail'] = JSON.stringify(entry.detail);
+ } catch {
+ // skip
+ }
+ }
+ }
+
// Measurements from third parties can be off, which would create invalid spans, dropping transactions in the process.
if (measureStartTimestamp <= measureEndTimestamp) {
startAndEndSpan(span, measureStartTimestamp, measureEndTimestamp, {
diff --git a/packages/browser-utils/test/browser/browserMetrics.test.ts b/packages/browser-utils/test/browser/browserMetrics.test.ts
index 99cf451f824e..a6004b73622a 100644
--- a/packages/browser-utils/test/browser/browserMetrics.test.ts
+++ b/packages/browser-utils/test/browser/browserMetrics.test.ts
@@ -70,7 +70,8 @@ describe('_addMeasureSpans', () => {
name: 'measure-1',
duration: 10,
startTime: 12,
- } as PerformanceEntry;
+ detail: null,
+ } as PerformanceMeasure;
const timeOrigin = 100;
const startTime = 23;
@@ -106,7 +107,8 @@ describe('_addMeasureSpans', () => {
name: 'measure-1',
duration: 10,
startTime: 12,
- } as PerformanceEntry;
+ detail: null,
+ } as PerformanceMeasure;
const timeOrigin = 100;
const startTime = 23;
@@ -116,6 +118,165 @@ describe('_addMeasureSpans', () => {
expect(spans).toHaveLength(0);
});
+
+ it('adds measure spans with primitive detail', () => {
+ const spans: Span[] = [];
+
+ getClient()?.on('spanEnd', span => {
+ spans.push(span);
+ });
+
+ const entry = {
+ entryType: 'measure',
+ name: 'measure-1',
+ duration: 10,
+ startTime: 12,
+ detail: 'test-detail',
+ } as PerformanceMeasure;
+
+ const timeOrigin = 100;
+ const startTime = 23;
+ const duration = 356;
+
+ _addMeasureSpans(span, entry, startTime, duration, timeOrigin);
+
+ expect(spans).toHaveLength(1);
+ expect(spanToJSON(spans[0]!)).toEqual(
+ expect.objectContaining({
+ description: 'measure-1',
+ start_timestamp: timeOrigin + startTime,
+ timestamp: timeOrigin + startTime + duration,
+ op: 'measure',
+ origin: 'auto.resource.browser.metrics',
+ data: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'measure',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics',
+ 'sentry.browser.measure.detail': 'test-detail',
+ },
+ }),
+ );
+ });
+
+ it('adds measure spans with object detail', () => {
+ const spans: Span[] = [];
+
+ getClient()?.on('spanEnd', span => {
+ spans.push(span);
+ });
+
+ const detail = {
+ component: 'Button',
+ action: 'click',
+ metadata: { id: 123 },
+ };
+
+ const entry = {
+ entryType: 'measure',
+ name: 'measure-1',
+ duration: 10,
+ startTime: 12,
+ detail,
+ } as PerformanceMeasure;
+
+ const timeOrigin = 100;
+ const startTime = 23;
+ const duration = 356;
+
+ _addMeasureSpans(span, entry, startTime, duration, timeOrigin);
+
+ expect(spans).toHaveLength(1);
+ expect(spanToJSON(spans[0]!)).toEqual(
+ expect.objectContaining({
+ description: 'measure-1',
+ start_timestamp: timeOrigin + startTime,
+ timestamp: timeOrigin + startTime + duration,
+ op: 'measure',
+ origin: 'auto.resource.browser.metrics',
+ data: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'measure',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.resource.browser.metrics',
+ 'sentry.browser.measure.detail.component': 'Button',
+ 'sentry.browser.measure.detail.action': 'click',
+ 'sentry.browser.measure.detail.metadata': JSON.stringify({ id: 123 }),
+ },
+ }),
+ );
+ });
+
+ it('handles non-primitive detail values by stringifying them', () => {
+ const spans: Span[] = [];
+
+ getClient()?.on('spanEnd', span => {
+ spans.push(span);
+ });
+
+ const detail = {
+ component: 'Button',
+ action: 'click',
+ metadata: { id: 123 },
+ callback: () => {},
+ };
+
+ const entry = {
+ entryType: 'measure',
+ name: 'measure-1',
+ duration: 10,
+ startTime: 12,
+ detail,
+ } as PerformanceMeasure;
+
+ const timeOrigin = 100;
+ const startTime = 23;
+ const duration = 356;
+
+ _addMeasureSpans(span, entry, startTime, duration, timeOrigin);
+
+ expect(spans).toHaveLength(1);
+ const spanData = spanToJSON(spans[0]!).data;
+ expect(spanData['sentry.browser.measure.detail.component']).toBe('Button');
+ expect(spanData['sentry.browser.measure.detail.action']).toBe('click');
+ expect(spanData['sentry.browser.measure.detail.metadata']).toBe(JSON.stringify({ id: 123 }));
+ expect(spanData['sentry.browser.measure.detail.callback']).toBe(JSON.stringify(detail.callback));
+ });
+
+ it('handles errors in object detail value stringification', () => {
+ const spans: Span[] = [];
+
+ getClient()?.on('spanEnd', span => {
+ spans.push(span);
+ });
+
+ const circular: any = {};
+ circular.self = circular;
+
+ const detail = {
+ component: 'Button',
+ action: 'click',
+ circular,
+ };
+
+ const entry = {
+ entryType: 'measure',
+ name: 'measure-1',
+ duration: 10,
+ startTime: 12,
+ detail,
+ } as PerformanceMeasure;
+
+ const timeOrigin = 100;
+ const startTime = 23;
+ const duration = 356;
+
+ // Should not throw
+ _addMeasureSpans(span, entry, startTime, duration, timeOrigin);
+
+ expect(spans).toHaveLength(1);
+ const spanData = spanToJSON(spans[0]!).data;
+ expect(spanData['sentry.browser.measure.detail.component']).toBe('Button');
+ expect(spanData['sentry.browser.measure.detail.action']).toBe('click');
+ // The circular reference should be skipped
+ expect(spanData['sentry.browser.measure.detail.circular']).toBeUndefined();
+ });
});
describe('_addResourceSpans', () => {
@@ -464,7 +625,6 @@ describe('_addNavigationSpans', () => {
transferSize: 14726,
encodedBodySize: 14426,
decodedBodySize: 67232,
- responseStatus: 200,
serverTiming: [],
unloadEventStart: 0,
unloadEventEnd: 0,
diff --git a/packages/browser/src/helpers.ts b/packages/browser/src/helpers.ts
index 76578fe356dc..8fe8d650f322 100644
--- a/packages/browser/src/helpers.ts
+++ b/packages/browser/src/helpers.ts
@@ -4,6 +4,7 @@ import {
addExceptionTypeValue,
addNonEnumerableProperty,
captureException,
+ getLocationHref,
getOriginalFunction,
GLOBAL_OBJ,
markFunctionWrapped,
@@ -175,3 +176,24 @@ export function wrap(
return sentryWrapped;
}
+
+/**
+ * Get HTTP request data from the current page.
+ */
+export function getHttpRequestData(): { url: string; headers: Record } {
+ // grab as much info as exists and add it to the event
+ const url = getLocationHref();
+ const { referrer } = WINDOW.document || {};
+ const { userAgent } = WINDOW.navigator || {};
+
+ const headers = {
+ ...(referrer && { Referer: referrer }),
+ ...(userAgent && { 'User-Agent': userAgent }),
+ };
+ const request = {
+ url,
+ headers,
+ };
+
+ return request;
+}
diff --git a/packages/browser/src/integrations/httpcontext.ts b/packages/browser/src/integrations/httpcontext.ts
index 78e27713c78f..9517b2364e83 100644
--- a/packages/browser/src/integrations/httpcontext.ts
+++ b/packages/browser/src/integrations/httpcontext.ts
@@ -1,5 +1,5 @@
-import { defineIntegration, getLocationHref } from '@sentry/core';
-import { WINDOW } from '../helpers';
+import { defineIntegration } from '@sentry/core';
+import { getHttpRequestData, WINDOW } from '../helpers';
/**
* Collects information about HTTP request headers and
@@ -14,23 +14,17 @@ export const httpContextIntegration = defineIntegration(() => {
return;
}
- // grab as much info as exists and add it to the event
- const url = event.request?.url || getLocationHref();
- const { referrer } = WINDOW.document || {};
- const { userAgent } = WINDOW.navigator || {};
-
+ const reqData = getHttpRequestData();
const headers = {
+ ...reqData.headers,
...event.request?.headers,
- ...(referrer && { Referer: referrer }),
- ...(userAgent && { 'User-Agent': userAgent }),
};
- const request = {
+
+ event.request = {
+ ...reqData,
...event.request,
- ...(url && { url }),
headers,
};
-
- event.request = request;
},
};
});
diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts
index 643b561af583..0a4579f40774 100644
--- a/packages/browser/src/tracing/browserTracingIntegration.ts
+++ b/packages/browser/src/tracing/browserTracingIntegration.ts
@@ -12,6 +12,7 @@ import {
getLocationHref,
GLOBAL_OBJ,
logger,
+ parseStringToURLObject,
propagationContextFromHeaders,
registerSpanErrorInstrumentation,
SEMANTIC_ATTRIBUTE_SENTRY_IDLE_SPAN_FINISH_REASON,
@@ -33,7 +34,7 @@ import {
startTrackingWebVitals,
} from '@sentry-internal/browser-utils';
import { DEBUG_BUILD } from '../debug-build';
-import { WINDOW } from '../helpers';
+import { getHttpRequestData, WINDOW } from '../helpers';
import { registerBackgroundTabDetection } from './backgroundtab';
import { linkTraces } from './linkedTraces';
import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request';
@@ -399,7 +400,14 @@ export const browserTracingIntegration = ((_options: Partial {
sampleRand: expect.any(Number),
dsc: {
release: undefined,
+ org_id: undefined,
environment: 'production',
public_key: 'examplePublicKey',
sample_rate: '1',
@@ -773,6 +774,7 @@ describe('browserTracingIntegration', () => {
sampleRand: expect.any(Number),
dsc: {
release: undefined,
+ org_id: undefined,
environment: 'production',
public_key: 'examplePublicKey',
sample_rate: '0',
@@ -898,6 +900,7 @@ describe('browserTracingIntegration', () => {
expect(dynamicSamplingContext).toBeDefined();
expect(dynamicSamplingContext).toStrictEqual({
release: undefined,
+ org_id: undefined,
environment: 'production',
public_key: 'examplePublicKey',
sample_rate: '1',
diff --git a/packages/cloudflare/src/durableobject.ts b/packages/cloudflare/src/durableobject.ts
index d595ccfa5985..35fbb5096a41 100644
--- a/packages/cloudflare/src/durableobject.ts
+++ b/packages/cloudflare/src/durableobject.ts
@@ -133,10 +133,11 @@ function wrapMethodWithSentry any>(
* );
* ```
*/
-export function instrumentDurableObjectWithSentry>(
- optionsCallback: (env: E) => CloudflareOptions,
- DurableObjectClass: new (state: DurableObjectState, env: E) => T,
-): new (state: DurableObjectState, env: E) => T {
+export function instrumentDurableObjectWithSentry<
+ E,
+ T extends DurableObject,
+ C extends new (state: DurableObjectState, env: E) => T,
+>(optionsCallback: (env: E) => CloudflareOptions, DurableObjectClass: C): C {
return new Proxy(DurableObjectClass, {
construct(target, [context, env]) {
setAsyncLocalStorageAsyncContextStrategy();
diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts
index 9380c75dd3be..5f10f11db19c 100644
--- a/packages/core/src/tracing/dynamicSamplingContext.ts
+++ b/packages/core/src/tracing/dynamicSamplingContext.ts
@@ -15,6 +15,7 @@ import {
baggageHeaderToDynamicSamplingContext,
dynamicSamplingContextToSentryBaggageHeader,
} from '../utils-hoist/baggage';
+import { extractOrgIdFromDsnHost } from '../utils-hoist/dsn';
import { addNonEnumerableProperty } from '../utils-hoist/object';
import { getCapturedScopesOnSpan } from './utils';
@@ -44,7 +45,14 @@ export function freezeDscOnSpan(span: Span, dsc: Partial
export function getDynamicSamplingContextFromClient(trace_id: string, client: Client): DynamicSamplingContext {
const options = client.getOptions();
- const { publicKey: public_key } = client.getDsn() || {};
+ const { publicKey: public_key, host } = client.getDsn() || {};
+
+ let org_id: string | undefined;
+ if (options.orgId) {
+ org_id = String(options.orgId);
+ } else if (host) {
+ org_id = extractOrgIdFromDsnHost(host);
+ }
// Instead of conditionally adding non-undefined values, we add them and then remove them if needed
// otherwise, the order of baggage entries changes, which "breaks" a bunch of tests etc.
@@ -53,6 +61,7 @@ export function getDynamicSamplingContextFromClient(trace_id: string, client: Cl
release: options.release,
public_key,
trace_id,
+ org_id,
};
client.emit('createDsc', dsc);
diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts
index 5f791a75e614..f0e2a6bb374a 100644
--- a/packages/core/src/tracing/sentrySpan.ts
+++ b/packages/core/src/tracing/sentrySpan.ts
@@ -336,6 +336,8 @@ export class SentrySpan implements Span {
const { scope: capturedSpanScope, isolationScope: capturedSpanIsolationScope } = getCapturedScopesOnSpan(this);
+ const normalizedRequest = capturedSpanScope?.getScopeData().sdkProcessingMetadata?.normalizedRequest;
+
if (this._sampled !== true) {
return undefined;
}
@@ -374,6 +376,7 @@ export class SentrySpan implements Span {
capturedSpanIsolationScope,
dynamicSamplingContext: getDynamicSamplingContextFromSpan(this),
},
+ request: normalizedRequest,
...(source && {
transaction_info: {
source,
diff --git a/packages/core/src/trpc.ts b/packages/core/src/trpc.ts
index d41030b22dd6..7e29a69903a1 100644
--- a/packages/core/src/trpc.ts
+++ b/packages/core/src/trpc.ts
@@ -1,4 +1,4 @@
-import { getClient, withScope } from './currentScopes';
+import { getClient, withIsolationScope } from './currentScopes';
import { captureException } from './exports';
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from './semanticAttributes';
import { startSpanManual } from './tracing';
@@ -76,7 +76,7 @@ export function trpcMiddleware(options: SentryTrpcMiddlewareOptions = {}) {
}
}
- return withScope(scope => {
+ return withIsolationScope(scope => {
scope.setContext('trpc', trpcContext);
return startSpanManual(
{
diff --git a/packages/core/src/types-hoist/envelope.ts b/packages/core/src/types-hoist/envelope.ts
index d874a4e65800..58671c1eba70 100644
--- a/packages/core/src/types-hoist/envelope.ts
+++ b/packages/core/src/types-hoist/envelope.ts
@@ -25,6 +25,7 @@ export type DynamicSamplingContext = {
replay_id?: string;
sampled?: string;
sample_rand?: string;
+ org_id?: string;
};
// https://github.com/getsentry/relay/blob/311b237cd4471042352fa45e7a0824b8995f216f/relay-server/src/envelope.rs#L154
diff --git a/packages/core/src/types-hoist/instrument.ts b/packages/core/src/types-hoist/instrument.ts
index 5eba6066432a..b067f6618558 100644
--- a/packages/core/src/types-hoist/instrument.ts
+++ b/packages/core/src/types-hoist/instrument.ts
@@ -78,7 +78,9 @@ export interface HandlerDataConsole {
}
export interface HandlerDataHistory {
+ /** The full URL of the previous page */
from: string | undefined;
+ /** The full URL of the new page */
to: string;
}
diff --git a/packages/core/src/types-hoist/options.ts b/packages/core/src/types-hoist/options.ts
index 4b0010f2b7d7..09dab550be4c 100644
--- a/packages/core/src/types-hoist/options.ts
+++ b/packages/core/src/types-hoist/options.ts
@@ -320,6 +320,14 @@ export interface ClientOptions {
expect(dynamicSamplingContext).toStrictEqual({
public_key: undefined,
+ org_id: undefined,
release: '1.0.1',
environment: 'production',
sampled: 'true',
@@ -91,6 +93,7 @@ describe('getDynamicSamplingContextFromSpan', () => {
expect(dynamicSamplingContext).toStrictEqual({
public_key: undefined,
+ org_id: undefined,
release: '1.0.1',
environment: 'production',
sampled: 'true',
@@ -115,6 +118,7 @@ describe('getDynamicSamplingContextFromSpan', () => {
expect(dynamicSamplingContext).toStrictEqual({
public_key: undefined,
+ org_id: undefined,
release: '1.0.1',
environment: 'production',
sampled: 'true',
@@ -171,6 +175,7 @@ describe('getDynamicSamplingContextFromSpan', () => {
expect(dynamicSamplingContext).toStrictEqual({
public_key: undefined,
+ org_id: undefined,
release: '1.0.1',
environment: 'production',
trace_id: expect.stringMatching(/^[a-f0-9]{32}$/),
@@ -178,3 +183,128 @@ describe('getDynamicSamplingContextFromSpan', () => {
});
});
});
+
+describe('getDynamicSamplingContextFromClient', () => {
+ const TRACE_ID = '4b25bc58f14243d8b208d1e22a054164';
+ let client: TestClient;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it('creates DSC with basic client information', () => {
+ client = new TestClient(
+ getDefaultTestClientOptions({
+ release: '1.0.0',
+ environment: 'test-env',
+ dsn: 'https://public@sentry.example.com/1',
+ }),
+ );
+
+ const dsc = getDynamicSamplingContextFromClient(TRACE_ID, client);
+
+ expect(dsc).toEqual({
+ trace_id: TRACE_ID,
+ release: '1.0.0',
+ environment: 'test-env',
+ public_key: 'public',
+ org_id: undefined,
+ });
+ });
+
+ it('uses DEFAULT_ENVIRONMENT when environment is not specified', () => {
+ client = new TestClient(
+ getDefaultTestClientOptions({
+ release: '1.0.0',
+ dsn: 'https://public@sentry.example.com/1',
+ }),
+ );
+
+ const dsc = getDynamicSamplingContextFromClient(TRACE_ID, client);
+
+ expect(dsc.environment).toBe(DEFAULT_ENVIRONMENT);
+ });
+
+ it('uses orgId from options when specified', () => {
+ client = new TestClient(
+ getDefaultTestClientOptions({
+ orgId: '00222111',
+ dsn: 'https://public@sentry.example.com/1',
+ }),
+ );
+
+ const dsc = getDynamicSamplingContextFromClient(TRACE_ID, client);
+
+ expect(dsc.org_id).toBe('00222111');
+ });
+
+ it('infers orgId from DSN host when not explicitly provided', () => {
+ client = new TestClient(
+ getDefaultTestClientOptions({
+ dsn: 'https://public@o123456.sentry.io/1',
+ }),
+ );
+
+ const dsc = getDynamicSamplingContextFromClient(TRACE_ID, client);
+
+ expect(dsc.org_id).toBe('123456');
+ });
+
+ it('prioritizes explicit orgId over inferred from DSN', () => {
+ client = new TestClient(
+ getDefaultTestClientOptions({
+ orgId: '1234560',
+ dsn: 'https://public@my-org.sentry.io/1',
+ }),
+ );
+
+ const dsc = getDynamicSamplingContextFromClient(TRACE_ID, client);
+
+ expect(dsc.org_id).toBe('1234560');
+ });
+
+ it('handles orgId passed as number', () => {
+ client = new TestClient(
+ getDefaultTestClientOptions({
+ dsn: 'https://public@my-org.sentry.io/1',
+ orgId: 123456,
+ }),
+ );
+
+ const dsc = getDynamicSamplingContextFromClient(TRACE_ID, client);
+
+ expect(dsc.org_id).toBe('123456');
+ });
+
+ it('handles missing DSN gracefully', () => {
+ client = new TestClient(
+ getDefaultTestClientOptions({
+ release: '1.0.0',
+ }),
+ );
+
+ const dsc = getDynamicSamplingContextFromClient(TRACE_ID, client);
+
+ expect(dsc.public_key).toBeUndefined();
+ expect(dsc.org_id).toBeUndefined();
+ });
+
+ it('emits createDsc event with the generated DSC', () => {
+ client = new TestClient(
+ getDefaultTestClientOptions({
+ release: '1.0.0',
+ dsn: 'https://public@sentry.example.com/1',
+ }),
+ );
+
+ const emitSpy = vi.spyOn(client, 'emit');
+
+ const dsc = getDynamicSamplingContextFromClient(TRACE_ID, client);
+
+ expect(emitSpy).toHaveBeenCalledWith('createDsc', dsc);
+ });
+});
diff --git a/packages/core/test/lib/trpc.test.ts b/packages/core/test/lib/trpc.test.ts
index 9a27e62c38ae..c3eca8cf4954 100644
--- a/packages/core/test/lib/trpc.test.ts
+++ b/packages/core/test/lib/trpc.test.ts
@@ -26,7 +26,7 @@ describe('trpcMiddleware', () => {
setExtra: vi.fn(),
};
- const withScope = vi.fn(callback => {
+ const withIsolationScope = vi.fn(callback => {
return callback(mockScope);
});
@@ -38,7 +38,7 @@ describe('trpcMiddleware', () => {
client.init();
vi.spyOn(currentScopes, 'getClient').mockReturnValue(mockClient);
vi.spyOn(tracing, 'startSpanManual').mockImplementation((name, callback) => callback(mockSpan, () => {}));
- vi.spyOn(currentScopes, 'withScope').mockImplementation(withScope);
+ vi.spyOn(currentScopes, 'withIsolationScope').mockImplementation(withIsolationScope);
vi.spyOn(exports, 'captureException').mockImplementation(() => 'mock-event-id');
});
diff --git a/packages/core/test/utils-hoist/dsn.test.ts b/packages/core/test/utils-hoist/dsn.test.ts
index 6d34b599c6c9..b5d22130816b 100644
--- a/packages/core/test/utils-hoist/dsn.test.ts
+++ b/packages/core/test/utils-hoist/dsn.test.ts
@@ -1,6 +1,6 @@
-import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { beforeEach, describe, expect, it, test, vi } from 'vitest';
import { DEBUG_BUILD } from '../../src/debug-build';
-import { dsnToString, makeDsn } from '../../src/utils-hoist/dsn';
+import { dsnToString, extractOrgIdFromDsnHost, makeDsn } from '../../src/utils-hoist/dsn';
import { logger } from '../../src/utils-hoist/logger';
function testIf(condition: boolean) {
@@ -215,3 +215,35 @@ describe('Dsn', () => {
});
});
});
+
+describe('extractOrgIdFromDsnHost', () => {
+ it('extracts the org ID from a DSN host with standard format', () => {
+ expect(extractOrgIdFromDsnHost('o123456.sentry.io')).toBe('123456');
+ });
+
+ it('extracts numeric org IDs of different lengths', () => {
+ expect(extractOrgIdFromDsnHost('o1.ingest.sentry.io')).toBe('1');
+ expect(extractOrgIdFromDsnHost('o42.sentry.io')).toBe('42');
+ expect(extractOrgIdFromDsnHost('o9999999.sentry.io')).toBe('9999999');
+ });
+
+ it('returns undefined for hosts without an org ID prefix', () => {
+ expect(extractOrgIdFromDsnHost('sentry.io')).toBeUndefined();
+ expect(extractOrgIdFromDsnHost('example.com')).toBeUndefined();
+ });
+
+ it('returns undefined for hosts with invalid org ID format', () => {
+ expect(extractOrgIdFromDsnHost('oabc.sentry.io')).toBeUndefined();
+ expect(extractOrgIdFromDsnHost('o.sentry.io')).toBeUndefined();
+ expect(extractOrgIdFromDsnHost('oX123.sentry.io')).toBeUndefined();
+ });
+
+ it('handles different domain variations', () => {
+ expect(extractOrgIdFromDsnHost('o123456.ingest.sentry.io')).toBe('123456');
+ expect(extractOrgIdFromDsnHost('o123456.custom-domain.com')).toBe('123456');
+ });
+
+ it('handles empty string input', () => {
+ expect(extractOrgIdFromDsnHost('')).toBeUndefined();
+ });
+});
diff --git a/packages/nextjs/src/server/index.ts b/packages/nextjs/src/server/index.ts
index e4e437ebd691..a6594e7fae1e 100644
--- a/packages/nextjs/src/server/index.ts
+++ b/packages/nextjs/src/server/index.ts
@@ -176,6 +176,8 @@ export function init(options: NodeOptions): NodeClient | undefined {
const route = spanAttributes['next.route'].replace(/\/route$/, '');
rootSpan.updateName(route);
rootSpan.setAttribute(ATTR_HTTP_ROUTE, route);
+ // Preserving the original attribute despite internally not depending on it
+ rootSpan.setAttribute('next.route', route);
}
}
@@ -322,11 +324,14 @@ export function init(options: NodeOptions): NodeClient | undefined {
const method = event.contexts.trace.data[SEMATTRS_HTTP_METHOD];
// eslint-disable-next-line deprecation/deprecation
const target = event.contexts?.trace?.data?.[SEMATTRS_HTTP_TARGET];
- const route = event.contexts.trace.data[ATTR_HTTP_ROUTE];
+ const route = event.contexts.trace.data[ATTR_HTTP_ROUTE] || event.contexts.trace.data['next.route'];
if (typeof method === 'string' && typeof route === 'string') {
- event.transaction = `${method} ${route.replace(/\/route$/, '')}`;
+ const cleanRoute = route.replace(/\/route$/, '');
+ event.transaction = `${method} ${cleanRoute}`;
event.contexts.trace.data[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE] = 'route';
+ // Preserve next.route in case it did not get hoisted
+ event.contexts.trace.data['next.route'] = cleanRoute;
}
// backfill transaction name for pages that would otherwise contain unparameterized routes
diff --git a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts
index 6b8f615479e4..8eb13bc144cf 100644
--- a/packages/node/src/integrations/http/SentryHttpInstrumentation.ts
+++ b/packages/node/src/integrations/http/SentryHttpInstrumentation.ts
@@ -82,6 +82,22 @@ export type SentryHttpInstrumentationOptions = InstrumentationConfig & {
*/
ignoreIncomingRequestBody?: (url: string, request: http.RequestOptions) => boolean;
+ /**
+ * Controls the maximum size of incoming HTTP request bodies attached to events.
+ *
+ * Available options:
+ * - 'none': No request bodies will be attached
+ * - 'small': Request bodies up to 1,000 bytes will be attached
+ * - 'medium': Request bodies up to 10,000 bytes will be attached (default)
+ * - 'always': Request bodies will always be attached
+ *
+ * Note that even with 'always' setting, bodies exceeding 1MB will never be attached
+ * for performance and security reasons.
+ *
+ * @default 'medium'
+ */
+ maxIncomingRequestBodySize?: 'none' | 'small' | 'medium' | 'always';
+
/**
* Whether the integration should create [Sessions](https://docs.sentry.io/product/releases/health/#sessions) for incoming requests to track the health and crash-free rate of your releases in Sentry.
* Read more about Release Health: https://docs.sentry.io/product/releases/health/
@@ -299,7 +315,7 @@ export class SentryHttpInstrumentation extends InstrumentationBase) => {
try {
const chunk = args[0] as Buffer | string;
const bufferifiedChunk = Buffer.from(chunk);
- if (bodyByteLength < MAX_BODY_BYTE_LENGTH) {
+ if (bodyByteLength < maxBodySize) {
chunks.push(bufferifiedChunk);
bodyByteLength += bufferifiedChunk.byteLength;
} else if (DEBUG_BUILD) {
logger.log(
INSTRUMENTATION_NAME,
- `Dropping request body chunk because maximum body length of ${MAX_BODY_BYTE_LENGTH}b is exceeded.`,
+ `Dropping request body chunk because maximum body length of ${maxBodySize}b is exceeded.`,
);
}
} catch (err) {
@@ -502,7 +531,16 @@ function patchRequestToCaptureBody(req: http.IncomingMessage, isolationScope: Sc
try {
const body = Buffer.concat(chunks).toString('utf-8');
if (body) {
- isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: body } });
+ // Using Buffer.byteLength here, because the body may contain characters that are not 1 byte long
+ const bodyByteLength = Buffer.byteLength(body, 'utf-8');
+ const truncatedBody =
+ bodyByteLength > maxBodySize
+ ? `${Buffer.from(body)
+ .subarray(0, maxBodySize - 3)
+ .toString('utf-8')}...`
+ : body;
+
+ isolationScope.setSDKProcessingMetadata({ normalizedRequest: { data: truncatedBody } });
}
} catch (error) {
if (DEBUG_BUILD) {
diff --git a/packages/node/src/integrations/http/index.ts b/packages/node/src/integrations/http/index.ts
index 4a0d3b0d00a4..72326e21e6f1 100644
--- a/packages/node/src/integrations/http/index.ts
+++ b/packages/node/src/integrations/http/index.ts
@@ -90,6 +90,22 @@ interface HttpOptions {
*/
ignoreIncomingRequestBody?: (url: string, request: RequestOptions) => boolean;
+ /**
+ * Controls the maximum size of incoming HTTP request bodies attached to events.
+ *
+ * Available options:
+ * - 'none': No request bodies will be attached
+ * - 'small': Request bodies up to 1,000 bytes will be attached
+ * - 'medium': Request bodies up to 10,000 bytes will be attached (default)
+ * - 'always': Request bodies will always be attached
+ *
+ * Note that even with 'always' setting, bodies exceeding 1MB will never be attached
+ * for performance and security reasons.
+ *
+ * @default 'medium'
+ */
+ maxIncomingRequestBodySize?: 'none' | 'small' | 'medium' | 'always';
+
/**
* If true, do not generate spans for incoming requests at all.
* This is used by Remix to avoid generating spans for incoming requests, as it generates its own spans.
diff --git a/packages/node/src/integrations/http/utils.ts b/packages/node/src/integrations/http/utils.ts
deleted file mode 100644
index ddb803c8fc58..000000000000
--- a/packages/node/src/integrations/http/utils.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-/**
- * This is a minimal version of `wrap` from shimmer:
- * https://github.com/othiym23/shimmer/blob/master/index.js
- *
- * In contrast to the original implementation, this version does not allow to unwrap,
- * and does not make it clear that the method is wrapped.
- * This is necessary because we want to wrap the http module with our own code,
- * while still allowing to use the HttpInstrumentation from OTEL.
- *
- * Without this, if we'd just use `wrap` from shimmer, the OTEL instrumentation would remove our wrapping,
- * because it only allows any module to be wrapped a single time.
- */
-export function stealthWrap(
- nodule: Nodule,
- name: FieldName,
- wrapper: (original: Nodule[FieldName]) => Nodule[FieldName],
-): Nodule[FieldName] {
- const original = nodule[name];
- const wrapped = wrapper(original);
-
- defineProperty(nodule, name, wrapped);
- return wrapped;
-}
-
-// Sets a property on an object, preserving its enumerability.
-function defineProperty(
- obj: Nodule,
- name: FieldName,
- value: Nodule[FieldName],
-): void {
- const enumerable = !!obj[name] && Object.prototype.propertyIsEnumerable.call(obj, name);
-
- Object.defineProperty(obj, name, {
- configurable: true,
- enumerable: enumerable,
- writable: true,
- value: value,
- });
-}
diff --git a/packages/profiling-node/README.md b/packages/profiling-node/README.md
index e96bc41eb569..962bc8e6834f 100644
--- a/packages/profiling-node/README.md
+++ b/packages/profiling-node/README.md
@@ -83,7 +83,7 @@ After the binaries are built, you should see them inside the profiling-node/lib
### Prebuilt binaries
-We currently ship prebuilt binaries for a few of the most common platforms and node versions (v18-22).
+We currently ship prebuilt binaries for a few of the most common platforms and node versions (v18-24).
- macOS x64
- Linux ARM64 (musl)
diff --git a/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts b/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts
similarity index 96%
rename from packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts
rename to packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts
index 705359eab62c..eec1cfa72403 100644
--- a/packages/react-router/src/server/lowQualityTransactionsFilterIntegration.ts
+++ b/packages/react-router/src/server/integration/lowQualityTransactionsFilterIntegration.ts
@@ -10,7 +10,7 @@ function _lowQualityTransactionsFilterIntegration(options: NodeOptions): {
name: string;
processEvent: (event: Event, hint: EventHint, client: Client) => Event | null;
} {
- const matchedRegexes = [/GET \/node_modules\//, /GET \/favicon\.ico/, /GET \/@id\//];
+ const matchedRegexes = [/GET \/node_modules\//, /GET \/favicon\.ico/, /GET \/@id\//, /GET \/__manifest\?/];
return {
name: 'LowQualityTransactionsFilter',
diff --git a/packages/react-router/src/server/sdk.ts b/packages/react-router/src/server/sdk.ts
index b0ca0e79bd49..55eaf6962a28 100644
--- a/packages/react-router/src/server/sdk.ts
+++ b/packages/react-router/src/server/sdk.ts
@@ -5,8 +5,8 @@ import type { NodeClient, NodeOptions } from '@sentry/node';
import { getDefaultIntegrations as getNodeDefaultIntegrations, init as initNodeSdk } from '@sentry/node';
import { DEBUG_BUILD } from '../common/debug-build';
import { SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE } from './instrumentation/util';
+import { lowQualityTransactionsFilterIntegration } from './integration/lowQualityTransactionsFilterIntegration';
import { reactRouterServerIntegration } from './integration/reactRouterServer';
-import { lowQualityTransactionsFilterIntegration } from './lowQualityTransactionsFilterIntegration';
/**
* Returns the default integrations for the React Router SDK.
@@ -45,7 +45,7 @@ export function init(options: NodeOptions): NodeClient | undefined {
const overwrite = event.contexts?.trace?.data?.[SEMANTIC_ATTRIBUTE_SENTRY_OVERWRITE];
if (
event.type === 'transaction' &&
- event.transaction === 'GET *' &&
+ (event.transaction === 'GET *' || event.transaction === 'POST *') &&
event.contexts?.trace?.data?.[ATTR_HTTP_ROUTE] === '*' &&
overwrite
) {
diff --git a/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts
index 58ddf3e215d6..3aac16d0d05d 100644
--- a/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts
+++ b/packages/react-router/test/server/lowQualityTransactionsFilterIntegration.test.ts
@@ -2,7 +2,7 @@ import type { Event, EventType, Integration } from '@sentry/core';
import * as SentryCore from '@sentry/core';
import * as SentryNode from '@sentry/node';
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { lowQualityTransactionsFilterIntegration } from '../../src/server/lowQualityTransactionsFilterIntegration';
+import { lowQualityTransactionsFilterIntegration } from '../../src/server/integration/lowQualityTransactionsFilterIntegration';
const loggerLog = vi.spyOn(SentryCore.logger, 'log').mockImplementation(() => {});
@@ -18,6 +18,7 @@ describe('Low Quality Transactions Filter Integration', () => {
['node_modules requests', 'GET /node_modules/some-package/index.js'],
['favicon.ico requests', 'GET /favicon.ico'],
['@id/ requests', 'GET /@id/some-id'],
+ ['manifest requests', 'GET /__manifest?p=%2Fperformance%2Fserver-action'],
])('%s', (description, transaction) => {
const integration = lowQualityTransactionsFilterIntegration({ debug: true }) as Integration;
const event = {
diff --git a/packages/react-router/test/server/sdk.test.ts b/packages/react-router/test/server/sdk.test.ts
index 57b51d16c042..fdb894299760 100644
--- a/packages/react-router/test/server/sdk.test.ts
+++ b/packages/react-router/test/server/sdk.test.ts
@@ -3,7 +3,7 @@ import type { NodeClient } from '@sentry/node';
import * as SentryNode from '@sentry/node';
import { SDK_VERSION } from '@sentry/node';
import { afterEach, describe, expect, it, vi } from 'vitest';
-import * as LowQualityModule from '../../src/server/lowQualityTransactionsFilterIntegration';
+import * as LowQualityModule from '../../src/server/integration/lowQualityTransactionsFilterIntegration';
import { init as reactRouterInit } from '../../src/server/sdk';
const nodeInit = vi.spyOn(SentryNode, 'init');
diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts
index bc916ba591a8..6db78dced270 100644
--- a/packages/replay-internal/src/integration.ts
+++ b/packages/replay-internal/src/integration.ts
@@ -95,7 +95,7 @@ export class Replay implements Integration {
networkResponseHeaders = [],
mask = [],
- maskAttributes = ['title', 'placeholder'],
+ maskAttributes = ['title', 'placeholder', 'aria-label'],
unmask = [],
block = [],
unblock = [],