diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b36572032b1..67d956b620be 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -895,6 +895,7 @@ jobs: 'node-express-cjs-preload', 'node-otel-sdk-node', 'node-otel-custom-sampler', + 'node-otel-without-tracing', 'ember-classic', 'ember-embroider', 'nextjs-app-dir', @@ -923,6 +924,7 @@ jobs: 'nestjs-distributed-tracing', 'nestjs-with-submodules', 'nestjs-with-submodules-decorator', + 'nestjs-basic-with-graphql', 'nestjs-graphql', 'node-exports-test-app', 'node-koa', diff --git a/.gitignore b/.gitignore index 3cc2319ea8a9..be853254d612 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ local.log .rpt2_cache lint-results.json +trace.zip # legacy tmp.js @@ -58,3 +59,6 @@ packages/deno/lib.deno.d.ts # gatsby packages/gatsby/gatsby-node.d.ts + +# intellij +*.iml diff --git a/CHANGELOG.md b/CHANGELOG.md index 52187092ea55..88b384d4c427 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,41 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 8.32.0 + +### Important Changes + +- **ref(browser): Move navigation span descriptions into op + ([#13527](https://github.com/getsentry/sentry-javascript/pull/13527))** + +Moves the description of navigation related browser spans into the op, e.g. browser - cache -> browser.cache and sets +the description to the performanceEntry objects' names (in this context it is the URL of the page). + +- **feat(node): Add amqplibIntegration ([#13714](https://github.com/getsentry/sentry-javascript/pull/13714))** + +- **feat(nestjs): Add `SentryGlobalGenericFilter` and allow specifying application ref in global filter + ([#13673](https://github.com/getsentry/sentry-javascript/pull/13673))** + +Adds a `SentryGlobalGenericFilter` that filters both graphql and http exceptions depending on the context. + +- **feat: Set log level for Fetch/XHR breadcrumbs based on status code + ([#13711](https://github.com/getsentry/sentry-javascript/pull/13711))** + +Sets log levels in breadcrumbs for 5xx to error and 4xx to warning. + +### Other Changes + +- chore(nextjs): Bump rollup to 3.29.5 ([#13761](https://github.com/getsentry/sentry-javascript/pull/13761)) +- fix(core): Remove `sampled` flag from dynamic sampling context in Tracing without Performance mode + ([#13753](https://github.com/getsentry/sentry-javascript/pull/13753)) +- fix(node): Ensure node-fetch does not emit spans without tracing + ([#13765](https://github.com/getsentry/sentry-javascript/pull/13765)) +- fix(nuxt): Use Nuxt error hooks instead of errorHandler to prevent 500 + ([#13748](https://github.com/getsentry/sentry-javascript/pull/13748)) +- fix(test): Unflake LCP test ([#13741](https://github.com/getsentry/sentry-javascript/pull/13741)) + +Work in this release was contributed by @Zen-cronic and @Sjoertjuh. Thank you for your contributions! + ## 8.31.0 ### Important Changes diff --git a/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/fetch/statusCode/subject.js b/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/fetch/statusCode/subject.js new file mode 100644 index 000000000000..1cbadc6e36e6 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/fetch/statusCode/subject.js @@ -0,0 +1,3 @@ +fetch('http://sentry-test.io/foo').then(() => { + Sentry.captureException('test error'); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/fetch/statusCode/test.ts b/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/fetch/statusCode/test.ts new file mode 100644 index 000000000000..70cd868ccfe1 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/fetch/statusCode/test.ts @@ -0,0 +1,71 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../../utils/helpers'; + +sentryTest('captures Breadcrumb with log level for 4xx response code', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/foo', async route => { + await route.fulfill({ + status: 404, + contentType: 'text/plain', + body: 'Not Found!', + }); + }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'GET', + status_code: 404, + url: 'http://sentry-test.io/foo', + }, + level: 'warning', + }); + + await page.route('**/foo', async route => { + await route.fulfill({ + status: 500, + contentType: 'text/plain', + body: 'Internal Server Error', + }); + }); +}); + +sentryTest('captures Breadcrumb with log level for 5xx response code', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/foo', async route => { + await route.fulfill({ + status: 500, + contentType: 'text/plain', + body: 'Internal Server Error', + }); + }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'fetch', + type: 'http', + data: { + method: 'GET', + status_code: 500, + url: 'http://sentry-test.io/foo', + }, + level: 'error', + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/xhr/statusCode/subject.js b/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/xhr/statusCode/subject.js new file mode 100644 index 000000000000..8202bb03803b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/xhr/statusCode/subject.js @@ -0,0 +1,10 @@ +const xhr = new XMLHttpRequest(); + +xhr.open('GET', 'http://sentry-test.io/foo'); +xhr.send(); + +xhr.addEventListener('readystatechange', function () { + if (xhr.readyState === 4) { + Sentry.captureException('test error'); + } +}); diff --git a/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/xhr/statusCode/test.ts b/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/xhr/statusCode/test.ts new file mode 100644 index 000000000000..eb7014df5890 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/integrations/Breadcrumbs/xhr/statusCode/test.ts @@ -0,0 +1,71 @@ +import { expect } from '@playwright/test'; +import type { Event } from '@sentry/types'; + +import { sentryTest } from '../../../../../utils/fixtures'; +import { getFirstSentryEnvelopeRequest } from '../../../../../utils/helpers'; + +sentryTest('captures Breadcrumb with log level for 4xx response code', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/foo', async route => { + await route.fulfill({ + status: 404, + contentType: 'text/plain', + body: 'Not Found!', + }); + }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'GET', + status_code: 404, + url: 'http://sentry-test.io/foo', + }, + level: 'warning', + }); + + await page.route('**/foo', async route => { + await route.fulfill({ + status: 500, + contentType: 'text/plain', + body: 'Internal Server Error', + }); + }); +}); + +sentryTest('captures Breadcrumb with log level for 5xx response code', async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + + await page.route('**/foo', async route => { + await route.fulfill({ + status: 500, + contentType: 'text/plain', + body: 'Internal Server Error', + }); + }); + + const eventData = await getFirstSentryEnvelopeRequest(page, url); + + expect(eventData.exception?.values).toHaveLength(1); + + expect(eventData?.breadcrumbs?.length).toBe(1); + expect(eventData!.breadcrumbs![0]).toEqual({ + timestamp: expect.any(Number), + category: 'xhr', + type: 'http', + data: { + method: 'GET', + status_code: 500, + url: 'http://sentry-test.io/foo', + }, + level: 'error', + }); +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/handlers-lcp/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/handlers-lcp/test.ts index a2801f4e4016..75f09e12e53d 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/handlers-lcp/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/handlers-lcp/test.ts @@ -20,32 +20,39 @@ sentryTest( ); const url = await getLocalTestPath({ testDir: __dirname }); + const [eventData] = await Promise.all([ getFirstSentryEnvelopeRequest(page), page.goto(url), - page.click('button'), + page.locator('button').click(), ]); expect(eventData.measurements).toBeDefined(); expect(eventData.measurements?.lcp?.value).toBeDefined(); - expect(eventData.contexts?.trace?.data?.['lcp.element']).toBe('body > img'); - expect(eventData.contexts?.trace?.data?.['lcp.size']).toBe(107400); - expect(eventData.contexts?.trace?.data?.['lcp.url']).toBe('https://example.com/path/to/image.png'); + // This should be body > img, but it can be flakey as sometimes it will report + // the button as LCP. + expect(eventData.contexts?.trace?.data?.['lcp.element'].startsWith('body >')).toBe(true); + + // Working around flakiness + // Only testing this when the LCP element is an image, not a button + if (eventData.contexts?.trace?.data?.['lcp.element'] === 'body > img') { + expect(eventData.contexts?.trace?.data?.['lcp.size']).toBe(107400); - const lcp = await (await page.waitForFunction('window._LCP')).jsonValue(); - const lcp2 = await (await page.waitForFunction('window._LCP2')).jsonValue(); - const lcp3 = await page.evaluate('window._LCP3'); + const lcp = await (await page.waitForFunction('window._LCP')).jsonValue(); + const lcp2 = await (await page.waitForFunction('window._LCP2')).jsonValue(); + const lcp3 = await page.evaluate('window._LCP3'); - expect(lcp).toEqual(107400); - expect(lcp2).toEqual(107400); - // this has not been triggered yet - expect(lcp3).toEqual(undefined); + expect(lcp).toEqual(107400); + expect(lcp2).toEqual(107400); + // this has not been triggered yet + expect(lcp3).toEqual(undefined); - // Adding a handler after LCP is completed still triggers the handler - await page.evaluate('window.ADD_HANDLER()'); - const lcp3_2 = await (await page.waitForFunction('window._LCP3')).jsonValue(); + // Adding a handler after LCP is completed still triggers the handler + await page.evaluate('window.ADD_HANDLER()'); + const lcp3_2 = await (await page.waitForFunction('window._LCP3')).jsonValue(); - expect(lcp3_2).toEqual(107400); + expect(lcp3_2).toEqual(107400); + } }, ); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-browser-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-browser-spans/test.ts index 2b2d5fa8bae5..90660de34ded 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-browser-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-browser-spans/test.ts @@ -12,7 +12,7 @@ sentryTest('should add browser-related spans to pageload transaction', async ({ const url = await getLocalTestPath({ testDir: __dirname }); const eventData = await getFirstSentryEnvelopeRequest(page, url); - const browserSpans = eventData.spans?.filter(({ op }) => op === 'browser'); + const browserSpans = eventData.spans?.filter(({ op }) => op?.startsWith('browser')); // Spans `domContentLoadedEvent`, `connect`, `cache` and `DNS` are not // always inside `pageload` transaction. @@ -21,7 +21,8 @@ sentryTest('should add browser-related spans to pageload transaction', async ({ ['loadEvent', 'request', 'response'].forEach(eventDesc => expect(browserSpans).toContainEqual( expect.objectContaining({ - description: eventDesc, + op: `browser.${eventDesc}`, + description: page.url(), parent_span_id: eventData.contexts?.trace?.span_id, }), ), diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/test.ts index 9209e8ca5c32..c53993cba21d 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/pageload-measure-spans/test.ts @@ -13,14 +13,15 @@ sentryTest('should add browser-related spans to pageload transaction', async ({ const url = await getLocalTestPath({ testDir: __dirname }); const eventData = await getFirstSentryEnvelopeRequest(page, url); - const browserSpans = eventData.spans?.filter(({ op }) => op === 'browser'); + const browserSpans = eventData.spans?.filter(({ op }) => op?.startsWith('browser')); // Spans `domContentLoadedEvent`, `connect`, `cache` and `DNS` are not // always inside `pageload` transaction. expect(browserSpans?.length).toBeGreaterThanOrEqual(4); - const requestSpan = browserSpans!.find(({ description }) => description === 'request'); + const requestSpan = browserSpans!.find(({ op }) => op === 'browser.request'); expect(requestSpan).toBeDefined(); + expect(requestSpan?.description).toBe(page.url()); const measureSpan = eventData.spans?.find(({ op }) => op === 'measure'); expect(measureSpan).toBeDefined(); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/.gitignore b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/.gitignore new file mode 100644 index 000000000000..4b56acfbebf4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/.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-basic-with-graphql/.npmrc b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/.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-basic-with-graphql/nest-cli.json b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/nest-cli.json new file mode 100644 index 000000000000..f9aa683b1ad5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/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-basic-with-graphql/package.json b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/package.json new file mode 100644 index 000000000000..45eccd244d9b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/package.json @@ -0,0 +1,50 @@ +{ + "name": "nestjs-basic-with-graphql", + "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": { + "@apollo/server": "^4.10.4", + "@nestjs/apollo": "^12.2.0", + "@nestjs/common": "^10.3.10", + "@nestjs/core": "^10.3.10", + "@nestjs/graphql": "^12.2.0", + "@nestjs/platform-express": "^10.3.10", + "@sentry/nestjs": "^8.21.0", + "graphql": "^16.9.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/express": "^4.17.17", + "@types/node": "18.15.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": "^4.9.5" + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/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-basic-with-graphql/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/app.controller.ts new file mode 100644 index 000000000000..50f9bc266c2d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/app.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @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); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/app.module.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/app.module.ts new file mode 100644 index 000000000000..7e76a0e0980f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/app.module.ts @@ -0,0 +1,28 @@ +import { ApolloDriver } from '@nestjs/apollo'; +import { Logger, Module } from '@nestjs/common'; +import { GraphQLModule } from '@nestjs/graphql'; +import { SentryModule } from '@sentry/nestjs/setup'; +import { AppController } from './app.controller'; +import { AppResolver } from './app.resolver'; +import { AppService } from './app.service'; + +@Module({ + imports: [ + SentryModule.forRoot(), + GraphQLModule.forRoot({ + driver: ApolloDriver, + autoSchemaFile: true, + playground: true, // sets up a playground on https://localhost:3000/graphql + }), + ], + controllers: [AppController], + providers: [ + AppService, + AppResolver, + { + provide: Logger, + useClass: Logger, + }, + ], +}) +export class AppModule {} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/app.resolver.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/app.resolver.ts new file mode 100644 index 000000000000..0e4dfc643918 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/app.resolver.ts @@ -0,0 +1,14 @@ +import { Query, Resolver } from '@nestjs/graphql'; + +@Resolver() +export class AppResolver { + @Query(() => String) + test(): string { + return 'Test endpoint!'; + } + + @Query(() => String) + error(): string { + throw new Error('This is an exception!'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/app.service.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/app.service.ts new file mode 100644 index 000000000000..79118f937ce2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/app.service.ts @@ -0,0 +1,16 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + 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); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/instrument.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/instrument.ts new file mode 100644 index 000000000000..f1f4de865435 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/instrument.ts @@ -0,0 +1,8 @@ +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, +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/main.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/main.ts new file mode 100644 index 000000000000..947539414ddf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/src/main.ts @@ -0,0 +1,20 @@ +// Import this first +import './instrument'; + +// Import other modules +import { HttpAdapterHost, NestFactory } from '@nestjs/core'; +import { SentryGlobalGenericFilter } from '@sentry/nestjs/setup'; +import { AppModule } from './app.module'; + +const PORT = 3030; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + const { httpAdapter } = app.get(HttpAdapterHost); + app.useGlobalFilters(new SentryGlobalGenericFilter(httpAdapter as any)); + + await app.listen(PORT); +} + +bootstrap(); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/start-event-proxy.mjs new file mode 100644 index 000000000000..7914cd10a146 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nestjs-basic-with-graphql', +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/tests/errors.test.ts new file mode 100644 index 000000000000..2071e436e133 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/tests/errors.test.ts @@ -0,0 +1,118 @@ +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-basic-with-graphql', 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.any(String), + span_id: expect.any(String), + }); +}); + +test('Does not send HttpExceptions to Sentry', async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError('nestjs-basic-with-graphql', 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-basic-with-graphql', 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-basic-with-graphql', transactionEvent => { + return transactionEvent?.transaction === 'GET /test-expected-400-exception/:id'; + }); + + const transactionEventPromise500 = waitForTransaction('nestjs-basic-with-graphql', transactionEvent => { + return transactionEvent?.transaction === 'GET /test-expected-500-exception/:id'; + }); + + 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('Sends graphql exception to Sentry', async ({ baseURL }) => { + const errorEventPromise = waitForError('nestjs-basic-with-graphql', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception!'; + }); + + const response = await fetch(`${baseURL}/graphql`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: `query { error }`, + }), + }); + + const json_response = await response.json(); + const errorEvent = await errorEventPromise; + + expect(json_response?.errors[0]).toEqual({ + message: 'This is an exception!', + locations: expect.any(Array), + path: ['error'], + extensions: { + code: 'INTERNAL_SERVER_ERROR', + stacktrace: expect.any(Array), + }, + }); + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception!'); + + expect(errorEvent.request).toEqual({ + method: 'POST', + cookies: {}, + data: '{"query":"query { error }"}', + headers: expect.any(Object), + url: 'http://localhost:3030/graphql', + }); + + expect(errorEvent.transaction).toEqual('POST /graphql'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/tsconfig.build.json b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/tsconfig.build.json new file mode 100644 index 000000000000..26c30d4eddf2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/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-basic-with-graphql/tsconfig.json b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/tsconfig.json new file mode 100644 index 000000000000..cf79f029c781 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic-with-graphql/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-graphql/package.json b/dev-packages/e2e-tests/test-applications/nestjs-graphql/package.json index 7981c64e0b2a..91f495776575 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-graphql/package.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-graphql/package.json @@ -21,7 +21,8 @@ "@nestjs/core": "^10.3.10", "@nestjs/graphql": "^12.2.0", "@nestjs/platform-express": "^10.3.10", - "@sentry/nestjs": "^8.21.0", + "@sentry/nestjs": "latest || *", + "@sentry/types": "latest || *", "graphql": "^16.9.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/browser-back/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/browser-back/page.tsx new file mode 100644 index 000000000000..9e32c27abce2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/browser-back/page.tsx @@ -0,0 +1,7 @@ +import Link from 'next/link'; + +export const dynamic = 'force-dynamic'; + +export default function Page() { + return Go back home; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/link-replace/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/link-replace/page.tsx new file mode 100644 index 000000000000..9e32c27abce2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/link-replace/page.tsx @@ -0,0 +1,7 @@ +import Link from 'next/link'; + +export const dynamic = 'force-dynamic'; + +export default function Page() { + return Go back home; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/link/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/link/page.tsx new file mode 100644 index 000000000000..9e32c27abce2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/link/page.tsx @@ -0,0 +1,7 @@ +import Link from 'next/link'; + +export const dynamic = 'force-dynamic'; + +export default function Page() { + return Go back home; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-back/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-back/page.tsx new file mode 100644 index 000000000000..9e32c27abce2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-back/page.tsx @@ -0,0 +1,7 @@ +import Link from 'next/link'; + +export const dynamic = 'force-dynamic'; + +export default function Page() { + return Go back home; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-push/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-push/page.tsx new file mode 100644 index 000000000000..de789f9af524 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-push/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

hello world

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-replace/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-replace/page.tsx new file mode 100644 index 000000000000..de789f9af524 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/[param]/router-replace/page.tsx @@ -0,0 +1,5 @@ +export const dynamic = 'force-dynamic'; + +export default function Page() { + return

hello world

; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/page.tsx new file mode 100644 index 000000000000..4f03a59d71cf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/app/navigation/page.tsx @@ -0,0 +1,57 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +export default function Page() { + const router = useRouter(); + + return ( +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + Normal Link +
  • +
  • + + Link Replace + +
  • +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts index 9143bd0b2f90..35984640bcf6 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts @@ -53,3 +53,148 @@ test('Creates a navigation transaction for app router routes', async ({ page }) expect(await clientNavigationTransactionPromise).toBeDefined(); expect(await serverComponentTransactionPromise).toBeDefined(); }); + +test('Creates a navigation transaction for `router.push()`', async ({ page }) => { + const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { + return ( + transactionEvent?.transaction === `/navigation/42/router-push` && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push' + ); + }); + + await page.goto('/navigation'); + await page.waitForTimeout(3000); + await page.getByText('router.push()').click(); + + expect(await navigationTransactionPromise).toBeDefined(); +}); + +test('Creates a navigation transaction for `router.replace()`', async ({ page }) => { + const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { + return ( + transactionEvent?.transaction === `/navigation/42/router-replace` && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.replace' + ); + }); + + await page.goto('/navigation'); + await page.waitForTimeout(3000); + await page.getByText('router.replace()').click(); + + expect(await navigationTransactionPromise).toBeDefined(); +}); + +test('Creates a navigation transaction for `router.back()`', async ({ page }) => { + const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { + return ( + transactionEvent?.transaction === `/navigation/1337/router-back` && + transactionEvent.contexts?.trace?.op === 'navigation' + ); + }); + + await page.goto('/navigation/1337/router-back'); + await page.waitForTimeout(3000); + await page.getByText('Go back home').click(); + await page.waitForTimeout(3000); + await page.getByText('router.back()').click(); + + expect(await navigationTransactionPromise).toMatchObject({ + contexts: { + trace: { + data: { + 'navigation.type': 'router.back', + }, + }, + }, + }); +}); + +test('Creates a navigation transaction for `router.forward()`', async ({ page }) => { + const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { + return ( + transactionEvent?.transaction === `/navigation/42/router-push` && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.forward' + ); + }); + + await page.goto('/navigation'); + await page.waitForTimeout(3000); + await page.getByText('router.push()').click(); + await page.waitForTimeout(3000); + await page.goBack(); + await page.waitForTimeout(3000); + await page.getByText('router.forward()').click(); + + expect(await navigationTransactionPromise).toBeDefined(); +}); + +test('Creates a navigation transaction for ``', async ({ page }) => { + const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { + return ( + transactionEvent?.transaction === `/navigation/42/link` && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push' + ); + }); + + await page.goto('/navigation'); + await page.getByText('Normal Link').click(); + + expect(await navigationTransactionPromise).toBeDefined(); +}); + +test('Creates a navigation transaction for ``', async ({ page }) => { + const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { + return ( + transactionEvent?.transaction === `/navigation/42/link-replace` && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.replace' + ); + }); + + await page.goto('/navigation'); + await page.waitForTimeout(3000); + await page.getByText('Link Replace').click(); + + expect(await navigationTransactionPromise).toBeDefined(); +}); + +test('Creates a navigation transaction for browser-back', async ({ page }) => { + const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { + return ( + transactionEvent?.transaction === `/navigation/42/browser-back` && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' + ); + }); + + await page.goto('/navigation/42/browser-back'); + await page.waitForTimeout(3000); + await page.getByText('Go back home').click(); + await page.waitForTimeout(3000); + await page.goBack(); + + expect(await navigationTransactionPromise).toBeDefined(); +}); + +test('Creates a navigation transaction for browser-forward', async ({ page }) => { + const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { + return ( + transactionEvent?.transaction === `/navigation/42/router-push` && + transactionEvent.contexts?.trace?.op === 'navigation' && + transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' + ); + }); + + await page.goto('/navigation'); + await page.getByText('router.push()').click(); + await page.waitForTimeout(3000); + await page.goBack(); + await page.waitForTimeout(3000); + await page.goForward(); + + expect(await navigationTransactionPromise).toBeDefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json index 1683d4166af9..afe666c2a8f1 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json @@ -11,10 +11,10 @@ "test:assert": "pnpm test" }, "dependencies": { - "@opentelemetry/sdk-trace-node": "1.25.1", - "@opentelemetry/exporter-trace-otlp-http": "0.52.1", - "@opentelemetry/instrumentation-undici": "0.4.0", - "@opentelemetry/instrumentation": "0.52.1", + "@opentelemetry/sdk-trace-node": "1.26.0", + "@opentelemetry/exporter-trace-otlp-http": "0.53.0", + "@opentelemetry/instrumentation-undici": "0.6.0", + "@opentelemetry/instrumentation": "0.53.0", "@sentry/core": "latest || *", "@sentry/node": "latest || *", "@sentry/opentelemetry": "latest || *", diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts index 8100d27af965..d887696b1e73 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts @@ -5,7 +5,7 @@ const { SentrySpanProcessor, SentryPropagator } = require('@sentry/opentelemetry const { UndiciInstrumentation } = require('@opentelemetry/instrumentation-undici'); const { registerInstrumentations } = require('@opentelemetry/instrumentation'); -const sentryClient = Sentry.init({ +Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.E2E_TEST_DSN || diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts index abc55344327c..9c91a0ed9531 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts @@ -12,7 +12,9 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { const scopeSpans = json.resourceSpans?.[0]?.scopeSpans; - const httpScope = scopeSpans?.find(scopeSpan => scopeSpan.scope.name === '@opentelemetry/instrumentation-http'); + const httpScope = scopeSpans?.find( + scopeSpan => scopeSpan.scope.name === '@opentelemetry_sentry-patched/instrumentation-http', + ); return ( httpScope && @@ -22,7 +24,7 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { ); }); - await fetch(`${baseURL}/test-transaction`); + fetch(`${baseURL}/test-transaction`); const otelData = await otelPromise; @@ -38,7 +40,9 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { // But our default node-fetch spans are not emitted expect(scopeSpans.length).toEqual(2); - const httpScopes = scopeSpans?.filter(scopeSpan => scopeSpan.scope.name === '@opentelemetry/instrumentation-http'); + const httpScopes = scopeSpans?.filter( + scopeSpan => scopeSpan.scope.name === '@opentelemetry_sentry-patched/instrumentation-http', + ); const undiciScopes = scopeSpans?.filter( scopeSpan => scopeSpan.scope.name === '@opentelemetry/instrumentation-undici', ); @@ -49,6 +53,38 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { expect(undiciScopes.length).toBe(1); expect(undiciScopes[0].spans.length).toBe(1); + expect(undiciScopes[0].spans).toEqual([ + { + traceId: expect.any(String), + spanId: expect.any(String), + name: 'GET', + kind: 3, + startTimeUnixNano: expect.any(String), + endTimeUnixNano: expect.any(String), + attributes: expect.arrayContaining([ + { key: 'http.request.method', value: { stringValue: 'GET' } }, + { key: 'http.request.method_original', value: { stringValue: 'GET' } }, + { key: 'url.full', value: { stringValue: 'http://localhost:3030/test-success' } }, + { key: 'url.path', value: { stringValue: '/test-success' } }, + { key: 'url.query', value: { stringValue: '' } }, + { key: 'url.scheme', value: { stringValue: 'http' } }, + { key: 'server.address', value: { stringValue: 'localhost' } }, + { key: 'server.port', value: { intValue: 3030 } }, + { key: 'user_agent.original', value: { stringValue: 'node' } }, + { key: 'network.peer.address', value: { stringValue: expect.any(String) } }, + { key: 'network.peer.port', value: { intValue: 3030 } }, + { key: 'http.response.status_code', value: { intValue: 200 } }, + { key: 'http.response.header.content-length', value: { intValue: 16 } }, + ]), + droppedAttributesCount: 0, + events: [], + droppedEventsCount: 0, + status: { code: 0 }, + links: [], + droppedLinksCount: 0, + }, + ]); + // There may be another span from another request, we can ignore that const httpSpans = httpScopes[0].spans.filter(span => span.attributes.some(attr => attr.key === 'http.target' && attr.value?.stringValue === '/test-transaction'), @@ -62,104 +98,24 @@ test('Sends an API route transaction to OTLP', async ({ baseURL }) => { kind: 2, startTimeUnixNano: expect.any(String), endTimeUnixNano: expect.any(String), - attributes: [ - { - key: 'http.url', - value: { - stringValue: 'http://localhost:3030/test-transaction', - }, - }, - { - key: 'http.host', - value: { - stringValue: 'localhost:3030', - }, - }, - { - key: 'net.host.name', - value: { - stringValue: 'localhost', - }, - }, - { - key: 'http.method', - value: { - stringValue: 'GET', - }, - }, - { - key: 'http.scheme', - value: { - stringValue: 'http', - }, - }, - { - key: 'http.target', - value: { - stringValue: '/test-transaction', - }, - }, - { - key: 'http.user_agent', - value: { - stringValue: 'node', - }, - }, - { - key: 'http.flavor', - value: { - stringValue: '1.1', - }, - }, - { - key: 'net.transport', - value: { - stringValue: 'ip_tcp', - }, - }, - { - key: 'sentry.origin', - value: { - stringValue: 'auto.http.otel.http', - }, - }, - { - key: 'net.host.ip', - value: { - stringValue: expect.any(String), - }, - }, - { - key: 'net.host.port', - value: { - intValue: 3030, - }, - }, - { - key: 'net.peer.ip', - value: { - stringValue: expect.any(String), - }, - }, - { - key: 'net.peer.port', - value: { - intValue: expect.any(Number), - }, - }, - { - key: 'http.status_code', - value: { - intValue: 200, - }, - }, - { - key: 'http.status_text', - value: { - stringValue: 'OK', - }, - }, - ], + attributes: expect.arrayContaining([ + { key: 'http.url', value: { stringValue: 'http://localhost:3030/test-transaction' } }, + { key: 'http.host', value: { stringValue: 'localhost:3030' } }, + { key: 'net.host.name', value: { stringValue: 'localhost' } }, + { key: 'http.method', value: { stringValue: 'GET' } }, + { key: 'http.scheme', value: { stringValue: 'http' } }, + { key: 'http.target', value: { stringValue: '/test-transaction' } }, + { key: 'http.user_agent', value: { stringValue: 'node' } }, + { key: 'http.flavor', value: { stringValue: '1.1' } }, + { key: 'net.transport', value: { stringValue: 'ip_tcp' } }, + { key: 'net.host.ip', value: { stringValue: expect.any(String) } }, + { key: 'net.host.port', value: { intValue: 3030 } }, + { key: 'net.peer.ip', value: { stringValue: expect.any(String) } }, + { key: 'net.peer.port', value: { intValue: expect.any(Number) } }, + { key: 'http.status_code', value: { intValue: 200 } }, + { key: 'http.status_text', value: { stringValue: 'OK' } }, + { key: 'sentry.origin', value: { stringValue: 'auto.http.otel.http' } }, + ]), droppedAttributesCount: 0, events: [], droppedEventsCount: 0, diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/components/ErrorButton.vue b/dev-packages/e2e-tests/test-applications/nuxt-3/components/ErrorButton.vue index 42d53ade03f7..92ea714ae489 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/components/ErrorButton.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/components/ErrorButton.vue @@ -5,6 +5,10 @@ const props = defineProps({ errorText: { type: String, required: true + }, + id: { + type: String, + required: true } }) @@ -14,5 +18,5 @@ const triggerError = () => { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/pages/client-error.vue b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/client-error.vue index 25eaa672c87c..5e1a14931f84 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/pages/client-error.vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/client-error.vue @@ -3,7 +3,8 @@ import ErrorButton from '../components/ErrorButton.vue'; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue index 2ac1b9095a0f..379e8e417b35 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/test-param/[param].vue @@ -1,7 +1,7 @@ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts index fb03a08b4033..4cb23e8b81df 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts @@ -55,4 +55,51 @@ test.describe('client-side errors', async () => { }, }); }); + + test('page is still interactive after client error', async ({ page }) => { + const error1Promise = waitForError('nuxt-3', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Error thrown from Nuxt-3 E2E test app'; + }); + + await page.goto(`/client-error`); + await page.locator('#errorBtn').click(); + + const error1 = await error1Promise; + + const error2Promise = waitForError('nuxt-3', async errorEvent => { + return errorEvent?.exception?.values?.[0]?.value === 'Another Error thrown from Nuxt-3 E2E test app'; + }); + + await page.locator('#errorBtn2').click(); + + const error2 = await error2Promise; + + expect(error1).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Nuxt-3 E2E test app', + mechanism: { + handled: false, + }, + }, + ], + }, + }); + + expect(error2).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Another Error thrown from Nuxt-3 E2E test app', + mechanism: { + handled: false, + }, + }, + ], + }, + }); + }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts index fa6b40c6a49d..cc5edc1fd878 100644 --- a/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-create-hash-router/tests/transactions.test.ts @@ -44,10 +44,10 @@ test('Captures a pageload transaction', async ({ page }) => { expect(transactionEvent.spans).toContainEqual({ data: { 'sentry.origin': 'auto.ui.browser.metrics', - 'sentry.op': 'browser', + 'sentry.op': 'browser.domContentLoadedEvent', }, - description: 'domContentLoadedEvent', - op: 'browser', + description: page.url(), + op: 'browser.domContentLoadedEvent', parent_span_id: expect.any(String), span_id: expect.any(String), start_timestamp: expect.any(Number), @@ -58,10 +58,10 @@ test('Captures a pageload transaction', async ({ page }) => { expect(transactionEvent.spans).toContainEqual({ data: { 'sentry.origin': 'auto.ui.browser.metrics', - 'sentry.op': 'browser', + 'sentry.op': 'browser.connect', }, - description: 'connect', - op: 'browser', + description: page.url(), + op: 'browser.connect', parent_span_id: expect.any(String), span_id: expect.any(String), start_timestamp: expect.any(Number), @@ -72,10 +72,10 @@ test('Captures a pageload transaction', async ({ page }) => { expect(transactionEvent.spans).toContainEqual({ data: { 'sentry.origin': 'auto.ui.browser.metrics', - 'sentry.op': 'browser', + 'sentry.op': 'browser.request', }, - description: 'request', - op: 'browser', + description: page.url(), + op: 'browser.request', parent_span_id: expect.any(String), span_id: expect.any(String), start_timestamp: expect.any(Number), @@ -86,10 +86,10 @@ test('Captures a pageload transaction', async ({ page }) => { expect(transactionEvent.spans).toContainEqual({ data: { 'sentry.origin': 'auto.ui.browser.metrics', - 'sentry.op': 'browser', + 'sentry.op': 'browser.response', }, - description: 'response', - op: 'browser', + description: page.url(), + op: 'browser.response', parent_span_id: expect.any(String), span_id: expect.any(String), start_timestamp: expect.any(Number), diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index cacb8b225b2d..91792650a13f 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -38,6 +38,7 @@ "@types/mongodb": "^3.6.20", "@types/mysql": "^2.15.21", "@types/pg": "^8.6.5", + "amqplib": "^0.10.4", "apollo-server": "^3.11.1", "axios": "^1.6.7", "connect": "^3.7.0", @@ -66,6 +67,7 @@ "yargs": "^16.2.0" }, "devDependencies": { + "@types/amqplib": "^0.10.5", "@types/node-cron": "^3.0.11", "@types/node-schedule": "^2.1.7", "globby": "11" diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/constants.ts b/dev-packages/node-integration-tests/suites/tracing/amqplib/constants.ts new file mode 100644 index 000000000000..b7a3e8f79ea2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/amqplib/constants.ts @@ -0,0 +1,15 @@ +const amqpUsername = 'sentry'; +const amqpPassword = 'sentry'; + +export const AMQP_URL = `amqp://${amqpUsername}:${amqpPassword}@localhost:5672/`; +export const ACKNOWLEDGEMENT = { noAck: false }; + +export const QUEUE_OPTIONS = { + durable: true, // Make the queue durable + exclusive: false, // Not exclusive + autoDelete: false, // Don't auto-delete the queue + arguments: { + 'x-message-ttl': 30000, // Message TTL of 30 seconds + 'x-max-length': 1000, // Maximum queue length of 1000 messages + }, +}; diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/docker-compose.yml b/dev-packages/node-integration-tests/suites/tracing/amqplib/docker-compose.yml new file mode 100644 index 000000000000..dc68d428c976 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/amqplib/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3' + +services: + rabbitmq: + image: rabbitmq:management + container_name: rabbitmq + environment: + - RABBITMQ_DEFAULT_USER=sentry + - RABBITMQ_DEFAULT_PASS=sentry + ports: + - "5672:5672" + - "15672:15672" + +networks: + default: + driver: bridge diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/init.ts b/dev-packages/node-integration-tests/suites/tracing/amqplib/init.ts new file mode 100644 index 000000000000..c3fd63415f78 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/amqplib/init.ts @@ -0,0 +1,9 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/scenario-message.ts b/dev-packages/node-integration-tests/suites/tracing/amqplib/scenario-message.ts new file mode 100644 index 000000000000..2fa0d0feaa89 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/amqplib/scenario-message.ts @@ -0,0 +1,19 @@ +import * as Sentry from '@sentry/node'; +import './init'; +import { connectToRabbitMQ, consumeMessageFromQueue, createQueue, sendMessageToQueue } from './utils'; + +const queueName = 'queue1'; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +(async () => { + const { connection, channel } = await connectToRabbitMQ(); + await createQueue(queueName, channel); + + await Sentry.startSpan({ name: 'root span' }, async () => { + sendMessageToQueue(queueName, channel, JSON.stringify({ foo: 'bar01' })); + }); + + await consumeMessageFromQueue(queueName, channel); + await channel.close(); + await connection.close(); +})(); diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts b/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts new file mode 100644 index 000000000000..3fd00abcd46c --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/amqplib/test.ts @@ -0,0 +1,54 @@ +import type { TransactionEvent } from '@sentry/types'; +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +jest.setTimeout(30_000); + +const EXPECTED_MESSAGE_SPAN_PRODUCER = expect.objectContaining({ + op: 'message', + data: expect.objectContaining({ + 'messaging.system': 'rabbitmq', + 'otel.kind': 'PRODUCER', + 'sentry.op': 'message', + 'sentry.origin': 'auto.amqplib.otel.publisher', + }), + status: 'ok', +}); + +const EXPECTED_MESSAGE_SPAN_CONSUMER = expect.objectContaining({ + op: 'message', + data: expect.objectContaining({ + 'messaging.system': 'rabbitmq', + 'otel.kind': 'CONSUMER', + 'sentry.op': 'message', + 'sentry.origin': 'auto.amqplib.otel.consumer', + }), + status: 'ok', +}); + +describe('amqplib auto-instrumentation', () => { + afterAll(async () => { + cleanupChildProcesses(); + }); + + test('should be able to send and receive messages', done => { + createRunner(__dirname, 'scenario-message.ts') + .withDockerCompose({ + workingDirectory: [__dirname], + readyMatches: ['Time to start RabbitMQ'], + }) + .expect({ + transaction: (transaction: TransactionEvent) => { + expect(transaction.transaction).toEqual('root span'); + expect(transaction.spans?.length).toEqual(1); + expect(transaction.spans![0]).toMatchObject(EXPECTED_MESSAGE_SPAN_PRODUCER); + }, + }) + .expect({ + transaction: (transaction: TransactionEvent) => { + expect(transaction.transaction).toEqual('queue1 process'); + expect(transaction.contexts?.trace).toMatchObject(EXPECTED_MESSAGE_SPAN_CONSUMER); + }, + }) + .start(done); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/amqplib/utils.ts b/dev-packages/node-integration-tests/suites/tracing/amqplib/utils.ts new file mode 100644 index 000000000000..cf6f452365f2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/amqplib/utils.ts @@ -0,0 +1,38 @@ +import amqp from 'amqplib'; +import type { Channel, Connection } from 'amqplib'; +import { ACKNOWLEDGEMENT, AMQP_URL, QUEUE_OPTIONS } from './constants'; + +export type RabbitMQData = { + connection: Connection; + channel: Channel; +}; + +export async function connectToRabbitMQ(): Promise { + const connection = await amqp.connect(AMQP_URL); + const channel = await connection.createChannel(); + return { connection, channel }; +} + +export async function createQueue(queueName: string, channel: Channel): Promise { + await channel.assertQueue(queueName, QUEUE_OPTIONS); +} + +export function sendMessageToQueue(queueName: string, channel: Channel, message: string): void { + channel.sendToQueue(queueName, Buffer.from(message)); +} + +async function consumer(queueName: string, channel: Channel): Promise { + await channel.consume( + queueName, + message => { + if (message) { + channel.ack(message); + } + }, + ACKNOWLEDGEMENT, + ); +} + +export async function consumeMessageFromQueue(queueName: string, channel: Channel): Promise { + await consumer(queueName, channel); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/server.js b/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/server.js index 19877ffe3613..3b615e93cd16 100644 --- a/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/server.js +++ b/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/server.js @@ -4,16 +4,6 @@ const Sentry = require('@sentry/node'); Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', transport: loggingTransport, - beforeSend(event) { - event.contexts = { - ...event.contexts, - traceData: { - ...Sentry.getTraceData(), - metaTags: Sentry.getTraceMetaTags(), - }, - }; - return event; - }, }); // express must be required after Sentry is initialized @@ -21,8 +11,15 @@ const express = require('express'); const app = express(); -app.get('/test', () => { - throw new Error('test error'); +app.get('/test', (_req, res) => { + Sentry.withScope(scope => { + scope.setContext('traceData', { + ...Sentry.getTraceData(), + metaTags: Sentry.getTraceMetaTags(), + }); + Sentry.captureException(new Error('test error 2')); + }); + res.status(200).send(); }); Sentry.setupExpressErrorHandler(app); diff --git a/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts b/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts index e6c0bfff822d..9abb7b1a631c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp-errors/test.ts @@ -5,7 +5,7 @@ describe('errors in TwP mode have same trace in trace context and getTraceData() cleanupChildProcesses(); }); - test('in incoming request', async () => { + test('in incoming request', done => { createRunner(__dirname, 'server.js') .expect({ event: event => { @@ -17,14 +17,16 @@ describe('errors in TwP mode have same trace in trace context and getTraceData() const traceData = contexts?.traceData || {}; expect(traceData['sentry-trace']).toEqual(`${trace_id}-${span_id}`); + expect(traceData.baggage).toContain(`sentry-trace_id=${trace_id}`); + expect(traceData.baggage).not.toContain('sentry-sampled='); expect(traceData.metaTags).toContain(``); - expect(traceData.metaTags).toContain(`sentr y-trace_id=${trace_id}`); + expect(traceData.metaTags).toContain(`sentry-trace_id=${trace_id}`); expect(traceData.metaTags).not.toContain('sentry-sampled='); }, }) - .start() + .start(done) .makeRequest('get', '/test'); }); @@ -41,6 +43,7 @@ describe('errors in TwP mode have same trace in trace context and getTraceData() expect(traceData['sentry-trace']).toEqual(`${trace_id}-${span_id}`); expect(traceData.baggage).toContain(`sentry-trace_id=${trace_id}`); + expect(traceData.baggage).not.toContain('sentry-sampled='); expect(traceData.metaTags).toContain(``); expect(traceData.metaTags).toContain(`sentry-trace_id=${trace_id}`); diff --git a/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp/test.ts b/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp/test.ts index f3179beede6d..cca1f34321a2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/meta-tags-twp/test.ts @@ -27,5 +27,6 @@ describe('getTraceMetaTags', () => { expect(sentryBaggageContent).toContain('sentry-environment=production'); expect(sentryBaggageContent).toContain('sentry-public_key=public'); expect(sentryBaggageContent).toContain(`sentry-trace_id=${traceId}`); + expect(sentryBaggageContent).not.toContain('sentry-sampled='); }); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.ts index d7fb81d22e1f..3ae59e5ee6b7 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-breadcrumbs/scenario.ts @@ -22,7 +22,7 @@ async function run(): Promise { Sentry.addBreadcrumb({ message: 'manual breadcrumb' }); await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); - await makeHttpRequest(`${process.env.SERVER_URL}/api/v1`); + await makeHttpGet(`${process.env.SERVER_URL}/api/v1`); await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); @@ -46,3 +46,16 @@ function makeHttpRequest(url: string): Promise { .end(); }); } + +function makeHttpGet(url: string): Promise { + return new Promise(resolve => { + http.get(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/scenario.ts index 8213ddf7034e..1eb618d97dcc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/scenario.ts @@ -13,7 +13,7 @@ import * as http from 'http'; async function run(): Promise { await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); - await makeHttpRequest(`${process.env.SERVER_URL}/api/v1`); + await makeHttpGet(`${process.env.SERVER_URL}/api/v1`); await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); @@ -37,3 +37,16 @@ function makeHttpRequest(url: string): Promise { .end(); }); } + +function makeHttpGet(url: string): Promise { + return new Promise(resolve => { + http.get(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts index d0b570625c2b..e65278c3efd5 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts @@ -38,6 +38,56 @@ test('outgoing http requests are correctly instrumented with tracing disabled', }, ], }, + breadcrumbs: [ + { + message: 'manual breadcrumb', + timestamp: expect.any(Number), + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v0`, + status_code: 404, + ADDED_PATH: '/api/v0', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v1`, + status_code: 404, + ADDED_PATH: '/api/v1', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v2`, + status_code: 404, + ADDED_PATH: '/api/v2', + }, + timestamp: expect.any(Number), + type: 'http', + }, + { + category: 'http', + data: { + 'http.method': 'GET', + url: `${SERVER_URL}/api/v3`, + status_code: 404, + ADDED_PATH: '/api/v3', + }, + timestamp: expect.any(Number), + type: 'http', + }, + ], }, }) .start(closeTestServer); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/scenario.ts index c346b617b9e6..373bb9b220d6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/scenario.ts @@ -12,9 +12,25 @@ Sentry.init({ import * as http from 'http'; -Sentry.startSpan({ name: 'test_span' }, () => { - http.get(`${process.env.SERVER_URL}/api/v0`); - http.get(`${process.env.SERVER_URL}/api/v1`); - http.get(`${process.env.SERVER_URL}/api/v2`); - http.get(`${process.env.SERVER_URL}/api/v3`); +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test_span' }, async () => { + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v1`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); }); + +function makeHttpRequest(url: string): Promise { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} diff --git a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/scenario.ts index c346b617b9e6..373bb9b220d6 100644 --- a/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/scenario.ts +++ b/dev-packages/node-integration-tests/suites/tracing/tracePropagationTargets/scenario.ts @@ -12,9 +12,25 @@ Sentry.init({ import * as http from 'http'; -Sentry.startSpan({ name: 'test_span' }, () => { - http.get(`${process.env.SERVER_URL}/api/v0`); - http.get(`${process.env.SERVER_URL}/api/v1`); - http.get(`${process.env.SERVER_URL}/api/v2`); - http.get(`${process.env.SERVER_URL}/api/v3`); +// eslint-disable-next-line @typescript-eslint/no-floating-promises +Sentry.startSpan({ name: 'test_span' }, async () => { + await makeHttpRequest(`${process.env.SERVER_URL}/api/v0`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v1`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v2`); + await makeHttpRequest(`${process.env.SERVER_URL}/api/v3`); }); + +function makeHttpRequest(url: string): Promise { + return new Promise(resolve => { + http + .request(url, httpRes => { + httpRes.on('data', () => { + // we don't care about data + }); + httpRes.on('end', () => { + resolve(); + }); + }) + .end(); + }); +} diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts index 76c19074ed9c..bde5bd06cd21 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -11,6 +11,7 @@ import type { SerializedCheckIn, SerializedSession, SessionAggregates, + TransactionEvent, } from '@sentry/types'; import axios from 'axios'; import { createBasicSentryServer } from './server'; @@ -151,7 +152,7 @@ type Expected = event: Partial | ((event: Event) => void); } | { - transaction: Partial | ((event: Event) => void); + transaction: Partial | ((event: TransactionEvent) => void); } | { session: Partial | ((event: SerializedSession) => void); @@ -317,7 +318,7 @@ export function createRunner(...paths: string[]) { } if ('transaction' in expected) { - const event = item[1] as Event; + const event = item[1] as TransactionEvent; if (typeof expected.transaction === 'function') { expected.transaction(event); } else { @@ -483,6 +484,7 @@ export function createRunner(...paths: string[]) { method: 'get' | 'post', path: string, headers: Record = {}, + data?: any, // axios accept any as data ): Promise { try { await waitFor(() => scenarioServerPort !== undefined); @@ -497,7 +499,7 @@ export function createRunner(...paths: string[]) { if (method === 'get') { await axios.get(url, { headers }); } else { - await axios.post(url, { headers }); + await axios.post(url, data, { headers }); } } catch (e) { return; @@ -506,7 +508,7 @@ export function createRunner(...paths: string[]) { } else if (method === 'get') { return (await axios.get(url, { headers })).data; } else { - return (await axios.post(url, { headers })).data; + return (await axios.post(url, data, { headers })).data; } }, }; diff --git a/package.json b/package.json index 4b9ad0383c02..365e1eb13922 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "clean:build": "lerna run clean", "clean:caches": "yarn rimraf eslintcache .nxcache && yarn jest --clearCache", "clean:deps": "lerna clean --yes && rm -rf node_modules && yarn", - "clean:tarballs": "rimraf **/*.tgz", + "clean:tarballs": "rimraf -g **/*.tgz", "clean:all": "run-s clean:build clean:tarballs clean:caches clean:deps", "fix": "run-s fix:biome fix:prettier fix:lerna", "fix:lerna": "lerna run fix", diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 0239e2a55798..bfd6886f3861 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -13,6 +13,7 @@ export { addIntegration, addOpenTelemetryInstrumentation, addRequestDataToEvent, + amqplibIntegration, anrIntegration, captureCheckIn, captureConsoleIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 44414824cdc1..9bbba6eeda66 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -111,6 +111,7 @@ export { addOpenTelemetryInstrumentation, zodErrorsIntegration, profiler, + amqplibIntegration, } from '@sentry/node'; export { diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 92fe66a832ee..3b98558f2728 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -470,8 +470,8 @@ function _addPerformanceNavigationTiming( return; } startAndEndSpan(span, timeOrigin + msToSec(start), timeOrigin + msToSec(end), { - op: 'browser', - name: name || event, + op: `browser.${name || event}`, + name: entry.name, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.metrics', }, @@ -490,16 +490,16 @@ function _addRequest(span: Span, entry: Record, timeOrigin: number) // In order not to produce faulty spans, where the end timestamp is before the start timestamp, we will only collect // these spans when the responseEnd value is available. The backend (Relay) would drop the entire span if it contained faulty spans. startAndEndSpan(span, requestStartTimestamp, responseEndTimestamp, { - op: 'browser', - name: 'request', + op: 'browser.request', + name: entry.name, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.metrics', }, }); startAndEndSpan(span, responseStartTimestamp, responseEndTimestamp, { - op: 'browser', - name: 'response', + op: 'browser.response', + name: entry.name, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.metrics', }, diff --git a/packages/browser/src/integrations/breadcrumbs.ts b/packages/browser/src/integrations/breadcrumbs.ts index e3c1120fca57..db30a48dda67 100644 --- a/packages/browser/src/integrations/breadcrumbs.ts +++ b/packages/browser/src/integrations/breadcrumbs.ts @@ -23,6 +23,7 @@ import type { import { addConsoleInstrumentationHandler, addFetchInstrumentationHandler, + getBreadcrumbLogLevelFromHttpStatusCode, getComponentName, getEventDescription, htmlTreeAsString, @@ -247,11 +248,14 @@ function _getXhrBreadcrumbHandler(client: Client): (handlerData: HandlerDataXhr) endTimestamp, }; + const level = getBreadcrumbLogLevelFromHttpStatusCode(status_code); + addBreadcrumb( { category: 'xhr', data, type: 'http', + level, }, hint, ); @@ -309,11 +313,14 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe startTimestamp, endTimestamp, }; + const level = getBreadcrumbLogLevelFromHttpStatusCode(data.status_code); + addBreadcrumb( { category: 'fetch', data, type: 'http', + level, }, hint, ); diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 267adda6fac4..ef3bcb020823 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -132,6 +132,7 @@ export { addOpenTelemetryInstrumentation, zodErrorsIntegration, profiler, + amqplibIntegration, } from '@sentry/node'; export { diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index 398153563f1c..8fc88a578808 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -15,9 +15,6 @@ - [Official SDK Docs](https://docs.sentry.io/quickstart/) - [TypeDoc](http://getsentry.github.io/sentry-javascript/) -**Note: This SDK is in an alpha state. Please follow the -[tracking GH issue](https://github.com/getsentry/sentry-javascript/issues/12620) for updates.** - ## Install To get started, first install the `@sentry/cloudflare` package: diff --git a/packages/cloudflare/src/integrations/fetch.ts b/packages/cloudflare/src/integrations/fetch.ts index 4781a71a896d..4bada212e7d5 100644 --- a/packages/cloudflare/src/integrations/fetch.ts +++ b/packages/cloudflare/src/integrations/fetch.ts @@ -7,7 +7,12 @@ import type { IntegrationFn, Span, } from '@sentry/types'; -import { LRUMap, addFetchInstrumentationHandler, stringMatchesSomePattern } from '@sentry/utils'; +import { + LRUMap, + addFetchInstrumentationHandler, + getBreadcrumbLogLevelFromHttpStatusCode, + stringMatchesSomePattern, +} from '@sentry/utils'; const INTEGRATION_NAME = 'Fetch'; @@ -144,11 +149,14 @@ function createBreadcrumb(handlerData: HandlerDataFetch): void { startTimestamp, endTimestamp, }; + const level = getBreadcrumbLogLevelFromHttpStatusCode(data.status_code); + addBreadcrumb( { category: 'fetch', data, type: 'http', + level, }, hint, ); diff --git a/packages/core/src/tracing/dynamicSamplingContext.ts b/packages/core/src/tracing/dynamicSamplingContext.ts index d47dfd7ff317..d96dd726d51f 100644 --- a/packages/core/src/tracing/dynamicSamplingContext.ts +++ b/packages/core/src/tracing/dynamicSamplingContext.ts @@ -9,6 +9,7 @@ import { import { DEFAULT_ENVIRONMENT } from '../constants'; import { getClient } from '../currentScopes'; import { SEMANTIC_ATTRIBUTE_SENTRY_SAMPLE_RATE, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE } from '../semanticAttributes'; +import { hasTracingEnabled } from '../utils/hasTracingEnabled'; import { getRootSpan, spanIsSampled, spanToJSON } from '../utils/spanUtils'; /** @@ -103,7 +104,12 @@ export function getDynamicSamplingContextFromSpan(span: Span): Readonly { environment: 'production', sampled: 'true', sample_rate: '0.56', - trace_id: expect.any(String), + trace_id: expect.stringMatching(/^[a-f0-9]{32}$/), transaction: 'tx', }); }); @@ -85,7 +86,7 @@ describe('getDynamicSamplingContextFromSpan', () => { environment: 'production', sampled: 'true', sample_rate: '1', - trace_id: expect.any(String), + trace_id: expect.stringMatching(/^[a-f0-9]{32}$/), transaction: 'tx', }); }); @@ -107,7 +108,7 @@ describe('getDynamicSamplingContextFromSpan', () => { environment: 'production', sampled: 'true', sample_rate: '0.56', - trace_id: expect.any(String), + trace_id: expect.stringMatching(/^[a-f0-9]{32}$/), transaction: 'tx', }); }); @@ -144,4 +145,23 @@ describe('getDynamicSamplingContextFromSpan', () => { expect(dsc.transaction).toEqual('tx'); }); }); + + it("doesn't return the sampled flag in the DSC if in Tracing without Performance mode", () => { + const rootSpan = new SentrySpan({ + name: 'tx', + sampled: undefined, + }); + + // Simulate TwP mode by deleting the tracesSampleRate option set in beforeEach + delete getClient()?.getOptions().tracesSampleRate; + + const dynamicSamplingContext = getDynamicSamplingContextFromSpan(rootSpan); + + expect(dynamicSamplingContext).toStrictEqual({ + release: '1.0.1', + environment: 'production', + trace_id: expect.stringMatching(/^[a-f0-9]{32}$/), + transaction: 'tx', + }); + }); }); diff --git a/packages/deno/src/integrations/breadcrumbs.ts b/packages/deno/src/integrations/breadcrumbs.ts index 47953d4d7ce8..6b945ebc37f5 100644 --- a/packages/deno/src/integrations/breadcrumbs.ts +++ b/packages/deno/src/integrations/breadcrumbs.ts @@ -11,6 +11,7 @@ import type { import { addConsoleInstrumentationHandler, addFetchInstrumentationHandler, + getBreadcrumbLogLevelFromHttpStatusCode, getEventDescription, safeJoin, severityLevelFromString, @@ -178,11 +179,14 @@ function _getFetchBreadcrumbHandler(client: Client): (handlerData: HandlerDataFe startTimestamp, endTimestamp, }; + const level = getBreadcrumbLogLevelFromHttpStatusCode(data.status_code); + addBreadcrumb( { category: 'fetch', data, type: 'http', + level, }, hint, ); diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 33fdc6ea314f..a9d3e5025d92 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -111,6 +111,7 @@ export { addOpenTelemetryInstrumentation, zodErrorsIntegration, profiler, + amqplibIntegration, } from '@sentry/node'; export { diff --git a/packages/nestjs/src/setup.ts b/packages/nestjs/src/setup.ts index 88d58ffea22f..a18f95417f11 100644 --- a/packages/nestjs/src/setup.ts +++ b/packages/nestjs/src/setup.ts @@ -7,6 +7,7 @@ import type { OnModuleInit, } from '@nestjs/common'; import { Catch, Global, HttpException, Injectable, Logger, Module } from '@nestjs/common'; +import type { HttpServer } from '@nestjs/common'; import { APP_INTERCEPTOR, BaseExceptionFilter } from '@nestjs/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -67,8 +68,8 @@ export { SentryTracingInterceptor }; class SentryGlobalFilter extends BaseExceptionFilter { public readonly __SENTRY_INTERNAL__: boolean; - public constructor() { - super(); + public constructor(applicationRef?: HttpServer) { + super(applicationRef); this.__SENTRY_INTERNAL__ = true; } @@ -123,6 +124,35 @@ class SentryGlobalGraphQLFilter { Catch()(SentryGlobalGraphQLFilter); export { SentryGlobalGraphQLFilter }; +/** + * Global filter to handle exceptions and report them to Sentry. + * + * This filter is a generic filter that can handle both HTTP and GraphQL exceptions. + */ +class SentryGlobalGenericFilter extends SentryGlobalFilter { + public readonly __SENTRY_INTERNAL__: boolean; + private readonly _graphqlFilter: SentryGlobalGraphQLFilter; + + public constructor(applicationRef?: HttpServer) { + super(applicationRef); + this.__SENTRY_INTERNAL__ = true; + this._graphqlFilter = new SentryGlobalGraphQLFilter(); + } + + /** + * Catches exceptions and forwards them to the according error filter. + */ + public catch(exception: unknown, host: ArgumentsHost): void { + if (host.getType<'graphql'>() === 'graphql') { + return this._graphqlFilter.catch(exception, host); + } + + super.catch(exception, host); + } +} +Catch()(SentryGlobalGenericFilter); +export { SentryGlobalGenericFilter }; + /** * Service to set up Sentry performance tracing for Nest.js applications. */ diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index f92c3de40744..8f795bb3601a 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -71,6 +71,7 @@ "@opentelemetry/instrumentation-http": "0.53.0", "@opentelemetry/semantic-conventions": "^1.27.0", "@rollup/plugin-commonjs": "26.0.1", + "@sentry-internal/browser-utils": "8.31.0", "@sentry/core": "8.31.0", "@sentry/node": "8.31.0", "@sentry/opentelemetry": "8.31.0", @@ -81,7 +82,7 @@ "@sentry/webpack-plugin": "2.22.3", "chalk": "3.0.0", "resolve": "1.22.8", - "rollup": "3.29.4", + "rollup": "3.29.5", "stacktrace-parser": "^0.1.10" }, "devDependencies": { @@ -127,4 +128,4 @@ "extends": "../../package.json" }, "sideEffects": false -} +} \ No newline at end of file diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index a68734a10398..c66f50a293f2 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -8,6 +8,7 @@ import { devErrorSymbolicationEventProcessor } from '../common/devErrorSymbolica import { getVercelEnv } from '../common/getVercelEnv'; import { browserTracingIntegration } from './browserTracingIntegration'; import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; +import { INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME } from './routing/appRouterRoutingInstrumentation'; import { applyTunnelRouteOption } from './tunnelRoute'; export * from '@sentry/react'; @@ -39,6 +40,13 @@ export function init(options: BrowserOptions): Client | undefined { filterTransactions.id = 'NextClient404Filter'; addEventProcessor(filterTransactions); + const filterIncompleteNavigationTransactions: EventProcessor = event => + event.type === 'transaction' && event.transaction === INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME + ? null + : event; + filterIncompleteNavigationTransactions.id = 'IncompleteTransactionFilter'; + addEventProcessor(filterIncompleteNavigationTransactions); + if (process.env.NODE_ENV === 'development') { addEventProcessor(devErrorSymbolicationEventProcessor); } diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 25c1496d25b4..741849c481ab 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -4,8 +4,10 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core'; import { WINDOW, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan } from '@sentry/react'; -import type { Client } from '@sentry/types'; -import { addFetchInstrumentationHandler, browserPerformanceTimeOrigin } from '@sentry/utils'; +import type { Client, Span } from '@sentry/types'; +import { GLOBAL_OBJ, browserPerformanceTimeOrigin } from '@sentry/utils'; + +export const INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME = 'incomplete-app-router-transaction'; /** Instruments the Next.js app router for pageloads. */ export function appRouterInstrumentPageLoad(client: Client): void { @@ -21,70 +23,111 @@ export function appRouterInstrumentPageLoad(client: Client): void { }); } -/** Instruments the Next.js app router for navigation. */ -export function appRouterInstrumentNavigation(client: Client): void { - addFetchInstrumentationHandler(handlerData => { - // The instrumentation handler is invoked twice - once for starting a request and once when the req finishes - // We can use the existence of the end-timestamp to filter out "finishing"-events. - if (handlerData.endTimestamp !== undefined) { - return; - } - - // Only GET requests can be navigating RSC requests - if (handlerData.fetchData.method !== 'GET') { - return; - } +interface NextRouter { + back: () => void; + forward: () => void; + push: (target: string) => void; + replace: (target: string) => void; +} - const parsedNavigatingRscFetchArgs = parseNavigatingRscFetchArgs(handlerData.args); +// Yes, yes, I know we shouldn't depend on these internals. But that's where we are at. We write the ugly code, so you don't have to. +const GLOBAL_OBJ_WITH_NEXT_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + // Available until 13.4.4-canary.3 - https://github.com/vercel/next.js/pull/50210 + nd?: { + router?: NextRouter; + }; + // Avalable from 13.4.4-canary.4 - https://github.com/vercel/next.js/pull/50210 + next?: { + router?: NextRouter; + }; +}; - if (parsedNavigatingRscFetchArgs === null) { - return; - } +/* + * The routing instrumentation needs to handle a few cases: + * - Router operations: + * - router.push() (either explicitly called or implicitly through tags) + * - router.replace() (either explicitly called or implicitly through tags) + * - router.back() + * - router.forward() + * - Browser operations: + * - native Browser-back / popstate event (implicitly called by router.back()) + * - native Browser-forward / popstate event (implicitly called by router.forward()) + */ - const newPathname = parsedNavigatingRscFetchArgs.targetPathname; +/** Instruments the Next.js app router for navigation. */ +export function appRouterInstrumentNavigation(client: Client): void { + let currentNavigationSpan: Span | undefined = undefined; - startBrowserTracingNavigationSpan(client, { - name: newPathname, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - }, - }); + WINDOW.addEventListener('popstate', () => { + if (currentNavigationSpan && currentNavigationSpan.isRecording()) { + currentNavigationSpan.updateName(WINDOW.location.pathname); + } else { + currentNavigationSpan = startBrowserTracingNavigationSpan(client, { + name: WINDOW.location.pathname, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + 'navigation.type': 'browser.popstate', + }, + }); + } }); -} -function parseNavigatingRscFetchArgs(fetchArgs: unknown[]): null | { - targetPathname: string; -} { - // Make sure the first arg is a URL object - if (!fetchArgs[0] || typeof fetchArgs[0] !== 'object' || (fetchArgs[0] as URL).searchParams === undefined) { - return null; - } + let routerPatched = false; + let triesToFindRouter = 0; + const MAX_TRIES_TO_FIND_ROUTER = 500; + const ROUTER_AVAILABILITY_CHECK_INTERVAL_MS = 20; + const checkForRouterAvailabilityInterval = setInterval(() => { + triesToFindRouter++; + const router = GLOBAL_OBJ_WITH_NEXT_ROUTER?.next?.router ?? GLOBAL_OBJ_WITH_NEXT_ROUTER?.nd?.router; - // Make sure the second argument is some kind of fetch config obj that contains headers - if (!fetchArgs[1] || typeof fetchArgs[1] !== 'object' || !('headers' in fetchArgs[1])) { - return null; - } + if (routerPatched || triesToFindRouter > MAX_TRIES_TO_FIND_ROUTER) { + clearInterval(checkForRouterAvailabilityInterval); + } else if (router) { + clearInterval(checkForRouterAvailabilityInterval); + routerPatched = true; + (['back', 'forward', 'push', 'replace'] as const).forEach(routerFunctionName => { + if (router?.[routerFunctionName]) { + // @ts-expect-error Weird type error related to not knowing how to associate return values with the individual functions - we can just ignore + router[routerFunctionName] = new Proxy(router[routerFunctionName], { + apply(target, thisArg, argArray) { + const span = startBrowserTracingNavigationSpan(client, { + name: INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + }, + }); - try { - const url = fetchArgs[0] as URL; - const headers = fetchArgs[1].headers as Record; + currentNavigationSpan = span; - // Not an RSC request - if (headers['RSC'] !== '1') { - return null; - } + if (routerFunctionName === 'push') { + span?.updateName(transactionNameifyRouterArgument(argArray[0])); + span?.setAttribute('navigation.type', 'router.push'); + } else if (routerFunctionName === 'replace') { + span?.updateName(transactionNameifyRouterArgument(argArray[0])); + span?.setAttribute('navigation.type', 'router.replace'); + } else if (routerFunctionName === 'back') { + span?.setAttribute('navigation.type', 'router.back'); + } else if (routerFunctionName === 'forward') { + span?.setAttribute('navigation.type', 'router.forward'); + } - // Prefetch requests are not navigating RSC requests - if (headers['Next-Router-Prefetch'] === '1') { - return null; + return target.apply(thisArg, argArray); + }, + }); + } + }); } + }, ROUTER_AVAILABILITY_CHECK_INTERVAL_MS); +} - return { - targetPathname: url.pathname, - }; +function transactionNameifyRouterArgument(target: string): string { + try { + return new URL(target, 'http://some-random-base.com/').pathname; } catch { - return null; + return '/'; } } diff --git a/packages/nextjs/test/clientSdk.test.ts b/packages/nextjs/test/clientSdk.test.ts index ac159564410b..f136b29e6887 100644 --- a/packages/nextjs/test/clientSdk.test.ts +++ b/packages/nextjs/test/clientSdk.test.ts @@ -16,13 +16,18 @@ const loggerLogSpy = jest.spyOn(logger, 'log'); const dom = new JSDOM(undefined, { url: 'https://example.com/' }); Object.defineProperty(global, 'document', { value: dom.window.document, writable: true }); Object.defineProperty(global, 'location', { value: dom.window.document.location, writable: true }); +Object.defineProperty(global, 'addEventListener', { value: () => undefined, writable: true }); const originalGlobalDocument = WINDOW.document; const originalGlobalLocation = WINDOW.location; +// eslint-disable-next-line @typescript-eslint/unbound-method +const originalGlobalAddEventListener = WINDOW.addEventListener; + afterAll(() => { // Clean up JSDom Object.defineProperty(WINDOW, 'document', { value: originalGlobalDocument }); Object.defineProperty(WINDOW, 'location', { value: originalGlobalLocation }); + Object.defineProperty(WINDOW, 'addEventListener', { value: originalGlobalAddEventListener }); }); function findIntegrationByName(integrations: Integration[] = [], name: string): Integration | undefined { diff --git a/packages/nextjs/test/performance/appRouterInstrumentation.test.ts b/packages/nextjs/test/performance/appRouterInstrumentation.test.ts deleted file mode 100644 index 16992a498f83..000000000000 --- a/packages/nextjs/test/performance/appRouterInstrumentation.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { WINDOW } from '@sentry/react'; -import type { Client, HandlerDataFetch } from '@sentry/types'; -import * as sentryUtils from '@sentry/utils'; -import { JSDOM } from 'jsdom'; - -import { - appRouterInstrumentNavigation, - appRouterInstrumentPageLoad, -} from '../../src/client/routing/appRouterRoutingInstrumentation'; - -const addFetchInstrumentationHandlerSpy = jest.spyOn(sentryUtils, 'addFetchInstrumentationHandler'); - -function setUpPage(url: string) { - const dom = new JSDOM('

nothingness

', { url }); - - // The Next.js routing instrumentations requires a few things to be present on pageload: - // 1. Access to window.document API for `window.document.getElementById` - // 2. Access to window.location API for `window.location.pathname` - Object.defineProperty(WINDOW, 'document', { value: dom.window.document, writable: true }); - Object.defineProperty(WINDOW, 'location', { value: dom.window.document.location, writable: true }); -} - -describe('appRouterInstrumentPageLoad', () => { - const originalGlobalDocument = WINDOW.document; - const originalGlobalLocation = WINDOW.location; - - afterEach(() => { - // Clean up JSDom - Object.defineProperty(WINDOW, 'document', { value: originalGlobalDocument }); - Object.defineProperty(WINDOW, 'location', { value: originalGlobalLocation }); - }); - - it('should create a pageload transactions with the current location name', () => { - setUpPage('https://example.com/some/page?someParam=foobar'); - - const emit = jest.fn(); - const client = { - emit, - } as unknown as Client; - - appRouterInstrumentPageLoad(client); - - expect(emit).toHaveBeenCalledTimes(1); - expect(emit).toHaveBeenCalledWith( - 'startPageLoadSpan', - expect.objectContaining({ - name: '/some/page', - attributes: { - 'sentry.op': 'pageload', - 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', - 'sentry.source': 'url', - }, - }), - undefined, - ); - }); -}); - -describe('appRouterInstrumentNavigation', () => { - const originalGlobalDocument = WINDOW.document; - const originalGlobalLocation = WINDOW.location; - - afterEach(() => { - // Clean up JSDom - Object.defineProperty(WINDOW, 'document', { value: originalGlobalDocument }); - Object.defineProperty(WINDOW, 'location', { value: originalGlobalLocation }); - }); - - it('should create a navigation transactions when a navigation RSC request is sent', () => { - setUpPage('https://example.com/some/page?someParam=foobar'); - let fetchInstrumentationHandlerCallback: (arg: HandlerDataFetch) => void; - - addFetchInstrumentationHandlerSpy.mockImplementationOnce(callback => { - fetchInstrumentationHandlerCallback = callback; - }); - - const emit = jest.fn(); - const client = { - emit, - } as unknown as Client; - - appRouterInstrumentNavigation(client); - - fetchInstrumentationHandlerCallback!({ - args: [ - new URL('https://example.com/some/server/component/page?_rsc=2rs8t'), - { - headers: { - RSC: '1', - }, - }, - ], - fetchData: { method: 'GET', url: 'https://example.com/some/server/component/page?_rsc=2rs8t' }, - startTimestamp: 1337, - }); - - expect(emit).toHaveBeenCalledTimes(1); - expect(emit).toHaveBeenCalledWith('startNavigationSpan', { - name: '/some/server/component/page', - attributes: { - 'sentry.op': 'navigation', - 'sentry.origin': 'auto.navigation.nextjs.app_router_instrumentation', - 'sentry.source': 'url', - }, - }); - }); - - it.each([ - [ - 'no RSC header', - { - args: [ - new URL('https://example.com/some/server/component/page?_rsc=2rs8t'), - { - headers: {}, - }, - ], - fetchData: { method: 'GET', url: 'https://example.com/some/server/component/page?_rsc=2rs8t' }, - startTimestamp: 1337, - }, - ], - [ - 'no GET request', - { - args: [ - new URL('https://example.com/some/server/component/page?_rsc=2rs8t'), - { - headers: { - RSC: '1', - }, - }, - ], - fetchData: { method: 'POST', url: 'https://example.com/some/server/component/page?_rsc=2rs8t' }, - startTimestamp: 1337, - }, - ], - [ - 'prefetch request', - { - args: [ - new URL('https://example.com/some/server/component/page?_rsc=2rs8t'), - { - headers: { - RSC: '1', - 'Next-Router-Prefetch': '1', - }, - }, - ], - fetchData: { method: 'GET', url: 'https://example.com/some/server/component/page?_rsc=2rs8t' }, - startTimestamp: 1337, - }, - ], - ])( - 'should not create navigation transactions for fetch requests that are not navigating RSC requests (%s)', - (_, fetchCallbackData) => { - setUpPage('https://example.com/some/page?someParam=foobar'); - let fetchInstrumentationHandlerCallback: (arg: HandlerDataFetch) => void; - - addFetchInstrumentationHandlerSpy.mockImplementationOnce(callback => { - fetchInstrumentationHandlerCallback = callback; - }); - - const emit = jest.fn(); - const client = { - emit, - } as unknown as Client; - - appRouterInstrumentNavigation(client); - fetchInstrumentationHandlerCallback!(fetchCallbackData); - - expect(emit).toHaveBeenCalledTimes(0); - }, - ); -}); diff --git a/packages/node/package.json b/packages/node/package.json index cf45090e36cf..1ace8a11f408 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -69,6 +69,7 @@ "@opentelemetry/context-async-hooks": "^1.25.1", "@opentelemetry/core": "^1.25.1", "@opentelemetry/instrumentation": "^0.53.0", + "@opentelemetry/instrumentation-amqplib": "^0.42.0", "@opentelemetry/instrumentation-connect": "0.39.0", "@opentelemetry/instrumentation-dataloader": "0.12.0", "@opentelemetry/instrumentation-express": "0.42.0", diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index f3c945f5316d..e97780f79ead 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -29,6 +29,7 @@ export { connectIntegration, setupConnectErrorHandler } from './integrations/tra export { spotlightIntegration } from './integrations/spotlight'; export { genericPoolIntegration } from './integrations/tracing/genericPool'; export { dataloaderIntegration } from './integrations/tracing/dataloader'; +export { amqplibIntegration } from './integrations/tracing/amqplib'; export { SentryContextManager } from './otel/contextManager'; export { generateInstrumentOnce } from './otel/instrument'; diff --git a/packages/node/src/integrations/http.ts b/packages/node/src/integrations/http.ts index 126f22a06063..d6796aa866e5 100644 --- a/packages/node/src/integrations/http.ts +++ b/packages/node/src/integrations/http.ts @@ -15,7 +15,12 @@ import { import { getClient } from '@sentry/opentelemetry'; import type { IntegrationFn, SanitizedRequestData } from '@sentry/types'; -import { getSanitizedUrlString, parseUrl, stripUrlQueryAndFragment } from '@sentry/utils'; +import { + getBreadcrumbLogLevelFromHttpStatusCode, + getSanitizedUrlString, + parseUrl, + stripUrlQueryAndFragment, +} from '@sentry/utils'; import type { NodeClient } from '../sdk/client'; import { setIsolationScope } from '../sdk/scope'; import type { HTTPModuleRequestIncomingMessage } from '../transports/http-module'; @@ -243,14 +248,18 @@ function _addRequestBreadcrumb( } const data = getBreadcrumbData(request); + const statusCode = response.statusCode; + const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); + addBreadcrumb( { category: 'http', data: { - status_code: response.statusCode, + status_code: statusCode, ...data, }, type: 'http', + level, }, { event: 'response', diff --git a/packages/node/src/integrations/node-fetch.ts b/packages/node/src/integrations/node-fetch.ts index 0726c2c63f9b..60abee504758 100644 --- a/packages/node/src/integrations/node-fetch.ts +++ b/packages/node/src/integrations/node-fetch.ts @@ -1,9 +1,20 @@ +import { context, propagation, trace } from '@opentelemetry/api'; import type { UndiciRequest, UndiciResponse } from '@opentelemetry/instrumentation-undici'; import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici'; -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, addBreadcrumb, defineIntegration } from '@sentry/core'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + addBreadcrumb, + defineIntegration, + getCurrentScope, + hasTracingEnabled, +} from '@sentry/core'; +import { + addOpenTelemetryInstrumentation, + generateSpanContextForPropagationContext, + getPropagationContextFromSpan, +} from '@sentry/opentelemetry'; import type { IntegrationFn, SanitizedRequestData } from '@sentry/types'; -import { getSanitizedUrlString, parseUrl } from '@sentry/utils'; +import { getBreadcrumbLogLevelFromHttpStatusCode, getSanitizedUrlString, parseUrl } from '@sentry/utils'; interface NodeFetchOptions { /** @@ -32,7 +43,44 @@ const _nativeNodeFetchIntegration = ((options: NodeFetchOptions = {}) => { const url = getAbsoluteUrl(request.origin, request.path); const shouldIgnore = _ignoreOutgoingRequests && url && _ignoreOutgoingRequests(url); - return !!shouldIgnore; + if (shouldIgnore) { + return true; + } + + // If tracing is disabled, we still want to propagate traces + // So we do that manually here, matching what the instrumentation does otherwise + if (!hasTracingEnabled()) { + const ctx = context.active(); + const addedHeaders: Record = {}; + + // We generate a virtual span context from the active one, + // Where we attach the URL to the trace state, so the propagator can pick it up + const activeSpan = trace.getSpan(ctx); + const propagationContext = activeSpan + ? getPropagationContextFromSpan(activeSpan) + : getCurrentScope().getPropagationContext(); + + const spanContext = generateSpanContextForPropagationContext(propagationContext); + // We know that in practice we'll _always_ haven a traceState here + spanContext.traceState = spanContext.traceState?.set('sentry.url', url); + const ctxWithUrlTraceState = trace.setSpanContext(ctx, spanContext); + + propagation.inject(ctxWithUrlTraceState, addedHeaders); + + const requestHeaders = request.headers; + if (Array.isArray(requestHeaders)) { + Object.entries(addedHeaders).forEach(headers => requestHeaders.push(...headers)); + } else { + request.headers += Object.entries(addedHeaders) + .map(([k, v]) => `${k}: ${v}\r\n`) + .join(''); + } + + // Prevent starting a span for this request + return true; + } + + return false; }, startSpanHook: () => { return { @@ -56,15 +104,18 @@ export const nativeNodeFetchIntegration = defineIntegration(_nativeNodeFetchInte /** Add a breadcrumb for outgoing requests. */ function addRequestBreadcrumb(request: UndiciRequest, response: UndiciResponse): void { const data = getBreadcrumbData(request); + const statusCode = response.statusCode; + const level = getBreadcrumbLogLevelFromHttpStatusCode(statusCode); addBreadcrumb( { category: 'http', data: { - status_code: response.statusCode, + status_code: statusCode, ...data, }, type: 'http', + level, }, { event: 'response', diff --git a/packages/node/src/integrations/tracing/amqplib.ts b/packages/node/src/integrations/tracing/amqplib.ts new file mode 100644 index 000000000000..4b44a145ae1f --- /dev/null +++ b/packages/node/src/integrations/tracing/amqplib.ts @@ -0,0 +1,30 @@ +import type { Span } from '@opentelemetry/api'; +import { AmqplibInstrumentation, type AmqplibInstrumentationConfig } from '@opentelemetry/instrumentation-amqplib'; +import { defineIntegration } from '@sentry/core'; +import type { IntegrationFn } from '@sentry/types'; +import { generateInstrumentOnce } from '../../otel/instrument'; +import { addOriginToSpan } from '../../utils/addOriginToSpan'; + +const INTEGRATION_NAME = 'Amqplib'; + +const config: AmqplibInstrumentationConfig = { + consumeEndHook: (span: Span) => { + addOriginToSpan(span, 'auto.amqplib.otel.consumer'); + }, + publishHook: (span: Span) => { + addOriginToSpan(span, 'auto.amqplib.otel.publisher'); + }, +}; + +export const instrumentAmqplib = generateInstrumentOnce(INTEGRATION_NAME, () => new AmqplibInstrumentation(config)); + +const _amqplibIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + instrumentAmqplib(); + }, + }; +}) satisfies IntegrationFn; + +export const amqplibIntegration = defineIntegration(_amqplibIntegration); diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index 0248e3fbae21..cc8ef752c815 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -1,6 +1,7 @@ import type { Integration } from '@sentry/types'; import { instrumentHttp } from '../http'; +import { amqplibIntegration, instrumentAmqplib } from './amqplib'; import { connectIntegration, instrumentConnect } from './connect'; import { dataloaderIntegration, instrumentDataloader } from './dataloader'; import { expressIntegration, instrumentExpress } from './express'; @@ -43,6 +44,7 @@ export function getAutoPerformanceIntegrations(): Integration[] { genericPoolIntegration(), kafkaIntegration(), dataloaderIntegration(), + amqplibIntegration(), ]; } @@ -70,5 +72,6 @@ export function getOpenTelemetryInstrumentationToPreload(): (((options?: any) => instrumentRedis, instrumentGenericPool, instrumentDataloader, + instrumentAmqplib, ]; } diff --git a/packages/nuxt/src/runtime/plugins/sentry.client.ts b/packages/nuxt/src/runtime/plugins/sentry.client.ts index 95dc954c4b89..b89a2fa87a8d 100644 --- a/packages/nuxt/src/runtime/plugins/sentry.client.ts +++ b/packages/nuxt/src/runtime/plugins/sentry.client.ts @@ -1,6 +1,7 @@ import { getClient } from '@sentry/core'; import { browserTracingIntegration, vueIntegration } from '@sentry/vue'; import { defineNuxtPlugin } from 'nuxt/app'; +import { reportNuxtError } from '../utils'; // --- Types are copied from @sentry/vue (so it does not need to be exported) --- // The following type is an intersection of the Route type from VueRouter v2, v3, and v4. @@ -49,8 +50,19 @@ export default defineNuxtPlugin({ const sentryClient = getClient(); if (sentryClient) { - sentryClient.addIntegration(vueIntegration({ app: vueApp })); + // Adding the Vue integration without the Vue error handler + // Nuxt is registering their own error handler, which is unset after hydration: https://github.com/nuxt/nuxt/blob/d3fdbcaac6cf66d21e25d259390d7824696f1a87/packages/nuxt/src/app/entry.ts#L64-L73 + // We don't want to wrap the existing error handler, as it leads to a 500 error: https://github.com/getsentry/sentry-javascript/issues/12515 + sentryClient.addIntegration(vueIntegration({ app: vueApp, attachErrorHandler: false })); } }); + + nuxtApp.hook('app:error', error => { + reportNuxtError({ error }); + }); + + nuxtApp.hook('vue:error', (error, instance, info) => { + reportNuxtError({ error, instance, info }); + }); }, }); diff --git a/packages/nuxt/src/runtime/utils.ts b/packages/nuxt/src/runtime/utils.ts index 585387f59003..7b56a258f708 100644 --- a/packages/nuxt/src/runtime/utils.ts +++ b/packages/nuxt/src/runtime/utils.ts @@ -1,8 +1,10 @@ -import { getTraceMetaTags } from '@sentry/core'; -import type { Context } from '@sentry/types'; +import { captureException, getClient, getTraceMetaTags } from '@sentry/core'; +import type { ClientOptions, Context } from '@sentry/types'; import { dropUndefinedKeys } from '@sentry/utils'; +import type { VueOptions } from '@sentry/vue/src/types'; import type { CapturedErrorContext } from 'nitropack'; import type { NuxtRenderHTMLContext } from 'nuxt/app'; +import type { ComponentPublicInstance } from 'vue'; /** * Extracts the relevant context information from the error context (H3Event in Nitro Error) @@ -41,3 +43,40 @@ export function addSentryTracingMetaTags(head: NuxtRenderHTMLContext['head']): v head.push(metaTags); } } + +/** + * Reports an error to Sentry. This function is similar to `attachErrorHandler` in `@sentry/vue`. + * The Nuxt SDK does not register an error handler, but uses the Nuxt error hooks to report errors. + * + * We don't want to use the error handling from `@sentry/vue` as it wraps the existing error handler, which leads to a 500 error: https://github.com/getsentry/sentry-javascript/issues/12515 + */ +export function reportNuxtError(options: { + error: unknown; + instance?: ComponentPublicInstance | null; + info?: string; +}): void { + const { error, instance, info } = options; + + const metadata: Record = { + info, + // todo: add component name and trace (like in the vue integration) + }; + + if (instance && instance.$props) { + const sentryClient = getClient(); + const sentryOptions = sentryClient ? (sentryClient.getOptions() as ClientOptions & VueOptions) : null; + + // `attachProps` is enabled by default and props should only not be attached if explicitly disabled (see DEFAULT_CONFIG in `vueIntegration`). + if (sentryOptions && sentryOptions.attachProps && instance.$props !== false) { + metadata.propsData = instance.$props; + } + } + + // Capture exception in the next event loop, to make sure that all breadcrumbs are recorded in time. + setTimeout(() => { + captureException(error, { + captureContext: { contexts: { nuxt: metadata } }, + mechanism: { handled: false }, + }); + }); +} diff --git a/packages/nuxt/test/runtime/utils.test.ts b/packages/nuxt/test/runtime/utils.test.ts index 08c66193caa3..a6afc03b05da 100644 --- a/packages/nuxt/test/runtime/utils.test.ts +++ b/packages/nuxt/test/runtime/utils.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from 'vitest'; -import { extractErrorContext } from '../../src/runtime/utils'; +import { captureException, getClient } from '@sentry/core'; +import { type Mock, afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; +import type { ComponentPublicInstance } from 'vue'; +import { extractErrorContext, reportNuxtError } from '../../src/runtime/utils'; describe('extractErrorContext', () => { it('returns empty object for undefined or empty context', () => { @@ -77,3 +79,73 @@ describe('extractErrorContext', () => { expect(() => extractErrorContext(weirdContext3)).not.toThrow(); }); }); + +describe('reportNuxtError', () => { + vi.mock('@sentry/core', () => ({ + captureException: vi.fn(), + getClient: vi.fn(), + })); + + const mockError = new Error('Test error'); + + const mockInstance: ComponentPublicInstance = { + $props: { foo: 'bar' }, + } as any; + + const mockClient = { + getOptions: vi.fn().mockReturnValue({ attachProps: true }), + }; + + beforeEach(() => { + // Using fake timers as setTimeout is used in `reportNuxtError` + vi.useFakeTimers(); + vi.clearAllMocks(); + (getClient as Mock).mockReturnValue(mockClient); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test('captures exception with correct error and metadata', () => { + reportNuxtError({ error: mockError }); + vi.runAllTimers(); + + expect(captureException).toHaveBeenCalledWith(mockError, { + captureContext: { contexts: { nuxt: { info: undefined } } }, + mechanism: { handled: false }, + }); + }); + + test('includes instance props if attachProps is not explicitly defined', () => { + reportNuxtError({ error: mockError, instance: mockInstance }); + vi.runAllTimers(); + + expect(captureException).toHaveBeenCalledWith(mockError, { + captureContext: { contexts: { nuxt: { info: undefined, propsData: { foo: 'bar' } } } }, + mechanism: { handled: false }, + }); + }); + + test('does not include instance props if attachProps is disabled', () => { + mockClient.getOptions.mockReturnValue({ attachProps: false }); + + reportNuxtError({ error: mockError, instance: mockInstance }); + vi.runAllTimers(); + + expect(captureException).toHaveBeenCalledWith(mockError, { + captureContext: { contexts: { nuxt: { info: undefined } } }, + mechanism: { handled: false }, + }); + }); + + test('handles absence of instance correctly', () => { + reportNuxtError({ error: mockError, info: 'Some info' }); + vi.runAllTimers(); + + expect(captureException).toHaveBeenCalledWith(mockError, { + captureContext: { contexts: { nuxt: { info: 'Some info' } } }, + mechanism: { handled: false }, + }); + }); +}); diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts index 37161b41715e..9218933b9896 100644 --- a/packages/remix/src/index.server.ts +++ b/packages/remix/src/index.server.ts @@ -18,6 +18,7 @@ export { addIntegration, addOpenTelemetryInstrumentation, addRequestDataToEvent, + amqplibIntegration, anrIntegration, captureCheckIn, captureConsoleIntegration, diff --git a/packages/solidstart/src/server/index.ts b/packages/solidstart/src/server/index.ts index 794106eca715..b22fbbe1de0d 100644 --- a/packages/solidstart/src/server/index.ts +++ b/packages/solidstart/src/server/index.ts @@ -9,6 +9,7 @@ export { addIntegration, addOpenTelemetryInstrumentation, addRequestDataToEvent, + amqplibIntegration, anrIntegration, captureCheckIn, captureConsoleIntegration, diff --git a/packages/sveltekit/src/server/index.ts b/packages/sveltekit/src/server/index.ts index e2902afb400b..e9fb6f256192 100644 --- a/packages/sveltekit/src/server/index.ts +++ b/packages/sveltekit/src/server/index.ts @@ -9,6 +9,7 @@ export { addIntegration, addOpenTelemetryInstrumentation, addRequestDataToEvent, + amqplibIntegration, anrIntegration, captureCheckIn, captureConsoleIntegration, diff --git a/packages/types/src/scope.ts b/packages/types/src/scope.ts index aa51c69035f1..a4b91f4b5d96 100644 --- a/packages/types/src/scope.ts +++ b/packages/types/src/scope.ts @@ -189,8 +189,8 @@ export interface Scope { clear(): this; /** - * Sets the breadcrumbs in the scope - * @param breadcrumbs Breadcrumb + * Adds a breadcrumb to the scope + * @param breadcrumb Breadcrumb * @param maxBreadcrumbs number of max breadcrumbs to merged into event. */ addBreadcrumb(breadcrumb: Breadcrumb, maxBreadcrumbs?: number): this; @@ -201,7 +201,7 @@ export interface Scope { getLastBreadcrumb(): Breadcrumb | undefined; /** - * Clears all currently set Breadcrumbs. + * Clears all breadcrumbs from the scope. */ clearBreadcrumbs(): this; diff --git a/packages/utils/src/breadcrumb-log-level.ts b/packages/utils/src/breadcrumb-log-level.ts new file mode 100644 index 000000000000..a19d70e00412 --- /dev/null +++ b/packages/utils/src/breadcrumb-log-level.ts @@ -0,0 +1,17 @@ +import type { SeverityLevel } from '@sentry/types'; + +/** + * Determine a breadcrumb's log level (only `warning` or `error`) based on an HTTP status code. + */ +export function getBreadcrumbLogLevelFromHttpStatusCode(statusCode: number | undefined): SeverityLevel | undefined { + // NOTE: undefined defaults to 'info' in Sentry + if (statusCode === undefined) { + return undefined; + } else if (statusCode >= 400 && statusCode < 500) { + return 'warning'; + } else if (statusCode >= 500) { + return 'error'; + } else { + return undefined; + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 822d150dfde1..245751b3e72c 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,5 +1,6 @@ export * from './aggregate-errors'; export * from './array'; +export * from './breadcrumb-log-level'; export * from './browser'; export * from './dsn'; export * from './error'; diff --git a/packages/utils/test/breadcrumb-log-level.test.ts b/packages/utils/test/breadcrumb-log-level.test.ts new file mode 100644 index 000000000000..49792d2726bb --- /dev/null +++ b/packages/utils/test/breadcrumb-log-level.test.ts @@ -0,0 +1,15 @@ +import { getBreadcrumbLogLevelFromHttpStatusCode } from '../src/breadcrumb-log-level'; + +describe('getBreadcrumbLogLevelFromHttpStatusCode()', () => { + it.each([ + ['warning', '4xx', 403], + ['error', '5xx', 500], + [undefined, '3xx', 307], + [undefined, '2xx', 200], + [undefined, '1xx', 103], + [undefined, '0', 0], + [undefined, 'undefined', undefined], + ])('should return `%s` for %s', (output, _codeRange, input) => { + expect(getBreadcrumbLogLevelFromHttpStatusCode(input)).toEqual(output); + }); +}); diff --git a/packages/vercel-edge/src/integrations/wintercg-fetch.ts b/packages/vercel-edge/src/integrations/wintercg-fetch.ts index 970e10958333..4dd4bf399e4c 100644 --- a/packages/vercel-edge/src/integrations/wintercg-fetch.ts +++ b/packages/vercel-edge/src/integrations/wintercg-fetch.ts @@ -7,7 +7,12 @@ import type { IntegrationFn, Span, } from '@sentry/types'; -import { LRUMap, addFetchInstrumentationHandler, stringMatchesSomePattern } from '@sentry/utils'; +import { + LRUMap, + addFetchInstrumentationHandler, + getBreadcrumbLogLevelFromHttpStatusCode, + stringMatchesSomePattern, +} from '@sentry/utils'; const INTEGRATION_NAME = 'WinterCGFetch'; @@ -150,11 +155,14 @@ function createBreadcrumb(handlerData: HandlerDataFetch): void { startTimestamp, endTimestamp, }; + const level = getBreadcrumbLogLevelFromHttpStatusCode(data.status_code); + addBreadcrumb( { category: 'fetch', data, type: 'http', + level, }, hint, ); diff --git a/packages/vue/src/errorhandler.ts b/packages/vue/src/errorhandler.ts index 725f9b56c714..07caeaf0f9cf 100644 --- a/packages/vue/src/errorhandler.ts +++ b/packages/vue/src/errorhandler.ts @@ -7,7 +7,7 @@ import { formatComponentName, generateComponentTrace } from './vendor/components type UnknownFunc = (...args: unknown[]) => void; export const attachErrorHandler = (app: Vue, options: VueOptions): void => { - const { errorHandler, warnHandler, silent } = app.config; + const { errorHandler: originalErrorHandler, warnHandler, silent } = app.config; app.config.errorHandler = (error: Error, vm: ViewModel, lifecycleHook: string): void => { const componentName = formatComponentName(vm, false); @@ -36,8 +36,9 @@ export const attachErrorHandler = (app: Vue, options: VueOptions): void => { }); }); - if (typeof errorHandler === 'function') { - (errorHandler as UnknownFunc).call(app, error, vm, lifecycleHook); + // Check if the current `app.config.errorHandler` is explicitly set by the user before calling it. + if (typeof originalErrorHandler === 'function' && app.config.errorHandler) { + (originalErrorHandler as UnknownFunc).call(app, error, vm, lifecycleHook); } if (options.logErrors) { diff --git a/packages/vue/src/integration.ts b/packages/vue/src/integration.ts index b62c43375bb5..900fa686dbcf 100644 --- a/packages/vue/src/integration.ts +++ b/packages/vue/src/integration.ts @@ -14,6 +14,7 @@ const DEFAULT_CONFIG: VueOptions = { Vue: globalWithVue.Vue, attachProps: true, logErrors: true, + attachErrorHandler: true, hooks: DEFAULT_HOOKS, timeout: 2000, trackComponents: false, @@ -76,7 +77,9 @@ const vueInit = (app: Vue, options: Options): void => { } } - attachErrorHandler(app, options); + if (options.attachErrorHandler) { + attachErrorHandler(app, options); + } if (hasTracingEnabled(options)) { app.mixin( diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index 13d9e8588350..9735923cd52c 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -47,6 +47,17 @@ export interface VueOptions extends TracingOptions { */ logErrors: boolean; + /** + * By default, Sentry attaches an error handler to capture exceptions and report them to Sentry. + * When `attachErrorHandler` is set to `false`, automatic error reporting is disabled. + * + * Usually, this option should stay enabled, unless you want to set up Sentry error reporting yourself. + * For example, the Sentry Nuxt SDK does not attach an error handler as it's using the error hooks provided by Nuxt. + * + * @default true + */ + attachErrorHandler: boolean; + /** {@link TracingOptions} */ tracingOptions?: Partial; } diff --git a/scripts/get-commit-list.ts b/scripts/get-commit-list.ts index 3992694cf8f0..bceccfb317de 100644 --- a/scripts/get-commit-list.ts +++ b/scripts/get-commit-list.ts @@ -24,8 +24,11 @@ function run(): void { newCommits.sort((a, b) => a.localeCompare(b)); + const issueUrl = 'https://github.com/getsentry/sentry-javascript/pull/'; + const newCommitsWithLink = newCommits.map(commit => commit.replace(/#(\d+)/, `[#$1](${issueUrl}$1)`)); + // eslint-disable-next-line no-console - console.log(newCommits.join('\n')); + console.log(newCommitsWithLink.join('\n')); } run(); diff --git a/yarn.lock b/yarn.lock index c94576d2e979..a32337e3835f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -58,6 +58,15 @@ resolved "https://registry.yarnpkg.com/@actions/io/-/io-1.1.3.tgz#4cdb6254da7962b07473ff5c335f3da485d94d71" integrity sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q== +"@acuminous/bitsyntax@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@acuminous/bitsyntax/-/bitsyntax-0.1.2.tgz#e0b31b9ee7ad1e4dd840c34864327c33d9f1f653" + integrity sha512-29lUK80d1muEQqiUsSo+3A0yP6CdspgC95EnKBMi22Xlwt79i/En4Vr67+cXhU+cZjbti3TgGGC5wy1stIywVQ== + dependencies: + buffer-more-ints "~1.0.0" + debug "^4.3.4" + safe-buffer "~5.1.2" + "@adobe/css-tools@^4.0.1": version "4.3.3" resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.3.tgz#90749bde8b89cd41764224f5aac29cd4138f75ff" @@ -7070,6 +7079,15 @@ "@opentelemetry/context-base" "^0.12.0" semver "^7.1.3" +"@opentelemetry/instrumentation-amqplib@^0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.42.0.tgz#b3cab5a7207736a30d769962eed3af3838f986c4" + integrity sha512-fiuU6OKsqHJiydHWgTRQ7MnIrJ2lEqsdgFtNIH4LbAUJl/5XmrIeoDzDnox+hfkgWK65jsleFuQDtYb5hW1koQ== + dependencies: + "@opentelemetry/core" "^1.8.0" + "@opentelemetry/instrumentation" "^0.53.0" + "@opentelemetry/semantic-conventions" "^1.27.0" + "@opentelemetry/instrumentation-aws-lambda@0.44.0": version "0.44.0" resolved "https://registry.yarnpkg.com/@opentelemetry/instrumentation-aws-lambda/-/instrumentation-aws-lambda-0.44.0.tgz#9b82bd6cc86f572be837578b29ef6bf242eb1a39" @@ -9196,6 +9214,13 @@ dependencies: "@types/node" "*" +"@types/amqplib@^0.10.5": + version "0.10.5" + resolved "https://registry.yarnpkg.com/@types/amqplib/-/amqplib-0.10.5.tgz#fd883eddfbd669702a727fa10007b27c4c1e6ec7" + integrity sha512-/cSykxROY7BWwDoi4Y4/jLAuZTshZxd8Ey1QYa/VaXriMotBDoou7V/twJiOSHzU6t1Kp1AHAUXGCgqq+6DNeg== + dependencies: + "@types/node" "*" + "@types/aria-query@^4.2.0": version "4.2.1" resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.1.tgz#78b5433344e2f92e8b306c06a5622c50c245bf6b" @@ -9700,8 +9725,17 @@ dependencies: "@types/unist" "*" -"@types/history-4@npm:@types/history@4.7.8", "@types/history-5@npm:@types/history@4.7.8", "@types/history@*": - name "@types/history-4" +"@types/history-4@npm:@types/history@4.7.8": + version "4.7.8" + resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934" + integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA== + +"@types/history-5@npm:@types/history@4.7.8": + version "4.7.8" + resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934" + integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA== + +"@types/history@*": version "4.7.8" resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.8.tgz#49348387983075705fe8f4e02fb67f7daaec4934" integrity sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA== @@ -10029,7 +10063,15 @@ "@types/history" "^3" "@types/react" "*" -"@types/react-router-4@npm:@types/react-router@5.1.14", "@types/react-router-5@npm:@types/react-router@5.1.14": +"@types/react-router-4@npm:@types/react-router@5.1.14": + version "5.1.14" + resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.14.tgz#e0442f4eb4c446541ad7435d44a97f8fe6df40da" + integrity sha512-LAJpqYUaCTMT2anZheoidiIymt8MuX286zoVFPM3DVb23aQBH0mAkFvzpd4LKqiolV8bBtZWT5Qp7hClCNDENw== + dependencies: + "@types/history" "*" + "@types/react" "*" + +"@types/react-router-5@npm:@types/react-router@5.1.14": version "5.1.14" resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.14.tgz#e0442f4eb4c446541ad7435d44a97f8fe6df40da" integrity sha512-LAJpqYUaCTMT2anZheoidiIymt8MuX286zoVFPM3DVb23aQBH0mAkFvzpd4LKqiolV8bBtZWT5Qp7hClCNDENw== @@ -11488,6 +11530,16 @@ amdefine@>=0.0.4: resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= +amqplib@^0.10.4: + version "0.10.4" + resolved "https://registry.yarnpkg.com/amqplib/-/amqplib-0.10.4.tgz#4058c775830c908267dc198969015e0e8d280e70" + integrity sha512-DMZ4eCEjAVdX1II2TfIUpJhfKAuoCeDIo/YyETbfAqehHTXxxs7WOOd+N1Xxr4cKhx12y23zk8/os98FxlZHrw== + dependencies: + "@acuminous/bitsyntax" "^0.1.2" + buffer-more-ints "~1.0.0" + readable-stream "1.x >=1.1.9" + url-parse "~1.5.10" + ansi-align@^3.0.0, ansi-align@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" @@ -13501,6 +13553,11 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +buffer-more-ints@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz#ef4f8e2dddbad429ed3828a9c55d44f05c611422" + integrity sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg== + buffer-writer@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-2.0.0.tgz#ce7eb81a38f7829db09c873f2fbb792c0c98ec04" @@ -28437,8 +28494,7 @@ react-is@^18.0.0: dependencies: "@remix-run/router" "1.0.2" -"react-router-6@npm:react-router@6.3.0", react-router@6.3.0: - name react-router-6 +"react-router-6@npm:react-router@6.3.0": version "6.3.0" resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== @@ -28453,6 +28509,13 @@ react-router-dom@^6.2.2: history "^5.2.0" react-router "6.3.0" +react-router@6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" + integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== + dependencies: + history "^5.2.0" + react@^18.0.0: version "18.0.0" resolved "https://registry.yarnpkg.com/react/-/react-18.0.0.tgz#b468736d1f4a5891f38585ba8e8fb29f91c3cb96" @@ -28568,6 +28631,16 @@ read@^2.0.0: dependencies: mute-stream "~1.0.0" +"readable-stream@1.x >=1.1.9": + version "1.1.14" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" + integrity sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + "readable-stream@2 || 3", readable-stream@^3.0.0, readable-stream@^3.0.2, readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" @@ -29470,10 +29543,10 @@ rollup-pluginutils@^2.8.1, rollup-pluginutils@^2.8.2: dependencies: estree-walker "^0.6.1" -rollup@3.29.4, rollup@^3.27.1, rollup@^3.28.1: - version "3.29.4" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.4.tgz#4d70c0f9834146df8705bfb69a9a19c9e1109981" - integrity sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw== +rollup@3.29.5: + version "3.29.5" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.5.tgz#8a2e477a758b520fb78daf04bca4c522c1da8a54" + integrity sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w== optionalDependencies: fsevents "~2.3.2" @@ -29484,6 +29557,13 @@ rollup@^2.70.0: optionalDependencies: fsevents "~2.3.2" +rollup@^3.27.1, rollup@^3.28.1: + version "3.29.4" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.4.tgz#4d70c0f9834146df8705bfb69a9a19c9e1109981" + integrity sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw== + optionalDependencies: + fsevents "~2.3.2" + rollup@^4.13.0: version "4.13.0" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.13.0.tgz#dd2ae144b4cdc2ea25420477f68d4937a721237a" @@ -29623,7 +29703,7 @@ sade@^1.7.3, sade@^1.8.1: dependencies: mri "^1.1.0" -safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1, safe-buffer@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== @@ -30920,7 +31000,16 @@ string-template@~0.2.1: resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add" integrity sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0= -"string-width-cjs@npm:string-width@^4.2.0", string-width@4.2.3, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@4.2.3, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -31032,7 +31121,14 @@ stringify-object@^3.2.1: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -32850,7 +32946,7 @@ url-parse-lax@^3.0.0: dependencies: prepend-http "^2.0.0" -url-parse@^1.5.3: +url-parse@^1.5.3, url-parse@~1.5.10: version "1.5.10" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== @@ -33184,10 +33280,10 @@ vite-plugin-vue-inspector@^5.1.0: kolorist "^1.8.0" magic-string "^0.30.4" -vite@4.5.3: - version "4.5.3" - resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.3.tgz#d88a4529ea58bae97294c7e2e6f0eab39a50fb1a" - integrity sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg== +vite@4.5.5: + version "4.5.5" + resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.5.tgz#639b9feca5c0a3bfe3c60cb630ef28bf219d742e" + integrity sha512-ifW3Lb2sMdX+WU91s3R0FyQlAyLxOzCSCP37ujw0+r5POeHPwe6udWVIElKQq8gk3t7b8rkmvqC6IHBpCff4GQ== dependencies: esbuild "^0.18.10" postcss "^8.4.27" @@ -34001,7 +34097,16 @@ wrangler@^3.67.1: optionalDependencies: fsevents "~2.3.2" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@7.0.0, wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@7.0.0, wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==