diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c1ff3d4b8e9c..9b36572032b1 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -458,8 +458,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- # TODO(lforst): Unpin Node.js version 22 when https://github.com/protobufjs/protobuf.js/issues/2025 is resolved which broke the nodejs tests
- node: [14, 16, 18, 20, '22.6.0']
+ node: [14, 16, 18, 20, 22]
steps:
- name: Check out base commit (${{ github.event.pull_request.base.sha }})
uses: actions/checkout@v4
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 005f0204866b..936edff4c346 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,7 +10,7 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
-Work in this release was contributed by @KyGuy2002 and @artzhookov. Thank you for your contributions!
+Work in this release was contributed by @KyGuy2002, @artzhookov, and @julianCast. Thank you for your contributions!
## 8.30.0
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/browser-back/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/browser-back/page.tsx
new file mode 100644
index 000000000000..9e32c27abce2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/browser-back/page.tsx
@@ -0,0 +1,7 @@
+import Link from 'next/link';
+
+export const dynamic = 'force-dynamic';
+
+export default function Page() {
+ return Go back home;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/link-replace/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/link-replace/page.tsx
new file mode 100644
index 000000000000..9e32c27abce2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/link-replace/page.tsx
@@ -0,0 +1,7 @@
+import Link from 'next/link';
+
+export const dynamic = 'force-dynamic';
+
+export default function Page() {
+ return Go back home;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/link/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/link/page.tsx
new file mode 100644
index 000000000000..9e32c27abce2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/link/page.tsx
@@ -0,0 +1,7 @@
+import Link from 'next/link';
+
+export const dynamic = 'force-dynamic';
+
+export default function Page() {
+ return Go back home;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-back/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-back/page.tsx
new file mode 100644
index 000000000000..9e32c27abce2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-back/page.tsx
@@ -0,0 +1,7 @@
+import Link from 'next/link';
+
+export const dynamic = 'force-dynamic';
+
+export default function Page() {
+ return Go back home;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-push/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-push/page.tsx
new file mode 100644
index 000000000000..de789f9af524
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-push/page.tsx
@@ -0,0 +1,5 @@
+export const dynamic = 'force-dynamic';
+
+export default function Page() {
+ return
hello world
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-replace/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-replace/page.tsx
new file mode 100644
index 000000000000..de789f9af524
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-replace/page.tsx
@@ -0,0 +1,5 @@
+export const dynamic = 'force-dynamic';
+
+export default function Page() {
+ return hello world
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/page.tsx
new file mode 100644
index 000000000000..4f03a59d71cf
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/page.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+
+export default function Page() {
+ const router = useRouter();
+
+ return (
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+ Normal Link
+
+ -
+
+ Link Replace
+
+
+
+ );
+}
diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts
index 9143bd0b2f90..35984640bcf6 100644
--- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts
@@ -53,3 +53,148 @@ test('Creates a navigation transaction for app router routes', async ({ page })
expect(await clientNavigationTransactionPromise).toBeDefined();
expect(await serverComponentTransactionPromise).toBeDefined();
});
+
+test('Creates a navigation transaction for `router.push()`', async ({ page }) => {
+ const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
+ return (
+ transactionEvent?.transaction === `/navigation/42/router-push` &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push'
+ );
+ });
+
+ await page.goto('/navigation');
+ await page.waitForTimeout(3000);
+ await page.getByText('router.push()').click();
+
+ expect(await navigationTransactionPromise).toBeDefined();
+});
+
+test('Creates a navigation transaction for `router.replace()`', async ({ page }) => {
+ const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
+ return (
+ transactionEvent?.transaction === `/navigation/42/router-replace` &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.replace'
+ );
+ });
+
+ await page.goto('/navigation');
+ await page.waitForTimeout(3000);
+ await page.getByText('router.replace()').click();
+
+ expect(await navigationTransactionPromise).toBeDefined();
+});
+
+test('Creates a navigation transaction for `router.back()`', async ({ page }) => {
+ const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
+ return (
+ transactionEvent?.transaction === `/navigation/1337/router-back` &&
+ transactionEvent.contexts?.trace?.op === 'navigation'
+ );
+ });
+
+ await page.goto('/navigation/1337/router-back');
+ await page.waitForTimeout(3000);
+ await page.getByText('Go back home').click();
+ await page.waitForTimeout(3000);
+ await page.getByText('router.back()').click();
+
+ expect(await navigationTransactionPromise).toMatchObject({
+ contexts: {
+ trace: {
+ data: {
+ 'navigation.type': 'router.back',
+ },
+ },
+ },
+ });
+});
+
+test('Creates a navigation transaction for `router.forward()`', async ({ page }) => {
+ const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
+ return (
+ transactionEvent?.transaction === `/navigation/42/router-push` &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.forward'
+ );
+ });
+
+ await page.goto('/navigation');
+ await page.waitForTimeout(3000);
+ await page.getByText('router.push()').click();
+ await page.waitForTimeout(3000);
+ await page.goBack();
+ await page.waitForTimeout(3000);
+ await page.getByText('router.forward()').click();
+
+ expect(await navigationTransactionPromise).toBeDefined();
+});
+
+test('Creates a navigation transaction for ``', async ({ page }) => {
+ const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
+ return (
+ transactionEvent?.transaction === `/navigation/42/link` &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push'
+ );
+ });
+
+ await page.goto('/navigation');
+ await page.getByText('Normal Link').click();
+
+ expect(await navigationTransactionPromise).toBeDefined();
+});
+
+test('Creates a navigation transaction for ``', async ({ page }) => {
+ const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
+ return (
+ transactionEvent?.transaction === `/navigation/42/link-replace` &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.replace'
+ );
+ });
+
+ await page.goto('/navigation');
+ await page.waitForTimeout(3000);
+ await page.getByText('Link Replace').click();
+
+ expect(await navigationTransactionPromise).toBeDefined();
+});
+
+test('Creates a navigation transaction for browser-back', async ({ page }) => {
+ const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
+ return (
+ transactionEvent?.transaction === `/navigation/42/browser-back` &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate'
+ );
+ });
+
+ await page.goto('/navigation/42/browser-back');
+ await page.waitForTimeout(3000);
+ await page.getByText('Go back home').click();
+ await page.waitForTimeout(3000);
+ await page.goBack();
+
+ expect(await navigationTransactionPromise).toBeDefined();
+});
+
+test('Creates a navigation transaction for browser-forward', async ({ page }) => {
+ const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => {
+ return (
+ transactionEvent?.transaction === `/navigation/42/router-push` &&
+ transactionEvent.contexts?.trace?.op === 'navigation' &&
+ transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate'
+ );
+ });
+
+ await page.goto('/navigation');
+ await page.getByText('router.push()').click();
+ await page.waitForTimeout(3000);
+ await page.goBack();
+ await page.waitForTimeout(3000);
+ await page.goForward();
+
+ expect(await navigationTransactionPromise).toBeDefined();
+});
diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json
index 9346a79115a1..8797bddac4a8 100644
--- a/dev-packages/node-integration-tests/package.json
+++ b/dev-packages/node-integration-tests/package.json
@@ -44,6 +44,7 @@
"connect": "^3.7.0",
"cors": "^2.8.5",
"cron": "^3.1.6",
+ "dataloader": "2.2.2",
"express": "^4.17.3",
"generic-pool": "^3.9.0",
"graphql": "^16.3.0",
diff --git a/dev-packages/node-integration-tests/suites/tracing/dataloader/scenario.js b/dev-packages/node-integration-tests/suites/tracing/dataloader/scenario.js
new file mode 100644
index 000000000000..569d23276f0b
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/dataloader/scenario.js
@@ -0,0 +1,33 @@
+const { loggingTransport, startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests');
+const Sentry = require('@sentry/node');
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ tracesSampleRate: 1.0,
+ transport: loggingTransport,
+});
+
+const PORT = 8008;
+
+// Stop the process from exiting before the transaction is sent
+setInterval(() => {}, 1000);
+
+const run = async () => {
+ const express = require('express');
+ const Dataloader = require('dataloader');
+
+ const app = express();
+ const dataloader = new Dataloader(async keys => keys.map((_, idx) => idx), {
+ cache: false,
+ });
+
+ app.get('/', (req, res) => {
+ const user = dataloader.load('user-1');
+ res.send(user);
+ });
+
+ startExpressServerAndSendPortToRunner(app, PORT);
+};
+
+run();
diff --git a/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts b/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts
new file mode 100644
index 000000000000..27a2511f1a6e
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/dataloader/test.ts
@@ -0,0 +1,40 @@
+import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
+
+describe('dataloader auto-instrumentation', () => {
+ afterAll(async () => {
+ cleanupChildProcesses();
+ });
+
+ const EXPECTED_TRANSACTION = {
+ transaction: 'GET /',
+ spans: expect.arrayContaining([
+ expect.objectContaining({
+ data: expect.objectContaining({
+ 'sentry.origin': 'auto.db.otel.dataloader',
+ 'sentry.op': 'cache.get',
+ }),
+ description: 'dataloader.load',
+ origin: 'auto.db.otel.dataloader',
+ op: 'cache.get',
+ status: 'ok',
+ }),
+ expect.objectContaining({
+ data: expect.objectContaining({
+ 'sentry.origin': 'auto.db.otel.dataloader',
+ 'sentry.op': 'cache.get',
+ }),
+ description: 'dataloader.batch',
+ origin: 'auto.db.otel.dataloader',
+ op: 'cache.get',
+ status: 'ok',
+ }),
+ ]),
+ };
+
+ test('should auto-instrument `dataloader` package.', done => {
+ createRunner(__dirname, 'scenario.js')
+ .expect({ transaction: EXPECTED_TRANSACTION })
+ .start(done)
+ .makeRequest('get', '/');
+ });
+});
diff --git a/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/no-server.js b/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/no-server.js
new file mode 100644
index 000000000000..ac0122b48380
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/no-server.js
@@ -0,0 +1,20 @@
+const { loggingTransport } = require('@sentry-internal/node-integration-tests');
+const Sentry = require('@sentry/node');
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ release: '1.0',
+ transport: loggingTransport,
+ beforeSend(event) {
+ event.contexts = {
+ ...event.contexts,
+ traceData: {
+ ...Sentry.getTraceData(),
+ metaTags: Sentry.getTraceMetaTags(),
+ },
+ };
+ return event;
+ },
+});
+
+Sentry.captureException(new Error('test error'));
diff --git a/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/server.js b/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/server.js
new file mode 100644
index 000000000000..19877ffe3613
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/server.js
@@ -0,0 +1,30 @@
+const { loggingTransport, startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests');
+const Sentry = require('@sentry/node');
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ transport: loggingTransport,
+ beforeSend(event) {
+ event.contexts = {
+ ...event.contexts,
+ traceData: {
+ ...Sentry.getTraceData(),
+ metaTags: Sentry.getTraceMetaTags(),
+ },
+ };
+ return event;
+ },
+});
+
+// express must be required after Sentry is initialized
+const express = require('express');
+
+const app = express();
+
+app.get('/test', () => {
+ throw new Error('test error');
+});
+
+Sentry.setupExpressErrorHandler(app);
+
+startExpressServerAndSendPortToRunner(app);
diff --git a/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts b/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts
new file mode 100644
index 000000000000..e6c0bfff822d
--- /dev/null
+++ b/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts
@@ -0,0 +1,52 @@
+import { cleanupChildProcesses, createRunner } from '../../../utils/runner';
+
+describe('errors in TwP mode have same trace in trace context and getTraceData()', () => {
+ afterAll(() => {
+ cleanupChildProcesses();
+ });
+
+ test('in incoming request', async () => {
+ createRunner(__dirname, 'server.js')
+ .expect({
+ event: event => {
+ const { contexts } = event;
+ const { trace_id, span_id } = contexts?.trace || {};
+ expect(trace_id).toMatch(/^[a-f0-9]{32}$/);
+ expect(span_id).toMatch(/^[a-f0-9]{16}$/);
+
+ const traceData = contexts?.traceData || {};
+
+ expect(traceData['sentry-trace']).toEqual(`${trace_id}-${span_id}`);
+ expect(traceData.baggage).toContain(`sentry-trace_id=${trace_id}`);
+
+ expect(traceData.metaTags).toContain(``);
+ expect(traceData.metaTags).toContain(`sentr y-trace_id=${trace_id}`);
+ expect(traceData.metaTags).not.toContain('sentry-sampled=');
+ },
+ })
+ .start()
+ .makeRequest('get', '/test');
+ });
+
+ test('outside of a request handler', done => {
+ createRunner(__dirname, 'no-server.js')
+ .expect({
+ event: event => {
+ const { contexts } = event;
+ const { trace_id, span_id } = contexts?.trace || {};
+ expect(trace_id).toMatch(/^[a-f0-9]{32}$/);
+ expect(span_id).toMatch(/^[a-f0-9]{16}$/);
+
+ const traceData = contexts?.traceData || {};
+
+ expect(traceData['sentry-trace']).toEqual(`${trace_id}-${span_id}`);
+ expect(traceData.baggage).toContain(`sentry-trace_id=${trace_id}`);
+
+ expect(traceData.metaTags).toContain(``);
+ expect(traceData.metaTags).toContain(`sentry-trace_id=${trace_id}`);
+ expect(traceData.metaTags).not.toContain('sentry-sampled=');
+ },
+ })
+ .start(done);
+ });
+});
diff --git a/package.json b/package.json
index 4b9ad0383c02..365e1eb13922 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
"clean:build": "lerna run clean",
"clean:caches": "yarn rimraf eslintcache .nxcache && yarn jest --clearCache",
"clean:deps": "lerna clean --yes && rm -rf node_modules && yarn",
- "clean:tarballs": "rimraf **/*.tgz",
+ "clean:tarballs": "rimraf -g **/*.tgz",
"clean:all": "run-s clean:build clean:tarballs clean:caches clean:deps",
"fix": "run-s fix:biome fix:prettier fix:lerna",
"fix:lerna": "lerna run fix",
diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts
index 49c186875d3a..bfd6886f3861 100644
--- a/packages/astro/src/index.server.ts
+++ b/packages/astro/src/index.server.ts
@@ -30,6 +30,7 @@ export {
createGetModuleFromFilename,
createTransport,
cron,
+ dataloaderIntegration,
debugIntegration,
dedupeIntegration,
DEFAULT_USER_INCLUDES,
diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts
index bbbbcd469e5a..9bbba6eeda66 100644
--- a/packages/aws-serverless/src/index.ts
+++ b/packages/aws-serverless/src/index.ts
@@ -78,6 +78,7 @@ export {
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ dataloaderIntegration,
expressIntegration,
expressErrorHandler,
setupExpressErrorHandler,
diff --git a/packages/browser/src/utils/lazyLoadIntegration.ts b/packages/browser/src/utils/lazyLoadIntegration.ts
index 168d1fd1013b..82260ae9724f 100644
--- a/packages/browser/src/utils/lazyLoadIntegration.ts
+++ b/packages/browser/src/utils/lazyLoadIntegration.ts
@@ -68,7 +68,14 @@ export async function lazyLoadIntegration(
script.addEventListener('error', reject);
});
- WINDOW.document.body.appendChild(script);
+ const currentScript = WINDOW.document.currentScript;
+ const parent = WINDOW.document.body || WINDOW.document.head || (currentScript && currentScript.parentElement);
+
+ if (parent) {
+ parent.appendChild(script);
+ } else {
+ throw new Error(`Could not find parent element to insert lazy-loaded ${name} script`);
+ }
try {
await waitForLoad;
diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts
index 87945f0a18cb..ef3bcb020823 100644
--- a/packages/bun/src/index.ts
+++ b/packages/bun/src/index.ts
@@ -99,6 +99,7 @@ export {
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ dataloaderIntegration,
expressIntegration,
expressErrorHandler,
setupExpressErrorHandler,
diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md
index 398153563f1c..8fc88a578808 100644
--- a/packages/cloudflare/README.md
+++ b/packages/cloudflare/README.md
@@ -15,9 +15,6 @@
- [Official SDK Docs](https://docs.sentry.io/quickstart/)
- [TypeDoc](http://getsentry.github.io/sentry-javascript/)
-**Note: This SDK is in an alpha state. Please follow the
-[tracking GH issue](https://github.com/getsentry/sentry-javascript/issues/12620) for updates.**
-
## Install
To get started, first install the `@sentry/cloudflare` package:
diff --git a/packages/gatsby/README.md b/packages/gatsby/README.md
index 5de12ed78410..cf5eadf7045b 100644
--- a/packages/gatsby/README.md
+++ b/packages/gatsby/README.md
@@ -65,6 +65,24 @@ module.exports = {
};
```
+Additionally, you can delete source map files after they have been uploaded by setting the `deleteSourcemapsAfterUpload`
+option to be `true`.
+
+```javascript
+module.exports = {
+ // ...
+ plugins: [
+ {
+ resolve: '@sentry/gatsby',
+ options: {
+ deleteSourcemapsAfterUpload: true,
+ },
+ },
+ // ...
+ ],
+};
+```
+
## Links
- [Official SDK Docs](https://docs.sentry.io/quickstart/)
diff --git a/packages/gatsby/gatsby-node.js b/packages/gatsby/gatsby-node.js
index de88ee73adc0..911fcda7b437 100644
--- a/packages/gatsby/gatsby-node.js
+++ b/packages/gatsby/gatsby-node.js
@@ -7,12 +7,15 @@ const SENTRY_USER_CONFIG = ['./sentry.config.js', './sentry.config.ts'];
exports.onCreateWebpackConfig = ({ getConfig, actions }, options) => {
const enableClientWebpackPlugin = options.enableClientWebpackPlugin !== false;
if (process.env.NODE_ENV === 'production' && enableClientWebpackPlugin) {
+ const deleteSourcemapsAfterUpload = options.deleteSourcemapsAfterUpload === true;
actions.setWebpackConfig({
plugins: [
sentryWebpackPlugin({
sourcemaps: {
// Only include files from the build output directory
assets: ['./public/**'],
+ // Delete source files after uploading
+ filesToDeleteAfterUpload: deleteSourcemapsAfterUpload ? ['./public/**/*.map'] : undefined,
// Ignore files that aren't users' source code related
ignore: [
'polyfill-*', // related to polyfills
diff --git a/packages/gatsby/test/gatsby-node.test.ts b/packages/gatsby/test/gatsby-node.test.ts
index 2e80ac03dcaa..006cb6f9e2c0 100644
--- a/packages/gatsby/test/gatsby-node.test.ts
+++ b/packages/gatsby/test/gatsby-node.test.ts
@@ -1,5 +1,12 @@
+import { sentryWebpackPlugin } from '@sentry/webpack-plugin';
import { onCreateWebpackConfig } from '../gatsby-node';
+jest.mock('@sentry/webpack-plugin', () => ({
+ sentryWebpackPlugin: jest.fn().mockReturnValue({
+ apply: jest.fn(),
+ }),
+}));
+
describe('onCreateWebpackConfig', () => {
let originalNodeEnv: string | undefined;
@@ -12,6 +19,10 @@ describe('onCreateWebpackConfig', () => {
process.env.NODE_ENV = originalNodeEnv;
});
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
it('sets a webpack config', () => {
const actions = {
setWebpackConfig: jest.fn(),
@@ -36,4 +47,24 @@ describe('onCreateWebpackConfig', () => {
expect(actions.setWebpackConfig).toHaveBeenCalledTimes(0);
});
+
+ it('sets sourceMapFilesToDeleteAfterUpload when provided in options', () => {
+ const actions = {
+ setWebpackConfig: jest.fn(),
+ };
+
+ const getConfig = jest.fn();
+
+ onCreateWebpackConfig({ actions, getConfig }, { deleteSourcemapsAfterUpload: true });
+
+ expect(actions.setWebpackConfig).toHaveBeenCalledTimes(1);
+
+ expect(sentryWebpackPlugin).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sourcemaps: expect.objectContaining({
+ filesToDeleteAfterUpload: ['./public/**/*.map'],
+ }),
+ }),
+ );
+ });
});
diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts
index 726ae0a29ae8..a9d3e5025d92 100644
--- a/packages/google-cloud-serverless/src/index.ts
+++ b/packages/google-cloud-serverless/src/index.ts
@@ -79,6 +79,7 @@ export {
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE,
+ dataloaderIntegration,
expressIntegration,
expressErrorHandler,
setupExpressErrorHandler,
diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json
index c401e82890dc..a9cd24e72bab 100644
--- a/packages/nextjs/package.json
+++ b/packages/nextjs/package.json
@@ -71,6 +71,7 @@
"@opentelemetry/instrumentation-http": "0.53.0",
"@opentelemetry/semantic-conventions": "^1.27.0",
"@rollup/plugin-commonjs": "26.0.1",
+ "@sentry-internal/browser-utils": "8.30.0",
"@sentry/core": "8.30.0",
"@sentry/node": "8.30.0",
"@sentry/opentelemetry": "8.30.0",
diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts
index a68734a10398..c66f50a293f2 100644
--- a/packages/nextjs/src/client/index.ts
+++ b/packages/nextjs/src/client/index.ts
@@ -8,6 +8,7 @@ import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolica
import { getVercelEnv } from '../common/getVercelEnv';
import { browserTracingIntegration } from './browserTracingIntegration';
import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration';
+import { INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME } from './routing/appRouterRoutingInstrumentation';
import { applyTunnelRouteOption } from './tunnelRoute';
export * from '@sentry/react';
@@ -39,6 +40,13 @@ export function init(options: BrowserOptions): Client | undefined {
filterTransactions.id = 'NextClient404Filter';
addEventProcessor(filterTransactions);
+ const filterIncompleteNavigationTransactions: EventProcessor = event =>
+ event.type === 'transaction' && event.transaction === INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME
+ ? null
+ : event;
+ filterIncompleteNavigationTransactions.id = 'IncompleteTransactionFilter';
+ addEventProcessor(filterIncompleteNavigationTransactions);
+
if (process.env.NODE_ENV === 'development') {
addEventProcessor(devErrorSymbolicationEventProcessor);
}
diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts
index 25c1496d25b4..741849c481ab 100644
--- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts
+++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts
@@ -4,8 +4,10 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_SOURCE,
} from '@sentry/core';
import { WINDOW, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan } from '@sentry/react';
-import type { Client } from '@sentry/types';
-import { addFetchInstrumentationHandler, browserPerformanceTimeOrigin } from '@sentry/utils';
+import type { Client, Span } from '@sentry/types';
+import { GLOBAL_OBJ, browserPerformanceTimeOrigin } from '@sentry/utils';
+
+export const INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME = 'incomplete-app-router-transaction';
/** Instruments the Next.js app router for pageloads. */
export function appRouterInstrumentPageLoad(client: Client): void {
@@ -21,70 +23,111 @@ export function appRouterInstrumentPageLoad(client: Client): void {
});
}
-/** Instruments the Next.js app router for navigation. */
-export function appRouterInstrumentNavigation(client: Client): void {
- addFetchInstrumentationHandler(handlerData => {
- // The instrumentation handler is invoked twice - once for starting a request and once when the req finishes
- // We can use the existence of the end-timestamp to filter out "finishing"-events.
- if (handlerData.endTimestamp !== undefined) {
- return;
- }
-
- // Only GET requests can be navigating RSC requests
- if (handlerData.fetchData.method !== 'GET') {
- return;
- }
+interface NextRouter {
+ back: () => void;
+ forward: () => void;
+ push: (target: string) => void;
+ replace: (target: string) => void;
+}
- const parsedNavigatingRscFetchArgs = parseNavigatingRscFetchArgs(handlerData.args);
+// Yes, yes, I know we shouldn't depend on these internals. But that's where we are at. We write the ugly code, so you don't have to.
+const GLOBAL_OBJ_WITH_NEXT_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & {
+ // Available until 13.4.4-canary.3 - https://github.com/vercel/next.js/pull/50210
+ nd?: {
+ router?: NextRouter;
+ };
+ // Avalable from 13.4.4-canary.4 - https://github.com/vercel/next.js/pull/50210
+ next?: {
+ router?: NextRouter;
+ };
+};
- if (parsedNavigatingRscFetchArgs === null) {
- return;
- }
+/*
+ * The routing instrumentation needs to handle a few cases:
+ * - Router operations:
+ * - router.push() (either explicitly called or implicitly through tags)
+ * - router.replace() (either explicitly called or implicitly through tags)
+ * - router.back()
+ * - router.forward()
+ * - Browser operations:
+ * - native Browser-back / popstate event (implicitly called by router.back())
+ * - native Browser-forward / popstate event (implicitly called by router.forward())
+ */
- const newPathname = parsedNavigatingRscFetchArgs.targetPathname;
+/** Instruments the Next.js app router for navigation. */
+export function appRouterInstrumentNavigation(client: Client): void {
+ let currentNavigationSpan: Span | undefined = undefined;
- startBrowserTracingNavigationSpan(client, {
- name: newPathname,
- attributes: {
- [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
- [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation',
- [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
- },
- });
+ WINDOW.addEventListener('popstate', () => {
+ if (currentNavigationSpan && currentNavigationSpan.isRecording()) {
+ currentNavigationSpan.updateName(WINDOW.location.pathname);
+ } else {
+ currentNavigationSpan = startBrowserTracingNavigationSpan(client, {
+ name: WINDOW.location.pathname,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
+ 'navigation.type': 'browser.popstate',
+ },
+ });
+ }
});
-}
-function parseNavigatingRscFetchArgs(fetchArgs: unknown[]): null | {
- targetPathname: string;
-} {
- // Make sure the first arg is a URL object
- if (!fetchArgs[0] || typeof fetchArgs[0] !== 'object' || (fetchArgs[0] as URL).searchParams === undefined) {
- return null;
- }
+ let routerPatched = false;
+ let triesToFindRouter = 0;
+ const MAX_TRIES_TO_FIND_ROUTER = 500;
+ const ROUTER_AVAILABILITY_CHECK_INTERVAL_MS = 20;
+ const checkForRouterAvailabilityInterval = setInterval(() => {
+ triesToFindRouter++;
+ const router = GLOBAL_OBJ_WITH_NEXT_ROUTER?.next?.router ?? GLOBAL_OBJ_WITH_NEXT_ROUTER?.nd?.router;
- // Make sure the second argument is some kind of fetch config obj that contains headers
- if (!fetchArgs[1] || typeof fetchArgs[1] !== 'object' || !('headers' in fetchArgs[1])) {
- return null;
- }
+ if (routerPatched || triesToFindRouter > MAX_TRIES_TO_FIND_ROUTER) {
+ clearInterval(checkForRouterAvailabilityInterval);
+ } else if (router) {
+ clearInterval(checkForRouterAvailabilityInterval);
+ routerPatched = true;
+ (['back', 'forward', 'push', 'replace'] as const).forEach(routerFunctionName => {
+ if (router?.[routerFunctionName]) {
+ // @ts-expect-error Weird type error related to not knowing how to associate return values with the individual functions - we can just ignore
+ router[routerFunctionName] = new Proxy(router[routerFunctionName], {
+ apply(target, thisArg, argArray) {
+ const span = startBrowserTracingNavigationSpan(client, {
+ name: INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME,
+ attributes: {
+ [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
+ [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation',
+ [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url',
+ },
+ });
- try {
- const url = fetchArgs[0] as URL;
- const headers = fetchArgs[1].headers as Record;
+ currentNavigationSpan = span;
- // Not an RSC request
- if (headers['RSC'] !== '1') {
- return null;
- }
+ if (routerFunctionName === 'push') {
+ span?.updateName(transactionNameifyRouterArgument(argArray[0]));
+ span?.setAttribute('navigation.type', 'router.push');
+ } else if (routerFunctionName === 'replace') {
+ span?.updateName(transactionNameifyRouterArgument(argArray[0]));
+ span?.setAttribute('navigation.type', 'router.replace');
+ } else if (routerFunctionName === 'back') {
+ span?.setAttribute('navigation.type', 'router.back');
+ } else if (routerFunctionName === 'forward') {
+ span?.setAttribute('navigation.type', 'router.forward');
+ }
- // Prefetch requests are not navigating RSC requests
- if (headers['Next-Router-Prefetch'] === '1') {
- return null;
+ return target.apply(thisArg, argArray);
+ },
+ });
+ }
+ });
}
+ }, ROUTER_AVAILABILITY_CHECK_INTERVAL_MS);
+}
- return {
- targetPathname: url.pathname,
- };
+function transactionNameifyRouterArgument(target: string): string {
+ try {
+ return new URL(target, 'http://some-random-base.com/').pathname;
} catch {
- return null;
+ return '/';
}
}
diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts
index ac159564410b..f136b29e6887 100644
--- a/packages/nextjs/test/clientSdk.test.ts
+++ b/packages/nextjs/test/clientSdk.test.ts
@@ -16,13 +16,18 @@ const loggerLogSpy = jest.spyOn(logger, 'log');
const dom = new JSDOM(undefined, { url: 'https://example.com/' });
Object.defineProperty(global, 'document', { value: dom.window.document, writable: true });
Object.defineProperty(global, 'location', { value: dom.window.document.location, writable: true });
+Object.defineProperty(global, 'addEventListener', { value: () => undefined, writable: true });
const originalGlobalDocument = WINDOW.document;
const originalGlobalLocation = WINDOW.location;
+// eslint-disable-next-line @typescript-eslint/unbound-method
+const originalGlobalAddEventListener = WINDOW.addEventListener;
+
afterAll(() => {
// Clean up JSDom
Object.defineProperty(WINDOW, 'document', { value: originalGlobalDocument });
Object.defineProperty(WINDOW, 'location', { value: originalGlobalLocation });
+ Object.defineProperty(WINDOW, 'addEventListener', { value: originalGlobalAddEventListener });
});
function findIntegrationByName(integrations: Integration[] = [], name: string): Integration | undefined {
diff --git a/packages/nextjs/test/performance/appRouterInstrumentation.test.ts b/packages/nextjs/test/performance/appRouterInstrumentation.test.ts
deleted file mode 100644
index 16992a498f83..000000000000
--- a/packages/nextjs/test/performance/appRouterInstrumentation.test.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-import { WINDOW } from '@sentry/react';
-import type { Client, HandlerDataFetch } from '@sentry/types';
-import * as sentryUtils from '@sentry/utils';
-import { JSDOM } from 'jsdom';
-
-import {
- appRouterInstrumentNavigation,
- appRouterInstrumentPageLoad,
-} from '../../src/client/routing/appRouterRoutingInstrumentation';
-
-const addFetchInstrumentationHandlerSpy = jest.spyOn(sentryUtils, 'addFetchInstrumentationHandler');
-
-function setUpPage(url: string) {
- const dom = new JSDOM('nothingness
', { url });
-
- // The Next.js routing instrumentations requires a few things to be present on pageload:
- // 1. Access to window.document API for `window.document.getElementById`
- // 2. Access to window.location API for `window.location.pathname`
- Object.defineProperty(WINDOW, 'document', { value: dom.window.document, writable: true });
- Object.defineProperty(WINDOW, 'location', { value: dom.window.document.location, writable: true });
-}
-
-describe('appRouterInstrumentPageLoad', () => {
- const originalGlobalDocument = WINDOW.document;
- const originalGlobalLocation = WINDOW.location;
-
- afterEach(() => {
- // Clean up JSDom
- Object.defineProperty(WINDOW, 'document', { value: originalGlobalDocument });
- Object.defineProperty(WINDOW, 'location', { value: originalGlobalLocation });
- });
-
- it('should create a pageload transactions with the current location name', () => {
- setUpPage('https://example.com/some/page?someParam=foobar');
-
- const emit = jest.fn();
- const client = {
- emit,
- } as unknown as Client;
-
- appRouterInstrumentPageLoad(client);
-
- expect(emit).toHaveBeenCalledTimes(1);
- expect(emit).toHaveBeenCalledWith(
- 'startPageLoadSpan',
- expect.objectContaining({
- name: '/some/page',
- attributes: {
- 'sentry.op': 'pageload',
- 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation',
- 'sentry.source': 'url',
- },
- }),
- undefined,
- );
- });
-});
-
-describe('appRouterInstrumentNavigation', () => {
- const originalGlobalDocument = WINDOW.document;
- const originalGlobalLocation = WINDOW.location;
-
- afterEach(() => {
- // Clean up JSDom
- Object.defineProperty(WINDOW, 'document', { value: originalGlobalDocument });
- Object.defineProperty(WINDOW, 'location', { value: originalGlobalLocation });
- });
-
- it('should create a navigation transactions when a navigation RSC request is sent', () => {
- setUpPage('https://example.com/some/page?someParam=foobar');
- let fetchInstrumentationHandlerCallback: (arg: HandlerDataFetch) => void;
-
- addFetchInstrumentationHandlerSpy.mockImplementationOnce(callback => {
- fetchInstrumentationHandlerCallback = callback;
- });
-
- const emit = jest.fn();
- const client = {
- emit,
- } as unknown as Client;
-
- appRouterInstrumentNavigation(client);
-
- fetchInstrumentationHandlerCallback!({
- args: [
- new URL('https://example.com/some/server/component/page?_rsc=2rs8t'),
- {
- headers: {
- RSC: '1',
- },
- },
- ],
- fetchData: { method: 'GET', url: 'https://example.com/some/server/component/page?_rsc=2rs8t' },
- startTimestamp: 1337,
- });
-
- expect(emit).toHaveBeenCalledTimes(1);
- expect(emit).toHaveBeenCalledWith('startNavigationSpan', {
- name: '/some/server/component/page',
- attributes: {
- 'sentry.op': 'navigation',
- 'sentry.origin': 'auto.navigation.nextjs.app_router_instrumentation',
- 'sentry.source': 'url',
- },
- });
- });
-
- it.each([
- [
- 'no RSC header',
- {
- args: [
- new URL('https://example.com/some/server/component/page?_rsc=2rs8t'),
- {
- headers: {},
- },
- ],
- fetchData: { method: 'GET', url: 'https://example.com/some/server/component/page?_rsc=2rs8t' },
- startTimestamp: 1337,
- },
- ],
- [
- 'no GET request',
- {
- args: [
- new URL('https://example.com/some/server/component/page?_rsc=2rs8t'),
- {
- headers: {
- RSC: '1',
- },
- },
- ],
- fetchData: { method: 'POST', url: 'https://example.com/some/server/component/page?_rsc=2rs8t' },
- startTimestamp: 1337,
- },
- ],
- [
- 'prefetch request',
- {
- args: [
- new URL('https://example.com/some/server/component/page?_rsc=2rs8t'),
- {
- headers: {
- RSC: '1',
- 'Next-Router-Prefetch': '1',
- },
- },
- ],
- fetchData: { method: 'GET', url: 'https://example.com/some/server/component/page?_rsc=2rs8t' },
- startTimestamp: 1337,
- },
- ],
- ])(
- 'should not create navigation transactions for fetch requests that are not navigating RSC requests (%s)',
- (_, fetchCallbackData) => {
- setUpPage('https://example.com/some/page?someParam=foobar');
- let fetchInstrumentationHandlerCallback: (arg: HandlerDataFetch) => void;
-
- addFetchInstrumentationHandlerSpy.mockImplementationOnce(callback => {
- fetchInstrumentationHandlerCallback = callback;
- });
-
- const emit = jest.fn();
- const client = {
- emit,
- } as unknown as Client;
-
- appRouterInstrumentNavigation(client);
- fetchInstrumentationHandlerCallback!(fetchCallbackData);
-
- expect(emit).toHaveBeenCalledTimes(0);
- },
- );
-});
diff --git a/packages/node/package.json b/packages/node/package.json
index f14ead1444b4..3f8dcc9a16a4 100644
--- a/packages/node/package.json
+++ b/packages/node/package.json
@@ -71,6 +71,7 @@
"@opentelemetry/instrumentation": "^0.53.0",
"@opentelemetry/instrumentation-amqplib": "^0.42.0",
"@opentelemetry/instrumentation-connect": "0.39.0",
+ "@opentelemetry/instrumentation-dataloader": "0.12.0",
"@opentelemetry/instrumentation-express": "0.42.0",
"@opentelemetry/instrumentation-fastify": "0.39.0",
"@opentelemetry/instrumentation-fs": "0.15.0",
diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts
index 44614a24ac87..e97780f79ead 100644
--- a/packages/node/src/index.ts
+++ b/packages/node/src/index.ts
@@ -28,6 +28,7 @@ export { koaIntegration, setupKoaErrorHandler } from './integrations/tracing/koa
export { connectIntegration, setupConnectErrorHandler } from './integrations/tracing/connect';
export { spotlightIntegration } from './integrations/spotlight';
export { genericPoolIntegration } from './integrations/tracing/genericPool';
+export { dataloaderIntegration } from './integrations/tracing/dataloader';
export { amqplibIntegration } from './integrations/tracing/amqplib';
export { SentryContextManager } from './otel/contextManager';
diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts
index d9e5e671b702..126f22a06063 100644
--- a/packages/node/src/integrations/http.ts
+++ b/packages/node/src/integrations/http.ts
@@ -1,5 +1,6 @@
import type { ClientRequest, IncomingMessage, RequestOptions, ServerResponse } from 'node:http';
import type { Span } from '@opentelemetry/api';
+import { diag } from '@opentelemetry/api';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry';
@@ -23,6 +24,8 @@ import { getRequestUrl } from '../utils/getRequestUrl';
const INTEGRATION_NAME = 'Http';
+const INSTRUMENTATION_NAME = '@opentelemetry_sentry-patched/instrumentation-http';
+
interface HttpOptions {
/**
* Whether breadcrumbs should be recorded for requests.
@@ -195,6 +198,17 @@ export const instrumentHttp = Object.assign(
},
});
+ // We want to update the logger namespace so we can better identify what is happening here
+ try {
+ _httpInstrumentation['_diag'] = diag.createComponentLogger({
+ namespace: INSTRUMENTATION_NAME,
+ });
+
+ // @ts-expect-error This is marked as read-only, but we overwrite it anyhow
+ _httpInstrumentation.instrumentationName = INSTRUMENTATION_NAME;
+ } catch {
+ // ignore errors here...
+ }
addOpenTelemetryInstrumentation(_httpInstrumentation);
},
{
diff --git a/packages/node/src/integrations/tracing/dataloader.ts b/packages/node/src/integrations/tracing/dataloader.ts
new file mode 100644
index 000000000000..d4567ea0dfbe
--- /dev/null
+++ b/packages/node/src/integrations/tracing/dataloader.ts
@@ -0,0 +1,57 @@
+import { DataloaderInstrumentation } from '@opentelemetry/instrumentation-dataloader';
+import {
+ SEMANTIC_ATTRIBUTE_SENTRY_OP,
+ SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
+ defineIntegration,
+ spanToJSON,
+} from '@sentry/core';
+import type { IntegrationFn } from '@sentry/types';
+import { generateInstrumentOnce } from '../../otel/instrument';
+
+const INTEGRATION_NAME = 'Dataloader';
+
+export const instrumentDataloader = generateInstrumentOnce(
+ INTEGRATION_NAME,
+ () =>
+ new DataloaderInstrumentation({
+ requireParentSpan: true,
+ }),
+);
+
+const _dataloaderIntegration = (() => {
+ return {
+ name: INTEGRATION_NAME,
+ setupOnce() {
+ instrumentDataloader();
+ },
+
+ setup(client) {
+ client.on('spanStart', span => {
+ const spanJSON = spanToJSON(span);
+ if (spanJSON.description?.startsWith('dataloader')) {
+ span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, 'auto.db.otel.dataloader');
+ }
+
+ // These are all possible dataloader span descriptions
+ // Still checking for the future versions
+ // in case they add support for `clear` and `prime`
+ if (
+ spanJSON.description === 'dataloader.load' ||
+ spanJSON.description === 'dataloader.loadMany' ||
+ spanJSON.description === 'dataloader.batch'
+ ) {
+ span.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_OP, 'cache.get');
+ // TODO: We can try adding `key` to the `data` attribute upstream.
+ // Or alternatively, we can add `requestHook` to the dataloader instrumentation.
+ }
+ });
+ },
+ };
+}) satisfies IntegrationFn;
+
+/**
+ * Dataloader integration
+ *
+ * Capture tracing data for Dataloader.
+ */
+export const dataloaderIntegration = defineIntegration(_dataloaderIntegration);
diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts
index dcfa1126e053..cc8ef752c815 100644
--- a/packages/node/src/integrations/tracing/index.ts
+++ b/packages/node/src/integrations/tracing/index.ts
@@ -3,6 +3,7 @@ import { instrumentHttp } from '../http';
import { amqplibIntegration, instrumentAmqplib } from './amqplib';
import { connectIntegration, instrumentConnect } from './connect';
+import { dataloaderIntegration, instrumentDataloader } from './dataloader';
import { expressIntegration, instrumentExpress } from './express';
import { fastifyIntegration, instrumentFastify } from './fastify';
import { genericPoolIntegration, instrumentGenericPool } from './genericPool';
@@ -42,6 +43,7 @@ export function getAutoPerformanceIntegrations(): Integration[] {
connectIntegration(),
genericPoolIntegration(),
kafkaIntegration(),
+ dataloaderIntegration(),
amqplibIntegration(),
];
}
@@ -69,6 +71,7 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) =>
instrumentGraphql,
instrumentRedis,
instrumentGenericPool,
+ instrumentDataloader,
instrumentAmqplib,
];
}
diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts
index f43f30d7e5ee..c74fe32b93fe 100644
--- a/packages/nuxt/src/module.ts
+++ b/packages/nuxt/src/module.ts
@@ -1,3 +1,4 @@
+import * as path from 'path';
import { addPlugin, addPluginTemplate, addServerPlugin, createResolver, defineNuxtModule } from '@nuxt/kit';
import { consoleSandbox } from '@sentry/utils';
import type { SentryNuxtModuleOptions } from './common/types';
@@ -64,21 +65,29 @@ export default defineNuxtModule({
setupSourceMaps(moduleOptions, nuxt);
}
- if (serverConfigFile && serverConfigFile.includes('.server.config')) {
- addServerConfigToBuild(moduleOptions, nuxt, serverConfigFile);
+ nuxt.hooks.hook('nitro:init', nitro => {
+ if (serverConfigFile && serverConfigFile.includes('.server.config')) {
+ addServerConfigToBuild(moduleOptions, nuxt, nitro, serverConfigFile);
- if (moduleOptions.experimental_basicServerTracing) {
- addSentryTopImport(moduleOptions, nuxt);
- } else {
- if (moduleOptions.debug) {
- consoleSandbox(() => {
- // eslint-disable-next-line no-console
- console.log(
- `[Sentry] Using your \`${serverConfigFile}\` file for the server-side Sentry configuration. In case you have a \`public/instrument.server\` file, the \`public/instrument.server\` file will be ignored. Make sure the file path in your node \`--import\` option matches the Sentry server config file in your \`.output\` folder and has a \`.mjs\` extension.`,
- );
- });
+ if (moduleOptions.experimental_basicServerTracing) {
+ addSentryTopImport(moduleOptions, nitro);
+ } else {
+ if (moduleOptions.debug) {
+ const serverDirResolver = createResolver(nitro.options.output.serverDir);
+ const serverConfigPath = serverDirResolver.resolve('sentry.server.config.mjs');
+
+ // For the default nitro node-preset build output this relative path would be: ./.output/server/sentry.server.config.mjs
+ const serverConfigRelativePath = `.${path.sep}${path.relative(nitro.options.rootDir, serverConfigPath)}`;
+
+ consoleSandbox(() => {
+ // eslint-disable-next-line no-console
+ console.log(
+ `[Sentry] Using your \`${serverConfigFile}\` file for the server-side Sentry configuration. Make sure to add the Node option \`import\` to the Node command where you deploy and/or run your application. This preloads the Sentry configuration at server startup. You can do this via a command-line flag (\`node --import ${serverConfigRelativePath} [...]\`) or via an environment variable (\`NODE_OPTIONS='--import ${serverConfigRelativePath}' node [...]\`).`,
+ );
+ });
+ }
}
}
- }
+ });
},
});
diff --git a/packages/nuxt/src/vite/addServerConfig.ts b/packages/nuxt/src/vite/addServerConfig.ts
index 60f2452cde5b..845228c58b0c 100644
--- a/packages/nuxt/src/vite/addServerConfig.ts
+++ b/packages/nuxt/src/vite/addServerConfig.ts
@@ -2,6 +2,7 @@ import * as fs from 'fs';
import { createResolver } from '@nuxt/kit';
import type { Nuxt } from '@nuxt/schema';
import { consoleSandbox } from '@sentry/utils';
+import type { Nitro } from 'nitropack';
import type { SentryNuxtModuleOptions } from '../common/types';
/**
@@ -13,6 +14,7 @@ import type { SentryNuxtModuleOptions } from '../common/types';
export function addServerConfigToBuild(
moduleOptions: SentryNuxtModuleOptions,
nuxt: Nuxt,
+ nitro: Nitro,
serverConfigFile: string,
): void {
nuxt.hook('vite:extendConfig', async (viteInlineConfig, _env) => {
@@ -29,10 +31,11 @@ export function addServerConfigToBuild(
* When the build process is finished, copy the `sentry.server.config` file to the `.output` directory.
* This is necessary because we need to reference this file path in the node --import option.
*/
- nuxt.hook('close', async () => {
- const rootDirResolver = createResolver(nuxt.options.rootDir);
- const source = rootDirResolver.resolve('.nuxt/dist/server/sentry.server.config.mjs');
- const destination = rootDirResolver.resolve('.output/server/sentry.server.config.mjs');
+ nitro.hooks.hook('close', async () => {
+ const buildDirResolver = createResolver(nitro.options.buildDir);
+ const serverDirResolver = createResolver(nitro.options.output.serverDir);
+ const source = buildDirResolver.resolve('dist/server/sentry.server.config.mjs');
+ const destination = serverDirResolver.resolve('sentry.server.config.mjs');
try {
await fs.promises.access(source, fs.constants.F_OK);
@@ -66,10 +69,19 @@ export function addServerConfigToBuild(
* This is necessary for environments where modifying the node option `--import` is not possible.
* However, only limited tracing instrumentation is supported when doing this.
*/
-export function addSentryTopImport(moduleOptions: SentryNuxtModuleOptions, nuxt: Nuxt): void {
- nuxt.hook('close', async () => {
- const rootDirResolver = createResolver(nuxt.options.rootDir);
- const entryFilePath = rootDirResolver.resolve('.output/server/index.mjs');
+export function addSentryTopImport(moduleOptions: SentryNuxtModuleOptions, nitro: Nitro): void {
+ nitro.hooks.hook('close', () => {
+ // other presets ('node-server' or 'vercel') have an index.mjs
+ const presetsWithServerFile = ['netlify'];
+ const entryFileName =
+ typeof nitro.options.rollupConfig?.output.entryFileNames === 'string'
+ ? nitro.options.rollupConfig?.output.entryFileNames
+ : presetsWithServerFile.includes(nitro.options.preset)
+ ? 'server.mjs'
+ : 'index.mjs';
+
+ const serverDirResolver = createResolver(nitro.options.output.serverDir);
+ const entryFilePath = serverDirResolver.resolve(entryFileName);
try {
fs.readFile(entryFilePath, 'utf8', (err, data) => {
diff --git a/packages/opentelemetry/src/propagator.ts b/packages/opentelemetry/src/propagator.ts
index 387943cf9cf0..f4fcb1fa91e9 100644
--- a/packages/opentelemetry/src/propagator.ts
+++ b/packages/opentelemetry/src/propagator.ts
@@ -5,7 +5,6 @@ import { propagation, trace } from '@opentelemetry/api';
import { W3CBaggagePropagator, isTracingSuppressed } from '@opentelemetry/core';
import { ATTR_URL_FULL, SEMATTRS_HTTP_URL } from '@opentelemetry/semantic-conventions';
import type { continueTrace } from '@sentry/core';
-import { hasTracingEnabled } from '@sentry/core';
import { getRootSpan } from '@sentry/core';
import { spanToJSON } from '@sentry/core';
import {
@@ -198,7 +197,7 @@ function getInjectionData(context: Context): {
spanId: string | undefined;
sampled: boolean | undefined;
} {
- const span = hasTracingEnabled() ? trace.getSpan(context) : undefined;
+ const span = trace.getSpan(context);
const spanIsRemote = span?.spanContext().isRemote;
// If we have a local span, we can just pick everything from it
diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts
index 06f81b6982c6..ea7df8b7afa7 100644
--- a/packages/replay-internal/src/replay.ts
+++ b/packages/replay-internal/src/replay.ts
@@ -54,6 +54,7 @@ import { getHandleRecordingEmit } from './util/handleRecordingEmit';
import { isExpired } from './util/isExpired';
import { isSessionExpired } from './util/isSessionExpired';
import { sendReplay } from './util/sendReplay';
+import { RateLimitError } from './util/sendReplayRequest';
import type { SKIPPED } from './util/throttle';
import { THROTTLED, throttle } from './util/throttle';
@@ -245,6 +246,9 @@ export class ReplayContainer implements ReplayContainerInterface {
/** A wrapper to conditionally capture exceptions. */
public handleException(error: unknown): void {
DEBUG_BUILD && logger.exception(error);
+ if (this._options.onError) {
+ this._options.onError(error);
+ }
}
/**
@@ -1157,8 +1161,8 @@ export class ReplayContainer implements ReplayContainerInterface {
segmentId,
eventContext,
session: this.session,
- options: this.getOptions(),
timestamp,
+ onError: err => this.handleException(err),
});
} catch (err) {
this.handleException(err);
@@ -1173,7 +1177,8 @@ export class ReplayContainer implements ReplayContainerInterface {
const client = getClient();
if (client) {
- client.recordDroppedEvent('send_error', 'replay');
+ const dropReason = err instanceof RateLimitError ? 'ratelimit_backoff' : 'send_error';
+ client.recordDroppedEvent(dropReason, 'replay');
}
}
}
diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts
index 0605ba97449a..6892c05ee179 100644
--- a/packages/replay-internal/src/types/replay.ts
+++ b/packages/replay-internal/src/types/replay.ts
@@ -26,7 +26,7 @@ export interface SendReplayData {
eventContext: PopEventContext;
timestamp: number;
session: Session;
- options: ReplayPluginOptions;
+ onError?: (err: unknown) => void;
}
export interface Timeouts {
@@ -222,6 +222,12 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions {
*/
beforeErrorSampling?: (event: ErrorEvent) => boolean;
+ /**
+ * Callback when an internal SDK error occurs. This can be used to debug SDK
+ * issues.
+ */
+ onError?: (err: unknown) => void;
+
/**
* _experiments allows users to enable experimental or internal features.
* We don't consider such features as part of the public API and hence we don't guarantee semver for them.
diff --git a/packages/replay-internal/src/util/logger.ts b/packages/replay-internal/src/util/logger.ts
index 80445409164b..1b505f41703a 100644
--- a/packages/replay-internal/src/util/logger.ts
+++ b/packages/replay-internal/src/util/logger.ts
@@ -1,6 +1,6 @@
import { addBreadcrumb, captureException } from '@sentry/core';
import type { ConsoleLevel, SeverityLevel } from '@sentry/types';
-import { logger as coreLogger } from '@sentry/utils';
+import { logger as coreLogger, severityLevelFromString } from '@sentry/utils';
import { DEBUG_BUILD } from '../debug-build';
@@ -64,13 +64,13 @@ function makeReplayLogger(): ReplayLogger {
_logger[name] = (...args: unknown[]) => {
coreLogger[name](PREFIX, ...args);
if (_trace) {
- _addBreadcrumb(args[0]);
+ _addBreadcrumb(args.join(''), severityLevelFromString(name));
}
};
});
_logger.exception = (error: unknown, ...message: unknown[]) => {
- if (_logger.error) {
+ if (message.length && _logger.error) {
_logger.error(...message);
}
@@ -79,9 +79,9 @@ function makeReplayLogger(): ReplayLogger {
if (_capture) {
captureException(error);
} else if (_trace) {
- // No need for a breadcrumb is `_capture` is enabled since it should be
+ // No need for a breadcrumb if `_capture` is enabled since it should be
// captured as an exception
- _addBreadcrumb(error);
+ _addBreadcrumb(error, 'error');
}
};
diff --git a/packages/replay-internal/src/util/sendReplay.ts b/packages/replay-internal/src/util/sendReplay.ts
index 973c3fb9a556..c0c6483502b9 100644
--- a/packages/replay-internal/src/util/sendReplay.ts
+++ b/packages/replay-internal/src/util/sendReplay.ts
@@ -1,8 +1,7 @@
import { setTimeout } from '@sentry-internal/browser-utils';
-import { captureException, setContext } from '@sentry/core';
+import { setContext } from '@sentry/core';
import { RETRY_BASE_INTERVAL, RETRY_MAX_COUNT, UNABLE_TO_SEND_REPLAY } from '../constants';
-import { DEBUG_BUILD } from '../debug-build';
import type { SendReplayData } from '../types';
import { RateLimitError, TransportStatusCodeError, sendReplayRequest } from './sendReplayRequest';
@@ -16,7 +15,7 @@ export async function sendReplay(
interval: RETRY_BASE_INTERVAL,
},
): Promise {
- const { recordingData, options } = replayData;
+ const { recordingData, onError } = replayData;
// short circuit if there's no events to upload (this shouldn't happen as _runFlush makes this check)
if (!recordingData.length) {
@@ -36,8 +35,8 @@ export async function sendReplay(
_retryCount: retryConfig.count,
});
- if (DEBUG_BUILD && options._experiments && options._experiments.captureExceptions) {
- captureException(err);
+ if (onError) {
+ onError(err);
}
// If an error happened here, it's likely that uploading the attachment
diff --git a/packages/replay-internal/test/integration/flush.test.ts b/packages/replay-internal/test/integration/flush.test.ts
index 52654fa909d3..72ef104d0633 100644
--- a/packages/replay-internal/test/integration/flush.test.ts
+++ b/packages/replay-internal/test/integration/flush.test.ts
@@ -188,8 +188,8 @@ describe('Integration | flush', () => {
segmentId: 0,
eventContext: expect.anything(),
session: expect.any(Object),
- options: expect.any(Object),
timestamp: expect.any(Number),
+ onError: expect.any(Function),
});
// Add this to test that segment ID increases
@@ -238,7 +238,7 @@ describe('Integration | flush', () => {
segmentId: 1,
eventContext: expect.anything(),
session: expect.any(Object),
- options: expect.any(Object),
+ onError: expect.any(Function),
timestamp: expect.any(Number),
});
diff --git a/packages/replay-internal/test/unit/util/logger.test.ts b/packages/replay-internal/test/unit/util/logger.test.ts
new file mode 100644
index 000000000000..075a6f27a841
--- /dev/null
+++ b/packages/replay-internal/test/unit/util/logger.test.ts
@@ -0,0 +1,88 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import * as SentryCore from '@sentry/core';
+import { logger as coreLogger } from '@sentry/utils';
+import { logger } from '../../../src/util/logger';
+
+const mockCaptureException = vi.spyOn(SentryCore, 'captureException');
+const mockAddBreadcrumb = vi.spyOn(SentryCore, 'addBreadcrumb');
+const mockLogError = vi.spyOn(coreLogger, 'error');
+vi.spyOn(coreLogger, 'info');
+vi.spyOn(coreLogger, 'log');
+vi.spyOn(coreLogger, 'warn');
+
+describe('logger', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe.each([
+ [false, false],
+ [false, true],
+ [true, false],
+ [true, true],
+ ])('with options: captureExceptions:%s, traceInternals:%s', (captureExceptions, traceInternals) => {
+ beforeEach(() => {
+ logger.setConfig({
+ captureExceptions,
+ traceInternals,
+ });
+ });
+
+ it.each([
+ ['info', 'info', 'info message'],
+ ['log', 'log', 'log message'],
+ ['warn', 'warning', 'warn message'],
+ ['error', 'error', 'error message'],
+ ])('%s', (fn, level, message) => {
+ logger[fn](message);
+ expect(coreLogger[fn]).toHaveBeenCalledWith('[Replay] ', message);
+
+ if (traceInternals) {
+ expect(mockAddBreadcrumb).toHaveBeenLastCalledWith(
+ {
+ category: 'console',
+ data: { logger: 'replay' },
+ level,
+ message: `[Replay] ${message}`,
+ },
+ { level },
+ );
+ }
+ });
+
+ it('logs exceptions with a message', () => {
+ const err = new Error('An error');
+ logger.exception(err, 'a message');
+ if (captureExceptions) {
+ expect(mockCaptureException).toHaveBeenCalledWith(err);
+ }
+ expect(mockLogError).toHaveBeenCalledWith('[Replay] ', 'a message');
+ expect(mockLogError).toHaveBeenLastCalledWith('[Replay] ', err);
+ expect(mockLogError).toHaveBeenCalledTimes(2);
+
+ if (traceInternals) {
+ expect(mockAddBreadcrumb).toHaveBeenCalledWith(
+ {
+ category: 'console',
+ data: { logger: 'replay' },
+ level: 'error',
+ message: '[Replay] a message',
+ },
+ { level: 'error' },
+ );
+ }
+ });
+
+ it('logs exceptions without a message', () => {
+ const err = new Error('An error');
+ logger.exception(err);
+ if (captureExceptions) {
+ expect(mockCaptureException).toHaveBeenCalledWith(err);
+ expect(mockAddBreadcrumb).not.toHaveBeenCalled();
+ }
+ expect(mockLogError).toHaveBeenCalledTimes(1);
+ expect(mockLogError).toHaveBeenLastCalledWith('[Replay] ', err);
+ });
+ });
+});
diff --git a/scripts/get-commit-list.ts b/scripts/get-commit-list.ts
index 3992694cf8f0..bceccfb317de 100644
--- a/scripts/get-commit-list.ts
+++ b/scripts/get-commit-list.ts
@@ -24,8 +24,11 @@ function run(): void {
newCommits.sort((a, b) => a.localeCompare(b));
+ const issueUrl = 'https://github.com/getsentry/sentry-javascript/pull/';
+ const newCommitsWithLink = newCommits.map(commit => commit.replace(/#(\d+)/, `[#$1](${issueUrl}$1)`));
+
// eslint-disable-next-line no-console
- console.log(newCommits.join('\n'));
+ console.log(newCommitsWithLink.join('\n'));
}
run();
diff --git a/yarn.lock b/yarn.lock
index 0ecbfdb082f3..ff892c6d55ad 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7119,6 +7119,13 @@
"@opentelemetry/semantic-conventions" "^1.27.0"
"@types/connect" "3.4.36"
+"@opentelemetry/instrumentation-dataloader@0.12.0":
+ version "0.12.0"
+ resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.12.0.tgz#de03a3948dec4f15fed80aa424d6bd5d6a8d10c7"
+ integrity sha512-pnPxatoFE0OXIZDQhL2okF//dmbiWFzcSc8pUg9TqofCLYZySSxDCgQc69CJBo5JnI3Gz1KP+mOjS4WAeRIH4g==
+ dependencies:
+ "@opentelemetry/instrumentation" "^0.53.0"
+
"@opentelemetry/instrumentation-express@0.42.0":
version "0.42.0"
resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-express/-/instrumentation-express-0.42.0.tgz#279f195aa66baee2b98623a16666c6229c8e7564"
@@ -15292,6 +15299,11 @@ data-urls@^4.0.0:
whatwg-mimetype "^3.0.0"
whatwg-url "^12.0.0"
+dataloader@2.2.2:
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/dataloader/-/dataloader-2.2.2.tgz#216dc509b5abe39d43a9b9d97e6e5e473dfbe3e0"
+ integrity sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==
+
date-fns@^2.29.2:
version "2.29.3"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8"