diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml
index 39e73722dfa5..dd41d7d50e5e 100644
--- a/.github/workflows/auto-release.yml
+++ b/.github/workflows/auto-release.yml
@@ -9,7 +9,7 @@ on:
# This workflow tirggers a release when merging a branch with the pattern `prepare-release/VERSION` into master.
jobs:
release:
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
name: 'Prepare a new version'
steps:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 5972af74bb89..77c94ab78a11 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -62,7 +62,7 @@ env:
jobs:
job_get_metadata:
name: Get Metadata
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
permissions:
pull-requests: read
steps:
@@ -118,7 +118,7 @@ jobs:
job_build:
name: Build
needs: job_get_metadata
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
timeout-minutes: 15
if: |
needs.job_get_metadata.outputs.changed_any_code == 'true' ||
@@ -196,7 +196,7 @@ jobs:
job_check_branches:
name: Check PR branches
needs: job_get_metadata
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
if: github.event_name == 'pull_request'
permissions:
pull-requests: write
@@ -212,7 +212,7 @@ jobs:
name: Size Check
needs: [job_get_metadata, job_build]
timeout-minutes: 15
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
if:
github.event_name == 'pull_request' || needs.job_get_metadata.outputs.is_base_branch == 'true' ||
needs.job_get_metadata.outputs.is_release == 'true'
@@ -242,7 +242,7 @@ jobs:
# inter-package dependencies resolve cleanly.
needs: [job_get_metadata, job_build]
timeout-minutes: 10
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
steps:
- name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})
uses: actions/checkout@v4
@@ -267,7 +267,7 @@ jobs:
name: Check file formatting
needs: [job_get_metadata]
timeout-minutes: 10
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
steps:
- name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})
uses: actions/checkout@v4
@@ -290,7 +290,7 @@ jobs:
name: Circular Dependency Check
needs: [job_get_metadata, job_build]
timeout-minutes: 10
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
steps:
- name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})
uses: actions/checkout@v4
@@ -310,7 +310,7 @@ jobs:
job_artifacts:
name: Upload Artifacts
needs: [job_get_metadata, job_build]
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
# Build artifacts are only needed for releasing workflow.
if: needs.job_get_metadata.outputs.is_release == 'true'
steps:
@@ -347,7 +347,7 @@ jobs:
name: Browser Unit Tests
needs: [job_get_metadata, job_build]
timeout-minutes: 10
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
steps:
- name: Check out base commit (${{ github.event.pull_request.base.sha }})
uses: actions/checkout@v4
@@ -381,12 +381,20 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
+ - name: Upload test results to Codecov
+ if: cancelled() == false
+ continue-on-error: true
+ uses: codecov/test-results-action@v1
+ with:
+ files: packages/**/*.junit.xml
+ token: ${{ secrets.CODECOV_TOKEN }}
+
job_bun_unit_tests:
name: Bun Unit Tests
needs: [job_get_metadata, job_build]
if: needs.job_build.outputs.changed_bun == 'true' || github.event_name != 'pull_request'
timeout-minutes: 10
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
strategy:
fail-fast: false
steps:
@@ -413,7 +421,7 @@ jobs:
needs: [job_get_metadata, job_build]
if: needs.job_build.outputs.changed_deno == 'true' || github.event_name != 'pull_request'
timeout-minutes: 10
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
strategy:
fail-fast: false
steps:
@@ -443,7 +451,7 @@ jobs:
name: Node (${{ matrix.node }}) Unit Tests
needs: [job_get_metadata, job_build]
timeout-minutes: 10
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
strategy:
fail-fast: false
matrix:
@@ -484,6 +492,14 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
+ - name: Upload test results to Codecov
+ if: cancelled() == false
+ continue-on-error: true
+ uses: codecov/test-results-action@v1
+ with:
+ files: packages/**/*.junit.xml
+ token: ${{ secrets.CODECOV_TOKEN }}
+
job_browser_playwright_tests:
name: Playwright ${{ matrix.bundle }}${{ matrix.project && matrix.project != 'chromium' && format(' {0}', matrix.project) || ''}}${{ matrix.shard && format(' ({0}/{1})', matrix.shard, matrix.shards) || ''}} Tests
needs: [job_get_metadata, job_build]
@@ -578,7 +594,7 @@ jobs:
name: PW ${{ matrix.bundle }} Tests
needs: [job_get_metadata, job_build]
if: needs.job_build.outputs.changed_browser_integration == 'true' || github.event_name != 'pull_request'
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
timeout-minutes: 15
strategy:
fail-fast: false
@@ -638,7 +654,7 @@ jobs:
job_check_for_faulty_dts:
name: Check for faulty .d.ts files
needs: [job_get_metadata, job_build]
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
timeout-minutes: 5
steps:
- name: Check out current commit (${{ needs.job_get_metadata.outputs.commit_label }})
@@ -666,7 +682,7 @@ jobs:
Tests
needs: [job_get_metadata, job_build]
if: needs.job_build.outputs.changed_node_integration == 'true' || github.event_name != 'pull_request'
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
timeout-minutes: 15
strategy:
fail-fast: false
@@ -701,11 +717,19 @@ jobs:
working-directory: dev-packages/node-integration-tests
run: yarn test
+ - name: Upload test results to Codecov
+ if: cancelled() == false
+ continue-on-error: true
+ uses: codecov/test-results-action@v1
+ with:
+ directory: dev-packages/node-integration-tests
+ token: ${{ secrets.CODECOV_TOKEN }}
+
job_remix_integration_tests:
name: Remix (Node ${{ matrix.node }}) Tests
needs: [job_get_metadata, job_build]
if: needs.job_build.outputs.changed_remix == 'true' || github.event_name != 'pull_request'
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
timeout-minutes: 10
strategy:
fail-fast: false
@@ -737,6 +761,14 @@ jobs:
cd packages/remix
yarn test:integration:ci
+ - name: Upload test results to Codecov
+ if: cancelled() == false
+ continue-on-error: true
+ uses: codecov/test-results-action@v1
+ with:
+ directory: packages/remix
+ token: ${{ secrets.CODECOV_TOKEN }}
+
job_e2e_prepare:
name: Prepare E2E tests
# We want to run this if:
@@ -801,7 +833,7 @@ jobs:
# See: https://github.com/actions/runner/issues/2205
if: always() && needs.job_e2e_prepare.result == 'success' && needs.job_e2e_prepare.outputs.matrix != '{"include":[]}'
needs: [job_get_metadata, job_build, job_e2e_prepare]
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
timeout-minutes: 15
env:
# We just use a dummy DSN here, only send to the tunnel anyhow
@@ -923,7 +955,7 @@ jobs:
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) &&
github.actor != 'dependabot[bot]'
needs: [job_get_metadata, job_build, job_e2e_prepare]
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
timeout-minutes: 15
env:
E2E_TEST_AUTH_TOKEN: ${{ secrets.E2E_TEST_AUTH_TOKEN }}
@@ -1043,7 +1075,7 @@ jobs:
]
# Always run this, even if a dependent job failed
if: always()
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
steps:
- name: Check for failures
if: contains(needs.*.result, 'failure')
diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml
index 614a971623b3..24f25fd1ea9a 100644
--- a/.github/workflows/canary.yml
+++ b/.github/workflows/canary.yml
@@ -27,7 +27,7 @@ permissions:
jobs:
job_e2e_prepare:
name: Prepare E2E Canary tests
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
timeout-minutes: 30
steps:
- name: Check out current commit
@@ -54,7 +54,7 @@ jobs:
job_e2e_tests:
name: E2E ${{ matrix.label }} Test
needs: [job_e2e_prepare]
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
timeout-minutes: 20
env:
# We just use a dummy DSN here, only send to the tunnel anyhow
diff --git a/.github/workflows/clear-cache.yml b/.github/workflows/clear-cache.yml
index 78f1e3f66586..5c327553e3b8 100644
--- a/.github/workflows/clear-cache.yml
+++ b/.github/workflows/clear-cache.yml
@@ -21,7 +21,7 @@ on:
jobs:
clear-caches:
name: Delete all caches
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml
index f83a03a51b42..776f8135178d 100644
--- a/.github/workflows/enforce-license-compliance.yml
+++ b/.github/workflows/enforce-license-compliance.yml
@@ -17,7 +17,7 @@ on:
jobs:
enforce-license-compliance:
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
steps:
- name: 'Enforce License Compliance'
uses: getsentry/action-enforce-license-compliance@main
diff --git a/.github/workflows/external-contributors.yml b/.github/workflows/external-contributors.yml
index cc2cbdb72774..e9b1e05a2c92 100644
--- a/.github/workflows/external-contributors.yml
+++ b/.github/workflows/external-contributors.yml
@@ -12,7 +12,7 @@ jobs:
permissions:
pull-requests: write
contents: write
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
if: |
github.event.pull_request.merged == true
&& github.event.pull_request.author_association != 'COLLABORATOR'
diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml
index c24f306c98ea..190b95e6df18 100644
--- a/.github/workflows/flaky-test-detector.yml
+++ b/.github/workflows/flaky-test-detector.yml
@@ -23,7 +23,7 @@ concurrency:
jobs:
flaky-detector:
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
timeout-minutes: 60
name: 'Check tests for flakiness'
# Also skip if PR is from master -> develop
diff --git a/.github/workflows/gitflow-sync-develop.yml b/.github/workflows/gitflow-sync-develop.yml
index 9bf8b6a556d6..893dbbbf56fb 100644
--- a/.github/workflows/gitflow-sync-develop.yml
+++ b/.github/workflows/gitflow-sync-develop.yml
@@ -17,7 +17,7 @@ env:
jobs:
main:
name: Create PR master->develop
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
permissions:
pull-requests: write
contents: write
diff --git a/.github/workflows/release-comment-issues.yml b/.github/workflows/release-comment-issues.yml
index ab4c45fc8f17..4bbcb29aba21 100644
--- a/.github/workflows/release-comment-issues.yml
+++ b/.github/workflows/release-comment-issues.yml
@@ -12,7 +12,7 @@ on:
# This workflow is triggered when a release is published
jobs:
release-comment-issues:
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
name: 'Notify issues'
steps:
- name: Get version
diff --git a/.github/workflows/release-size-info.yml b/.github/workflows/release-size-info.yml
index ea0cef636b8e..04e51e5ae14e 100644
--- a/.github/workflows/release-size-info.yml
+++ b/.github/workflows/release-size-info.yml
@@ -13,7 +13,7 @@ on:
# It fetches the size-limit info from the release branch and adds it to the release
jobs:
release-size-info:
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
name: 'Add size-limit info to release'
steps:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 99569fd7f1aa..2768f18a5bc8 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -14,7 +14,7 @@ on:
default: master
jobs:
release:
- runs-on: ubuntu-22.04
+ runs-on: ubuntu-20.04
name: 'Release a new version'
steps:
- name: Get auth token
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a0116544b2de..5ddde6885dac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,10 @@
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
+## 9.0.1
+
+- ref(flags): rename unleash integration param ([#15343](https://github.com/getsentry/sentry-javascript/pull/15343))
+
## 9.0.0
Version `9.0.0` marks a release of the Sentry JavaScript SDKs that contains breaking changes.
diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/badSignature/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/badSignature/init.js
index dc92fbc296a4..1e0303b9c356 100644
--- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/badSignature/init.js
+++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/badSignature/init.js
@@ -7,7 +7,7 @@ window.UnleashClient = class {
};
window.Sentry = Sentry;
-window.sentryUnleashIntegration = Sentry.unleashIntegration({ unleashClientClass: window.UnleashClient });
+window.sentryUnleashIntegration = Sentry.unleashIntegration({ featureFlagClientClass: window.UnleashClient });
Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
diff --git a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/init.js b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/init.js
index 9f1f28730cf7..ddc74b6427b4 100644
--- a/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/init.js
+++ b/dev-packages/browser-integration-tests/suites/integrations/featureFlags/unleash/init.js
@@ -41,7 +41,7 @@ window.UnleashClient = class {
};
window.Sentry = Sentry;
-window.sentryUnleashIntegration = Sentry.unleashIntegration({ unleashClientClass: window.UnleashClient });
+window.sentryUnleashIntegration = Sentry.unleashIntegration({ featureFlagClientClass: window.UnleashClient });
Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
diff --git a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/init.js b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/init.js
new file mode 100644
index 000000000000..ecbfac30016e
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/init.js
@@ -0,0 +1,20 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window.Replay = Sentry.replayIntegration({
+ flushMinDelay: 200,
+ flushMaxDelay: 200,
+ useCompression: false,
+ _experiments: {
+ autoFlushOnFeedback: true,
+ },
+});
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ sampleRate: 0,
+ replaysSessionSampleRate: 1.0,
+ replaysOnErrorSampleRate: 0.0,
+
+ integrations: [window.Replay],
+});
diff --git a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/subject.js b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/subject.js
new file mode 100644
index 000000000000..06fee7313b72
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/subject.js
@@ -0,0 +1,9 @@
+import * as Sentry from '@sentry/browser';
+
+document.getElementById('open').addEventListener('click', () => {
+ Sentry.getClient().emit('openFeedbackWidget');
+});
+
+document.getElementById('send').addEventListener('click', () => {
+ Sentry.getClient().emit('beforeSendFeedback');
+});
diff --git a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/template.html b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/template.html
new file mode 100644
index 000000000000..2218082097dc
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/template.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/test.ts b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/test.ts
new file mode 100644
index 000000000000..41e94eef690b
--- /dev/null
+++ b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/test.ts
@@ -0,0 +1,46 @@
+import { expect } from '@playwright/test';
+
+import { sentryTest } from '../../../utils/fixtures';
+import { getExpectedReplayEvent } from '../../../utils/replayEventTemplates';
+import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers';
+
+/*
+ * In this test we want to verify that replay events are automatically flushed when user feedback is submitted via API / opening the widget.
+ * We emulate this by firing the feedback events directly, which should trigger an immediate flush of any
+ * buffered replay events, rather than waiting for the normal flush delay.
+ */
+sentryTest('replay events are flushed automatically on feedback events', async ({ getLocalTestUrl, page }) => {
+ if (shouldSkipReplayTest()) {
+ sentryTest.skip();
+ }
+
+ const reqPromise0 = waitForReplayRequest(page, 0);
+ const reqPromise1 = waitForReplayRequest(page, 1);
+ const reqPromise2 = waitForReplayRequest(page, 2);
+
+ const url = await getLocalTestUrl({ testDir: __dirname });
+
+ await page.goto(url);
+ const replayEvent0 = getReplayEvent(await reqPromise0);
+ expect(replayEvent0).toEqual(getExpectedReplayEvent());
+
+ // Trigger one mouse click
+ void page.locator('#something').click();
+
+ // Open the feedback widget which should trigger an immediate flush
+ await page.locator('#open').click();
+
+ // This should be flushed immediately due to feedback widget being opened
+ const replayEvent1 = getReplayEvent(await reqPromise1);
+ expect(replayEvent1).toEqual(getExpectedReplayEvent({ segment_id: 1, urls: [] }));
+
+ // trigger another click
+ void page.locator('#something').click();
+
+ // Send feedback via API which should trigger another immediate flush
+ await page.locator('#send').click();
+
+ // This should be flushed immediately due to feedback being sent
+ const replayEvent2 = getReplayEvent(await reqPromise2);
+ expect(replayEvent2).toEqual(getExpectedReplayEvent({ segment_id: 2, urls: [] }));
+});
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/.gitignore b/dev-packages/e2e-tests/test-applications/nestjs-11/.gitignore
new file mode 100644
index 000000000000..4b56acfbebf4
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/.gitignore
@@ -0,0 +1,56 @@
+# compiled output
+/dist
+/node_modules
+/build
+
+# Logs
+logs
+*.log
+npm-debug.log*
+pnpm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# OS
+.DS_Store
+
+# Tests
+/coverage
+/.nyc_output
+
+# IDEs and editors
+/.idea
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+
+# IDE - VSCode
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# temp directory
+.temp
+.tmp
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/.npmrc b/dev-packages/e2e-tests/test-applications/nestjs-11/.npmrc
new file mode 100644
index 000000000000..070f80f05092
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/.npmrc
@@ -0,0 +1,2 @@
+@sentry:registry=http://127.0.0.1:4873
+@sentry-internal:registry=http://127.0.0.1:4873
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/nest-cli.json b/dev-packages/e2e-tests/test-applications/nestjs-11/nest-cli.json
new file mode 100644
index 000000000000..f9aa683b1ad5
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/nest-cli.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "https://json.schemastore.org/nest-cli",
+ "collection": "@nestjs/schematics",
+ "sourceRoot": "src",
+ "compilerOptions": {
+ "deleteOutDir": true
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/package.json b/dev-packages/e2e-tests/test-applications/nestjs-11/package.json
new file mode 100644
index 000000000000..9ba374954190
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "nestjs-11",
+ "version": "0.0.1",
+ "private": true,
+ "scripts": {
+ "build": "nest build",
+ "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
+ "start": "nest start",
+ "start:dev": "nest start --watch",
+ "start:debug": "nest start --debug --watch",
+ "start:prod": "node dist/main",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test": "playwright test",
+ "test:build": "pnpm install",
+ "test:assert": "pnpm test"
+ },
+ "dependencies": {
+ "@nestjs/common": "^11.0.0",
+ "@nestjs/core": "^11.0.0",
+ "@nestjs/microservices": "^11.0.0",
+ "@nestjs/schedule": "^5.0.0",
+ "@nestjs/platform-express": "^11.0.0",
+ "@sentry/nestjs": "latest || *",
+ "reflect-metadata": "^0.2.0",
+ "rxjs": "^7.8.1"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.44.1",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "@nestjs/cli": "^11.0.0",
+ "@nestjs/schematics": "^11.0.0",
+ "@nestjs/testing": "^11.0.0",
+ "@types/express": "^4.17.17",
+ "@types/node": "^18.19.1",
+ "@types/supertest": "^6.0.0",
+ "@typescript-eslint/eslint-plugin": "^6.0.0",
+ "@typescript-eslint/parser": "^6.0.0",
+ "eslint": "^8.42.0",
+ "eslint-config-prettier": "^9.0.0",
+ "eslint-plugin-prettier": "^5.0.0",
+ "prettier": "^3.0.0",
+ "source-map-support": "^0.5.21",
+ "supertest": "^6.3.3",
+ "ts-loader": "^9.4.3",
+ "tsconfig-paths": "^4.2.0",
+ "typescript": "~5.0.0"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nestjs-11/playwright.config.mjs
new file mode 100644
index 000000000000..31f2b913b58b
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/playwright.config.mjs
@@ -0,0 +1,7 @@
+import { getPlaywrightConfig } from '@sentry-internal/test-utils';
+
+const config = getPlaywrightConfig({
+ startCommand: `pnpm start`,
+});
+
+export default config;
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/src/app.controller.ts
new file mode 100644
index 000000000000..33a6b1957d99
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/src/app.controller.ts
@@ -0,0 +1,124 @@
+import { Controller, Get, Param, ParseIntPipe, UseFilters, UseGuards, UseInterceptors } from '@nestjs/common';
+import { flush } from '@sentry/nestjs';
+import { AppService } from './app.service';
+import { AsyncInterceptor } from './async-example.interceptor';
+import { ExampleInterceptor1 } from './example-1.interceptor';
+import { ExampleInterceptor2 } from './example-2.interceptor';
+import { ExampleExceptionGlobalFilter } from './example-global-filter.exception';
+import { ExampleExceptionLocalFilter } from './example-local-filter.exception';
+import { ExampleLocalFilter } from './example-local.filter';
+import { ExampleGuard } from './example.guard';
+
+@Controller()
+@UseFilters(ExampleLocalFilter)
+export class AppController {
+ constructor(private readonly appService: AppService) {}
+
+ @Get('test-transaction')
+ testTransaction() {
+ return this.appService.testTransaction();
+ }
+
+ @Get('test-middleware-instrumentation')
+ testMiddlewareInstrumentation() {
+ return this.appService.testSpan();
+ }
+
+ @Get('test-guard-instrumentation')
+ @UseGuards(ExampleGuard)
+ testGuardInstrumentation() {
+ return {};
+ }
+
+ @Get('test-interceptor-instrumentation')
+ @UseInterceptors(ExampleInterceptor1, ExampleInterceptor2)
+ testInterceptorInstrumentation() {
+ return this.appService.testSpan();
+ }
+
+ @Get('test-async-interceptor-instrumentation')
+ @UseInterceptors(AsyncInterceptor)
+ testAsyncInterceptorInstrumentation() {
+ return this.appService.testSpan();
+ }
+
+ @Get('test-pipe-instrumentation/:id')
+ testPipeInstrumentation(@Param('id', ParseIntPipe) id: number) {
+ return { value: id };
+ }
+
+ @Get('test-exception/:id')
+ async testException(@Param('id') id: string) {
+ return this.appService.testException(id);
+ }
+
+ @Get('test-expected-400-exception/:id')
+ async testExpected400Exception(@Param('id') id: string) {
+ return this.appService.testExpected400Exception(id);
+ }
+
+ @Get('test-expected-500-exception/:id')
+ async testExpected500Exception(@Param('id') id: string) {
+ return this.appService.testExpected500Exception(id);
+ }
+
+ @Get('test-expected-rpc-exception/:id')
+ async testExpectedRpcException(@Param('id') id: string) {
+ return this.appService.testExpectedRpcException(id);
+ }
+
+ @Get('test-span-decorator-async')
+ async testSpanDecoratorAsync() {
+ return { result: await this.appService.testSpanDecoratorAsync() };
+ }
+
+ @Get('test-span-decorator-sync')
+ async testSpanDecoratorSync() {
+ return { result: await this.appService.testSpanDecoratorSync() };
+ }
+
+ @Get('kill-test-cron/:job')
+ async killTestCron(@Param('job') job: string) {
+ this.appService.killTestCron(job);
+ }
+
+ @Get('flush')
+ async flush() {
+ await flush();
+ }
+
+ @Get('example-exception-global-filter')
+ async exampleExceptionGlobalFilter() {
+ throw new ExampleExceptionGlobalFilter();
+ }
+
+ @Get('example-exception-local-filter')
+ async exampleExceptionLocalFilter() {
+ throw new ExampleExceptionLocalFilter();
+ }
+
+ @Get('test-service-use')
+ testServiceWithUseMethod() {
+ return this.appService.use();
+ }
+
+ @Get('test-service-transform')
+ testServiceWithTransform() {
+ return this.appService.transform();
+ }
+
+ @Get('test-service-intercept')
+ testServiceWithIntercept() {
+ return this.appService.intercept();
+ }
+
+ @Get('test-service-canActivate')
+ testServiceWithCanActivate() {
+ return this.appService.canActivate();
+ }
+
+ @Get('test-function-name')
+ testFunctionName() {
+ return this.appService.getFunctionName();
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/src/app.module.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/src/app.module.ts
new file mode 100644
index 000000000000..3de3c82dc925
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/src/app.module.ts
@@ -0,0 +1,29 @@
+import { MiddlewareConsumer, Module } from '@nestjs/common';
+import { APP_FILTER } from '@nestjs/core';
+import { ScheduleModule } from '@nestjs/schedule';
+import { SentryGlobalFilter, SentryModule } from '@sentry/nestjs/setup';
+import { AppController } from './app.controller';
+import { AppService } from './app.service';
+import { ExampleGlobalFilter } from './example-global.filter';
+import { ExampleMiddleware } from './example.middleware';
+
+@Module({
+ imports: [SentryModule.forRoot(), ScheduleModule.forRoot()],
+ controllers: [AppController],
+ providers: [
+ AppService,
+ {
+ provide: APP_FILTER,
+ useClass: SentryGlobalFilter,
+ },
+ {
+ provide: APP_FILTER,
+ useClass: ExampleGlobalFilter,
+ },
+ ],
+})
+export class AppModule {
+ configure(consumer: MiddlewareConsumer): void {
+ consumer.apply(ExampleMiddleware).forRoutes('test-middleware-instrumentation');
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/src/app.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/src/app.service.ts
new file mode 100644
index 000000000000..242b4c778a0e
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/src/app.service.ts
@@ -0,0 +1,113 @@
+import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
+import { RpcException } from '@nestjs/microservices';
+import { Cron, SchedulerRegistry } from '@nestjs/schedule';
+import type { MonitorConfig } from '@sentry/core';
+import * as Sentry from '@sentry/nestjs';
+import { SentryCron, SentryTraced } from '@sentry/nestjs';
+
+const monitorConfig: MonitorConfig = {
+ schedule: {
+ type: 'crontab',
+ value: '* * * * *',
+ },
+};
+
+@Injectable()
+export class AppService {
+ constructor(private schedulerRegistry: SchedulerRegistry) {}
+
+ testTransaction() {
+ Sentry.startSpan({ name: 'test-span' }, () => {
+ Sentry.startSpan({ name: 'child-span' }, () => {});
+ });
+ }
+
+ testSpan() {
+ // span that should not be a child span of the middleware span
+ Sentry.startSpan({ name: 'test-controller-span' }, () => {});
+ }
+
+ testException(id: string) {
+ throw new Error(`This is an exception with id ${id}`);
+ }
+
+ testExpected400Exception(id: string) {
+ throw new HttpException(`This is an expected 400 exception with id ${id}`, HttpStatus.BAD_REQUEST);
+ }
+
+ testExpected500Exception(id: string) {
+ throw new HttpException(`This is an expected 500 exception with id ${id}`, HttpStatus.INTERNAL_SERVER_ERROR);
+ }
+
+ testExpectedRpcException(id: string) {
+ throw new RpcException(`This is an expected RPC exception with id ${id}`);
+ }
+
+ @SentryTraced('wait and return a string')
+ async wait() {
+ await new Promise(resolve => setTimeout(resolve, 500));
+ return 'test';
+ }
+
+ async testSpanDecoratorAsync() {
+ return await this.wait();
+ }
+
+ @SentryTraced('return a string')
+ getString(): { result: string } {
+ return { result: 'test' };
+ }
+
+ @SentryTraced('return the function name')
+ getFunctionName(): { result: string } {
+ return { result: this.getFunctionName.name };
+ }
+
+ async testSpanDecoratorSync() {
+ const returned = this.getString();
+ // Will fail if getString() is async, because returned will be a Promise<>
+ return returned.result;
+ }
+
+ /*
+ Actual cron schedule differs from schedule defined in config because Sentry
+ only supports minute granularity, but we don't want to wait (worst case) a
+ full minute for the tests to finish.
+ */
+ @Cron('*/5 * * * * *', { name: 'test-cron-job' })
+ @SentryCron('test-cron-slug', monitorConfig)
+ async testCron() {
+ console.log('Test cron!');
+ }
+
+ /*
+ Actual cron schedule differs from schedule defined in config because Sentry
+ only supports minute granularity, but we don't want to wait (worst case) a
+ full minute for the tests to finish.
+ */
+ @Cron('*/5 * * * * *', { name: 'test-cron-error' })
+ @SentryCron('test-cron-error-slug', monitorConfig)
+ async testCronError() {
+ throw new Error('Test error from cron job');
+ }
+
+ async killTestCron(job: string) {
+ this.schedulerRegistry.deleteCronJob(job);
+ }
+
+ use() {
+ console.log('Test use!');
+ }
+
+ transform() {
+ console.log('Test transform!');
+ }
+
+ intercept() {
+ console.log('Test intercept!');
+ }
+
+ canActivate() {
+ console.log('Test canActivate!');
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/src/async-example.interceptor.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/src/async-example.interceptor.ts
new file mode 100644
index 000000000000..ac0ee60acc51
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/src/async-example.interceptor.ts
@@ -0,0 +1,17 @@
+import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
+import * as Sentry from '@sentry/nestjs';
+import { tap } from 'rxjs';
+
+@Injectable()
+export class AsyncInterceptor implements NestInterceptor {
+ intercept(context: ExecutionContext, next: CallHandler) {
+ Sentry.startSpan({ name: 'test-async-interceptor-span' }, () => {});
+ return Promise.resolve(
+ next.handle().pipe(
+ tap(() => {
+ Sentry.startSpan({ name: 'test-async-interceptor-span-after-route' }, () => {});
+ }),
+ ),
+ );
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-1.interceptor.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-1.interceptor.ts
new file mode 100644
index 000000000000..81c9f70d30e2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-1.interceptor.ts
@@ -0,0 +1,15 @@
+import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
+import * as Sentry from '@sentry/nestjs';
+import { tap } from 'rxjs';
+
+@Injectable()
+export class ExampleInterceptor1 implements NestInterceptor {
+ intercept(context: ExecutionContext, next: CallHandler) {
+ Sentry.startSpan({ name: 'test-interceptor-span-1' }, () => {});
+ return next.handle().pipe(
+ tap(() => {
+ Sentry.startSpan({ name: 'test-interceptor-span-after-route' }, () => {});
+ }),
+ );
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-2.interceptor.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-2.interceptor.ts
new file mode 100644
index 000000000000..2cf9dfb9e043
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-2.interceptor.ts
@@ -0,0 +1,10 @@
+import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
+import * as Sentry from '@sentry/nestjs';
+
+@Injectable()
+export class ExampleInterceptor2 implements NestInterceptor {
+ intercept(context: ExecutionContext, next: CallHandler) {
+ Sentry.startSpan({ name: 'test-interceptor-span-2' }, () => {});
+ return next.handle().pipe();
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-global-filter.exception.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-global-filter.exception.ts
new file mode 100644
index 000000000000..41981ba748fe
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-global-filter.exception.ts
@@ -0,0 +1,5 @@
+export class ExampleExceptionGlobalFilter extends Error {
+ constructor() {
+ super('Original global example exception!');
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-global.filter.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-global.filter.ts
new file mode 100644
index 000000000000..988696d0e13d
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-global.filter.ts
@@ -0,0 +1,19 @@
+import { ArgumentsHost, BadRequestException, Catch, ExceptionFilter } from '@nestjs/common';
+import { Request, Response } from 'express';
+import { ExampleExceptionGlobalFilter } from './example-global-filter.exception';
+
+@Catch(ExampleExceptionGlobalFilter)
+export class ExampleGlobalFilter implements ExceptionFilter {
+ catch(exception: BadRequestException, host: ArgumentsHost): void {
+ const ctx = host.switchToHttp();
+ const response = ctx.getResponse();
+ const request = ctx.getRequest();
+
+ response.status(400).json({
+ statusCode: 400,
+ timestamp: new Date().toISOString(),
+ path: request.url,
+ message: 'Example exception was handled by global filter!',
+ });
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-local-filter.exception.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-local-filter.exception.ts
new file mode 100644
index 000000000000..8f76520a3b94
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-local-filter.exception.ts
@@ -0,0 +1,5 @@
+export class ExampleExceptionLocalFilter extends Error {
+ constructor() {
+ super('Original local example exception!');
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-local.filter.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-local.filter.ts
new file mode 100644
index 000000000000..505217f5dcbd
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/src/example-local.filter.ts
@@ -0,0 +1,19 @@
+import { ArgumentsHost, BadRequestException, Catch, ExceptionFilter } from '@nestjs/common';
+import { Request, Response } from 'express';
+import { ExampleExceptionLocalFilter } from './example-local-filter.exception';
+
+@Catch(ExampleExceptionLocalFilter)
+export class ExampleLocalFilter implements ExceptionFilter {
+ catch(exception: BadRequestException, host: ArgumentsHost): void {
+ const ctx = host.switchToHttp();
+ const response = ctx.getResponse();
+ const request = ctx.getRequest();
+
+ response.status(400).json({
+ statusCode: 400,
+ timestamp: new Date().toISOString(),
+ path: request.url,
+ message: 'Example exception was handled by local filter!',
+ });
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/src/example.guard.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/src/example.guard.ts
new file mode 100644
index 000000000000..e12bbdc4e994
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/src/example.guard.ts
@@ -0,0 +1,10 @@
+import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
+import * as Sentry from '@sentry/nestjs';
+
+@Injectable()
+export class ExampleGuard implements CanActivate {
+ canActivate(context: ExecutionContext): boolean {
+ Sentry.startSpan({ name: 'test-guard-span' }, () => {});
+ return true;
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/src/example.middleware.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/src/example.middleware.ts
new file mode 100644
index 000000000000..31d15c9372ea
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/src/example.middleware.ts
@@ -0,0 +1,12 @@
+import { Injectable, NestMiddleware } from '@nestjs/common';
+import * as Sentry from '@sentry/nestjs';
+import { NextFunction, Request, Response } from 'express';
+
+@Injectable()
+export class ExampleMiddleware implements NestMiddleware {
+ use(req: Request, res: Response, next: NextFunction) {
+ // span that should be a child span of the middleware span
+ Sentry.startSpan({ name: 'test-middleware-span' }, () => {});
+ next();
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/src/instrument.ts
new file mode 100644
index 000000000000..4f16ebb36d11
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/src/instrument.ts
@@ -0,0 +1,12 @@
+import * as Sentry from '@sentry/nestjs';
+
+Sentry.init({
+ environment: 'qa', // dynamic sampling bias to keep transactions
+ dsn: process.env.E2E_TEST_DSN,
+ tunnel: `http://localhost:3031/`, // proxy server
+ tracesSampleRate: 1,
+ transportOptions: {
+ // We expect the app to send a lot of events in a short time
+ bufferSize: 1000,
+ },
+});
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/src/main.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/src/main.ts
new file mode 100644
index 000000000000..71ce685f4d61
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/src/main.ts
@@ -0,0 +1,15 @@
+// Import this first
+import './instrument';
+
+// Import other modules
+import { NestFactory } from '@nestjs/core';
+import { AppModule } from './app.module';
+
+const PORT = 3030;
+
+async function bootstrap() {
+ const app = await NestFactory.create(AppModule);
+ await app.listen(PORT);
+}
+
+bootstrap();
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nestjs-11/start-event-proxy.mjs
new file mode 100644
index 000000000000..d61ed3b5d609
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/start-event-proxy.mjs
@@ -0,0 +1,6 @@
+import { startEventProxyServer } from '@sentry-internal/test-utils';
+
+startEventProxyServer({
+ port: 3031,
+ proxyServerName: 'nestjs-11',
+});
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/cron-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/cron-decorator.test.ts
new file mode 100644
index 000000000000..e6ac7ae855ae
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/cron-decorator.test.ts
@@ -0,0 +1,81 @@
+import { expect, test } from '@playwright/test';
+import { waitForEnvelopeItem, waitForError } from '@sentry-internal/test-utils';
+
+test('Cron job triggers send of in_progress envelope', async ({ baseURL }) => {
+ const inProgressEnvelopePromise = waitForEnvelopeItem('nestjs-11', envelope => {
+ return (
+ envelope[0].type === 'check_in' &&
+ envelope[1]['monitor_slug'] === 'test-cron-slug' &&
+ envelope[1]['status'] === 'in_progress'
+ );
+ });
+
+ const okEnvelopePromise = waitForEnvelopeItem('nestjs-11', envelope => {
+ return (
+ envelope[0].type === 'check_in' &&
+ envelope[1]['monitor_slug'] === 'test-cron-slug' &&
+ envelope[1]['status'] === 'ok'
+ );
+ });
+
+ const inProgressEnvelope = await inProgressEnvelopePromise;
+ const okEnvelope = await okEnvelopePromise;
+
+ expect(inProgressEnvelope[1]).toEqual(
+ expect.objectContaining({
+ check_in_id: expect.any(String),
+ monitor_slug: 'test-cron-slug',
+ status: 'in_progress',
+ environment: 'qa',
+ monitor_config: {
+ schedule: {
+ type: 'crontab',
+ value: '* * * * *',
+ },
+ },
+ contexts: {
+ trace: {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ },
+ },
+ }),
+ );
+
+ expect(okEnvelope[1]).toEqual(
+ expect.objectContaining({
+ check_in_id: expect.any(String),
+ monitor_slug: 'test-cron-slug',
+ status: 'ok',
+ environment: 'qa',
+ duration: expect.any(Number),
+ contexts: {
+ trace: {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ },
+ },
+ }),
+ );
+
+ // kill cron so tests don't get stuck
+ await fetch(`${baseURL}/kill-test-cron/test-cron-job`);
+});
+
+test('Sends exceptions to Sentry on error in cron job', async ({ baseURL }) => {
+ const errorEventPromise = waitForError('nestjs-11', event => {
+ return !event.type && event.exception?.values?.[0]?.value === 'Test error from cron job';
+ });
+
+ const errorEvent = await errorEventPromise;
+
+ expect(errorEvent.exception?.values).toHaveLength(1);
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('Test error from cron job');
+ expect(errorEvent.contexts?.trace).toEqual({
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ });
+
+ // kill cron so tests don't get stuck
+ await fetch(`${baseURL}/kill-test-cron/test-cron-error`);
+});
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/errors.test.ts
new file mode 100644
index 000000000000..0fa13fea32aa
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/errors.test.ts
@@ -0,0 +1,171 @@
+import { expect, test } from '@playwright/test';
+import { waitForError, waitForTransaction } from '@sentry-internal/test-utils';
+
+test('Sends exception to Sentry', async ({ baseURL }) => {
+ const errorEventPromise = waitForError('nestjs-11', event => {
+ return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123';
+ });
+
+ const response = await fetch(`${baseURL}/test-exception/123`);
+ expect(response.status).toBe(500);
+
+ const errorEvent = await errorEventPromise;
+
+ expect(errorEvent.exception?.values).toHaveLength(1);
+ expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123');
+
+ expect(errorEvent.request).toEqual({
+ method: 'GET',
+ cookies: {},
+ headers: expect.any(Object),
+ url: 'http://localhost:3030/test-exception/123',
+ });
+
+ expect(errorEvent.transaction).toEqual('GET /test-exception/:id');
+
+ expect(errorEvent.contexts?.trace).toEqual({
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ });
+});
+
+test('Does not send HttpExceptions to Sentry', async ({ baseURL }) => {
+ let errorEventOccurred = false;
+
+ waitForError('nestjs-11', event => {
+ if (!event.type && event.exception?.values?.[0]?.value === 'This is an expected 400 exception with id 123') {
+ errorEventOccurred = true;
+ }
+
+ return event?.transaction === 'GET /test-expected-400-exception/:id';
+ });
+
+ waitForError('nestjs-11', event => {
+ if (!event.type && event.exception?.values?.[0]?.value === 'This is an expected 500 exception with id 123') {
+ errorEventOccurred = true;
+ }
+
+ return event?.transaction === 'GET /test-expected-500-exception/:id';
+ });
+
+ const transactionEventPromise400 = waitForTransaction('nestjs-11', transactionEvent => {
+ // todo(express-5): parametrize /test-expected-400-exception/:id
+ return transactionEvent?.transaction === 'GET /test-expected-400-exception/123';
+ });
+
+ const transactionEventPromise500 = waitForTransaction('nestjs-11', transactionEvent => {
+ // todo(express-5): parametrize /test-expected-500-exception/:id
+ return transactionEvent?.transaction === 'GET /test-expected-500-exception/123';
+ });
+
+ const response400 = await fetch(`${baseURL}/test-expected-400-exception/123`);
+ expect(response400.status).toBe(400);
+
+ const response500 = await fetch(`${baseURL}/test-expected-500-exception/123`);
+ expect(response500.status).toBe(500);
+
+ await transactionEventPromise400;
+ await transactionEventPromise500;
+
+ (await fetch(`${baseURL}/flush`)).text();
+
+ expect(errorEventOccurred).toBe(false);
+});
+
+test('Does not send RpcExceptions to Sentry', async ({ baseURL }) => {
+ let errorEventOccurred = false;
+
+ waitForError('nestjs-11', event => {
+ if (!event.type && event.exception?.values?.[0]?.value === 'This is an expected RPC exception with id 123') {
+ errorEventOccurred = true;
+ }
+
+ // todo(express-5): parametrize /test-expected-rpc-exception/:id
+ return event?.transaction === 'GET /test-expected-rpc-exception/123';
+ });
+
+ const transactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => {
+ // todo(express-5): parametrize /test-expected-rpc-exception/:id
+ return transactionEvent?.transaction === 'GET /test-expected-rpc-exception/123';
+ });
+
+ const response = await fetch(`${baseURL}/test-expected-rpc-exception/123`);
+ expect(response.status).toBe(500);
+
+ await transactionEventPromise;
+
+ (await fetch(`${baseURL}/flush`)).text();
+
+ expect(errorEventOccurred).toBe(false);
+});
+
+test('Global exception filter registered in main module is applied and exception is not sent to Sentry', async ({
+ baseURL,
+}) => {
+ let errorEventOccurred = false;
+
+ waitForError('nestjs-11', event => {
+ if (!event.type && event.exception?.values?.[0]?.value === 'Example exception was handled by global filter!') {
+ errorEventOccurred = true;
+ }
+
+ return event?.transaction === 'GET /example-exception-global-filter';
+ });
+
+ const transactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => {
+ return transactionEvent?.transaction === 'GET /example-exception-global-filter';
+ });
+
+ const response = await fetch(`${baseURL}/example-exception-global-filter`);
+ const responseBody = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(responseBody).toEqual({
+ statusCode: 400,
+ timestamp: expect.any(String),
+ path: '/example-exception-global-filter',
+ message: 'Example exception was handled by global filter!',
+ });
+
+ await transactionEventPromise;
+
+ (await fetch(`${baseURL}/flush`)).text();
+
+ expect(errorEventOccurred).toBe(false);
+});
+
+test('Local exception filter registered in main module is applied and exception is not sent to Sentry', async ({
+ baseURL,
+}) => {
+ let errorEventOccurred = false;
+
+ waitForError('nestjs-11', event => {
+ if (!event.type && event.exception?.values?.[0]?.value === 'Example exception was handled by local filter!') {
+ errorEventOccurred = true;
+ }
+
+ return event?.transaction === 'GET /example-exception-local-filter';
+ });
+
+ const transactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => {
+ return transactionEvent?.transaction === 'GET /example-exception-local-filter';
+ });
+
+ const response = await fetch(`${baseURL}/example-exception-local-filter`);
+ const responseBody = await response.json();
+
+ expect(response.status).toBe(400);
+ expect(responseBody).toEqual({
+ statusCode: 400,
+ timestamp: expect.any(String),
+ path: '/example-exception-local-filter',
+ message: 'Example exception was handled by local filter!',
+ });
+
+ await transactionEventPromise;
+
+ (await fetch(`${baseURL}/flush`)).text();
+
+ expect(errorEventOccurred).toBe(false);
+});
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/span-decorator.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/span-decorator.test.ts
new file mode 100644
index 000000000000..3d63c1d17953
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/span-decorator.test.ts
@@ -0,0 +1,79 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('Transaction includes span and correct value for decorated async function', async ({ baseURL }) => {
+ const transactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => {
+ return (
+ transactionEvent?.contexts?.trace?.op === 'http.server' &&
+ transactionEvent?.transaction === 'GET /test-span-decorator-async'
+ );
+ });
+
+ const response = await fetch(`${baseURL}/test-span-decorator-async`);
+ const body = await response.json();
+
+ expect(body.result).toEqual('test');
+
+ const transactionEvent = await transactionEventPromise;
+
+ expect(transactionEvent.spans).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: {
+ 'sentry.origin': 'manual',
+ 'sentry.op': 'wait and return a string',
+ },
+ description: 'wait',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ op: 'wait and return a string',
+ origin: 'manual',
+ }),
+ ]),
+ );
+});
+
+test('Transaction includes span and correct value for decorated sync function', async ({ baseURL }) => {
+ const transactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => {
+ return (
+ transactionEvent?.contexts?.trace?.op === 'http.server' &&
+ transactionEvent?.transaction === 'GET /test-span-decorator-sync'
+ );
+ });
+
+ const response = await fetch(`${baseURL}/test-span-decorator-sync`);
+ const body = await response.json();
+
+ expect(body.result).toEqual('test');
+
+ const transactionEvent = await transactionEventPromise;
+
+ expect(transactionEvent.spans).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: {
+ 'sentry.origin': 'manual',
+ 'sentry.op': 'return a string',
+ },
+ description: 'getString',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ op: 'return a string',
+ origin: 'manual',
+ }),
+ ]),
+ );
+});
+
+test('preserves original function name on decorated functions', async ({ baseURL }) => {
+ const response = await fetch(`${baseURL}/test-function-name`);
+ const body = await response.json();
+
+ expect(body.result).toEqual('getFunctionName');
+});
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts
new file mode 100644
index 000000000000..7e0947d53ec1
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/tests/transactions.test.ts
@@ -0,0 +1,732 @@
+import { expect, test } from '@playwright/test';
+import { waitForTransaction } from '@sentry-internal/test-utils';
+
+test('Sends an API route transaction', async ({ baseURL }) => {
+ const pageloadTransactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => {
+ return (
+ transactionEvent?.contexts?.trace?.op === 'http.server' &&
+ transactionEvent?.transaction === 'GET /test-transaction'
+ );
+ });
+
+ await fetch(`${baseURL}/test-transaction`);
+
+ const transactionEvent = await pageloadTransactionEventPromise;
+
+ expect(transactionEvent.contexts?.trace).toEqual({
+ data: {
+ 'sentry.source': 'url', // todo(express-5): 'route'
+ 'sentry.origin': 'auto.http.otel.http',
+ 'sentry.op': 'http.server',
+ 'sentry.sample_rate': 1,
+ url: 'http://localhost:3030/test-transaction',
+ 'otel.kind': 'SERVER',
+ 'http.response.status_code': 200,
+ 'http.url': 'http://localhost:3030/test-transaction',
+ 'http.host': 'localhost:3030',
+ 'net.host.name': 'localhost',
+ 'http.method': 'GET',
+ 'http.scheme': 'http',
+ 'http.target': '/test-transaction',
+ 'http.user_agent': 'node',
+ 'http.flavor': '1.1',
+ 'net.transport': 'ip_tcp',
+ 'net.host.ip': expect.any(String),
+ 'net.host.port': expect.any(Number),
+ 'net.peer.ip': expect.any(String),
+ 'net.peer.port': expect.any(Number),
+ 'http.status_code': 200,
+ 'http.status_text': 'OK',
+ // 'http.route': '/test-transaction', // todo(express-5): add this line again
+ },
+ op: 'http.server',
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ status: 'ok',
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ origin: 'auto.http.otel.http',
+ });
+
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ /* todo(express-5): add this part again
+ {
+ data: {
+ 'express.name': '/test-transaction',
+ 'express.type': 'request_handler',
+ 'http.route': '/test-transaction',
+ 'sentry.origin': 'auto.http.otel.express',
+ 'sentry.op': 'request_handler.express',
+ },
+ op: 'request_handler.express',
+ description: '/test-transaction',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ timestamp: expect.any(Number),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ origin: 'auto.http.otel.express',
+ }, */
+ {
+ data: {
+ 'sentry.origin': 'manual',
+ },
+ description: 'test-span',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ timestamp: expect.any(Number),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ origin: 'manual',
+ },
+ {
+ data: {
+ 'sentry.origin': 'manual',
+ },
+ description: 'child-span',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ status: 'ok',
+ timestamp: expect.any(Number),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ origin: 'manual',
+ },
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: {
+ 'sentry.origin': 'auto.http.otel.nestjs',
+ 'sentry.op': 'handler.nestjs',
+ component: '@nestjs/core',
+ 'nestjs.version': expect.any(String),
+ 'nestjs.type': 'handler',
+ 'nestjs.callback': 'testTransaction',
+ },
+ description: 'testTransaction',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ origin: 'auto.http.otel.nestjs',
+ op: 'handler.nestjs',
+ },
+ ]),
+ transaction: 'GET /test-transaction',
+ type: 'transaction',
+ transaction_info: {
+ source: 'url', // todo(express-5): 'route'
+ },
+ }),
+ );
+});
+
+test('API route transaction includes nest middleware span. Spans created in and after middleware are nested correctly', async ({
+ baseURL,
+}) => {
+ const pageloadTransactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => {
+ return (
+ transactionEvent?.contexts?.trace?.op === 'http.server' &&
+ transactionEvent?.transaction === 'GET /test-middleware-instrumentation'
+ );
+ });
+
+ const response = await fetch(`${baseURL}/test-middleware-instrumentation`);
+ expect(response.status).toBe(200);
+
+ const transactionEvent = await pageloadTransactionEventPromise;
+
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: {
+ 'sentry.op': 'middleware.nestjs',
+ 'sentry.origin': 'auto.middleware.nestjs',
+ },
+ description: 'ExampleMiddleware',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ op: 'middleware.nestjs',
+ origin: 'auto.middleware.nestjs',
+ },
+ ]),
+ }),
+ );
+
+ const exampleMiddlewareSpan = transactionEvent.spans.find(span => span.description === 'ExampleMiddleware');
+ const exampleMiddlewareSpanId = exampleMiddlewareSpan?.span_id;
+
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: expect.any(Object),
+ description: 'test-controller-span',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ origin: 'manual',
+ },
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: expect.any(Object),
+ description: 'test-middleware-span',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ origin: 'manual',
+ },
+ ]),
+ }),
+ );
+
+ // verify correct span parent-child relationships
+ const testMiddlewareSpan = transactionEvent.spans.find(span => span.description === 'test-middleware-span');
+ const testControllerSpan = transactionEvent.spans.find(span => span.description === 'test-controller-span');
+
+ // 'ExampleMiddleware' is the parent of 'test-middleware-span'
+ expect(testMiddlewareSpan.parent_span_id).toBe(exampleMiddlewareSpanId);
+
+ // 'ExampleMiddleware' is NOT the parent of 'test-controller-span'
+ expect(testControllerSpan.parent_span_id).not.toBe(exampleMiddlewareSpanId);
+});
+
+test('API route transaction includes nest guard span and span started in guard is nested correctly', async ({
+ baseURL,
+}) => {
+ const transactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => {
+ return (
+ transactionEvent?.contexts?.trace?.op === 'http.server' &&
+ transactionEvent?.transaction === 'GET /test-guard-instrumentation'
+ );
+ });
+
+ const response = await fetch(`${baseURL}/test-guard-instrumentation`);
+ expect(response.status).toBe(200);
+
+ const transactionEvent = await transactionEventPromise;
+
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: {
+ 'sentry.op': 'middleware.nestjs',
+ 'sentry.origin': 'auto.middleware.nestjs',
+ },
+ description: 'ExampleGuard',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ op: 'middleware.nestjs',
+ origin: 'auto.middleware.nestjs',
+ },
+ ]),
+ }),
+ );
+
+ const exampleGuardSpan = transactionEvent.spans.find(span => span.description === 'ExampleGuard');
+ const exampleGuardSpanId = exampleGuardSpan?.span_id;
+
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: expect.any(Object),
+ description: 'test-guard-span',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ origin: 'manual',
+ },
+ ]),
+ }),
+ );
+
+ // verify correct span parent-child relationships
+ const testGuardSpan = transactionEvent.spans.find(span => span.description === 'test-guard-span');
+
+ // 'ExampleGuard' is the parent of 'test-guard-span'
+ expect(testGuardSpan.parent_span_id).toBe(exampleGuardSpanId);
+});
+
+test('API route transaction includes nest pipe span for valid request', async ({ baseURL }) => {
+ const transactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => {
+ return (
+ transactionEvent?.contexts?.trace?.op === 'http.server' &&
+ // todo(express-5): parametrize test-pipe-instrumentation/:id
+ transactionEvent?.transaction === 'GET /test-pipe-instrumentation/123' &&
+ transactionEvent?.request?.url?.includes('/test-pipe-instrumentation/123')
+ );
+ });
+
+ const response = await fetch(`${baseURL}/test-pipe-instrumentation/123`);
+ expect(response.status).toBe(200);
+
+ const transactionEvent = await transactionEventPromise;
+
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: {
+ 'sentry.op': 'middleware.nestjs',
+ 'sentry.origin': 'auto.middleware.nestjs',
+ },
+ description: 'ParseIntPipe',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ op: 'middleware.nestjs',
+ origin: 'auto.middleware.nestjs',
+ },
+ ]),
+ }),
+ );
+});
+
+test('API route transaction includes nest pipe span for invalid request', async ({ baseURL }) => {
+ const transactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => {
+ return (
+ transactionEvent?.contexts?.trace?.op === 'http.server' &&
+ // todo(express-5): parametrize test-pipe-instrumentation/:id
+ transactionEvent?.transaction === 'GET /test-pipe-instrumentation/abc' &&
+ transactionEvent?.request?.url?.includes('/test-pipe-instrumentation/abc')
+ );
+ });
+
+ const response = await fetch(`${baseURL}/test-pipe-instrumentation/abc`);
+ expect(response.status).toBe(400);
+
+ const transactionEvent = await transactionEventPromise;
+
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: {
+ 'sentry.op': 'middleware.nestjs',
+ 'sentry.origin': 'auto.middleware.nestjs',
+ },
+ description: 'ParseIntPipe',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'unknown_error',
+ op: 'middleware.nestjs',
+ origin: 'auto.middleware.nestjs',
+ },
+ ]),
+ }),
+ );
+});
+
+test('API route transaction includes nest interceptor spans before route execution. Spans created in and after interceptor are nested correctly', async ({
+ baseURL,
+}) => {
+ const pageloadTransactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => {
+ return (
+ transactionEvent?.contexts?.trace?.op === 'http.server' &&
+ transactionEvent?.transaction === 'GET /test-interceptor-instrumentation'
+ );
+ });
+
+ const response = await fetch(`${baseURL}/test-interceptor-instrumentation`);
+ expect(response.status).toBe(200);
+
+ const transactionEvent = await pageloadTransactionEventPromise;
+
+ // check if interceptor spans before route execution exist
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: {
+ 'sentry.op': 'middleware.nestjs',
+ 'sentry.origin': 'auto.middleware.nestjs',
+ },
+ description: 'ExampleInterceptor1',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ op: 'middleware.nestjs',
+ origin: 'auto.middleware.nestjs',
+ },
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: {
+ 'sentry.op': 'middleware.nestjs',
+ 'sentry.origin': 'auto.middleware.nestjs',
+ },
+ description: 'ExampleInterceptor2',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ op: 'middleware.nestjs',
+ origin: 'auto.middleware.nestjs',
+ },
+ ]),
+ }),
+ );
+
+ // get interceptor spans
+ const exampleInterceptor1Span = transactionEvent.spans.find(span => span.description === 'ExampleInterceptor1');
+ const exampleInterceptor1SpanId = exampleInterceptor1Span?.span_id;
+ const exampleInterceptor2Span = transactionEvent.spans.find(span => span.description === 'ExampleInterceptor2');
+ const exampleInterceptor2SpanId = exampleInterceptor2Span?.span_id;
+
+ // check if manually started spans exist
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: expect.any(Object),
+ description: 'test-controller-span',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ origin: 'manual',
+ },
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: expect.any(Object),
+ description: 'test-interceptor-span-1',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ origin: 'manual',
+ },
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: expect.any(Object),
+ description: 'test-interceptor-span-2',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ origin: 'manual',
+ },
+ ]),
+ }),
+ );
+
+ // verify correct span parent-child relationships
+ const testInterceptor1Span = transactionEvent.spans.find(span => span.description === 'test-interceptor-span-1');
+ const testInterceptor2Span = transactionEvent.spans.find(span => span.description === 'test-interceptor-span-2');
+ const testControllerSpan = transactionEvent.spans.find(span => span.description === 'test-controller-span');
+
+ // 'ExampleInterceptor1' is the parent of 'test-interceptor-span-1'
+ expect(testInterceptor1Span.parent_span_id).toBe(exampleInterceptor1SpanId);
+
+ // 'ExampleInterceptor1' is NOT the parent of 'test-controller-span'
+ expect(testControllerSpan.parent_span_id).not.toBe(exampleInterceptor1SpanId);
+
+ // 'ExampleInterceptor2' is the parent of 'test-interceptor-span-2'
+ expect(testInterceptor2Span.parent_span_id).toBe(exampleInterceptor2SpanId);
+
+ // 'ExampleInterceptor2' is NOT the parent of 'test-controller-span'
+ expect(testControllerSpan.parent_span_id).not.toBe(exampleInterceptor2SpanId);
+});
+
+test('API route transaction includes exactly one nest interceptor span after route execution. Spans created in controller and in interceptor are nested correctly', async ({
+ baseURL,
+}) => {
+ const pageloadTransactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => {
+ return (
+ transactionEvent?.contexts?.trace?.op === 'http.server' &&
+ transactionEvent?.transaction === 'GET /test-interceptor-instrumentation'
+ );
+ });
+
+ const response = await fetch(`${baseURL}/test-interceptor-instrumentation`);
+ expect(response.status).toBe(200);
+
+ const transactionEvent = await pageloadTransactionEventPromise;
+
+ // check if interceptor spans after route execution exist
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: {
+ 'sentry.op': 'middleware.nestjs',
+ 'sentry.origin': 'auto.middleware.nestjs',
+ },
+ description: 'Interceptors - After Route',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ op: 'middleware.nestjs',
+ origin: 'auto.middleware.nestjs',
+ },
+ ]),
+ }),
+ );
+
+ // check that exactly one after route span is sent
+ const allInterceptorSpansAfterRoute = transactionEvent.spans.filter(
+ span => span.description === 'Interceptors - After Route',
+ );
+ expect(allInterceptorSpansAfterRoute.length).toBe(1);
+
+ // get interceptor span
+ const exampleInterceptorSpanAfterRoute = transactionEvent.spans.find(
+ span => span.description === 'Interceptors - After Route',
+ );
+ const exampleInterceptorSpanAfterRouteId = exampleInterceptorSpanAfterRoute?.span_id;
+
+ // check if manually started span in interceptor after route exists
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: expect.any(Object),
+ description: 'test-interceptor-span-after-route',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ origin: 'manual',
+ },
+ ]),
+ }),
+ );
+
+ // verify correct span parent-child relationships
+ const testInterceptorSpanAfterRoute = transactionEvent.spans.find(
+ span => span.description === 'test-interceptor-span-after-route',
+ );
+ const testControllerSpan = transactionEvent.spans.find(span => span.description === 'test-controller-span');
+
+ // 'Interceptor - After Route' is the parent of 'test-interceptor-span-after-route'
+ expect(testInterceptorSpanAfterRoute.parent_span_id).toBe(exampleInterceptorSpanAfterRouteId);
+
+ // 'Interceptor - After Route' is NOT the parent of 'test-controller-span'
+ expect(testControllerSpan.parent_span_id).not.toBe(exampleInterceptorSpanAfterRouteId);
+});
+
+test('API route transaction includes nest async interceptor spans before route execution. Spans created in and after async interceptor are nested correctly', async ({
+ baseURL,
+}) => {
+ const pageloadTransactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => {
+ return (
+ transactionEvent?.contexts?.trace?.op === 'http.server' &&
+ transactionEvent?.transaction === 'GET /test-async-interceptor-instrumentation'
+ );
+ });
+
+ const response = await fetch(`${baseURL}/test-async-interceptor-instrumentation`);
+ expect(response.status).toBe(200);
+
+ const transactionEvent = await pageloadTransactionEventPromise;
+
+ // check if interceptor spans before route execution exist
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: {
+ 'sentry.op': 'middleware.nestjs',
+ 'sentry.origin': 'auto.middleware.nestjs',
+ },
+ description: 'AsyncInterceptor',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ op: 'middleware.nestjs',
+ origin: 'auto.middleware.nestjs',
+ },
+ ]),
+ }),
+ );
+
+ // get interceptor spans
+ const exampleAsyncInterceptor = transactionEvent.spans.find(span => span.description === 'AsyncInterceptor');
+ const exampleAsyncInterceptorSpanId = exampleAsyncInterceptor?.span_id;
+
+ // check if manually started spans exist
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: expect.any(Object),
+ description: 'test-controller-span',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ origin: 'manual',
+ },
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: expect.any(Object),
+ description: 'test-async-interceptor-span',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ origin: 'manual',
+ },
+ ]),
+ }),
+ );
+
+ // verify correct span parent-child relationships
+ const testAsyncInterceptorSpan = transactionEvent.spans.find(
+ span => span.description === 'test-async-interceptor-span',
+ );
+ const testControllerSpan = transactionEvent.spans.find(span => span.description === 'test-controller-span');
+
+ // 'AsyncInterceptor' is the parent of 'test-async-interceptor-span'
+ expect(testAsyncInterceptorSpan.parent_span_id).toBe(exampleAsyncInterceptorSpanId);
+
+ // 'AsyncInterceptor' is NOT the parent of 'test-controller-span'
+ expect(testControllerSpan.parent_span_id).not.toBe(exampleAsyncInterceptorSpanId);
+});
+
+test('API route transaction includes exactly one nest async interceptor span after route execution. Spans created in controller and in async interceptor are nested correctly', async ({
+ baseURL,
+}) => {
+ const pageloadTransactionEventPromise = waitForTransaction('nestjs-11', transactionEvent => {
+ return (
+ transactionEvent?.contexts?.trace?.op === 'http.server' &&
+ transactionEvent?.transaction === 'GET /test-async-interceptor-instrumentation'
+ );
+ });
+
+ const response = await fetch(`${baseURL}/test-async-interceptor-instrumentation`);
+ expect(response.status).toBe(200);
+
+ const transactionEvent = await pageloadTransactionEventPromise;
+
+ // check if interceptor spans after route execution exist
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: {
+ 'sentry.op': 'middleware.nestjs',
+ 'sentry.origin': 'auto.middleware.nestjs',
+ },
+ description: 'Interceptors - After Route',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ op: 'middleware.nestjs',
+ origin: 'auto.middleware.nestjs',
+ },
+ ]),
+ }),
+ );
+
+ // check that exactly one after route span is sent
+ const allInterceptorSpansAfterRoute = transactionEvent.spans.filter(
+ span => span.description === 'Interceptors - After Route',
+ );
+ expect(allInterceptorSpansAfterRoute.length).toBe(1);
+
+ // get interceptor span
+ const exampleInterceptorSpanAfterRoute = transactionEvent.spans.find(
+ span => span.description === 'Interceptors - After Route',
+ );
+ const exampleInterceptorSpanAfterRouteId = exampleInterceptorSpanAfterRoute?.span_id;
+
+ // check if manually started span in interceptor after route exists
+ expect(transactionEvent).toEqual(
+ expect.objectContaining({
+ spans: expect.arrayContaining([
+ {
+ span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ trace_id: expect.stringMatching(/[a-f0-9]{32}/),
+ data: expect.any(Object),
+ description: 'test-async-interceptor-span-after-route',
+ parent_span_id: expect.stringMatching(/[a-f0-9]{16}/),
+ start_timestamp: expect.any(Number),
+ timestamp: expect.any(Number),
+ status: 'ok',
+ origin: 'manual',
+ },
+ ]),
+ }),
+ );
+
+ // verify correct span parent-child relationships
+ const testInterceptorSpanAfterRoute = transactionEvent.spans.find(
+ span => span.description === 'test-async-interceptor-span-after-route',
+ );
+ const testControllerSpan = transactionEvent.spans.find(span => span.description === 'test-controller-span');
+
+ // 'Interceptor - After Route' is the parent of 'test-interceptor-span-after-route'
+ expect(testInterceptorSpanAfterRoute.parent_span_id).toBe(exampleInterceptorSpanAfterRouteId);
+
+ // 'Interceptor - After Route' is NOT the parent of 'test-controller-span'
+ expect(testControllerSpan.parent_span_id).not.toBe(exampleInterceptorSpanAfterRouteId);
+});
+
+test('Calling use method on service with Injectable decorator returns 200', async ({ baseURL }) => {
+ const response = await fetch(`${baseURL}/test-service-use`);
+ expect(response.status).toBe(200);
+});
+
+test('Calling transform method on service with Injectable decorator returns 200', async ({ baseURL }) => {
+ const response = await fetch(`${baseURL}/test-service-transform`);
+ expect(response.status).toBe(200);
+});
+
+test('Calling intercept method on service with Injectable decorator returns 200', async ({ baseURL }) => {
+ const response = await fetch(`${baseURL}/test-service-intercept`);
+ expect(response.status).toBe(200);
+});
+
+test('Calling canActivate method on service with Injectable decorator returns 200', async ({ baseURL }) => {
+ const response = await fetch(`${baseURL}/test-service-canActivate`);
+ expect(response.status).toBe(200);
+});
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/tsconfig.build.json b/dev-packages/e2e-tests/test-applications/nestjs-11/tsconfig.build.json
new file mode 100644
index 000000000000..26c30d4eddf2
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/tsconfig.build.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig.json",
+ "exclude": ["node_modules", "test", "dist"]
+}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-11/tsconfig.json b/dev-packages/e2e-tests/test-applications/nestjs-11/tsconfig.json
new file mode 100644
index 000000000000..cf79f029c781
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/nestjs-11/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "module": "commonjs",
+ "declaration": true,
+ "removeComments": true,
+ "emitDecoratorMetadata": true,
+ "experimentalDecorators": true,
+ "allowSyntheticDefaultImports": true,
+ "target": "ES2021",
+ "sourceMap": true,
+ "outDir": "./dist",
+ "baseUrl": "./",
+ "incremental": true,
+ "skipLibCheck": true,
+ "strictNullChecks": false,
+ "noImplicitAny": false,
+ "strictBindCallApply": false,
+ "forceConsistentCasingInFileNames": false,
+ "noFallthroughCasesInSwitch": false,
+ "moduleResolution": "Node16"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/transactions.test.ts
index 96b60e5d976f..337e98decc31 100644
--- a/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/transactions.test.ts
+++ b/dev-packages/e2e-tests/test-applications/nestjs-fastify/tests/transactions.test.ts
@@ -92,7 +92,7 @@ test('Sends an API route transaction', async ({ baseURL }) => {
component: '@nestjs/core',
'nestjs.version': expect.any(String),
'nestjs.type': 'request_context',
- 'http.method': 'GET',
+ 'http.request.method': 'GET',
'http.url': '/test-transaction',
'http.route': '/test-transaction',
'nestjs.controller': 'AppController',
diff --git a/jest/jest.config.js b/jest/jest.config.js
index 495035994b37..804519918182 100644
--- a/jest/jest.config.js
+++ b/jest/jest.config.js
@@ -28,6 +28,16 @@ module.exports = {
...(process.env.CI
? {
coverageReporters: ['json', 'lcov', 'clover'],
+ reporters: [
+ 'default',
+ [
+ 'jest-junit',
+ {
+ outputName: 'jest.junit.xml',
+ classNameTemplate: '{filepath}',
+ },
+ ],
+ ],
}
: {}),
};
diff --git a/package.json b/package.json
index a57893dde86c..1f63d4c57dda 100644
--- a/package.json
+++ b/package.json
@@ -120,6 +120,7 @@
"eslint": "7.32.0",
"jest": "^27.5.1",
"jest-environment-node": "^27.5.1",
+ "jest-junit": "^16.0.0",
"jsdom": "^21.1.2",
"lerna": "7.1.1",
"madge": "7.0.0",
diff --git a/packages/angular/vitest.config.ts b/packages/angular/vitest.config.ts
index 82015893133b..4b3cb2ffacfa 100644
--- a/packages/angular/vitest.config.ts
+++ b/packages/angular/vitest.config.ts
@@ -7,7 +7,6 @@ export default defineConfig({
coverage: {},
globals: true,
setupFiles: ['./setup-test.ts'],
- reporters: ['default'],
environment: 'jsdom',
},
});
diff --git a/packages/browser/src/integrations/featureFlags/unleash/integration.ts b/packages/browser/src/integrations/featureFlags/unleash/integration.ts
index c451afb831ba..e7f4602d17d7 100644
--- a/packages/browser/src/integrations/featureFlags/unleash/integration.ts
+++ b/packages/browser/src/integrations/featureFlags/unleash/integration.ts
@@ -5,6 +5,10 @@ import { DEBUG_BUILD } from '../../../debug-build';
import { copyFlagsFromScopeToEvent, insertFlagToScope } from '../../../utils/featureFlags';
import type { UnleashClient, UnleashClientClass } from './types';
+type UnleashIntegrationOptions = {
+ featureFlagClientClass: UnleashClientClass;
+};
+
/**
* Sentry integration for capturing feature flag evaluations from the Unleash SDK.
*
@@ -17,19 +21,18 @@ import type { UnleashClient, UnleashClientClass } from './types';
*
* Sentry.init({
* dsn: '___PUBLIC_DSN___',
- * integrations: [Sentry.unleashIntegration({unleashClientClass: UnleashClient})],
+ * integrations: [Sentry.unleashIntegration({featureFlagClientClass: UnleashClient})],
* });
*
* const unleash = new UnleashClient(...);
* unleash.start();
*
* unleash.isEnabled('my-feature');
- * unleash.getVariant('other-feature');
* Sentry.captureException(new Error('something went wrong'));
* ```
*/
export const unleashIntegration = defineIntegration(
- ({ unleashClientClass }: { unleashClientClass: UnleashClientClass }) => {
+ ({ featureFlagClientClass: unleashClientClass }: UnleashIntegrationOptions) => {
return {
name: 'Unleash',
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index 805596a8a855..0c74625e31a4 100644
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -561,6 +561,11 @@ export abstract class Client {
callback: (feedback: FeedbackEvent, options?: { includeReplay?: boolean }) => void,
): () => void;
+ /**
+ * Register a callback when the feedback widget is opened in a user's browser
+ */
+ public on(hook: 'openFeedbackWidget', callback: () => void): () => void;
+
/**
* A hook for the browser tracing integrations to trigger a span start for a page load.
* @returns {() => void} A function that, when executed, removes the registered callback.
@@ -695,6 +700,11 @@ export abstract class Client {
*/
public emit(hook: 'beforeSendFeedback', feedback: FeedbackEvent, options?: { includeReplay?: boolean }): void;
+ /**
+ * Fire a hook event for when the feedback widget is opened in a user's browser
+ */
+ public emit(hook: 'openFeedbackWidget'): void;
+
/**
* Emit a hook event for browser tracing integrations to trigger a span start for a page load.
*/
diff --git a/packages/core/src/types-hoist/feedback/sendFeedback.ts b/packages/core/src/types-hoist/feedback/sendFeedback.ts
index 8f865b57038d..63d63b402b50 100644
--- a/packages/core/src/types-hoist/feedback/sendFeedback.ts
+++ b/packages/core/src/types-hoist/feedback/sendFeedback.ts
@@ -19,6 +19,7 @@ interface FeedbackContext extends Record {
replay_id?: string;
url?: string;
associated_event_id?: string;
+ source?: string;
}
/**
diff --git a/packages/feedback/src/modal/integration.tsx b/packages/feedback/src/modal/integration.tsx
index bd8b9b84f148..f4b228d814b6 100644
--- a/packages/feedback/src/modal/integration.tsx
+++ b/packages/feedback/src/modal/integration.tsx
@@ -1,4 +1,4 @@
-import { getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core';
+import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core';
import type { FeedbackFormData, FeedbackModalIntegration, IntegrationFn, User } from '@sentry/core';
import { h, render } from 'preact';
import * as hooks from 'preact/hooks';
@@ -51,6 +51,7 @@ export const feedbackModalIntegration = ((): FeedbackModalIntegration => {
open() {
renderContent(true);
options.onFormOpen?.();
+ getClient()?.emit('openFeedbackWidget');
originalOverflow = DOCUMENT.body.style.overflow;
DOCUMENT.body.style.overflow = 'hidden';
},
diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json
index ec5cbdf4668a..1ac2f8af6549 100644
--- a/packages/nestjs/package.json
+++ b/packages/nestjs/package.json
@@ -45,18 +45,20 @@
},
"dependencies": {
"@opentelemetry/core": "^1.30.1",
+ "@opentelemetry/api": "^1.9.0",
"@opentelemetry/instrumentation": "^0.57.1",
"@opentelemetry/instrumentation-nestjs-core": "0.44.0",
+ "@opentelemetry/semantic-conventions": "^1.27.0",
"@sentry/core": "9.0.0-alpha.2",
"@sentry/node": "9.0.0-alpha.2"
},
"devDependencies": {
- "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
- "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
+ "@nestjs/common": "^10.0.0",
+ "@nestjs/core": "^10.0.0"
},
"peerDependencies": {
- "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
- "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
+ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
+ "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0"
},
"scripts": {
"build": "run-p build:transpile build:types",
diff --git a/packages/nestjs/src/integrations/nest.ts b/packages/nestjs/src/integrations/nest.ts
index b7f1a8ef1485..4cc68c720541 100644
--- a/packages/nestjs/src/integrations/nest.ts
+++ b/packages/nestjs/src/integrations/nest.ts
@@ -1,6 +1,6 @@
-import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core';
import { defineIntegration } from '@sentry/core';
import { generateInstrumentOnce } from '@sentry/node';
+import { NestInstrumentation } from './sentry-nest-core-instrumentation';
import { SentryNestEventInstrumentation } from './sentry-nest-event-instrumentation';
import { SentryNestInstrumentation } from './sentry-nest-instrumentation';
diff --git a/packages/nestjs/src/integrations/sentry-nest-core-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-core-instrumentation.ts
new file mode 100644
index 000000000000..a7fdb66558ba
--- /dev/null
+++ b/packages/nestjs/src/integrations/sentry-nest-core-instrumentation.ts
@@ -0,0 +1,308 @@
+/*
+ * This file is based on code from the OpenTelemetry Authors
+ * Source: https://github.com/open-telemetry/opentelemetry-js-contrib
+ *
+ * Modified for immediate requirements while maintaining compliance
+ * with the original Apache 2.0 license terms.
+ *
+ * Original License:
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { Controller } from '@nestjs/common/interfaces';
+import type { NestFactory } from '@nestjs/core/nest-factory.js';
+import type { RouterExecutionContext } from '@nestjs/core/router/router-execution-context.js';
+import * as api from '@opentelemetry/api';
+import type { InstrumentationConfig } from '@opentelemetry/instrumentation';
+import {
+ InstrumentationBase,
+ InstrumentationNodeModuleDefinition,
+ InstrumentationNodeModuleFile,
+ isWrapped,
+} from '@opentelemetry/instrumentation';
+import { ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_ROUTE, SEMATTRS_HTTP_URL } from '@opentelemetry/semantic-conventions';
+
+import { SDK_VERSION } from '@sentry/core';
+
+const supportedVersions = ['>=4.0.0 <12'];
+const COMPONENT = '@nestjs/core';
+
+enum AttributeNames {
+ VERSION = 'nestjs.version',
+ TYPE = 'nestjs.type',
+ MODULE = 'nestjs.module',
+ CONTROLLER = 'nestjs.controller',
+ CALLBACK = 'nestjs.callback',
+ PIPES = 'nestjs.pipes',
+ INTERCEPTORS = 'nestjs.interceptors',
+ GUARDS = 'nestjs.guards',
+}
+
+export enum NestType {
+ APP_CREATION = 'app_creation',
+ REQUEST_CONTEXT = 'request_context',
+ REQUEST_HANDLER = 'handler',
+}
+
+/**
+ *
+ */
+export class NestInstrumentation extends InstrumentationBase {
+ public constructor(config: InstrumentationConfig = {}) {
+ super('sentry-nestjs', SDK_VERSION, config);
+ }
+
+ /**
+ *
+ */
+ public init(): InstrumentationNodeModuleDefinition {
+ const module = new InstrumentationNodeModuleDefinition(COMPONENT, supportedVersions);
+
+ module.files.push(
+ this._getNestFactoryFileInstrumentation(supportedVersions),
+ this._getRouterExecutionContextFileInstrumentation(supportedVersions),
+ );
+
+ return module;
+ }
+
+ /**
+ *
+ */
+ private _getNestFactoryFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile {
+ return new InstrumentationNodeModuleFile(
+ '@nestjs/core/nest-factory.js',
+ versions,
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (NestFactoryStatic: any, moduleVersion?: string) => {
+ this._ensureWrapped(
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ NestFactoryStatic.NestFactoryStatic.prototype,
+ 'create',
+ createWrapNestFactoryCreate(this.tracer, moduleVersion),
+ );
+ return NestFactoryStatic;
+ },
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (NestFactoryStatic: any) => {
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ this._unwrap(NestFactoryStatic.NestFactoryStatic.prototype, 'create');
+ },
+ );
+ }
+
+ /**
+ *
+ */
+ private _getRouterExecutionContextFileInstrumentation(versions: string[]): InstrumentationNodeModuleFile {
+ return new InstrumentationNodeModuleFile(
+ '@nestjs/core/router/router-execution-context.js',
+ versions,
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (RouterExecutionContext: any, moduleVersion?: string) => {
+ this._ensureWrapped(
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ RouterExecutionContext.RouterExecutionContext.prototype,
+ 'create',
+ createWrapCreateHandler(this.tracer, moduleVersion),
+ );
+ return RouterExecutionContext;
+ },
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (RouterExecutionContext: any) => {
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ this._unwrap(RouterExecutionContext.RouterExecutionContext.prototype, 'create');
+ },
+ );
+ }
+
+ /**
+ *
+ */
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ private _ensureWrapped(obj: any, methodName: string, wrapper: (original: any) => any): void {
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ if (isWrapped(obj[methodName])) {
+ this._unwrap(obj, methodName);
+ }
+ this._wrap(obj, methodName, wrapper);
+ }
+}
+
+function createWrapNestFactoryCreate(tracer: api.Tracer, moduleVersion?: string) {
+ return function wrapCreate(original: typeof NestFactory.create) {
+ return function createWithTrace(
+ this: typeof NestFactory,
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ nestModule: any,
+ /* serverOrOptions */
+ ) {
+ const span = tracer.startSpan('Create Nest App', {
+ attributes: {
+ component: COMPONENT,
+ [AttributeNames.TYPE]: NestType.APP_CREATION,
+ [AttributeNames.VERSION]: moduleVersion,
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ [AttributeNames.MODULE]: nestModule.name,
+ },
+ });
+ const spanContext = api.trace.setSpan(api.context.active(), span);
+
+ return api.context.with(spanContext, async () => {
+ try {
+ // todo
+ // eslint-disable-next-line prefer-rest-params, @typescript-eslint/no-explicit-any
+ return await original.apply(this, arguments as any);
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } catch (e: any) {
+ throw addError(span, e);
+ } finally {
+ span.end();
+ }
+ });
+ };
+ };
+}
+
+function createWrapCreateHandler(tracer: api.Tracer, moduleVersion?: string) {
+ return function wrapCreateHandler(original: RouterExecutionContext['create']) {
+ return function createHandlerWithTrace(
+ this: RouterExecutionContext,
+ instance: Controller,
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ callback: (...args: any[]) => unknown,
+ ) {
+ // todo
+ // eslint-disable-next-line prefer-rest-params
+ arguments[1] = createWrapHandler(tracer, moduleVersion, callback);
+ // todo
+ // eslint-disable-next-line prefer-rest-params, @typescript-eslint/no-explicit-any
+ const handler = original.apply(this, arguments as any);
+ const callbackName = callback.name;
+ const instanceName =
+ // todo
+ // eslint-disable-next-line @typescript-eslint/prefer-optional-chain
+ instance.constructor && instance.constructor.name ? instance.constructor.name : 'UnnamedInstance';
+ const spanName = callbackName ? `${instanceName}.${callbackName}` : instanceName;
+
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
+ return function (this: any, req: any, res: any, next: (...args: any[]) => unknown) {
+ const span = tracer.startSpan(spanName, {
+ attributes: {
+ component: COMPONENT,
+ [AttributeNames.VERSION]: moduleVersion,
+ [AttributeNames.TYPE]: NestType.REQUEST_CONTEXT,
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ [ATTR_HTTP_REQUEST_METHOD]: req.method,
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, deprecation/deprecation
+ [SEMATTRS_HTTP_URL]: req.originalUrl || req.url,
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
+ [ATTR_HTTP_ROUTE]: req.route?.path || req.routeOptions?.url || req.routerPath,
+ [AttributeNames.CONTROLLER]: instanceName,
+ [AttributeNames.CALLBACK]: callbackName,
+ },
+ });
+ const spanContext = api.trace.setSpan(api.context.active(), span);
+
+ return api.context.with(spanContext, async () => {
+ try {
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, prefer-rest-params
+ return await handler.apply(this, arguments as unknown);
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } catch (e: any) {
+ throw addError(span, e);
+ } finally {
+ span.end();
+ }
+ });
+ };
+ };
+ };
+}
+
+function createWrapHandler(
+ tracer: api.Tracer,
+ moduleVersion: string | undefined,
+ // todo
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ handler: Function,
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+): (this: RouterExecutionContext) => Promise {
+ const spanName = handler.name || 'anonymous nest handler';
+ const options = {
+ attributes: {
+ component: COMPONENT,
+ [AttributeNames.VERSION]: moduleVersion,
+ [AttributeNames.TYPE]: NestType.REQUEST_HANDLER,
+ [AttributeNames.CALLBACK]: handler.name,
+ },
+ };
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const wrappedHandler = function (this: RouterExecutionContext): Promise {
+ const span = tracer.startSpan(spanName, options);
+ const spanContext = api.trace.setSpan(api.context.active(), span);
+
+ return api.context.with(spanContext, async () => {
+ try {
+ // todo
+ // eslint-disable-next-line prefer-rest-params
+ return await handler.apply(this, arguments);
+ // todo
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } catch (e: any) {
+ throw addError(span, e);
+ } finally {
+ span.end();
+ }
+ });
+ };
+
+ if (handler.name) {
+ Object.defineProperty(wrappedHandler, 'name', { value: handler.name });
+ }
+
+ // Get the current metadata and set onto the wrapper to ensure other decorators ( ie: NestJS EventPattern / RolesGuard )
+ // won't be affected by the use of this instrumentation
+ Reflect.getMetadataKeys(handler).forEach(metadataKey => {
+ Reflect.defineMetadata(metadataKey, Reflect.getMetadata(metadataKey, handler), wrappedHandler);
+ });
+ return wrappedHandler;
+}
+
+const addError = (span: api.Span, error: Error): Error => {
+ span.recordException(error);
+ span.setStatus({ code: api.SpanStatusCode.ERROR, message: error.message });
+ return error;
+};
diff --git a/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts b/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts
index ea7d65176aed..c4f36728c906 100644
--- a/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts
+++ b/packages/nestjs/src/integrations/sentry-nest-instrumentation.ts
@@ -19,7 +19,7 @@ import {
import { getMiddlewareSpanOptions, getNextProxy, instrumentObservable, isPatched } from './helpers';
import type { CallHandler, CatchTarget, InjectableTarget, MinimalNestJsExecutionContext, Observable } from './types';
-const supportedVersions = ['>=8.0.0 <11'];
+const supportedVersions = ['>=8.0.0 <12'];
const COMPONENT = '@nestjs/common';
/**
diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts
index 7a19f1ea7070..89df655050e5 100644
--- a/packages/replay-internal/src/replay.ts
+++ b/packages/replay-internal/src/replay.ts
@@ -933,7 +933,7 @@ export class ReplayContainer implements ReplayContainerInterface {
// There is no way to remove these listeners, so ensure they are only added once
if (!this._hasInitializedCoreListeners) {
- addGlobalListeners(this);
+ addGlobalListeners(this, { autoFlushOnFeedback: this._options._experiments.autoFlushOnFeedback });
this._hasInitializedCoreListeners = true;
}
diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts
index 7cd4c78a21c5..6ac77d7b672f 100644
--- a/packages/replay-internal/src/types/replay.ts
+++ b/packages/replay-internal/src/types/replay.ts
@@ -239,6 +239,7 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions {
captureExceptions: boolean;
traceInternals: boolean;
continuousCheckout: number;
+ autoFlushOnFeedback: boolean;
}>;
}
diff --git a/packages/replay-internal/src/util/addGlobalListeners.ts b/packages/replay-internal/src/util/addGlobalListeners.ts
index f29ed4087d95..afa005669a46 100644
--- a/packages/replay-internal/src/util/addGlobalListeners.ts
+++ b/packages/replay-internal/src/util/addGlobalListeners.ts
@@ -17,7 +17,10 @@ import type { ReplayContainer } from '../types';
/**
* Add global listeners that cannot be removed.
*/
-export function addGlobalListeners(replay: ReplayContainer): void {
+export function addGlobalListeners(
+ replay: ReplayContainer,
+ { autoFlushOnFeedback }: { autoFlushOnFeedback?: boolean },
+): void {
// Listeners from core SDK //
const client = getClient();
@@ -57,15 +60,22 @@ export function addGlobalListeners(replay: ReplayContainer): void {
replay.lastActiveSpan = span;
});
- // We want to flush replay
- client.on('beforeSendFeedback', (feedbackEvent, options) => {
+ // We want to attach the replay id to the feedback event
+ client.on('beforeSendFeedback', async (feedbackEvent, options) => {
const replayId = replay.getSessionId();
- if (options?.includeReplay && replay.isEnabled() && replayId) {
- // This should never reject
- if (feedbackEvent.contexts?.feedback) {
- feedbackEvent.contexts.feedback.replay_id = replayId;
+ if (options?.includeReplay && replay.isEnabled() && replayId && feedbackEvent.contexts?.feedback) {
+ // In case the feedback is sent via API and not through our widget, we want to flush replay
+ if (feedbackEvent.contexts.feedback.source === 'api' && autoFlushOnFeedback) {
+ await replay.flush();
}
+ feedbackEvent.contexts.feedback.replay_id = replayId;
}
});
+
+ if (autoFlushOnFeedback) {
+ client.on('openFeedbackWidget', async () => {
+ await replay.flush();
+ });
+ }
}
}
diff --git a/packages/replay-internal/vitest.config.ts b/packages/replay-internal/vitest.config.ts
index 976d9c37074d..3aa37d27166c 100644
--- a/packages/replay-internal/vitest.config.ts
+++ b/packages/replay-internal/vitest.config.ts
@@ -7,6 +7,5 @@ export default defineConfig({
test: {
...baseConfig.test,
setupFiles: ['./test.setup.ts'],
- reporters: ['default'],
},
});
diff --git a/vite/vite.config.ts b/vite/vite.config.ts
index a5be84382388..2717ad778e1d 100644
--- a/vite/vite.config.ts
+++ b/vite/vite.config.ts
@@ -10,6 +10,10 @@ export default defineConfig({
enabled: true,
reportsDirectory: './coverage',
},
+ reporters: ['default', ...(process.env.CI ? [['junit', { classnameTemplate: '{filepath}' }]] : [])],
+ outputFile: {
+ junit: 'vitest.junit.xml',
+ },
typecheck: {
tsconfig: './tsconfig.test.json',
},
diff --git a/yarn.lock b/yarn.lock
index f1e8c2068fdb..794c60e3975a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4773,14 +4773,14 @@
iterare "1.2.1"
tslib "2.7.0"
-"@nestjs/common@^8.0.0 || ^9.0.0 || ^10.0.0":
- version "10.4.7"
- resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.7.tgz#076cb77c06149805cb1e193d8cdc69bbe8446c75"
- integrity sha512-gIOpjD3Mx8gfYGxYm/RHPcJzqdknNNFCyY+AxzBT3gc5Xvvik1Dn5OxaMGw5EbVfhZgJKVP0n83giUOAlZQe7w==
+"@nestjs/common@^10.0.0":
+ version "10.4.15"
+ resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.4.15.tgz#27c291466d9100eb86fdbe6f7bbb4d1a6ad55f70"
+ integrity sha512-vaLg1ZgwhG29BuLDxPA9OAcIlgqzp9/N8iG0wGapyUNTf4IY4O6zAHgN6QalwLhFxq7nOI021vdRojR1oF3bqg==
dependencies:
uid "2.0.2"
iterare "1.2.1"
- tslib "2.7.0"
+ tslib "2.8.1"
"@nestjs/core@10.4.6":
version "10.4.6"
@@ -4794,17 +4794,17 @@
path-to-regexp "3.3.0"
tslib "2.7.0"
-"@nestjs/core@^8.0.0 || ^9.0.0 || ^10.0.0":
- version "10.4.7"
- resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-10.4.7.tgz#adb27067a8c40b79f0713b417457fdfc6cf3406a"
- integrity sha512-AIpQzW/vGGqSLkKvll1R7uaSNv99AxZI2EFyVJPNGDgFsfXaohfV1Ukl6f+s75Km+6Fj/7aNl80EqzNWQCS8Ig==
+"@nestjs/core@^10.0.0":
+ version "10.4.15"
+ resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-10.4.15.tgz#1343a3395d5c54e9b792608cb75eef39053806d5"
+ integrity sha512-UBejmdiYwaH6fTsz2QFBlC1cJHM+3UDeLZN+CiP9I1fRv2KlBZsmozGLbV5eS1JAVWJB4T5N5yQ0gjN8ZvcS2w==
dependencies:
uid "2.0.2"
"@nuxtjs/opencollective" "0.3.2"
fast-safe-stringify "2.1.1"
iterare "1.2.1"
path-to-regexp "3.3.0"
- tslib "2.7.0"
+ tslib "2.8.1"
"@nestjs/platform-express@10.4.6":
version "10.4.6"
@@ -19114,6 +19114,16 @@ jest-jasmine2@^27.5.1:
pretty-format "^27.5.1"
throat "^6.0.1"
+jest-junit@^16.0.0:
+ version "16.0.0"
+ resolved "https://registry.yarnpkg.com/jest-junit/-/jest-junit-16.0.0.tgz#d838e8c561cf9fdd7eb54f63020777eee4136785"
+ integrity sha512-A94mmw6NfJab4Fg/BlvVOUXzXgF0XIH6EmTgJ5NDPp4xoKq0Kr7sErb+4Xs9nZvu58pJojz5RFGpqnZYJTrRfQ==
+ dependencies:
+ mkdirp "^1.0.4"
+ strip-ansi "^6.0.1"
+ uuid "^8.3.2"
+ xml "^1.0.1"
+
jest-leak-detector@^27.5.1:
version "27.5.1"
resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz#6ec9d54c3579dd6e3e66d70e3498adf80fde3fb8"
@@ -28613,6 +28623,11 @@ tslib@2.7.0, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01"
integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==
+tslib@2.8.1:
+ version "2.8.1"
+ resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
+ integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
+
tslib@^1.11.1, tslib@^1.8.1, tslib@^1.9.0:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
@@ -30554,6 +30569,11 @@ xml-name-validator@^4.0.0:
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835"
integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==
+xml@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5"
+ integrity sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==
+
xmlchars@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"