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"