diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8ab3e3d77091..44057f39da11 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -910,6 +910,12 @@ jobs: - name: Set up Bun if: matrix.test-application == 'node-exports-test-app' uses: oven-sh/setup-bun@v2 + - name: Set up AWS SAM + if: matrix.test-application == 'aws-serverless' + uses: aws-actions/setup-sam@v2 + with: + use-installer: true + token: ${{ secrets.GITHUB_TOKEN }} - name: Restore caches uses: ./.github/actions/restore-cache with: diff --git a/.size-limit.js b/.size-limit.js index d53eaae56712..dd65a987d506 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -233,7 +233,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '147 KB', + limit: '148 KB', }, { name: '@sentry/node - without tracing', diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f73317a2392..12cdde3b7bea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,27 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 10.5.0 + +- feat(core): better cause data extraction ([#17375](https://github.com/getsentry/sentry-javascript/pull/17375)) +- feat(deps): Bump @sentry/cli from 2.50.2 to 2.51.1 ([#17382](https://github.com/getsentry/sentry-javascript/pull/17382)) +- feat(deps): Bump @sentry/rollup-plugin and @sentry/vite-plugin from 4.0.2 to 4.1.0 ([#17383](https://github.com/getsentry/sentry-javascript/pull/17383)) +- feat(deps): Bump @sentry/webpack-plugin from 4.0.2 to 4.1.0 ([#17381](https://github.com/getsentry/sentry-javascript/pull/17381)) +- feat(node): Capture `SystemError` context and remove paths from message ([#17331](https://github.com/getsentry/sentry-javascript/pull/17331)) +- fix(nextjs): Inject Next.js version for dev symbolication ([#17379](https://github.com/getsentry/sentry-javascript/pull/17379)) +- fix(mcp-server): Add defensive patches for Transport edge cases ([#17291](https://github.com/getsentry/sentry-javascript/pull/17291)) + +
+ Internal Changes + +- chore(repo): Adjust "Publishing a Release" document to include internal changes section in changelog ([#17374](https://github.com/getsentry/sentry-javascript/pull/17374)) +- test(aws): Run E2E tests with AWS SAM ([#17367](https://github.com/getsentry/sentry-javascript/pull/17367)) +- test(node): Add tests for full http.server span attribute coverage ([#17373](https://github.com/getsentry/sentry-javascript/pull/17373)) + +
+ +Work in this release was contributed by @ha1fstack. Thank you for your contribution! + ## 10.4.0 ### Important Changes diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/package.json b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/package.json deleted file mode 100644 index 25489cf0a35e..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "aws-lambda-layer-cjs", - "version": "1.0.0", - "private": true, - "type": "commonjs", - "scripts": { - "start": "node src/run.js", - "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:build": "pnpm install", - "test:assert": "pnpm test" - }, - "dependencies": { - "@sentry/aws-serverless": "link:../../../../packages/aws-serverless/build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless" - }, - "devDependencies": { - "@sentry-internal/test-utils": "link:../../../test-utils", - "@playwright/test": "~1.53.2" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/run-lambda.js b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/run-lambda.js deleted file mode 100644 index 1d6e059e78f3..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/run-lambda.js +++ /dev/null @@ -1,7 +0,0 @@ -const { handle } = require('./lambda-function'); -const event = {}; -const context = { - invokedFunctionArn: 'arn:aws:lambda:us-east-1:123453789012:function:my-lambda', - functionName: 'my-lambda', -}; -handle(event, context); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/run.js b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/run.js deleted file mode 100644 index 2605f624ca9a..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/run.js +++ /dev/null @@ -1,17 +0,0 @@ -const child_process = require('child_process'); - -child_process.execSync('node ./src/run-lambda.js', { - stdio: 'inherit', - env: { - ...process.env, - // On AWS, LAMBDA_TASK_ROOT is usually /var/task but for testing, we set it to the CWD to correctly apply our handler - LAMBDA_TASK_ROOT: process.cwd(), - _HANDLER: 'src/lambda-function.handle', - - NODE_OPTIONS: '--require @sentry/aws-serverless/dist/awslambda-auto', - SENTRY_DSN: 'http://public@localhost:3031/1337', - SENTRY_TRACES_SAMPLE_RATE: '1.0', - SENTRY_DEBUG: 'true', - }, - cwd: process.cwd(), -}); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/start-event-proxy.mjs deleted file mode 100644 index fc4ac82aa7c6..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'aws-serverless-lambda-layer-cjs', -}); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/tests/basic.test.ts b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/tests/basic.test.ts deleted file mode 100644 index b8f7a4b4d51e..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/tests/basic.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import * as child_process from 'child_process'; -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Lambda layer SDK bundle sends events', async ({ request }) => { - const transactionEventPromise = waitForTransaction('aws-serverless-lambda-layer-cjs', transactionEvent => { - return transactionEvent?.transaction === 'my-lambda'; - }); - - // Waiting for 1s here because attaching the listener for events in `waitForTransaction` is not synchronous - // Since in this test, we don't start a browser via playwright, we don't have the usual delays (page.goto, etc) - // which are usually enough for us to never have noticed this race condition before. - // This is a workaround but probably sufficient as long as we only experience it in this test. - await new Promise(resolve => - setTimeout(() => { - resolve(); - }, 1000), - ); - - child_process.execSync('pnpm start', { - stdio: 'inherit', - }); - - const transactionEvent = await transactionEventPromise; - - // shows the SDK sent a transaction - expect(transactionEvent.transaction).toEqual('my-lambda'); // name should be the function name - expect(transactionEvent.contexts?.trace).toEqual({ - data: { - 'sentry.sample_rate': 1, - 'sentry.source': 'custom', - 'sentry.origin': 'auto.otel.aws-lambda', - 'sentry.op': 'function.aws.lambda', - 'cloud.account.id': '123453789012', - 'faas.id': 'arn:aws:lambda:us-east-1:123453789012:function:my-lambda', - 'faas.coldstart': true, - 'otel.kind': 'SERVER', - }, - op: 'function.aws.lambda', - origin: 'auto.otel.aws-lambda', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - status: 'ok', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }); - - expect(transactionEvent.spans).toHaveLength(2); - - // shows that the Otel Http instrumentation is working - expect(transactionEvent.spans).toContainEqual( - expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', - url: 'http://example.com/', - }), - description: 'GET http://example.com/', - op: 'http.client', - }), - ); - - // shows that the manual span creation is working - expect(transactionEvent.spans).toContainEqual( - expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.op': 'test', - 'sentry.origin': 'manual', - }), - description: 'manual-span', - op: 'test', - }), - ); - - // shows that the SDK source is correctly detected - expect(transactionEvent.sdk?.packages).toContainEqual( - expect.objectContaining({ name: 'aws-lambda-layer:@sentry/aws-serverless' }), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/.npmrc b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/.npmrc deleted file mode 100644 index 070f80f05092..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -@sentry:registry=http://127.0.0.1:4873 -@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/package.json b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/package.json deleted file mode 100644 index 7a25061dde1c..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "aws-lambda-layer-esm", - "version": "1.0.0", - "private": true, - "scripts": { - "start": "node src/run.mjs", - "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:build": "pnpm install", - "test:assert": "pnpm test" - }, - "//": "Link from local Lambda layer build", - "dependencies": { - "@sentry/aws-serverless": "link:../../../../packages/aws-serverless/build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless" - }, - "devDependencies": { - "@sentry-internal/test-utils": "link:../../../test-utils", - "@playwright/test": "~1.53.2" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/playwright.config.ts b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/playwright.config.ts deleted file mode 100644 index 174593c307df..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/playwright.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -export default getPlaywrightConfig(); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run-lambda.mjs b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run-lambda.mjs deleted file mode 100644 index c30903f9883d..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run-lambda.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import { handle } from './lambda-function.mjs'; - -const event = {}; -const context = { - invokedFunctionArn: 'arn:aws:lambda:us-east-1:123453789012:function:my-lambda', - functionName: 'my-lambda', -}; -await handle(event, context); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run.mjs b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run.mjs deleted file mode 100644 index 4bcd5886a865..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run.mjs +++ /dev/null @@ -1,17 +0,0 @@ -import child_process from 'node:child_process'; - -child_process.execSync('node ./src/run-lambda.mjs', { - stdio: 'inherit', - env: { - ...process.env, - // On AWS, LAMBDA_TASK_ROOT is usually /var/task but for testing, we set it to the CWD to correctly apply our handler - LAMBDA_TASK_ROOT: process.cwd(), - _HANDLER: 'src/lambda-function.handle', - - NODE_OPTIONS: '--import @sentry/aws-serverless/awslambda-auto', - SENTRY_DSN: 'http://public@localhost:3031/1337', - SENTRY_TRACES_SAMPLE_RATE: '1.0', - SENTRY_DEBUG: 'true', - }, - cwd: process.cwd(), -}); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/start-event-proxy.mjs deleted file mode 100644 index 03fc10269998..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'aws-lambda-layer-esm', -}); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/tests/basic.test.ts b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/tests/basic.test.ts deleted file mode 100644 index 14ae8f9b81b0..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/tests/basic.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import * as child_process from 'child_process'; -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Lambda layer SDK bundle sends events', async ({ request }) => { - const transactionEventPromise = waitForTransaction('aws-lambda-layer-esm', transactionEvent => { - return transactionEvent?.transaction === 'my-lambda'; - }); - - // Waiting for 1s here because attaching the listener for events in `waitForTransaction` is not synchronous - // Since in this test, we don't start a browser via playwright, we don't have the usual delays (page.goto, etc) - // which are usually enough for us to never have noticed this race condition before. - // This is a workaround but probably sufficient as long as we only experience it in this test. - await new Promise(resolve => - setTimeout(() => { - resolve(); - }, 1000), - ); - - child_process.execSync('pnpm start', { - stdio: 'inherit', - }); - - const transactionEvent = await transactionEventPromise; - - // shows the SDK sent a transaction - expect(transactionEvent.transaction).toEqual('my-lambda'); // name should be the function name - expect(transactionEvent.contexts?.trace).toEqual({ - data: { - 'sentry.sample_rate': 1, - 'sentry.source': 'custom', - 'sentry.origin': 'auto.otel.aws-lambda', - 'sentry.op': 'function.aws.lambda', - 'cloud.account.id': '123453789012', - 'faas.id': 'arn:aws:lambda:us-east-1:123453789012:function:my-lambda', - 'faas.coldstart': true, - 'otel.kind': 'SERVER', - }, - op: 'function.aws.lambda', - origin: 'auto.otel.aws-lambda', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - status: 'ok', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }); - - expect(transactionEvent.spans).toHaveLength(2); - - // shows that the Otel Http instrumentation is working - expect(transactionEvent.spans).toContainEqual( - expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', - url: 'http://example.com/', - }), - description: 'GET http://example.com/', - op: 'http.client', - }), - ); - - // shows that the manual span creation is working - expect(transactionEvent.spans).toContainEqual( - expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.op': 'test', - 'sentry.origin': 'manual', - }), - description: 'manual-span', - op: 'test', - }), - ); - - // shows that the SDK source is correctly detected - expect(transactionEvent.sdk?.packages).toContainEqual( - expect.objectContaining({ name: 'aws-lambda-layer:@sentry/aws-serverless' }), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/.npmrc b/dev-packages/e2e-tests/test-applications/aws-serverless-esm/.npmrc deleted file mode 100644 index 070f80f05092..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -@sentry:registry=http://127.0.0.1:4873 -@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/package.json b/dev-packages/e2e-tests/test-applications/aws-serverless-esm/package.json deleted file mode 100644 index c9dc4c959d09..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "node-express-app", - "version": "1.0.0", - "private": true, - "scripts": { - "start": "node src/run.mjs", - "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:build": "pnpm install", - "test:assert": "pnpm test" - }, - "dependencies": { - "@sentry/aws-serverless": "* || latest" - }, - "devDependencies": { - "@sentry-internal/test-utils": "link:../../../test-utils", - "@playwright/test": "~1.53.2" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/playwright.config.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-esm/playwright.config.ts deleted file mode 100644 index 174593c307df..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/playwright.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -export default getPlaywrightConfig(); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/src/package.json b/dev-packages/e2e-tests/test-applications/aws-serverless-esm/src/package.json deleted file mode 100644 index 43afe1b9fe77..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/src/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "//": "This is a mock package.json file which is usually created by AWS when deploying the lambda. OTEL instrumentation tries to read this file to get the lambda version", - "name": "lambda", - "version": "1.0.0" -} diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/src/run-lambda.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless-esm/src/run-lambda.mjs deleted file mode 100644 index 8356a5ef9bff..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/src/run-lambda.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import { handler } from './lambda-function.mjs'; - -// Simulate minimal event and context objects being passed to the handler by the AWS runtime -const event = {}; -const context = { - invokedFunctionArn: 'arn:aws:lambda:us-east-1:123453789012:function:my-lambda', - functionName: 'my-lambda', -}; - -await handler(event, context); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/src/run.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless-esm/src/run.mjs deleted file mode 100644 index 2f67c14a54f7..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/src/run.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import child_process from 'child_process'; - -child_process.execSync('node ./src/run-lambda.mjs', { - stdio: 'inherit', - env: { - ...process.env, - // On AWS, LAMBDA_TASK_ROOT is usually /var/task but for testing, we set it to the CWD to correctly apply our handler - LAMBDA_TASK_ROOT: process.cwd(), - _HANDLER: 'src/lambda-function.handler', - - NODE_OPTIONS: '--import @sentry/aws-serverless/awslambda-auto', - SENTRY_DSN: 'http://public@localhost:3031/1337', - SENTRY_TRACES_SAMPLE_RATE: '1.0', - }, - cwd: process.cwd(), -}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/tests/basic.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-esm/tests/basic.test.ts deleted file mode 100644 index 38c6e82043cf..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/tests/basic.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import * as child_process from 'child_process'; -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('AWS Serverless SDK sends events in ESM mode', async ({ request }) => { - const transactionEventPromise = waitForTransaction('aws-serverless-esm', transactionEvent => { - return transactionEvent?.transaction === 'my-lambda'; - }); - - // Waiting for 1s here because attaching the listener for events in `waitForTransaction` is not synchronous - // Since in this test, we don't start a browser via playwright, we don't have the usual delays (page.goto, etc) - // which are usually enough for us to never have noticed this race condition before. - // This is a workaround but probably sufficient as long as we only experience it in this test. - await new Promise(resolve => - setTimeout(() => { - resolve(); - }, 1000), - ); - - child_process.execSync('pnpm start', { - stdio: 'inherit', - }); - - const transactionEvent = await transactionEventPromise; - - // shows the SDK sent a transaction - expect(transactionEvent.transaction).toEqual('my-lambda'); // name should be the function name - expect(transactionEvent.contexts?.trace).toEqual({ - data: { - 'sentry.sample_rate': 1, - 'sentry.source': 'custom', - 'sentry.origin': 'auto.otel.aws-lambda', - 'sentry.op': 'function.aws.lambda', - 'cloud.account.id': '123453789012', - 'faas.id': 'arn:aws:lambda:us-east-1:123453789012:function:my-lambda', - 'faas.coldstart': true, - 'otel.kind': 'SERVER', - }, - op: 'function.aws.lambda', - origin: 'auto.otel.aws-lambda', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - status: 'ok', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }); - - expect(transactionEvent.spans).toHaveLength(2); - - // shows that the Otel Http instrumentation is working - expect(transactionEvent.spans).toContainEqual( - expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.op': 'http.client', - 'sentry.origin': 'auto.http.otel.http', - url: 'http://example.com/', - }), - description: 'GET http://example.com/', - op: 'http.client', - }), - ); - - // shows that the manual span creation is working - expect(transactionEvent.spans).toContainEqual( - expect.objectContaining({ - data: expect.objectContaining({ - 'sentry.op': 'manual', - 'sentry.origin': 'manual', - }), - description: 'manual-span', - op: 'manual', - }), - ); - - // shows that the SDK source is correctly detected - expect(transactionEvent.sdk?.packages).toContainEqual( - expect.objectContaining({ name: 'npm:@sentry/aws-serverless' }), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/.npmrc b/dev-packages/e2e-tests/test-applications/aws-serverless/.npmrc similarity index 100% rename from dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/.npmrc rename to dev-packages/e2e-tests/test-applications/aws-serverless/.npmrc diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/package.json b/dev-packages/e2e-tests/test-applications/aws-serverless/package.json new file mode 100644 index 000000000000..83437b2f9fbf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/package.json @@ -0,0 +1,27 @@ +{ + "name": "aws-lambda-sam", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "scripts": { + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && npx rimraf node_modules/@sentry/aws-serverless/nodejs", + "test:assert": "pnpm test" + }, + "//": "We just need the @sentry/aws-serverless layer zip file, not the NPM package", + "devDependencies": { + "@aws-sdk/client-lambda": "^3.863.0", + "@playwright/test": "~1.53.2", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/aws-serverless": "link:../../../../packages/aws-serverless/build/aws/dist-serverless/", + "@types/tmp": "^0.2.6", + "aws-cdk-lib": "^2.210.0", + "constructs": "^10.4.2", + "glob": "^11.0.3", + "tmp": "^0.2.5" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/playwright.config.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/playwright.config.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/playwright.config.ts rename to dev-packages/e2e-tests/test-applications/aws-serverless/playwright.config.ts diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/Error/index.js b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/Error/index.js new file mode 100644 index 000000000000..06a9c37a610c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/Error/index.js @@ -0,0 +1,3 @@ +exports.handler = async (event, context) => { + throw new Error('test'); +}; diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/lambda-function.js b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/TracingCjs/index.js similarity index 87% rename from dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/lambda-function.js rename to dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/TracingCjs/index.js index c688ed35a0c4..a614387ddccd 100644 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-cjs/src/lambda-function.js +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/TracingCjs/index.js @@ -1,8 +1,7 @@ const Sentry = require('@sentry/aws-serverless'); - const http = require('http'); -async function handle() { +exports.handler = async (event, context) => { await Sentry.startSpan({ name: 'manual-span', op: 'test' }, async () => { await new Promise(resolve => { http.get('http://example.com', res => { @@ -16,6 +15,4 @@ async function handle() { }); }); }); -} - -module.exports = { handle }; +}; diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/lambda-function.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/TracingEsm/index.mjs similarity index 87% rename from dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/lambda-function.mjs rename to dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/TracingEsm/index.mjs index a9cdd48c1197..b13f30397b62 100644 --- a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/lambda-function.mjs +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/TracingEsm/index.mjs @@ -2,7 +2,7 @@ import * as Sentry from '@sentry/aws-serverless'; import * as http from 'node:http'; -async function handle() { +export const handler = Sentry.wrapHandler(async () => { await Sentry.startSpan({ name: 'manual-span', op: 'test' }, async () => { await new Promise(resolve => { http.get('http://example.com', res => { @@ -16,6 +16,4 @@ async function handle() { }); }); }); -} - -export { handle }; +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TracingCjs/index.js b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TracingCjs/index.js new file mode 100644 index 000000000000..534909d6764e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TracingCjs/index.js @@ -0,0 +1,24 @@ +const http = require('http'); +const Sentry = require('@sentry/aws-serverless'); + +exports.handler = Sentry.wrapHandler(async () => { + await new Promise(resolve => { + const req = http.request( + { + host: 'example.com', + }, + res => { + res.on('data', d => { + process.stdout.write(d); + }); + + res.on('end', () => { + resolve(); + }); + }, + ); + req.end(); + }); + + Sentry.startSpan({ name: 'manual-span', op: 'manual' }, () => {}); +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/src/lambda-function.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TracingEsm/index.mjs similarity index 86% rename from dev-packages/e2e-tests/test-applications/aws-serverless-esm/src/lambda-function.mjs rename to dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TracingEsm/index.mjs index 4d248c4432c7..346613025497 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/src/lambda-function.mjs +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TracingEsm/index.mjs @@ -1,7 +1,7 @@ import * as http from 'node:http'; import * as Sentry from '@sentry/aws-serverless'; -const handler = Sentry.wrapHandler(async () => { +export const handler = Sentry.wrapHandler(async () => { await new Promise(resolve => { const req = http.request( { @@ -22,5 +22,3 @@ const handler = Sentry.wrapHandler(async () => { Sentry.startSpan({ name: 'manual-span', op: 'manual' }, () => {}); }); - -export { handler }; diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts new file mode 100644 index 000000000000..825c9648ee66 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts @@ -0,0 +1,134 @@ +import { Stack, CfnResource, StackProps } from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as dns from 'node:dns/promises'; +import { platform } from 'node:process'; +import { globSync } from 'glob'; +import { execFileSync } from 'node:child_process'; + +const LAMBDA_FUNCTIONS_WITH_LAYER_DIR = './src/lambda-functions-layer'; +const LAMBDA_FUNCTIONS_WITH_NPM_DIR = './src/lambda-functions-npm'; +const LAMBDA_FUNCTION_TIMEOUT = 10; +const LAYER_DIR = './node_modules/@sentry/aws-serverless/'; +export const SAM_PORT = 3001; +const NODE_RUNTIME = `nodejs${process.version.split('.').at(0)?.replace('v', '')}.x`; + +export class LocalLambdaStack extends Stack { + sentryLayer: CfnResource; + + constructor(scope: Construct, id: string, props: StackProps, hostIp: string) { + console.log('[LocalLambdaStack] Creating local SAM Lambda Stack'); + super(scope, id, props); + + this.templateOptions.templateFormatVersion = '2010-09-09'; + this.templateOptions.transforms = ['AWS::Serverless-2016-10-31']; + + console.log('[LocalLambdaStack] Add Sentry Lambda layer containing the Sentry SDK to the SAM stack'); + + const [layerZipFile] = globSync('sentry-node-serverless-*.zip', { cwd: LAYER_DIR }); + + if (!layerZipFile) { + throw new Error(`[LocalLambdaStack] Could not find sentry-node-serverless zip file in ${LAYER_DIR}`); + } + + this.sentryLayer = new CfnResource(this, 'SentryNodeServerlessSDK', { + type: 'AWS::Serverless::LayerVersion', + properties: { + ContentUri: path.join(LAYER_DIR, layerZipFile), + CompatibleRuntimes: ['nodejs18.x', 'nodejs20.x', 'nodejs22.x'], + }, + }); + + const dsn = `http://public@${hostIp}:3031/1337`; + console.log(`[LocalLambdaStack] Using Sentry DSN: ${dsn}`); + + this.addLambdaFunctions({ functionsDir: LAMBDA_FUNCTIONS_WITH_LAYER_DIR, dsn, addLayer: true }); + this.addLambdaFunctions({ functionsDir: LAMBDA_FUNCTIONS_WITH_NPM_DIR, dsn, addLayer: false }); + } + + private addLambdaFunctions({ + functionsDir, + dsn, + addLayer, + }: { + functionsDir: string; + dsn: string; + addLayer: boolean; + }) { + console.log(`[LocalLambdaStack] Add all Lambda functions defined in ${functionsDir} to the SAM stack`); + + const lambdaDirs = fs + .readdirSync(functionsDir) + .filter(dir => fs.statSync(path.join(functionsDir, dir)).isDirectory()); + + for (const lambdaDir of lambdaDirs) { + const functionName = `${addLayer ? 'Layer' : 'Npm'}${lambdaDir}`; + + if (!addLayer) { + console.log(`[LocalLambdaStack] Install dependencies for ${functionName}`); + const packageJson = { dependencies: { '@sentry/aws-serverless': '* || latest' } }; + fs.writeFileSync(path.join(functionsDir, lambdaDir, 'package.json'), JSON.stringify(packageJson, null, 2)); + execFileSync('npm', ['install', '--prefix', path.join(functionsDir, lambdaDir)], { stdio: 'inherit' }); + } + + const isEsm = fs.existsSync(path.join(functionsDir, lambdaDir, 'index.mjs')); + + new CfnResource(this, functionName, { + type: 'AWS::Serverless::Function', + properties: { + CodeUri: path.join(functionsDir, lambdaDir), + Handler: 'index.handler', + Runtime: NODE_RUNTIME, + Timeout: LAMBDA_FUNCTION_TIMEOUT, + Layers: addLayer ? [{ Ref: this.sentryLayer.logicalId }] : undefined, + Environment: { + Variables: { + SENTRY_DSN: dsn, + SENTRY_TRACES_SAMPLE_RATE: 1.0, + SENTRY_DEBUG: true, + NODE_OPTIONS: `--${isEsm ? 'import' : 'require'}=@sentry/aws-serverless/awslambda-auto`, + }, + }, + }, + }); + + console.log(`[LocalLambdaStack] Added Lambda function: ${functionName}`); + } + } + + static async waitForStack(timeout = 60000, port = SAM_PORT) { + const startTime = Date.now(); + const maxWaitTime = timeout; + + while (Date.now() - startTime < maxWaitTime) { + try { + const response = await fetch(`http://127.0.0.1:${port}/`); + + if (response.ok || response.status === 404) { + console.log(`[LocalLambdaStack] SAM stack is ready`); + return; + } + } catch { + await new Promise(resolve => setTimeout(resolve, 1000)); + } + } + + throw new Error(`[LocalLambdaStack] Failed to start SAM stack after ${timeout}ms`); + } +} + +export async function getHostIp() { + if (process.env.GITHUB_ACTIONS) { + const host = await dns.lookup(os.hostname()); + return host.address; + } + + if (platform === 'darwin' || platform === 'win32') { + return 'host.docker.internal'; + } + + const host = await dns.lookup(os.hostname()); + return host.address; +} diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/start-event-proxy.mjs similarity index 70% rename from dev-packages/e2e-tests/test-applications/aws-serverless-esm/start-event-proxy.mjs rename to dev-packages/e2e-tests/test-applications/aws-serverless/start-event-proxy.mjs index 86605fcb7b9a..196ae2471c69 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-esm/start-event-proxy.mjs +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/start-event-proxy.mjs @@ -2,5 +2,5 @@ import { startEventProxyServer } from '@sentry-internal/test-utils'; startEventProxyServer({ port: 3031, - proxyServerName: 'aws-serverless-esm', + proxyServerName: 'aws-serverless-lambda-sam', }); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts new file mode 100644 index 000000000000..707f808218fb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts @@ -0,0 +1,83 @@ +import { test as base, expect } from '@playwright/test'; +import { App } from 'aws-cdk-lib'; +import * as tmp from 'tmp'; +import { LocalLambdaStack, SAM_PORT, getHostIp } from '../src/stack'; +import { writeFileSync } from 'node:fs'; +import { spawn, execSync } from 'node:child_process'; +import { LambdaClient } from '@aws-sdk/client-lambda'; + +const DOCKER_NETWORK_NAME = 'lambda-test-network'; +const SAM_TEMPLATE_FILE = 'sam.template.yml'; + +export { expect }; + +export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClient: LambdaClient }>({ + testEnvironment: [ + async ({}, use) => { + console.log('[testEnvironment fixture] Setting up AWS Lambda test infrastructure'); + + execSync('docker network prune -f'); + execSync(`docker network create --driver bridge ${DOCKER_NETWORK_NAME}`); + + const hostIp = await getHostIp(); + const app = new App(); + + const stack = new LocalLambdaStack(app, 'LocalLambdaStack', {}, hostIp); + const template = app.synth().getStackByName('LocalLambdaStack').template; + writeFileSync(SAM_TEMPLATE_FILE, JSON.stringify(template, null, 2)); + + const debugLog = tmp.fileSync({ prefix: 'sentry_aws_lambda_tests_sam_debug', postfix: '.log' }); + console.log(`[test_environment fixture] Writing SAM debug log to: ${debugLog.name}`); + + const process = spawn( + 'sam', + [ + 'local', + 'start-lambda', + '--debug', + '--template', + SAM_TEMPLATE_FILE, + '--warm-containers', + 'EAGER', + '--docker-network', + DOCKER_NETWORK_NAME, + ], + { + stdio: ['ignore', debugLog.fd, debugLog.fd], + }, + ); + + try { + await LocalLambdaStack.waitForStack(); + + await use(stack); + } finally { + console.log('[testEnvironment fixture] Tearing down AWS Lambda test infrastructure'); + + process.kill('SIGTERM'); + await new Promise(resolve => { + process.once('exit', resolve); + setTimeout(() => { + if (!process.killed) { + process.kill('SIGKILL'); + } + resolve(void 0); + }, 5000); + }); + } + }, + { scope: 'worker', auto: true }, + ], + lambdaClient: async ({}, use) => { + const lambdaClient = new LambdaClient({ + endpoint: `http://127.0.0.1:${SAM_PORT}`, + region: 'us-east-1', + credentials: { + accessKeyId: 'dummy', + secretAccessKey: 'dummy', + }, + }); + + await use(lambdaClient); + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts new file mode 100644 index 000000000000..79ad0fa31070 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts @@ -0,0 +1,169 @@ +import { waitForTransaction, waitForError } from '@sentry-internal/test-utils'; +import { InvokeCommand } from '@aws-sdk/client-lambda'; +import { test, expect } from './lambda-fixtures'; + +test.describe('Lambda layer', () => { + test('tracing in CJS works', async ({ lambdaClient }) => { + const transactionEventPromise = waitForTransaction('aws-serverless-lambda-sam', transactionEvent => { + return transactionEvent?.transaction === 'LayerTracingCjs'; + }); + + await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerTracingCjs', + Payload: JSON.stringify({}), + }), + ); + + const transactionEvent = await transactionEventPromise; + + // shows the SDK sent a transaction + expect(transactionEvent.transaction).toEqual('LayerTracingCjs'); + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + 'sentry.origin': 'auto.otel.aws-lambda', + 'sentry.op': 'function.aws.lambda', + 'cloud.account.id': '012345678912', + 'faas.execution': expect.any(String), + 'faas.id': 'arn:aws:lambda:us-east-1:012345678912:function:LayerTracingCjs', + 'faas.coldstart': true, + 'otel.kind': 'SERVER', + }, + op: 'function.aws.lambda', + origin: 'auto.otel.aws-lambda', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.spans).toHaveLength(2); + + // shows that the Otel Http instrumentation is working + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.http', + url: 'http://example.com/', + }), + description: 'GET http://example.com/', + op: 'http.client', + }), + ); + + // shows that the manual span creation is working + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'test', + 'sentry.origin': 'manual', + }), + description: 'manual-span', + op: 'test', + }), + ); + + // shows that the SDK source is correctly detected + expect(transactionEvent.sdk?.packages).toContainEqual( + expect.objectContaining({ name: 'aws-lambda-layer:@sentry/aws-serverless' }), + ); + }); + + test('tracing in ESM works', async ({ lambdaClient }) => { + const transactionEventPromise = waitForTransaction('aws-serverless-lambda-sam', transactionEvent => { + return transactionEvent?.transaction === 'LayerTracingEsm'; + }); + + await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerTracingEsm', + Payload: JSON.stringify({}), + }), + ); + + const transactionEvent = await transactionEventPromise; + + // shows the SDK sent a transaction + expect(transactionEvent.transaction).toEqual('LayerTracingEsm'); // name should be the function name + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + 'sentry.origin': 'auto.otel.aws-lambda', + 'sentry.op': 'function.aws.lambda', + 'cloud.account.id': '012345678912', + 'faas.execution': expect.any(String), + 'faas.id': 'arn:aws:lambda:us-east-1:012345678912:function:LayerTracingEsm', + 'faas.coldstart': true, + 'otel.kind': 'SERVER', + }, + op: 'function.aws.lambda', + origin: 'auto.otel.aws-lambda', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.spans).toHaveLength(2); + + // shows that the Otel Http instrumentation is working + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.http', + url: 'http://example.com/', + }), + description: 'GET http://example.com/', + op: 'http.client', + }), + ); + + // shows that the manual span creation is working + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'test', + 'sentry.origin': 'manual', + }), + description: 'manual-span', + op: 'test', + }), + ); + + // shows that the SDK source is correctly detected + expect(transactionEvent.sdk?.packages).toContainEqual( + expect.objectContaining({ name: 'aws-lambda-layer:@sentry/aws-serverless' }), + ); + }); + + test('capturing errors works', async ({ lambdaClient }) => { + const errorEventPromise = waitForError('aws-serverless-lambda-sam', errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'test'; + }); + + await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerError', + Payload: JSON.stringify({}), + }), + ); + + const errorEvent = await errorEventPromise; + + // shows the SDK sent an error event + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]).toEqual( + expect.objectContaining({ + type: 'Error', + value: 'test', + mechanism: { + type: 'auto.function.aws-serverless.handler', + handled: false, + }, + }), + ); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts new file mode 100644 index 000000000000..9b4183425c95 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/npm.test.ts @@ -0,0 +1,141 @@ +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { InvokeCommand } from '@aws-sdk/client-lambda'; +import { test, expect } from './lambda-fixtures'; + +test.describe('NPM package', () => { + test('tracing in CJS works', async ({ lambdaClient }) => { + const transactionEventPromise = waitForTransaction('aws-serverless-lambda-sam', transactionEvent => { + return transactionEvent?.transaction === 'NpmTracingCjs'; + }); + + await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'NpmTracingCjs', + Payload: JSON.stringify({}), + }), + ); + + const transactionEvent = await transactionEventPromise; + + // shows the SDK sent a transaction + expect(transactionEvent.transaction).toEqual('NpmTracingCjs'); // name should be the function name + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + 'sentry.origin': 'auto.otel.aws-lambda', + 'sentry.op': 'function.aws.lambda', + 'cloud.account.id': '012345678912', + 'faas.execution': expect.any(String), + 'faas.id': 'arn:aws:lambda:us-east-1:012345678912:function:NpmTracingCjs', + 'faas.coldstart': true, + 'otel.kind': 'SERVER', + }, + op: 'function.aws.lambda', + origin: 'auto.otel.aws-lambda', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.spans).toHaveLength(2); + + // shows that the Otel Http instrumentation is working + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.http', + url: 'http://example.com/', + }), + description: 'GET http://example.com/', + op: 'http.client', + }), + ); + + // shows that the manual span creation is working + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'manual', + 'sentry.origin': 'manual', + }), + description: 'manual-span', + op: 'manual', + }), + ); + + // shows that the SDK source is correctly detected + expect(transactionEvent.sdk?.packages).toContainEqual( + expect.objectContaining({ name: 'npm:@sentry/aws-serverless' }), + ); + }); + + test('tracing in ESM works', async ({ lambdaClient }) => { + const transactionEventPromise = waitForTransaction('aws-serverless-lambda-sam', transactionEvent => { + return transactionEvent?.transaction === 'NpmTracingEsm'; + }); + + await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'NpmTracingEsm', + Payload: JSON.stringify({}), + }), + ); + + const transactionEvent = await transactionEventPromise; + + // shows the SDK sent a transaction + expect(transactionEvent.transaction).toEqual('NpmTracingEsm'); // name should be the function name + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + 'sentry.origin': 'auto.otel.aws-lambda', + 'sentry.op': 'function.aws.lambda', + 'cloud.account.id': '012345678912', + 'faas.execution': expect.any(String), + 'faas.id': 'arn:aws:lambda:us-east-1:012345678912:function:NpmTracingEsm', + 'faas.coldstart': true, + 'otel.kind': 'SERVER', + }, + op: 'function.aws.lambda', + origin: 'auto.otel.aws-lambda', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.spans).toHaveLength(2); + + // shows that the Otel Http instrumentation is working + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.http', + url: 'http://example.com/', + }), + description: 'GET http://example.com/', + op: 'http.client', + }), + ); + + // shows that the manual span creation is working + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'manual', + 'sentry.origin': 'manual', + }), + description: 'manual-span', + op: 'manual', + }), + ); + + // shows that the SDK source is correctly detected + expect(transactionEvent.sdk?.packages).toContainEqual( + expect.objectContaining({ name: 'npm:@sentry/aws-serverless' }), + ); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index f355654bf6a2..596109c0a596 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -52,6 +52,7 @@ const DEPENDENTS: Dependent[] = [ 'NodeClient', 'NODE_VERSION', 'childProcessIntegration', + 'systemErrorIntegration', ], }, { diff --git a/dev-packages/node-core-integration-tests/suites/system-error/basic-pii.mjs b/dev-packages/node-core-integration-tests/suites/system-error/basic-pii.mjs new file mode 100644 index 000000000000..1dd8c40c6ccf --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/system-error/basic-pii.mjs @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { readFileSync } from 'fs'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + sendDefaultPii: true, +}); + +readFileSync('non-existent-file.txt'); diff --git a/dev-packages/node-core-integration-tests/suites/system-error/basic.mjs b/dev-packages/node-core-integration-tests/suites/system-error/basic.mjs new file mode 100644 index 000000000000..5321dd062fa2 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/system-error/basic.mjs @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node-core'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import { readFileSync } from 'fs'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, +}); + +readFileSync('non-existent-file.txt'); diff --git a/dev-packages/node-core-integration-tests/suites/system-error/test.ts b/dev-packages/node-core-integration-tests/suites/system-error/test.ts new file mode 100644 index 000000000000..1725bd11a0f6 --- /dev/null +++ b/dev-packages/node-core-integration-tests/suites/system-error/test.ts @@ -0,0 +1,59 @@ +import { afterAll, describe, test } from 'vitest'; +import { cleanupChildProcesses, createRunner } from '../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +describe('SystemError integration', () => { + test('sendDefaultPii: false', async () => { + await createRunner(__dirname, 'basic.mjs') + .expect({ + event: { + contexts: { + node_system_error: { + errno: -2, + code: 'ENOENT', + syscall: 'open', + }, + }, + exception: { + values: [ + { + type: 'Error', + value: 'ENOENT: no such file or directory, open', + }, + ], + }, + }, + }) + .start() + .completed(); + }); + + test('sendDefaultPii: true', async () => { + await createRunner(__dirname, 'basic-pii.mjs') + .expect({ + event: { + contexts: { + node_system_error: { + errno: -2, + code: 'ENOENT', + syscall: 'open', + path: 'non-existent-file.txt', + }, + }, + exception: { + values: [ + { + type: 'Error', + value: 'ENOENT: no such file or directory, open', + }, + ], + }, + }, + }) + .start() + .completed(); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs new file mode 100644 index 000000000000..8cf2e8a5248f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument-options.mjs @@ -0,0 +1,41 @@ +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', + // disable attaching headers to /test/* endpoints + tracePropagationTargets: [/^(?!.*test).*$/], + tracesSampleRate: 1.0, + transport: loggingTransport, + + integrations: [ + Sentry.httpIntegration({ + instrumentation: { + requestHook: (span, req) => { + span.setAttribute('attr1', 'yes'); + Sentry.setExtra('requestHookCalled', { + url: req.url, + method: req.method, + }); + }, + responseHook: (span, res) => { + span.setAttribute('attr2', 'yes'); + Sentry.setExtra('responseHookCalled', { + url: res.req.url, + method: res.req.method, + }); + }, + applyCustomAttributesOnSpan: (span, req, res) => { + span.setAttribute('attr3', 'yes'); + Sentry.setExtra('applyCustomAttributesOnSpanCalled', { + reqUrl: req.url, + reqMethod: req.method, + resUrl: res.req.url, + resMethod: res.req.method, + }); + }, + }, + }), + ], +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument.mjs index 8cf2e8a5248f..46a27dd03b74 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/instrument.mjs @@ -4,38 +4,6 @@ import { loggingTransport } from '@sentry-internal/node-integration-tests'; Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', - // disable attaching headers to /test/* endpoints - tracePropagationTargets: [/^(?!.*test).*$/], tracesSampleRate: 1.0, transport: loggingTransport, - - integrations: [ - Sentry.httpIntegration({ - instrumentation: { - requestHook: (span, req) => { - span.setAttribute('attr1', 'yes'); - Sentry.setExtra('requestHookCalled', { - url: req.url, - method: req.method, - }); - }, - responseHook: (span, res) => { - span.setAttribute('attr2', 'yes'); - Sentry.setExtra('responseHookCalled', { - url: res.req.url, - method: res.req.method, - }); - }, - applyCustomAttributesOnSpan: (span, req, res) => { - span.setAttribute('attr3', 'yes'); - Sentry.setExtra('applyCustomAttributesOnSpanCalled', { - reqUrl: req.url, - reqMethod: req.method, - resUrl: res.req.url, - resMethod: res.req.method, - }); - }, - }, - }), - ], }); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.mjs b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.mjs index 44122f375857..37e629758828 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/server.mjs @@ -11,6 +11,10 @@ app.get('/test', (_req, res) => { res.send({ response: 'response 1' }); }); +app.post('/test', (_req, res) => { + res.send({ response: 'response 2' }); +}); + Sentry.setupExpressErrorHandler(app); startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts index 97043c998814..397b6baa7cc7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/httpIntegration/test.ts @@ -7,47 +7,134 @@ describe('httpIntegration', () => { cleanupChildProcesses(); }); - createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { - test('allows to pass instrumentation options to integration', async () => { - const runner = createRunner() - .expect({ - transaction: { - contexts: { - trace: { - span_id: expect.stringMatching(/[a-f0-9]{16}/), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - data: { + describe('instrumentation options', () => { + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument-options.mjs', (createRunner, test) => { + test('allows to pass instrumentation options to integration', async () => { + const runner = createRunner() + .expect({ + transaction: { + contexts: { + trace: { + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + data: { + url: expect.stringMatching(/\/test$/), + 'http.response.status_code': 200, + attr1: 'yes', + attr2: 'yes', + attr3: 'yes', + }, + op: 'http.server', + status: 'ok', + }, + }, + extra: { + requestHookCalled: { url: expect.stringMatching(/\/test$/), - 'http.response.status_code': 200, - attr1: 'yes', - attr2: 'yes', - attr3: 'yes', + method: 'GET', + }, + responseHookCalled: { + url: expect.stringMatching(/\/test$/), + method: 'GET', + }, + applyCustomAttributesOnSpanCalled: { + reqUrl: expect.stringMatching(/\/test$/), + reqMethod: 'GET', + resUrl: expect.stringMatching(/\/test$/), + resMethod: 'GET', }, - op: 'http.server', - status: 'ok', }, }, - extra: { - requestHookCalled: { - url: expect.stringMatching(/\/test$/), - method: 'GET', - }, - responseHookCalled: { - url: expect.stringMatching(/\/test$/), - method: 'GET', - }, - applyCustomAttributesOnSpanCalled: { - reqUrl: expect.stringMatching(/\/test$/), - reqMethod: 'GET', - resUrl: expect.stringMatching(/\/test$/), - resMethod: 'GET', - }, + }) + .start(); + runner.makeRequest('get', '/test'); + await runner.completed(); + }); + }); + }); + + describe('http.server spans', () => { + createEsmAndCjsTests(__dirname, 'server.mjs', 'instrument.mjs', (createRunner, test) => { + test('captures correct attributes for GET requests', async () => { + const runner = createRunner() + .expect({ + transaction: transaction => { + const port = runner.getPort(); + expect(transaction.transaction).toBe('GET /test'); + expect(transaction.contexts?.trace?.data).toEqual({ + 'http.flavor': '1.1', + 'http.host': `localhost:${port}`, + 'http.method': 'GET', + 'http.query': 'a=1&b=2', + 'http.response.status_code': 200, + 'http.route': '/test', + 'http.scheme': 'http', + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.target': '/test?a=1&b=2', + 'http.url': `http://localhost:${port}/test?a=1&b=2`, + 'http.user_agent': 'node', + 'net.host.ip': '::1', + 'net.host.name': 'localhost', + 'net.host.port': port, + 'net.peer.ip': '::1', + 'net.peer.port': expect.any(Number), + 'net.transport': 'ip_tcp', + 'otel.kind': 'SERVER', + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + url: `http://localhost:${port}/test`, + }); }, - }, - }) - .start(); - runner.makeRequest('get', '/test'); - await runner.completed(); + }) + .start(); + + runner.makeRequest('get', '/test?a=1&b=2#hash'); + await runner.completed(); + }); + + test('captures correct attributes for POST requests', async () => { + const runner = createRunner() + .expect({ + transaction: transaction => { + const port = runner.getPort(); + expect(transaction.transaction).toBe('POST /test'); + expect(transaction.contexts?.trace?.data).toEqual({ + 'http.flavor': '1.1', + 'http.host': `localhost:${port}`, + 'http.method': 'POST', + 'http.query': 'a=1&b=2', + 'http.request_content_length_uncompressed': 9, + 'http.response.status_code': 200, + 'http.route': '/test', + 'http.scheme': 'http', + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.target': '/test?a=1&b=2', + 'http.url': `http://localhost:${port}/test?a=1&b=2`, + 'http.user_agent': 'node', + 'net.host.ip': '::1', + 'net.host.name': 'localhost', + 'net.host.port': port, + 'net.peer.ip': '::1', + 'net.peer.port': expect.any(Number), + 'net.transport': 'ip_tcp', + 'otel.kind': 'SERVER', + 'sentry.op': 'http.server', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.sample_rate': 1, + 'sentry.source': 'route', + url: `http://localhost:${port}/test`, + }); + }, + }) + .start(); + + runner.makeRequest('post', '/test?a=1&b=2#hash', { data: 'test body' }); + await runner.completed(); + }); }); }); diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts index f4a176688280..46c031c2c786 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -17,7 +17,7 @@ import { execSync, spawn, spawnSync } from 'child_process'; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { basename, join } from 'path'; import { inspect } from 'util'; -import { afterAll, describe, test } from 'vitest'; +import { afterAll, beforeAll, describe, test } from 'vitest'; import { assertEnvelopeHeader, assertSentryCheckIn, @@ -158,6 +158,7 @@ type StartResult = { completed(): Promise; childHasExited(): boolean; getLogs(): string[]; + getPort(): number | undefined; makeRequest( method: 'get' | 'post', path: string, @@ -194,74 +195,79 @@ export function createEsmAndCjsTests( // If additionalDependencies are provided, we also create a nested package.json and install them there. const uniqueId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; const tmpDirPath = join(cwd, `tmp_${uniqueId}`); - mkdirSync(tmpDirPath); - - // Copy ESM files as-is into tmp dir const esmScenarioBasename = basename(scenarioPath); const esmInstrumentBasename = basename(instrumentPath); const esmScenarioPathForRun = join(tmpDirPath, esmScenarioBasename); const esmInstrumentPathForRun = join(tmpDirPath, esmInstrumentBasename); - writeFileSync(esmScenarioPathForRun, readFileSync(mjsScenarioPath, 'utf8')); - writeFileSync(esmInstrumentPathForRun, readFileSync(mjsInstrumentPath, 'utf8')); - - // Pre-create CJS converted files inside tmp dir const cjsScenarioPath = join(tmpDirPath, esmScenarioBasename.replace('.mjs', '.cjs')); const cjsInstrumentPath = join(tmpDirPath, esmInstrumentBasename.replace('.mjs', '.cjs')); - convertEsmFileToCjs(esmScenarioPathForRun, cjsScenarioPath); - convertEsmFileToCjs(esmInstrumentPathForRun, cjsInstrumentPath); - - // Create a minimal package.json with requested dependencies (if any) and install them - const additionalDependencies = options?.additionalDependencies ?? {}; - if (Object.keys(additionalDependencies).length > 0) { - const packageJson = { - name: 'tmp-integration-test', - private: true, - version: '0.0.0', - dependencies: additionalDependencies, - } as const; - - writeFileSync(join(tmpDirPath, 'package.json'), JSON.stringify(packageJson, null, 2)); - - try { - const deps = Object.entries(additionalDependencies).map(([name, range]) => { - if (!range || typeof range !== 'string') { - throw new Error(`Invalid version range for "${name}": ${String(range)}`); - } - return `${name}@${range}`; - }); - if (deps.length > 0) { - // Prefer npm for temp installs to avoid Yarn engine strictness; see https://github.com/vercel/ai/issues/7777 - // We rely on the generated package.json dependencies and run a plain install. - const result = spawnSync('npm', ['install', '--silent', '--no-audit', '--no-fund'], { - cwd: tmpDirPath, - encoding: 'utf8', + function createTmpDir(): void { + mkdirSync(tmpDirPath); + + // Copy ESM files as-is into tmp dir + writeFileSync(esmScenarioPathForRun, readFileSync(mjsScenarioPath, 'utf8')); + writeFileSync(esmInstrumentPathForRun, readFileSync(mjsInstrumentPath, 'utf8')); + + // Pre-create CJS converted files inside tmp dir + convertEsmFileToCjs(esmScenarioPathForRun, cjsScenarioPath); + convertEsmFileToCjs(esmInstrumentPathForRun, cjsInstrumentPath); + + // Create a minimal package.json with requested dependencies (if any) and install them + const additionalDependencies = options?.additionalDependencies ?? {}; + if (Object.keys(additionalDependencies).length > 0) { + const packageJson = { + name: 'tmp-integration-test', + private: true, + version: '0.0.0', + dependencies: additionalDependencies, + } as const; + + writeFileSync(join(tmpDirPath, 'package.json'), JSON.stringify(packageJson, null, 2)); + + try { + const deps = Object.entries(additionalDependencies).map(([name, range]) => { + if (!range || typeof range !== 'string') { + throw new Error(`Invalid version range for "${name}": ${String(range)}`); + } + return `${name}@${range}`; }); - if (process.env.DEBUG) { - // eslint-disable-next-line no-console - console.log('[additionalDependencies via npm]', deps.join(' ')); - // eslint-disable-next-line no-console - console.log('[npm stdout]', result.stdout); - // eslint-disable-next-line no-console - console.log('[npm stderr]', result.stderr); - } + if (deps.length > 0) { + // Prefer npm for temp installs to avoid Yarn engine strictness; see https://github.com/vercel/ai/issues/7777 + // We rely on the generated package.json dependencies and run a plain install. + const result = spawnSync('npm', ['install', '--silent', '--no-audit', '--no-fund'], { + cwd: tmpDirPath, + encoding: 'utf8', + }); - if (result.error) { - throw new Error(`Failed to install additionalDependencies in tmp dir ${tmpDirPath}: ${result.error.message}`); - } - if (typeof result.status === 'number' && result.status !== 0) { - throw new Error( - `Failed to install additionalDependencies in tmp dir ${tmpDirPath} (exit ${result.status}):\n${ - result.stderr || result.stdout || '(no output)' - }`, - ); + if (process.env.DEBUG) { + // eslint-disable-next-line no-console + console.log('[additionalDependencies via npm]', deps.join(' ')); + // eslint-disable-next-line no-console + console.log('[npm stdout]', result.stdout); + // eslint-disable-next-line no-console + console.log('[npm stderr]', result.stderr); + } + + if (result.error) { + throw new Error( + `Failed to install additionalDependencies in tmp dir ${tmpDirPath}: ${result.error.message}`, + ); + } + if (typeof result.status === 'number' && result.status !== 0) { + throw new Error( + `Failed to install additionalDependencies in tmp dir ${tmpDirPath} (exit ${result.status}):\n${ + result.stderr || result.stdout || '(no output)' + }`, + ); + } } + } catch (e) { + // eslint-disable-next-line no-console + console.error('Failed to install additionalDependencies:', e); + throw e; } - } catch (e) { - // eslint-disable-next-line no-console - console.error('Failed to install additionalDependencies:', e); - throw e; } } @@ -280,6 +286,11 @@ export function createEsmAndCjsTests( callback(() => createRunner(cjsScenarioPath).withFlags('--require', cjsInstrumentPath), cjsTestFn, 'cjs'); }); + // Create tmp directory + beforeAll(() => { + createTmpDir(); + }); + // Clean up the tmp directory after both esm and cjs suites have run afterAll(() => { try { @@ -617,6 +628,9 @@ export function createRunner(...paths: string[]) { getLogs(): string[] { return logs; }, + getPort(): number | undefined { + return scenarioServerPort; + }, makeRequest: async function ( method: 'get' | 'post', path: string, diff --git a/docs/publishing-a-release.md b/docs/publishing-a-release.md index 3b952672e4e9..810b180c3ba9 100644 --- a/docs/publishing-a-release.md +++ b/docs/publishing-a-release.md @@ -44,8 +44,8 @@ These have also been documented via [Cursor Rules](../.cursor/rules/publishing-r 1. Run `yarn changelog` and copy everything. 2. Create a new section in the changelog with the previously determined version number. 3. Paste in the logs you copied earlier. -4. Delete any which aren't user-facing changes (such as docs or tests). -5. If there are any important features or fixes, highlight them under the `Important Changes` subheading. If there are no important changes, don't include this section. If the `Important Changes` subheading is used, put all other changes under the `Other Changes` subheading. +4. If there are any important features or fixes, highlight them under the `Important Changes` subheading. If there are no important changes, don't include this section. If the `Important Changes` subheading is used, put all other user-facing changes under the `Other Changes` subheading. +5. Any changes that are purely internal (e.g. internal refactors (`ref`) without user-facing changes, tests, chores, etc) should be put under a `
` block, where the `` heading is "Internal Changes" (see example). 6. Make sure the changelog entries are ordered alphabetically. 7. If any of the PRs are from external contributors, include underneath the commits `Work in this release contributed by . Thank you for your contributions!`. @@ -78,5 +78,13 @@ This feature ships updates to the span names and ops to better match OpenTelemet - fix(sveltekit): Export `vercelAIIntegration` from `@sentry/node` ([#16496](https://github.com/getsentry/sentry-javascript/pull/16496)) +
+ Internal Changes + +- ref(node): Split up incoming & outgoing http handling ([#17358](https://github.com/getsentry/sentry-javascript/pull/17358)) +- test(node): Enable additionalDependencies in integration runner ([#17361](https://github.com/getsentry/sentry-javascript/pull/17361)) + +
+ Work in this release was contributed by @agrattan0820. Thank you for your contribution! ``` diff --git a/packages/astro/package.json b/packages/astro/package.json index ec6310d269ba..efa86e9d2417 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -59,7 +59,7 @@ "@sentry/browser": "10.4.0", "@sentry/core": "10.4.0", "@sentry/node": "10.4.0", - "@sentry/vite-plugin": "^4.0.0" + "@sentry/vite-plugin": "^4.1.0" }, "devDependencies": { "astro": "^3.5.0", diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index a9e81aee7db5..3b2f589f7fc2 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -124,6 +124,7 @@ export { startSession, startSpan, startSpanManual, + systemErrorIntegration, tediousIntegration, trpcMiddleware, updateSpanName, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index b99c481fd1d3..8cbcd31c50a5 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -114,6 +114,7 @@ export { spanToJSON, spanToTraceHeader, spanToBaggageHeader, + systemErrorIntegration, trpcMiddleware, updateSpanName, supabaseIntegration, diff --git a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts index 826ec0a4ae4c..5c40d53174c1 100644 --- a/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts +++ b/packages/core/src/build-time-plugins/buildTimeOptionsBase.ts @@ -205,9 +205,12 @@ interface SourceMapsOptions { * * By default (`false`), the plugin automatically uploads source maps during a production build if a Sentry auth token is detected. * + * If set to `"disable-upload"`, the plugin will not upload source maps to Sentry, but will inject debug IDs into the build artifacts. + * This is useful if you want to manually upload source maps to Sentry at a later point in time. + * * @default false */ - disable?: boolean; + disable?: boolean | 'disable-upload'; /** * A glob or an array of globs that specify the build artifacts and source maps that will be uploaded to Sentry. diff --git a/packages/core/src/integrations/extraerrordata.ts b/packages/core/src/integrations/extraerrordata.ts index 291648244f6c..ae65739aed5f 100644 --- a/packages/core/src/integrations/extraerrordata.ts +++ b/packages/core/src/integrations/extraerrordata.ts @@ -115,7 +115,12 @@ function _extractErrorData( // Error.cause is a standard property that is non enumerable, we therefore need to access it separately. // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause if (captureErrorCause && error.cause !== undefined) { - extraErrorInfo.cause = isError(error.cause) ? error.cause.toString() : error.cause; + if (isError(error.cause)) { + const errorName = error.cause.name || error.cause.constructor.name; + extraErrorInfo.cause = { [errorName]: _extractErrorData(error.cause as ExtendedError, false, maxValueLength) }; + } else { + extraErrorInfo.cause = error.cause; + } } // Check if someone attached `toJSON` method to grab even more properties (eg. axios is doing that) diff --git a/packages/core/src/integrations/mcp-server/correlation.ts b/packages/core/src/integrations/mcp-server/correlation.ts index 7a73f63f64e3..4da7e78e4009 100644 --- a/packages/core/src/integrations/mcp-server/correlation.ts +++ b/packages/core/src/integrations/mcp-server/correlation.ts @@ -86,24 +86,17 @@ export function completeSpanWithResults(transport: MCPTransport, requestId: Requ /** * Cleans up pending spans for a specific transport (when that transport closes) * @param transport - MCP transport instance - * @returns Number of pending spans that were cleaned up */ -export function cleanupPendingSpansForTransport(transport: MCPTransport): number { +export function cleanupPendingSpansForTransport(transport: MCPTransport): void { const spanMap = transportToSpanMap.get(transport); - if (!spanMap) { - return 0; - } - - const pendingCount = spanMap.size; - - for (const [, spanData] of spanMap) { - spanData.span.setStatus({ - code: SPAN_STATUS_ERROR, - message: 'cancelled', - }); - spanData.span.end(); + if (spanMap) { + for (const [, spanData] of spanMap) { + spanData.span.setStatus({ + code: SPAN_STATUS_ERROR, + message: 'cancelled', + }); + spanData.span.end(); + } + spanMap.clear(); } - - spanMap.clear(); - return pendingCount; } diff --git a/packages/core/src/integrations/mcp-server/sessionExtraction.ts b/packages/core/src/integrations/mcp-server/sessionExtraction.ts index 90e235d4e544..62eaa94f9b71 100644 --- a/packages/core/src/integrations/mcp-server/sessionExtraction.ts +++ b/packages/core/src/integrations/mcp-server/sessionExtraction.ts @@ -152,21 +152,23 @@ export function extractClientInfo(extra: ExtraHandlerData): { * @returns Transport type mapping for span attributes */ export function getTransportTypes(transport: MCPTransport): { mcpTransport: string; networkTransport: string } { - const transportName = transport.constructor?.name?.toLowerCase() || ''; - - if (transportName.includes('stdio')) { - return { mcpTransport: 'stdio', networkTransport: 'pipe' }; - } - - if (transportName.includes('streamablehttp') || transportName.includes('streamable')) { - return { mcpTransport: 'http', networkTransport: 'tcp' }; + if (!transport?.constructor) { + return { mcpTransport: 'unknown', networkTransport: 'unknown' }; } - - if (transportName.includes('sse')) { - return { mcpTransport: 'sse', networkTransport: 'tcp' }; + const transportName = typeof transport.constructor?.name === 'string' ? transport.constructor.name : 'unknown'; + let networkTransport = 'unknown'; + + const lowerTransportName = transportName.toLowerCase(); + if (lowerTransportName.includes('stdio')) { + networkTransport = 'pipe'; + } else if (lowerTransportName.includes('http') || lowerTransportName.includes('sse')) { + networkTransport = 'tcp'; } - return { mcpTransport: 'unknown', networkTransport: 'unknown' }; + return { + mcpTransport: transportName, + networkTransport, + }; } /** @@ -174,12 +176,13 @@ export function getTransportTypes(transport: MCPTransport): { mcpTransport: stri * @param transport - MCP transport instance * @param extra - Optional extra handler data * @returns Transport attributes for span instrumentation + * @note sessionId may be undefined during initial setup - session should be established by client during initialize flow */ export function buildTransportAttributes( transport: MCPTransport, extra?: ExtraHandlerData, ): Record { - const sessionId = transport.sessionId; + const sessionId = transport && 'sessionId' in transport ? transport.sessionId : undefined; const clientInfo = extra ? extractClientInfo(extra) : {}; const { mcpTransport, networkTransport } = getTransportTypes(transport); const clientAttributes = getClientAttributes(transport); diff --git a/packages/core/test/lib/integrations/extraerrordata.test.ts b/packages/core/test/lib/integrations/extraerrordata.test.ts index a6470c3f6b2c..f935acc689f9 100644 --- a/packages/core/test/lib/integrations/extraerrordata.test.ts +++ b/packages/core/test/lib/integrations/extraerrordata.test.ts @@ -55,9 +55,11 @@ describe('ExtraErrorData()', () => { }); }); - it('doesnt choke on linked errors and stringify names instead', () => { + it('should extract error data from the error cause with the same policy', () => { const error = new TypeError('foo') as ExtendedError; - error.cause = new SyntaxError('bar'); + error.cause = new SyntaxError('bar') as ExtendedError; + error.cause.baz = 42; + error.cause.foo = 'a'.repeat(300); const enhancedEvent = extraErrorData.processEvent?.( event, @@ -69,7 +71,12 @@ describe('ExtraErrorData()', () => { expect(enhancedEvent.contexts).toEqual({ TypeError: { - cause: 'SyntaxError: bar', + cause: { + SyntaxError: { + baz: 42, + foo: `${'a'.repeat(250)}...`, + }, + }, }, }); }); diff --git a/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts b/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts index 5437d4a5a13a..0ad969d5b46e 100644 --- a/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts @@ -61,7 +61,7 @@ describe('MCP Server Semantic Conventions', () => { 'mcp.session.id': 'test-session-123', 'client.address': '192.168.1.100', 'client.port': 54321, - 'mcp.transport': 'http', + 'mcp.transport': 'StreamableHTTPServerTransport', 'network.transport': 'tcp', 'network.protocol.version': '2.0', 'mcp.request.argument.location': '"Seattle, WA"', @@ -93,7 +93,7 @@ describe('MCP Server Semantic Conventions', () => { 'mcp.resource.uri': 'file:///docs/api.md', 'mcp.request.id': 'req-2', 'mcp.session.id': 'test-session-123', - 'mcp.transport': 'http', + 'mcp.transport': 'StreamableHTTPServerTransport', 'network.transport': 'tcp', 'network.protocol.version': '2.0', 'mcp.request.argument.uri': '"file:///docs/api.md"', @@ -125,7 +125,7 @@ describe('MCP Server Semantic Conventions', () => { 'mcp.prompt.name': 'analyze-code', 'mcp.request.id': 'req-3', 'mcp.session.id': 'test-session-123', - 'mcp.transport': 'http', + 'mcp.transport': 'StreamableHTTPServerTransport', 'network.transport': 'tcp', 'network.protocol.version': '2.0', 'mcp.request.argument.name': '"analyze-code"', @@ -154,7 +154,7 @@ describe('MCP Server Semantic Conventions', () => { attributes: { 'mcp.method.name': 'notifications/tools/list_changed', 'mcp.session.id': 'test-session-123', - 'mcp.transport': 'http', + 'mcp.transport': 'StreamableHTTPServerTransport', 'network.transport': 'tcp', 'network.protocol.version': '2.0', 'sentry.op': 'mcp.notification.client_to_server', @@ -193,7 +193,7 @@ describe('MCP Server Semantic Conventions', () => { 'mcp.request.id': 'req-4', 'mcp.session.id': 'test-session-123', // Transport attributes - 'mcp.transport': 'http', + 'mcp.transport': 'StreamableHTTPServerTransport', 'network.transport': 'tcp', 'network.protocol.version': '2.0', // Sentry-specific @@ -227,7 +227,7 @@ describe('MCP Server Semantic Conventions', () => { attributes: { 'mcp.method.name': 'notifications/message', 'mcp.session.id': 'test-session-123', - 'mcp.transport': 'http', + 'mcp.transport': 'StreamableHTTPServerTransport', 'network.transport': 'tcp', 'network.protocol.version': '2.0', 'mcp.logging.level': 'info', diff --git a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts index 008942ac4099..996779455574 100644 --- a/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/transportInstrumentation.test.ts @@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as currentScopes from '../../../../src/currentScopes'; import { wrapMcpServerWithSentry } from '../../../../src/integrations/mcp-server'; import { + buildTransportAttributes, extractSessionDataFromInitializeRequest, extractSessionDataFromInitializeResponse, + getTransportTypes, } from '../../../../src/integrations/mcp-server/sessionExtraction'; import { cleanupSessionDataForTransport, @@ -214,7 +216,7 @@ describe('MCP Server Transport Instrumentation', () => { 'mcp.tool.name': 'process-file', 'mcp.request.id': 'req-stdio-1', 'mcp.session.id': 'stdio-session-456', - 'mcp.transport': 'stdio', // Should be stdio, not http + 'mcp.transport': 'StdioServerTransport', 'network.transport': 'pipe', // Should be pipe, not tcp 'network.protocol.version': '2.0', 'mcp.request.argument.path': '"/tmp/data.txt"', @@ -245,7 +247,7 @@ describe('MCP Server Transport Instrumentation', () => { attributes: expect.objectContaining({ 'mcp.method.name': 'notifications/message', 'mcp.session.id': 'stdio-session-456', - 'mcp.transport': 'stdio', + 'mcp.transport': 'StdioServerTransport', 'network.transport': 'pipe', 'mcp.logging.level': 'debug', 'mcp.logging.message': 'Processing stdin input', @@ -286,7 +288,7 @@ describe('MCP Server Transport Instrumentation', () => { attributes: expect.objectContaining({ 'mcp.method.name': 'resources/read', 'mcp.resource.uri': 'https://api.example.com/data', - 'mcp.transport': 'sse', // Deprecated but supported + 'mcp.transport': 'SSEServerTransport', 'network.transport': 'tcp', 'mcp.session.id': 'sse-session-789', }), @@ -361,7 +363,7 @@ describe('MCP Server Transport Instrumentation', () => { 'mcp.session.id': 'test-session-direct', 'client.address': '127.0.0.1', 'client.port': 8080, - 'mcp.transport': 'http', + 'mcp.transport': 'StreamableHTTPServerTransport', 'network.transport': 'tcp', 'network.protocol.version': '2.0', 'mcp.request.argument.input': '"test"', @@ -500,4 +502,86 @@ describe('MCP Server Transport Instrumentation', () => { expect(getSessionDataForTransport(transportWithoutSession)).toBeUndefined(); }); }); + + describe('Transport Type Detection', () => { + it('extracts HTTP transport name correctly', () => { + const transport = createMockTransport(); + const result = getTransportTypes(transport); + + expect(result.mcpTransport).toBe('StreamableHTTPServerTransport'); + expect(result.networkTransport).toBe('tcp'); + }); + + it('extracts stdio transport and maps to pipe network', () => { + const transport = createMockStdioTransport(); + const result = getTransportTypes(transport); + + expect(result.mcpTransport).toBe('StdioServerTransport'); + expect(result.networkTransport).toBe('pipe'); + }); + + it('extracts SSE transport name', () => { + const transport = createMockSseTransport(); + const result = getTransportTypes(transport); + + expect(result.mcpTransport).toBe('SSEServerTransport'); + expect(result.networkTransport).toBe('tcp'); + }); + + it('handles transport without constructor', () => { + const transport = Object.create(null); + const result = getTransportTypes(transport); + + expect(result.mcpTransport).toBe('unknown'); + expect(result.networkTransport).toBe('unknown'); + }); + + it('handles transport with null/undefined constructor name', () => { + const transport = { + constructor: { name: null }, + onmessage: () => {}, + send: async () => {}, + }; + const result = getTransportTypes(transport); + + expect(result.mcpTransport).toBe('unknown'); + expect(result.networkTransport).toBe('unknown'); + }); + + it('returns unknown network transport for unrecognized transport types', () => { + const transport = { + constructor: { name: 'CustomTransport' }, + onmessage: () => {}, + send: async () => {}, + }; + const result = getTransportTypes(transport); + + expect(result.mcpTransport).toBe('CustomTransport'); + expect(result.networkTransport).toBe('unknown'); + }); + }); + + describe('buildTransportAttributes sessionId handling', () => { + it('includes sessionId when present', () => { + const transport = createMockTransport(); + const attributes = buildTransportAttributes(transport); + + expect(attributes['mcp.session.id']).toBe('test-session-123'); + }); + + it('excludes sessionId when undefined', () => { + const transport = createMockTransport(); + transport.sessionId = undefined; + const attributes = buildTransportAttributes(transport); + + expect(attributes['mcp.session.id']).toBeUndefined(); + }); + + it('excludes sessionId when not present in transport', () => { + const transport = { onmessage: () => {}, send: async () => {} }; + const attributes = buildTransportAttributes(transport); + + expect(attributes['mcp.session.id']).toBeUndefined(); + }); + }); }); diff --git a/packages/gatsby/package.json b/packages/gatsby/package.json index 3d28dfe86d34..9aeb3f42e903 100644 --- a/packages/gatsby/package.json +++ b/packages/gatsby/package.json @@ -47,7 +47,7 @@ "dependencies": { "@sentry/core": "10.4.0", "@sentry/react": "10.4.0", - "@sentry/webpack-plugin": "^4.0.2" + "@sentry/webpack-plugin": "^4.1.0" }, "peerDependencies": { "gatsby": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 8339e95c77a3..26ed56f031d8 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -115,6 +115,7 @@ export { trpcMiddleware, updateSpanName, supabaseIntegration, + systemErrorIntegration, instrumentSupabaseClient, zodErrorsIntegration, profiler, diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 9bc9926d4634..9ff2a4ad537a 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -85,7 +85,7 @@ "@sentry/opentelemetry": "10.4.0", "@sentry/react": "10.4.0", "@sentry/vercel-edge": "10.4.0", - "@sentry/webpack-plugin": "^4.0.2", + "@sentry/webpack-plugin": "^4.1.0", "chalk": "3.0.0", "resolve": "1.22.8", "rollup": "^4.35.0", diff --git a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts index ea9f0aeb63ea..9ae0a5ee0bb2 100644 --- a/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts +++ b/packages/nextjs/src/common/devErrorSymbolicationEventProcessor.ts @@ -12,9 +12,7 @@ type OriginalStackFrameResponse = { const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryBasePath?: string; - next?: { - version?: string; - }; + _sentryNextJsVersion: string | undefined; }; /** @@ -39,9 +37,15 @@ export async function devErrorSymbolicationEventProcessor(event: Event, hint: Ev try { if (hint.originalException && hint.originalException instanceof Error && hint.originalException.stack) { const frames = stackTraceParser.parse(hint.originalException.stack); + const nextJsVersion = globalWithInjectedValues._sentryNextJsVersion; + + // If we for whatever reason don't have a Next.js version, + // we don't want to symbolicate as this previously lead to infinite loops + if (!nextJsVersion) { + return event; + } - const nextjsVersion = globalWithInjectedValues.next?.version || '0.0.0'; - const parsedNextjsVersion = nextjsVersion ? parseSemver(nextjsVersion) : {}; + const parsedNextjsVersion = parseSemver(nextJsVersion); let resolvedFrames: ({ originalCodeFrame: string | null; @@ -83,7 +87,9 @@ export async function devErrorSymbolicationEventProcessor(event: Event, hint: Ev context_line: contextLine, post_context: postContextLines, function: resolvedFrame.originalStackFrame.methodName, - filename: resolvedFrame.originalStackFrame.file || undefined, + filename: resolvedFrame.originalStackFrame.file + ? stripWebpackInternalPrefix(resolvedFrame.originalStackFrame.file) + : undefined, lineno: resolvedFrame.originalStackFrame.lineNumber || resolvedFrame.originalStackFrame.line1 || undefined, colno: resolvedFrame.originalStackFrame.column || resolvedFrame.originalStackFrame.column1 || undefined, @@ -281,3 +287,21 @@ function parseOriginalCodeFrame(codeFrame: string): { postContextLines, }; } + +/** + * Strips webpack-internal prefixes from filenames to clean up stack traces. + * + * Examples: + * - "webpack-internal:///./components/file.tsx" -> "./components/file.tsx" + * - "webpack-internal:///(app-pages-browser)/./components/file.tsx" -> "./components/file.tsx" + */ +function stripWebpackInternalPrefix(filename: string): string | undefined { + if (!filename) { + return filename; + } + + const webpackInternalRegex = /^webpack-internal:(?:\/+)?(?:\([^)]*\)\/)?(.+)$/; + const match = filename.match(webpackInternalRegex); + + return match ? match[1] : filename; +} diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 50dd1a14588a..76d98fda25e8 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -1,8 +1,8 @@ import { debug } from '@sentry/core'; import * as chalk from 'chalk'; -import * as path from 'path'; import type { RouteManifest } from '../manifest/types'; -import type { NextConfigObject, TurbopackOptions, TurbopackRuleConfigItemOrShortcut } from '../types'; +import type { NextConfigObject, TurbopackMatcherWithRule, TurbopackOptions } from '../types'; +import { generateValueInjectionRules } from './generateValueInjectionRules'; /** * Construct a Turbopack config object from a Next.js config object and a Turbopack options object. @@ -14,30 +14,23 @@ import type { NextConfigObject, TurbopackOptions, TurbopackRuleConfigItemOrShort export function constructTurbopackConfig({ userNextConfig, routeManifest, + nextJsVersion, }: { userNextConfig: NextConfigObject; routeManifest?: RouteManifest; + nextJsVersion?: string; }): TurbopackOptions { const newConfig: TurbopackOptions = { ...userNextConfig.turbopack, }; - if (routeManifest) { - newConfig.rules = safelyAddTurbopackRule(newConfig.rules, { - matcher: '**/instrumentation-client.*', - rule: { - loaders: [ - { - loader: path.resolve(__dirname, '..', 'loaders', 'valueInjectionLoader.js'), - options: { - values: { - _sentryRouteManifest: JSON.stringify(routeManifest), - }, - }, - }, - ], - }, - }); + const valueInjectionRules = generateValueInjectionRules({ + routeManifest, + nextJsVersion, + }); + + for (const { matcher, rule } of valueInjectionRules) { + newConfig.rules = safelyAddTurbopackRule(newConfig.rules, { matcher, rule }); } return newConfig; @@ -53,7 +46,7 @@ export function constructTurbopackConfig({ */ export function safelyAddTurbopackRule( existingRules: TurbopackOptions['rules'], - { matcher, rule }: { matcher: string; rule: TurbopackRuleConfigItemOrShortcut }, + { matcher, rule }: TurbopackMatcherWithRule, ): TurbopackOptions['rules'] { if (!existingRules) { return { diff --git a/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts new file mode 100644 index 000000000000..58cf7cdd0a15 --- /dev/null +++ b/packages/nextjs/src/config/turbopack/generateValueInjectionRules.ts @@ -0,0 +1,69 @@ +import * as path from 'path'; +import type { RouteManifest } from '../manifest/types'; +import type { JSONValue, TurbopackMatcherWithRule } from '../types'; + +/** + * Generate the value injection rules for client and server in turbopack config. + */ +export function generateValueInjectionRules({ + routeManifest, + nextJsVersion, +}: { + routeManifest?: RouteManifest; + nextJsVersion?: string; +}): TurbopackMatcherWithRule[] { + const rules: TurbopackMatcherWithRule[] = []; + const isomorphicValues: Record = {}; + let clientValues: Record = {}; + let serverValues: Record = {}; + + if (nextJsVersion) { + // This is used to determine version-based dev-symbolication behavior + isomorphicValues._sentryNextJsVersion = nextJsVersion; + } + + if (routeManifest) { + clientValues._sentryRouteManifest = JSON.stringify(routeManifest); + } + + if (Object.keys(isomorphicValues).length > 0) { + clientValues = { ...clientValues, ...isomorphicValues }; + serverValues = { ...serverValues, ...isomorphicValues }; + } + + // Client value injection + if (Object.keys(clientValues).length > 0) { + rules.push({ + matcher: '**/instrumentation-client.*', + rule: { + loaders: [ + { + loader: path.resolve(__dirname, '..', 'loaders', 'valueInjectionLoader.js'), + options: { + values: clientValues, + }, + }, + ], + }, + }); + } + + // Server value injection + if (Object.keys(serverValues).length > 0) { + rules.push({ + matcher: '**/instrumentation.*', + rule: { + loaders: [ + { + loader: path.resolve(__dirname, '..', 'loaders', 'valueInjectionLoader.js'), + options: { + values: serverValues, + }, + }, + ], + }, + }); + } + + return rules; +} diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index b29fbb6881af..18cdc2d38cfc 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -621,7 +621,7 @@ export type EnhancedGlobal = typeof GLOBAL_OBJ & { SENTRY_RELEASES?: { [key: string]: { id: string } }; }; -type JSONValue = string | number | boolean | JSONValue[] | { [k: string]: JSONValue }; +export type JSONValue = string | number | boolean | JSONValue[] | { [k: string]: JSONValue }; type TurbopackLoaderItem = | string @@ -637,6 +637,11 @@ type TurbopackRuleCondition = { export type TurbopackRuleConfigItemOrShortcut = TurbopackLoaderItem[] | TurbopackRuleConfigItem; +export type TurbopackMatcherWithRule = { + matcher: string; + rule: TurbopackRuleConfigItemOrShortcut; +}; + type TurbopackRuleConfigItemOptions = { loaders: TurbopackLoaderItem[]; as?: string; diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 9c9c479cd724..b336e1e2ee9b 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -45,6 +45,7 @@ export function constructWebpackConfigFunction( userSentryOptions: SentryBuildOptions = {}, releaseName: string | undefined, routeManifest: RouteManifest | undefined, + nextJsVersion: string | undefined, ): WebpackConfigFunction { // Will be called by nextjs and passed its default webpack configuration and context data about the build (whether // we're building server or client, whether we're in dev, what version of webpack we're using, etc). Note that @@ -90,7 +91,15 @@ export function constructWebpackConfigFunction( const newConfig = setUpModuleRules(rawNewConfig); // Add a loader which will inject code that sets global values - addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions, buildContext, releaseName, routeManifest); + addValueInjectionLoader({ + newConfig, + userNextConfig, + userSentryOptions, + buildContext, + releaseName, + routeManifest, + nextJsVersion, + }); addOtelWarningIgnoreRule(newConfig); @@ -682,14 +691,23 @@ function setUpModuleRules(newConfig: WebpackConfigObject): WebpackConfigObjectWi */ // TODO: Remove this loader and replace it with a nextConfig.env (https://web.archive.org/web/20240917153554/https://nextjs.org/docs/app/api-reference/next-config-js/env) or define based (https://github.com/vercel/next.js/discussions/71476) approach. // In order to remove this loader though we need to make sure the minimum supported Next.js version includes this PR (https://github.com/vercel/next.js/pull/61194), otherwise the nextConfig.env based approach will not work, as our SDK code is not processed by Next.js. -function addValueInjectionLoader( - newConfig: WebpackConfigObjectWithModuleRules, - userNextConfig: NextConfigObject, - userSentryOptions: SentryBuildOptions, - buildContext: BuildContext, - releaseName: string | undefined, - routeManifest: RouteManifest | undefined, -): void { +function addValueInjectionLoader({ + newConfig, + userNextConfig, + userSentryOptions, + buildContext, + releaseName, + routeManifest, + nextJsVersion, +}: { + newConfig: WebpackConfigObjectWithModuleRules; + userNextConfig: NextConfigObject; + userSentryOptions: SentryBuildOptions; + buildContext: BuildContext; + releaseName: string | undefined; + routeManifest: RouteManifest | undefined; + nextJsVersion: string | undefined; +}): void { const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || ''; // Check if release creation is disabled to prevent injection that breaks build determinism @@ -710,6 +728,8 @@ function addValueInjectionLoader( // Only inject if release creation is not explicitly disabled (to maintain build determinism) SENTRY_RELEASE: releaseToInject && !buildContext.dev ? { id: releaseToInject } : undefined, _sentryBasePath: buildContext.dev ? userNextConfig.basePath : undefined, + // This is used to determine version-based dev-symbolication behavior + _sentryNextJsVersion: nextJsVersion, }; const serverValues = { diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 57fff867f64a..4404fded7e36 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -314,12 +314,19 @@ function getFinalConfigObject( webpack: isTurbopack || userSentryOptions.disableSentryWebpackConfig ? incomingUserNextConfigObject.webpack // just return the original webpack config - : constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions, releaseName, routeManifest), + : constructWebpackConfigFunction( + incomingUserNextConfigObject, + userSentryOptions, + releaseName, + routeManifest, + nextJsVersion, + ), ...(isTurbopackSupported && isTurbopack ? { turbopack: constructTurbopackConfig({ userNextConfig: incomingUserNextConfigObject, routeManifest, + nextJsVersion, }), } : {}), diff --git a/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts b/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts new file mode 100644 index 000000000000..4305aad537a8 --- /dev/null +++ b/packages/nextjs/test/common/devErrorSymbolicationEventProcessor.test.ts @@ -0,0 +1,261 @@ +import type { Event, EventHint, SpanJSON } from '@sentry/core'; +import { GLOBAL_OBJ } from '@sentry/core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { devErrorSymbolicationEventProcessor } from '../../src/common/devErrorSymbolicationEventProcessor'; + +vi.mock('@sentry/core', async () => { + const actual = await vi.importActual('@sentry/core'); + return { + ...actual, + debug: { + error: vi.fn(), + }, + suppressTracing: vi.fn(fn => fn()), + }; +}); + +vi.mock('stacktrace-parser', () => ({ + parse: vi.fn(), +})); + +global.fetch = vi.fn(); + +describe('devErrorSymbolicationEventProcessor', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete (GLOBAL_OBJ as any)._sentryNextJsVersion; + delete (GLOBAL_OBJ as any)._sentryBasePath; + }); + + describe('Next.js version handling', () => { + it('should return event early when _sentryNextJsVersion is undefined', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const mockHint: EventHint = { + originalException: new Error('test error'), + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = undefined; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result).toBe(mockEvent); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should return event early when _sentryNextJsVersion is null', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const mockHint: EventHint = { + originalException: new Error('test error'), + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = null; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result).toBe(mockEvent); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should return event early when _sentryNextJsVersion is empty string', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const mockHint: EventHint = { + originalException: new Error('test error'), + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = ''; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result).toBe(mockEvent); + expect(fetch).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('should return original event when no originalException in hint', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const mockHint: EventHint = {}; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '14.1.0'; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result).toBe(mockEvent); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should return original event when originalException is not an Error', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const mockHint: EventHint = { + originalException: 'string error', + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '14.1.0'; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result).toBe(mockEvent); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should return original event when Error has no stack', async () => { + const mockEvent: Event = { + exception: { + values: [ + { + stacktrace: { + frames: [{ filename: 'test.js', lineno: 1 }], + }, + }, + ], + }, + }; + + const errorWithoutStack = new Error('test error'); + delete errorWithoutStack.stack; + + const mockHint: EventHint = { + originalException: errorWithoutStack, + }; + + (GLOBAL_OBJ as any)._sentryNextJsVersion = '14.1.0'; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result).toBe(mockEvent); + expect(fetch).not.toHaveBeenCalled(); + }); + }); + + describe('transaction span filtering', () => { + it('should filter out spans with __nextjs_original-stack-frame URLs', async () => { + const mockEvent: Event = { + type: 'transaction', + spans: [ + { + data: { + 'http.url': 'http://localhost:3000/__nextjs_original-stack-frame?file=test.js', + }, + }, + { + data: { + 'http.url': 'http://localhost:3000/__nextjs_original-stack-frames', + }, + }, + { + data: { + 'http.url': 'http://localhost:3000/api/users', + }, + }, + { + data: { + 'other.attribute': 'value', + }, + }, + ] as unknown as SpanJSON[], // :^) + }; + + const mockHint: EventHint = {}; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result?.spans).toHaveLength(2); + expect(result?.spans?.[0]?.data?.['http.url']).toBe('http://localhost:3000/api/users'); + expect(result?.spans?.[1]?.data?.['other.attribute']).toBe('value'); + }); + + it('should preserve spans without http.url attribute', async () => { + const mockEvent: Event = { + type: 'transaction', + spans: [ + { + data: { + 'other.attribute': 'value', + }, + }, + ] as unknown as SpanJSON[], + }; + + const mockHint: EventHint = {}; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result?.spans).toHaveLength(1); + expect(result?.spans?.[0]?.data?.['other.attribute']).toBe('value'); + }); + + it('should handle spans with non-string http.url attribute', async () => { + const mockEvent: Event = { + type: 'transaction', + spans: [ + { + data: { + 'http.url': 123, // non-string + }, + }, + ] as unknown as SpanJSON[], + }; + + const mockHint: EventHint = {}; + + const result = await devErrorSymbolicationEventProcessor(mockEvent, mockHint); + + expect(result?.spans).toHaveLength(1); + }); + }); +}); diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts index 813d3c0f8894..9750e4245894 100644 --- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -105,7 +105,6 @@ describe('constructTurbopackConfig', () => { expect(loader.loader).toBe(windowsLoaderPath); expect(pathResolveSpy).toHaveBeenCalledWith(expect.any(String), '..', 'loaders', 'valueInjectionLoader.js'); - // Restore the original mock behavior pathResolveSpy.mockReturnValue('/mocked/path/to/valueInjectionLoader.js'); }); }); @@ -189,7 +188,7 @@ describe('constructTurbopackConfig', () => { const userNextConfig: NextConfigObject = { turbopack: { rules: { - '**/instrumentation-client.*': existingRule, + '**/instrumentation.*': existingRule, }, }, }; @@ -201,7 +200,19 @@ describe('constructTurbopackConfig', () => { expect(result).toEqual({ rules: { - '**/instrumentation-client.*': existingRule, + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + '**/instrumentation.*': existingRule, }, }); }); @@ -268,6 +279,458 @@ describe('constructTurbopackConfig', () => { }); }); }); + + describe('additional edge cases', () => { + it('should handle undefined turbopack property', () => { + const userNextConfig: NextConfigObject = { + turbopack: undefined, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + }, + }); + }); + + it('should handle null turbopack property', () => { + const userNextConfig: NextConfigObject = { + turbopack: null as any, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + nextJsVersion: '15.0.0', + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: '15.0.0', + }, + }, + }, + ], + }, + '**/instrumentation.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: '15.0.0', + }, + }, + }, + ], + }, + }, + }); + }); + + it('should preserve other turbopack properties when adding rules', () => { + const userNextConfig: NextConfigObject = { + turbopack: { + resolveAlias: { + '@': './src', + '@components': './src/components', + }, + rules: { + '*.css': ['css-loader'], + }, + }, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + nextJsVersion: '14.0.0', + }); + + expect(result).toEqual({ + resolveAlias: { + '@': './src', + '@components': './src/components', + }, + rules: { + '*.css': ['css-loader'], + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: '14.0.0', + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + '**/instrumentation.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: '14.0.0', + }, + }, + }, + ], + }, + }, + }); + }); + + it('should handle empty rules object in existing turbopack config', () => { + const userNextConfig: NextConfigObject = { + turbopack: { + rules: {}, + }, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + }, + }); + }); + + it('should handle multiple colliding instrumentation rules', () => { + const userNextConfig: NextConfigObject = { + turbopack: { + rules: { + '**/instrumentation.*': ['existing-loader'], + '**/instrumentation-client.*': { loaders: ['client-loader'] }, + }, + }, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + nextJsVersion: '14.0.0', + }); + + // Should preserve existing rules and not add new ones + expect(result).toEqual({ + rules: { + '**/instrumentation.*': ['existing-loader'], + '**/instrumentation-client.*': { loaders: ['client-loader'] }, + }, + }); + }); + }); + + describe('Next.js version injection', () => { + it('should create turbopack config with Next.js version rule when nextJsVersion is provided', () => { + const userNextConfig: NextConfigObject = {}; + const nextJsVersion = '15.1.0'; + + const result = constructTurbopackConfig({ + userNextConfig, + nextJsVersion, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + }, + }, + }, + ], + }, + '**/instrumentation.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + }, + }, + }, + ], + }, + }, + }); + }); + + it('should create turbopack config with both manifest and Next.js version rules', () => { + const userNextConfig: NextConfigObject = {}; + const nextJsVersion = '14.2.5'; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + nextJsVersion, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + '**/instrumentation.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + }, + }, + }, + ], + }, + }, + }); + }); + + it('should merge Next.js version rule with existing turbopack config', () => { + const userNextConfig: NextConfigObject = { + turbopack: { + resolveAlias: { + '@': './src', + }, + rules: { + '*.test.js': ['jest-loader'], + }, + }, + }; + const nextJsVersion = '15.0.0'; + + const result = constructTurbopackConfig({ + userNextConfig, + nextJsVersion, + }); + + expect(result).toEqual({ + resolveAlias: { + '@': './src', + }, + rules: { + '*.test.js': ['jest-loader'], + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + }, + }, + }, + ], + }, + '**/instrumentation.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + }, + }, + }, + ], + }, + }, + }); + }); + + it('should handle different Next.js version formats', () => { + const userNextConfig: NextConfigObject = {}; + const testVersions = ['13.0.0', '14.1.2-canary.1', '15.0.0-rc.1', '16.0.0']; + + testVersions.forEach(version => { + const result = constructTurbopackConfig({ + userNextConfig, + nextJsVersion: version, + }); + + expect(result.rules).toBeDefined(); + expect(result.rules!['**/instrumentation.*']).toBeDefined(); + + const rule = result.rules!['**/instrumentation.*']; + const ruleWithLoaders = rule as { loaders: Array<{ loader: string; options: any }> }; + expect(ruleWithLoaders.loaders[0]!.options.values._sentryNextJsVersion).toBe(version); + }); + }); + + it('should not create Next.js version rule when nextJsVersion is undefined', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + nextJsVersion: undefined, + }); + + expect(result).toEqual({}); + }); + + it('should not create Next.js version rule when nextJsVersion is empty string', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + nextJsVersion: '', + }); + + expect(result).toEqual({}); + }); + + it('should not override existing instrumentation rule when nextJsVersion is provided', () => { + const existingRule = { + loaders: [ + { + loader: '/existing/loader.js', + options: { custom: 'value' }, + }, + ], + }; + + const userNextConfig: NextConfigObject = { + turbopack: { + rules: { + '**/instrumentation.*': existingRule, + }, + }, + }; + const nextJsVersion = '15.1.0'; + + const result = constructTurbopackConfig({ + userNextConfig, + nextJsVersion, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + }, + }, + }, + ], + }, + '**/instrumentation.*': existingRule, + }, + }); + }); + + it('should handle all parameters together with existing config', () => { + const userNextConfig: NextConfigObject = { + turbopack: { + resolveAlias: { + '@components': './src/components', + }, + rules: { + '*.scss': ['sass-loader'], + }, + }, + }; + const nextJsVersion = '14.0.0'; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + nextJsVersion, + }); + + expect(result).toEqual({ + resolveAlias: { + '@components': './src/components', + }, + rules: { + '*.scss': ['sass-loader'], + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + '**/instrumentation.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: nextJsVersion, + }, + }, + }, + ], + }, + }, + }); + }); + }); }); describe('safelyAddTurbopackRule', () => { @@ -440,4 +903,101 @@ describe('safelyAddTurbopackRule', () => { }); }); }); + + describe('additional edge cases for safelyAddTurbopackRule', () => { + it('should handle falsy values in rules', () => { + const existingRules = { + '*.css': ['css-loader'], + '*.disabled': false as any, + '*.null': null as any, + } as any; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.disabled': false, + '*.null': null, + '*.test.js': mockRule, + } as any); + }); + + it('should handle undefined rule value', () => { + const existingRules = { + '*.css': ['css-loader'], + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: undefined as any, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': undefined, + }); + }); + + it('should handle complex matchers with special characters', () => { + const existingRules = {}; + const complexMatcher = '**/node_modules/**/*.{js,ts}'; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: complexMatcher, + rule: mockRule, + }); + + expect(result).toEqual({ + [complexMatcher]: mockRule, + }); + }); + + it('should preserve nested rule objects', () => { + const complexRule = { + loaders: [ + { + loader: '/test/loader.js', + options: { + nested: { + deep: 'value', + array: [1, 2, 3], + }, + }, + }, + ], + as: 'javascript/auto', + condition: 'test-condition', + }; + + const result = safelyAddTurbopackRule(undefined, { + matcher: '*.complex.js', + rule: complexRule, + }); + + expect(result).toEqual({ + '*.complex.js': complexRule, + }); + }); + + it('should handle matcher that matches an object property key pattern', () => { + const existingRules = { + '*.test': ['test-loader'], + 'test.*': ['pattern-loader'], + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test', + rule: mockRule, + }); + + // Should not override the existing rule + expect(result).toEqual({ + '*.test': ['test-loader'], + 'test.*': ['pattern-loader'], + }); + }); + }); }); diff --git a/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts new file mode 100644 index 000000000000..74e3d24b2cc4 --- /dev/null +++ b/packages/nextjs/test/config/turbopack/generateValueInjectionRules.test.ts @@ -0,0 +1,338 @@ +import * as path from 'path'; +import { describe, expect, it, vi } from 'vitest'; +import type { RouteManifest } from '../../../src/config/manifest/types'; +import { generateValueInjectionRules } from '../../../src/config/turbopack/generateValueInjectionRules'; + +// Mock path.resolve to return a predictable loader path +vi.mock('path', async () => { + const actual = await vi.importActual('path'); + return { + ...actual, + resolve: vi.fn().mockReturnValue('/mocked/path/to/valueInjectionLoader.js'), + }; +}); + +describe('generateValueInjectionRules', () => { + const mockRouteManifest: RouteManifest = { + dynamicRoutes: [{ path: '/users/[id]', regex: '/users/([^/]+)', paramNames: ['id'] }], + staticRoutes: [ + { path: '/users', regex: '/users' }, + { path: '/api/health', regex: '/api/health' }, + ], + }; + + describe('with no inputs', () => { + it('should return empty array when no inputs are provided', () => { + const result = generateValueInjectionRules({}); + + expect(result).toEqual([]); + }); + + it('should return empty array when inputs are undefined', () => { + const result = generateValueInjectionRules({ + routeManifest: undefined, + nextJsVersion: undefined, + }); + + expect(result).toEqual([]); + }); + }); + + describe('with nextJsVersion only', () => { + it('should generate client and server rules when nextJsVersion is provided', () => { + const result = generateValueInjectionRules({ + nextJsVersion: '14.0.0', + }); + + expect(result).toHaveLength(2); + + // Client rule + const clientRule = result.find(rule => rule.matcher === '**/instrumentation-client.*'); + expect(clientRule).toBeDefined(); + expect(clientRule?.rule).toEqual({ + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: '14.0.0', + }, + }, + }, + ], + }); + + // Server rule + const serverRule = result.find(rule => rule.matcher === '**/instrumentation.*'); + expect(serverRule).toBeDefined(); + expect(serverRule?.rule).toEqual({ + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: '14.0.0', + }, + }, + }, + ], + }); + }); + }); + + describe('with routeManifest only', () => { + it('should generate only client rule when routeManifest is provided', () => { + const result = generateValueInjectionRules({ + routeManifest: mockRouteManifest, + }); + + expect(result).toHaveLength(1); + + // Only client rule should exist + const clientRule = result.find(rule => rule.matcher === '**/instrumentation-client.*'); + expect(clientRule).toBeDefined(); + expect(clientRule?.rule).toEqual({ + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }); + + // Server rule should not exist + const serverRule = result.find(rule => rule.matcher === '**/instrumentation.*'); + expect(serverRule).toBeUndefined(); + }); + + it('should handle empty route manifest', () => { + const emptyManifest: RouteManifest = { + dynamicRoutes: [], + staticRoutes: [], + }; + + const result = generateValueInjectionRules({ + routeManifest: emptyManifest, + }); + + expect(result).toHaveLength(1); + + const clientRule = result.find(rule => rule.matcher === '**/instrumentation-client.*'); + expect(clientRule?.rule).toMatchObject({ + loaders: [ + { + options: { + values: { + _sentryRouteManifest: JSON.stringify(emptyManifest), + }, + }, + }, + ], + }); + }); + + it('should handle complex route manifest', () => { + const complexManifest: RouteManifest = { + dynamicRoutes: [ + { path: '/users/[id]', regex: '/users/([^/]+)', paramNames: ['id'] }, + { path: '/posts/[...slug]', regex: '/posts/(.*)', paramNames: ['slug'] }, + { path: '/category/[category]/[id]', regex: '/category/([^/]+)/([^/]+)', paramNames: ['category', 'id'] }, + ], + staticRoutes: [ + { path: '/', regex: '/' }, + { path: '/about', regex: '/about' }, + { path: '/api/health', regex: '/api/health' }, + { path: '/api/users', regex: '/api/users' }, + ], + }; + + const result = generateValueInjectionRules({ + routeManifest: complexManifest, + }); + + expect(result).toHaveLength(1); + + const clientRule = result.find(rule => rule.matcher === '**/instrumentation-client.*'); + expect(clientRule?.rule).toMatchObject({ + loaders: [ + { + options: { + values: { + _sentryRouteManifest: JSON.stringify(complexManifest), + }, + }, + }, + ], + }); + }); + }); + + describe('with both nextJsVersion and routeManifest', () => { + it('should generate both client and server rules with combined values', () => { + const result = generateValueInjectionRules({ + nextJsVersion: '14.0.0', + routeManifest: mockRouteManifest, + }); + + expect(result).toHaveLength(2); + + // Client rule should have both values + const clientRule = result.find(rule => rule.matcher === '**/instrumentation-client.*'); + expect(clientRule).toBeDefined(); + expect(clientRule?.rule).toEqual({ + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: '14.0.0', + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }); + + // Server rule should have only nextJsVersion + const serverRule = result.find(rule => rule.matcher === '**/instrumentation.*'); + expect(serverRule).toBeDefined(); + expect(serverRule?.rule).toEqual({ + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryNextJsVersion: '14.0.0', + }, + }, + }, + ], + }); + }); + + it('should handle all combinations of truthy and falsy values', () => { + const testCases = [ + { nextJsVersion: '14.0.0', routeManifest: mockRouteManifest, expectedRules: 2 }, + { nextJsVersion: '', routeManifest: mockRouteManifest, expectedRules: 1 }, + { nextJsVersion: '14.0.0', routeManifest: undefined, expectedRules: 2 }, + { nextJsVersion: '', routeManifest: undefined, expectedRules: 0 }, + ]; + + testCases.forEach(({ nextJsVersion, routeManifest, expectedRules }) => { + const result = generateValueInjectionRules({ + nextJsVersion: nextJsVersion || undefined, + routeManifest, + }); + + expect(result).toHaveLength(expectedRules); + }); + }); + }); + + describe('path resolution', () => { + it('should call path.resolve with correct arguments', () => { + const pathResolveSpy = vi.spyOn(path, 'resolve'); + + generateValueInjectionRules({ + nextJsVersion: '14.0.0', + }); + + expect(pathResolveSpy).toHaveBeenCalledWith(expect.any(String), '..', 'loaders', 'valueInjectionLoader.js'); + }); + + it('should use the resolved path in loader configuration', () => { + const customLoaderPath = '/custom/path/to/loader.js'; + const pathResolveSpy = vi.spyOn(path, 'resolve'); + pathResolveSpy.mockReturnValue(customLoaderPath); + + const result = generateValueInjectionRules({ + nextJsVersion: '14.0.0', + }); + + expect(result).toHaveLength(2); + + result.forEach(rule => { + const ruleWithLoaders = rule.rule as unknown as { loaders: Array<{ loader: string }> }; + expect(ruleWithLoaders.loaders[0]?.loader).toBe(customLoaderPath); + }); + }); + }); + + describe('rule structure validation', () => { + it('should generate rules with correct structure', () => { + const result = generateValueInjectionRules({ + nextJsVersion: '14.0.0', + routeManifest: mockRouteManifest, + }); + + result.forEach(rule => { + // Validate top-level structure + expect(rule).toHaveProperty('matcher'); + expect(rule).toHaveProperty('rule'); + expect(typeof rule.matcher).toBe('string'); + + // Validate rule structure + const ruleObj = rule.rule as unknown as { loaders: Array }; + expect(ruleObj).toHaveProperty('loaders'); + expect(Array.isArray(ruleObj.loaders)).toBe(true); + expect(ruleObj.loaders).toHaveLength(1); + + // Validate loader structure + const loader = ruleObj.loaders[0]; + expect(loader).toHaveProperty('loader'); + expect(loader).toHaveProperty('options'); + expect(typeof loader.loader).toBe('string'); + expect(loader.options).toHaveProperty('values'); + expect(typeof loader.options.values).toBe('object'); + }); + }); + + it('should generate different matchers for client and server rules', () => { + const result = generateValueInjectionRules({ + nextJsVersion: '14.0.0', + }); + + const matchers = result.map(rule => rule.matcher); + expect(matchers).toContain('**/instrumentation-client.*'); + expect(matchers).toContain('**/instrumentation.*'); + expect(matchers).toHaveLength(2); + }); + + it('should ensure client rules come before server rules', () => { + const result = generateValueInjectionRules({ + nextJsVersion: '14.0.0', + }); + + expect(result).toHaveLength(2); + expect(result[0]?.matcher).toBe('**/instrumentation-client.*'); + expect(result[1]?.matcher).toBe('**/instrumentation.*'); + }); + }); + + describe('edge cases', () => { + it('should handle zero-length nextJsVersion', () => { + const result = generateValueInjectionRules({ + nextJsVersion: '', + }); + + expect(result).toEqual([]); + }); + + it('should handle whitespace-only nextJsVersion', () => { + const result = generateValueInjectionRules({ + nextJsVersion: ' ', + }); + + expect(result).toHaveLength(2); + + result.forEach(rule => { + const ruleObj = rule.rule as unknown as { loaders: Array<{ options: { values: any } }> }; + expect(ruleObj.loaders[0]?.options.values._sentryNextJsVersion).toBe(' '); + }); + }); + }); +}); diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts index 6d478ea912e9..cf581bd63b66 100644 --- a/packages/node-core/src/index.ts +++ b/packages/node-core/src/index.ts @@ -21,6 +21,7 @@ export { onUnhandledRejectionIntegration } from './integrations/onunhandledrejec export { anrIntegration, disableAnrDetectionForCallback } from './integrations/anr'; export { spotlightIntegration } from './integrations/spotlight'; +export { systemErrorIntegration } from './integrations/systemError'; export { childProcessIntegration } from './integrations/childProcess'; export { createSentryWinstonTransport } from './integrations/winston'; diff --git a/packages/node-core/src/integrations/systemError.ts b/packages/node-core/src/integrations/systemError.ts new file mode 100644 index 000000000000..f1fd3f4db0dc --- /dev/null +++ b/packages/node-core/src/integrations/systemError.ts @@ -0,0 +1,76 @@ +import * as util from 'node:util'; +import { defineIntegration } from '@sentry/core'; + +const INTEGRATION_NAME = 'NodeSystemError'; + +type SystemErrorContext = { + dest?: string; // If present, the file path destination when reporting a file system error + errno: number; // The system-provided error number + path?: string; // If present, the file path when reporting a file system error +}; + +type SystemError = Error & SystemErrorContext; + +function isSystemError(error: unknown): error is SystemError { + if (!(error instanceof Error)) { + return false; + } + + if (!('errno' in error) || typeof error.errno !== 'number') { + return false; + } + + // Appears this is the recommended way to check for Node.js SystemError + // https://github.com/nodejs/node/issues/46869 + return util.getSystemErrorMap().has(error.errno); +} + +type Options = { + /** + * If true, includes the `path` and `dest` properties in the error context. + */ + includePaths?: boolean; +}; + +/** + * Captures context for Node.js SystemError errors. + */ +export const systemErrorIntegration = defineIntegration((options: Options = {}) => { + return { + name: INTEGRATION_NAME, + processEvent: (event, hint, client) => { + if (!isSystemError(hint.originalException)) { + return event; + } + + const error = hint.originalException; + + const errorContext: SystemErrorContext = { + ...error, + }; + + if (!client.getOptions().sendDefaultPii && options.includePaths !== true) { + delete errorContext.path; + delete errorContext.dest; + } + + event.contexts = { + ...event.contexts, + node_system_error: errorContext, + }; + + for (const exception of event.exception?.values || []) { + if (exception.value) { + if (error.path && exception.value.includes(error.path)) { + exception.value = exception.value.replace(`'${error.path}'`, '').trim(); + } + if (error.dest && exception.value.includes(error.dest)) { + exception.value = exception.value.replace(`'${error.dest}'`, '').trim(); + } + } + } + + return event; + }, + }; +}); diff --git a/packages/node-core/src/sdk/index.ts b/packages/node-core/src/sdk/index.ts index eb2807193b9b..e5b12166d962 100644 --- a/packages/node-core/src/sdk/index.ts +++ b/packages/node-core/src/sdk/index.ts @@ -32,6 +32,7 @@ import { onUncaughtExceptionIntegration } from '../integrations/onuncaughtexcept import { onUnhandledRejectionIntegration } from '../integrations/onunhandledrejection'; import { processSessionIntegration } from '../integrations/processSession'; import { INTEGRATION_NAME as SPOTLIGHT_INTEGRATION_NAME, spotlightIntegration } from '../integrations/spotlight'; +import { systemErrorIntegration } from '../integrations/systemError'; import { makeNodeTransport } from '../transports'; import type { NodeClientOptions, NodeOptions } from '../types'; import { isCjs } from '../utils/commonjs'; @@ -52,6 +53,7 @@ export function getDefaultIntegrations(): Integration[] { functionToStringIntegration(), linkedErrorsIntegration(), requestDataIntegration(), + systemErrorIntegration(), // Native Wrappers consoleIntegration(), httpIntegration(), diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index bba0f98bc75e..da97071bdd32 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -165,6 +165,7 @@ export { childProcessIntegration, createSentryWinstonTransport, SentryContextManager, + systemErrorIntegration, generateInstrumentOnce, getSentryRelease, defaultStackParser, diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json index 063375fe78a1..447ea338ef55 100644 --- a/packages/nuxt/package.json +++ b/packages/nuxt/package.json @@ -51,8 +51,8 @@ "@sentry/cloudflare": "10.4.0", "@sentry/core": "10.4.0", "@sentry/node": "10.4.0", - "@sentry/rollup-plugin": "^4.0.2", - "@sentry/vite-plugin": "^4.0.0", + "@sentry/rollup-plugin": "^4.1.0", + "@sentry/vite-plugin": "^4.1.0", "@sentry/vue": "10.4.0" }, "devDependencies": { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 9ffa02c164d7..35ba4419be63 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -50,11 +50,11 @@ "@opentelemetry/instrumentation": "^0.203.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@sentry/browser": "10.4.0", - "@sentry/cli": "^2.50.2", + "@sentry/cli": "^2.51.1", "@sentry/core": "10.4.0", "@sentry/node": "10.4.0", "@sentry/react": "10.4.0", - "@sentry/vite-plugin": "^4.0.0", + "@sentry/vite-plugin": "^4.1.0", "glob": "11.0.1" }, "devDependencies": { diff --git a/packages/remix/package.json b/packages/remix/package.json index d4ea69813cb3..d4f1d874d04a 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -68,7 +68,7 @@ "@opentelemetry/instrumentation": "^0.203.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@remix-run/router": "1.x", - "@sentry/cli": "^2.50.2", + "@sentry/cli": "^2.51.1", "@sentry/core": "10.4.0", "@sentry/node": "10.4.0", "@sentry/react": "10.4.0", diff --git a/packages/solidstart/package.json b/packages/solidstart/package.json index 7f8a8109ed6f..58a0debebc04 100644 --- a/packages/solidstart/package.json +++ b/packages/solidstart/package.json @@ -69,7 +69,7 @@ "@sentry/core": "10.4.0", "@sentry/node": "10.4.0", "@sentry/solid": "10.4.0", - "@sentry/vite-plugin": "^4.0.0" + "@sentry/vite-plugin": "^4.1.0" }, "devDependencies": { "@solidjs/router": "^0.13.4", diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index b51f06355d8f..2b432720ca7b 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -52,7 +52,7 @@ "@sentry/core": "10.4.0", "@sentry/node": "10.4.0", "@sentry/svelte": "10.4.0", - "@sentry/vite-plugin": "^4.0.0", + "@sentry/vite-plugin": "^4.1.0", "magic-string": "0.30.7", "recast": "0.23.11", "sorcery": "1.0.0" diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index 043bad823bb3..56400dcc5423 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -113,6 +113,7 @@ export { startSession, startSpan, startSpanManual, + systemErrorIntegration, tediousIntegration, trpcMiddleware, updateSpanName, diff --git a/yarn.lock b/yarn.lock index a79397ae7beb..4175e91b712c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6924,132 +6924,73 @@ mitt "^3.0.0" "@sentry-internal/test-utils@link:dev-packages/test-utils": - version "10.2.0" + version "10.4.0" dependencies: express "^4.21.1" -"@sentry/babel-plugin-component-annotate@4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.0.0.tgz#2f4dfeabba28a76b5a1b32a1058386e52f32634f" - integrity sha512-1sozj4esnQBhJ2QO4imiLMl1858StkLjUxFF1KxgX/X1uEL/QlW2MYL8CKzbLeACy1SkR9h4V8GXSZvCnci5Dw== - -"@sentry/babel-plugin-component-annotate@4.0.2": - version "4.0.2" - resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.0.2.tgz#7c8eb80a38b5e6b4c4cea4c391d07581020c91e4" - integrity sha512-Nr/VamvpQs6w642EI5t+qaCUGnVEro0qqk+S8XO1gc8qSdpc8kkZJFnUk7ozAr+ljYWGfVgWXrxI9lLiriLsRA== - -"@sentry/bundler-plugin-core@4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.0.0.tgz#564463cf53f869496ab5d4986e97f86618a67677" - integrity sha512-dTdbcctT5MJUwdbttZm2zomO+ui1F062ZIkogHeHqlA938Fwd1+9JIJ328+XL4XdcUG2yiFAZBWUPW3bYwoN9A== - dependencies: - "@babel/core" "^7.18.5" - "@sentry/babel-plugin-component-annotate" "4.0.0" - "@sentry/cli" "^2.49.0" - dotenv "^16.3.1" - find-up "^5.0.0" - glob "^9.3.2" - magic-string "0.30.8" - unplugin "1.0.1" +"@sentry/babel-plugin-component-annotate@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-4.1.0.tgz#6e7168f5fa59f53ac4b68e3f79c5fd54adc13f2e" + integrity sha512-UkcnqC7Bp9ODyoBN7BKcRotd1jz/I2vyruE/qjNfRC7UnP+jIRItUWYaXxQPON1fTw+N+egKdByk0M1y2OPv/Q== -"@sentry/bundler-plugin-core@4.0.2": - version "4.0.2" - resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.0.2.tgz#2e106ac564d2e4e83e8dbc84f1e84f4eed1d6dde" - integrity sha512-LeARs8qHhEw19tk+KZd9DDV+Rh/UeapIH0+C09fTmff9p8Y82Cj89pEQ2a1rdUiF/oYIjQX45vnZscB7ra42yw== +"@sentry/bundler-plugin-core@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@sentry/bundler-plugin-core/-/bundler-plugin-core-4.1.0.tgz#c1b2f7a890a44e5ac5decc984a133aacf6147dd4" + integrity sha512-/5XBtCF6M+9frEXrrvfSWOdOC2q6I1L7oY7qbUVegNkp3kYVGihNZZnJIXGzo9rmwnA0IV7jI3o0pF/HDRqPeA== dependencies: "@babel/core" "^7.18.5" - "@sentry/babel-plugin-component-annotate" "4.0.2" - "@sentry/cli" "^2.49.0" + "@sentry/babel-plugin-component-annotate" "4.1.0" + "@sentry/cli" "^2.51.0" dotenv "^16.3.1" find-up "^5.0.0" glob "^9.3.2" magic-string "0.30.8" unplugin "1.0.1" -"@sentry/cli-darwin@2.49.0": - version "2.49.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.49.0.tgz#290657e5840b360cb8ca25c8a78f8c0f15c66b03" - integrity sha512-bgowyDeFuXbjkGq1ZKqcWhmzgfBe7oKIXYWJOOps4+32QfG+YsrdNnottHS01td3bzrJq0QnHj8H12fA81DqrA== - -"@sentry/cli-darwin@2.50.2": - version "2.50.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.50.2.tgz#fcf924fcc02cfa54748ff07a380334e533635c74" - integrity sha512-0Pjpl0vQqKhwuZm19z6AlEF+ds3fJg1KWabv8WzGaSc/fwxMEwjFwOZj+IxWBJPV578cXXNvB39vYjjpCH8j7A== - -"@sentry/cli-linux-arm64@2.49.0": - version "2.49.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.49.0.tgz#a732004d7131f7e7b44f6a64abdccc36efb35d52" - integrity sha512-dqxsDUd76aDm03fUwUOs5BR7RHLpSb2EH/B1hlWm0mFvo9uY907XxW9wDFx/qDpCdmpC0aF+lF/lOBOrG9B5Fg== - -"@sentry/cli-linux-arm64@2.50.2": - version "2.50.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.50.2.tgz#ac9e6dba42095832bac8084abab4b86fdd2956f3" - integrity sha512-03Cj215M3IdoHAwevCxm5oOm9WICFpuLR05DQnODFCeIUsGvE1pZsc+Gm0Ky/ZArq2PlShBJTpbHvXbCUka+0w== - -"@sentry/cli-linux-arm@2.49.0": - version "2.49.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.49.0.tgz#73719561510df3369e05e9a4898b4e43b8753e4c" - integrity sha512-RBDIjIGmNsFw+a6vAt6m3D7ROKsMEB9i3u+UuIRxk0/DyHTcfVWxnK/ScPXGILM6PxQ2XOBfOKad0mmiDHBzZA== - -"@sentry/cli-linux-arm@2.50.2": - version "2.50.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.50.2.tgz#835acd53ca83f6be9fc0d3d85a3cd4c694051bce" - integrity sha512-jzFwg9AeeuFAFtoCcyaDEPG05TU02uOy1nAX09c1g7FtsyQlPcbhI94JQGmnPzdRjjDmORtwIUiVZQrVTkDM7w== - -"@sentry/cli-linux-i686@2.49.0": - version "2.49.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.49.0.tgz#8d1bb1378251a3aa995cc4b56bd352fa12a84b66" - integrity sha512-gDAd5/vJbEhd4Waud0Cd8ZRqLEagDlOvWwNH3KB694EiHJUwzRSiTA1YUVMYGI8Z9UyEA1sKxARwm2Trv99BxA== - -"@sentry/cli-linux-i686@2.50.2": - version "2.50.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.50.2.tgz#72f0e4bc1c515754aa11225efce711a24fb53524" - integrity sha512-J+POvB34uVyHbIYF++Bc/OCLw+gqKW0H/y/mY7rRZCiocgpk266M4NtsOBl6bEaurMx1D+BCIEjr4nc01I/rqA== - -"@sentry/cli-linux-x64@2.49.0": - version "2.49.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.49.0.tgz#7bf58fb7005c89fdde4e1262d5ed35e23065aceb" - integrity sha512-mbohGvPNhHjUciYNXzkt9TYUebTmxeAp9v9JfLSb/Soz6fubKwEHhpRJuz1zASxVWIR4PuqkePchqN5zhcLC0A== - -"@sentry/cli-linux-x64@2.50.2": - version "2.50.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.50.2.tgz#d06f8ffd65871b1373a0d2228ab254d9456a615c" - integrity sha512-81yQVRLj8rnuHoYcrM7QbOw8ubA3weiMdPtTxTim1s6WExmPgnPTKxLCr9xzxGJxFdYo3xIOhtf5JFpUX/3j4A== - -"@sentry/cli-win32-arm64@2.49.0": - version "2.49.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.49.0.tgz#2bf6dd911acbe3ddb02eec0afb4301bb8fb25b53" - integrity sha512-3zwvsp61EPpSuGpGdXY4JelVJmNEjoj4vn5m6EFoOtk7OUI5/VFqqR4wchjy9Hjm3Eh6MB5K+KTKXs4W2p18ng== - -"@sentry/cli-win32-arm64@2.50.2": - version "2.50.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.50.2.tgz#4bd7a140367c17f77d621903cfe0914232108657" - integrity sha512-QjentLGvpibgiZlmlV9ifZyxV73lnGH6pFZWU5wLeRiaYKxWtNrrHpVs+HiWlRhkwQ0mG1/S40PGNgJ20DJ3gA== - -"@sentry/cli-win32-i686@2.49.0": - version "2.49.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.49.0.tgz#32e31472ae6c5f69e538a4061d651937fcb8f14a" - integrity sha512-2oWaNl6z0BaOCAjM1Jxequfgjod3XO6wothxow4kA8e9+43JLhgarSdpwJPgQjcVyxjygwQ3/jKPdUFh0qNOmg== - -"@sentry/cli-win32-i686@2.50.2": - version "2.50.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.50.2.tgz#1eb997cf780c396446cdd8e63c6d4309894465e8" - integrity sha512-UkBIIzkQkQ1UkjQX8kHm/+e7IxnEhK6CdgSjFyNlxkwALjDWHJjMztevqAPz3kv4LdM6q1MxpQ/mOqXICNhEGg== - -"@sentry/cli-win32-x64@2.49.0": - version "2.49.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.49.0.tgz#86aab38cb41f885914d7c99ceaab7b6ce52c72c6" - integrity sha512-dR4ulyrA6ZT7x7cg4Rwm0tcHf4TZz5QO6t1W1jX6uJ9n/U0bOSqSFZHNf/RryiUzQE1g8LBthOYyKGMkET6T8w== - -"@sentry/cli-win32-x64@2.50.2": - version "2.50.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.50.2.tgz#1d0c106125b6dc87f3a598ac02519c699f17a6c0" - integrity sha512-tE27pu1sRRub1Jpmemykv3QHddBcyUk39Fsvv+n4NDpQyMgsyVPcboxBZyby44F0jkpI/q3bUH2tfCB1TYDNLg== - -"@sentry/cli@^2.49.0", "@sentry/cli@^2.50.2": - version "2.50.2" - resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.50.2.tgz#9fb90f2ae6648fc0104f0ca7f528d2e19ee0ecae" - integrity sha512-m1L9shxutF3WHSyNld6Y1vMPoXfEyQhoRh1V3SYSdl+4AB40U+zr2sRzFa2OPm7XP4zYNaWuuuHLkY/iHITs8Q== +"@sentry/cli-darwin@2.51.1": + version "2.51.1" + resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.51.1.tgz#3a1db065651893f72dad3a502b2d7c2f5e6a7dd8" + integrity sha512-R1u8IQdn/7Rr8sf6bVVr0vJT4OqwCFdYsS44Y3OoWGVJW2aAQTWRJOTlV4ueclVLAyUQzmgBjfR8AtiUhd/M5w== + +"@sentry/cli-linux-arm64@2.51.1": + version "2.51.1" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.51.1.tgz#b4c957a06bafc13623c48971eadb0cff7d3662a3" + integrity sha512-nvA/hdhsw4bKLhslgbBqqvETjXwN1FVmwHLOrRvRcejDO6zeIKUElDiL5UOjGG0NC+62AxyNw5ri8Wzp/7rg9Q== + +"@sentry/cli-linux-arm@2.51.1": + version "2.51.1" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.51.1.tgz#f761d0c58d27be503471cee4ffc41875a7d9430b" + integrity sha512-Klro17OmSSKOOSaxVKBBNPXet2+HrIDZUTSp8NRl4LQsIubdc1S/aQ79cH/g52Muwzpl3aFwPxyXw+46isfEgA== + +"@sentry/cli-linux-i686@2.51.1": + version "2.51.1" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.51.1.tgz#62baaf83c5995e478186289a45315d0acd5bd3bf" + integrity sha512-jp4TmR8VXBdT9dLo6mHniQHN0xKnmJoPGVz9h9VDvO2Vp/8o96rBc555D4Am5wJOXmfuPlyjGcmwHlB3+kQRWw== + +"@sentry/cli-linux-x64@2.51.1": + version "2.51.1" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.51.1.tgz#0010fe24ad8ef492a917c12feb351ba768e72603" + integrity sha512-JuLt0MXM2KHNFmjqXjv23sly56mJmUQzGBWktkpY3r+jE08f5NLKPd5wQ6W/SoLXGIOKnwLz0WoUg7aBVyQdeQ== + +"@sentry/cli-win32-arm64@2.51.1": + version "2.51.1" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.51.1.tgz#0894f9a91e6ecb3021ca09fe644f995ff4ff826d" + integrity sha512-PiwjTdIFDazTQCTyDCutiSkt4omggYSKnO3HE1+LDjElsFrWY9pJs4fU3D40WAyE2oKu0MarjNH/WxYGdqEAlg== + +"@sentry/cli-win32-i686@2.51.1": + version "2.51.1" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.51.1.tgz#6a6c6402cdce4fd038716b2c1e0bfa788b54f3e9" + integrity sha512-TMvZZpeiI2HmrDFNVQ0uOiTuYKvjEGOZdmUxe3WlhZW82A/2Oka7sQ24ljcOovbmBOj5+fjCHRUMYvLMCWiysA== + +"@sentry/cli-win32-x64@2.51.1": + version "2.51.1" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.51.1.tgz#d361e37146c9269d40c37459271a6c2cfa1fa8a6" + integrity sha512-v2hreYUPPTNK1/N7+DeX7XBN/zb7p539k+2Osf0HFyVBaoUC3Y3+KBwSf4ASsnmgTAK7HCGR+X0NH1vP+icw4w== + +"@sentry/cli@^2.51.0", "@sentry/cli@^2.51.1": + version "2.51.1" + resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.51.1.tgz#c6bdc6025e8f600e44fc76f8274c369aeb5d4df4" + integrity sha512-FU+54kNcKJABU0+ekvtnoXHM9zVrDe1zXVFbQT7mS0On0m1P0zFRGdzbnWe2XzpzuEAJXtK6aog/W+esRU9AIA== dependencies: https-proxy-agent "^5.0.0" node-fetch "^2.6.7" @@ -7057,37 +6998,37 @@ proxy-from-env "^1.1.0" which "^2.0.2" optionalDependencies: - "@sentry/cli-darwin" "2.50.2" - "@sentry/cli-linux-arm" "2.50.2" - "@sentry/cli-linux-arm64" "2.50.2" - "@sentry/cli-linux-i686" "2.50.2" - "@sentry/cli-linux-x64" "2.50.2" - "@sentry/cli-win32-arm64" "2.50.2" - "@sentry/cli-win32-i686" "2.50.2" - "@sentry/cli-win32-x64" "2.50.2" - -"@sentry/rollup-plugin@^4.0.2": - version "4.0.2" - resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-4.0.2.tgz#4fda1f2456cdbef7f7527921da93a92c35e67612" - integrity sha512-p8cbo34m8qcJx5LDo1hPiMlR8aSKMyVewBGBV+nGrh6cgeBUOXu8DpIVD0piOna2igon3jyUD6ydrxUtcM5/Qw== + "@sentry/cli-darwin" "2.51.1" + "@sentry/cli-linux-arm" "2.51.1" + "@sentry/cli-linux-arm64" "2.51.1" + "@sentry/cli-linux-i686" "2.51.1" + "@sentry/cli-linux-x64" "2.51.1" + "@sentry/cli-win32-arm64" "2.51.1" + "@sentry/cli-win32-i686" "2.51.1" + "@sentry/cli-win32-x64" "2.51.1" + +"@sentry/rollup-plugin@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@sentry/rollup-plugin/-/rollup-plugin-4.1.0.tgz#3948d067bd7cf8a61904b4042190dc9f6888bceb" + integrity sha512-HDwWgQRH7JhG15N1Y4XmPik/Qk03TGbiupDkZ8WL+8257BuyQE+s6feJJGCEUoWwROED+jvsFNvWvT2tqnILrw== dependencies: - "@sentry/bundler-plugin-core" "4.0.2" + "@sentry/bundler-plugin-core" "4.1.0" unplugin "1.0.1" -"@sentry/vite-plugin@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-4.0.0.tgz#ac2780e1d2d88371b0a4a6dc834eadc8bc92d8ea" - integrity sha512-JX5irzvyoOSKto0U0eXDqigsTXdXnPRQaAms/kcU6A6Bf+WaPfCTE5NrJWg6ZeLvi7GiPWch11OO+TB6ZN8RKA== +"@sentry/vite-plugin@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@sentry/vite-plugin/-/vite-plugin-4.1.0.tgz#a94eaf2a294b9f16dec99b088cb05d37b364dcf5" + integrity sha512-uLZxOAW79sOQH77yWiQct8f3i+LUi36wn2fK62cejZfrGaHu5P+9R4f0Es1L70I3MrsPXOvJ0A6r5PkVS9562g== dependencies: - "@sentry/bundler-plugin-core" "4.0.0" + "@sentry/bundler-plugin-core" "4.1.0" unplugin "1.0.1" -"@sentry/webpack-plugin@^4.0.2": - version "4.0.2" - resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-4.0.2.tgz#be90c73145d8001bc72c73e4eab83d2b1a28d330" - integrity sha512-UklVtG7Iiw+AvcL0PfiiyW/u3XT+joDAMDvWbx90rFhVSU10ENW5AV5y4pC41qChqEu3P1eBFdaSxg+kdLeqvw== +"@sentry/webpack-plugin@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@sentry/webpack-plugin/-/webpack-plugin-4.1.0.tgz#e95e2dcd10e71dc8c3a16ba5cad9153f5e78c3bc" + integrity sha512-YqfDfyGAuT/9YW1kgAPfD7kGUKQCh1E5co+qMdToxi/Mz4xsWJY02rFS5GrJixYktYJfSMze8NiRr89yJMxYHw== dependencies: - "@sentry/bundler-plugin-core" "4.0.2" + "@sentry/bundler-plugin-core" "4.1.0" unplugin "1.0.1" uuid "^9.0.0" @@ -28607,6 +28548,7 @@ stylus@0.59.0, stylus@^0.59.0: sucrase@^3.27.0, sucrase@^3.35.0, sucrase@getsentry/sucrase#es2020-polyfills: version "3.36.0" + uid fd682f6129e507c00bb4e6319cc5d6b767e36061 resolved "https://codeload.github.com/getsentry/sucrase/tar.gz/fd682f6129e507c00bb4e6319cc5d6b767e36061" dependencies: "@jridgewell/gen-mapping" "^0.3.2"