diff --git a/.github/ISSUE_TEMPLATE/internal.yml b/.github/ISSUE_TEMPLATE/internal.yml new file mode 100644 index 000000000000..bd5b1d1f1970 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/internal.yml @@ -0,0 +1,9 @@ +name: 💡 [Internal] Blank Issue +description: Only for Sentry Employees! Create an issue without a template. +body: + - type: textarea + id: description + attributes: + label: Description + validations: + required: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3f8cd2ac44ac..87d34d1731d4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1010,12 +1010,15 @@ jobs: 'generic-ts3.8', 'node-fastify', 'node-hapi', + 'node-nestjs-basic', + 'node-nestjs-distributed-tracing', 'nestjs-basic', 'nestjs-distributed-tracing', 'nestjs-with-submodules', 'node-exports-test-app', 'node-koa', 'node-connect', + 'nuxt-3', 'vue-3', 'webpack-4', 'webpack-5' diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f09f7f8167..acbad7637675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott +## 8.20.0 + +### Important Changes + +- **feat(node): Allow to pass `registerEsmLoaderHooks` to preload (#12998)** + +You can write your own custom preload script and configure this in the preload options. `registerEsmLoaderHooks` can be +passed as an option to `preloadOpenTelemetry`, which allows to exclude/include packages in the preload. + +- **fix(node): Do not emit fetch spans when tracing is disabled (#13003)** + +Sentry will not emit "fetch" spans if tracing is disabled. This is relevant for user who use their own sampler. + +### Other Changes + +- feat(feedback): Trigger button aria label configuration (#13008) +- feat(nestjs): Change nest sdk setup (#12920) +- feat(node): Extend ESM hooks options for iitm v1.10.0 (#13016) +- feat(node): Send client reports (#12951) +- feat(nuxt): Automatically add BrowserTracing (#13005) +- feat(nuxt): Setup source maps with vite config (#13018) +- feat(replay): Improve public Replay APIs (#13000) + ## 8.19.0 - feat(core): Align Span interface with OTEL (#12898) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.module.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.module.ts index ceb7199a99cf..f4c5ceb0cc5a 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.module.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/app.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; +import { SentryModule } from '@sentry/nestjs/setup'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @Module({ - imports: [ScheduleModule.forRoot()], + imports: [SentryModule.forRoot(), ScheduleModule.forRoot()], controllers: [AppController], providers: [AppService], }) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/main.ts b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/main.ts index 3a7b5ded8645..71ce685f4d61 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/src/main.ts @@ -2,18 +2,13 @@ import './instrument'; // Import other modules -import { BaseExceptionFilter, HttpAdapterHost, NestFactory } from '@nestjs/core'; -import * as Sentry from '@sentry/nestjs'; +import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; const PORT = 3030; async function bootstrap() { const app = await NestFactory.create(AppModule); - - const { httpAdapter } = app.get(HttpAdapterHost); - Sentry.setupNestErrorHandler(app, new BaseExceptionFilter(httpAdapter)); - await app.listen(PORT); } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-basic/tsconfig.json b/dev-packages/e2e-tests/test-applications/nestjs-basic/tsconfig.json index 95f5641cf7f3..cf79f029c781 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-basic/tsconfig.json +++ b/dev-packages/e2e-tests/test-applications/nestjs-basic/tsconfig.json @@ -16,6 +16,7 @@ "noImplicitAny": false, "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": false, + "moduleResolution": "Node16" } } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/main.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/main.ts index 83d0b33d687d..5aad5748b244 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/main.ts @@ -2,8 +2,7 @@ import './instrument'; // Import other modules -import { BaseExceptionFilter, HttpAdapterHost, NestFactory } from '@nestjs/core'; -import * as Sentry from '@sentry/nestjs'; +import { NestFactory } from '@nestjs/core'; import { TraceInitiatorModule } from './trace-initiator.module'; import { TraceReceiverModule } from './trace-receiver.module'; @@ -12,10 +11,6 @@ const TRACE_RECEIVER_PORT = 3040; async function bootstrap() { const trace_initiator_app = await NestFactory.create(TraceInitiatorModule); - - const { httpAdapter } = trace_initiator_app.get(HttpAdapterHost); - Sentry.setupNestErrorHandler(trace_initiator_app, new BaseExceptionFilter(httpAdapter)); - await trace_initiator_app.listen(TRACE_INITIATOR_PORT); const trace_receiver_app = await NestFactory.create(TraceReceiverModule); diff --git a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/trace-initiator.module.ts b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/trace-initiator.module.ts index 9256f29928ab..e7d27aa94f42 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/trace-initiator.module.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-distributed-tracing/src/trace-initiator.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; +import { SentryModule } from '@sentry/nestjs/setup'; import { TraceInitiatorController } from './trace-initiator.controller'; import { TraceInitiatorService } from './trace-initiator.service'; @Module({ - imports: [], + imports: [SentryModule.forRoot()], controllers: [TraceInitiatorController], providers: [TraceInitiatorService], }) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/app.module.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/app.module.ts index 944b84e66d27..212b17a3556b 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/app.module.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/app.module.ts @@ -1,10 +1,18 @@ import { Module } from '@nestjs/common'; +import { SentryModule } from '@sentry/nestjs/setup'; import { AppController } from './app.controller'; import { AppService } from './app.service'; -import { ExampleModule } from './example-module/example.module'; +import { ExampleModuleGlobalFilterWrongRegistrationOrder } from './example-module-global-filter-wrong-registration-order/example.module'; +import { ExampleModuleGlobalFilter } from './example-module-global-filter/example.module'; +import { ExampleModuleLocalFilter } from './example-module-local-filter/example.module'; @Module({ - imports: [ExampleModule], + imports: [ + ExampleModuleGlobalFilterWrongRegistrationOrder, + SentryModule.forRoot(), + ExampleModuleGlobalFilter, + ExampleModuleLocalFilter, + ], controllers: [AppController], providers: [AppService], }) diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter-wrong-registration-order/example.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter-wrong-registration-order/example.controller.ts new file mode 100644 index 000000000000..028af4a43f87 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter-wrong-registration-order/example.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get } from '@nestjs/common'; +import { ExampleExceptionWrongRegistrationOrder } from './example.exception'; + +@Controller('example-module-wrong-order') +export class ExampleController { + constructor() {} + + @Get('/expected-exception') + getCaughtException(): string { + throw new ExampleExceptionWrongRegistrationOrder(); + } + + @Get('/unexpected-exception') + getUncaughtException(): string { + throw new Error(`This is an uncaught exception!`); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter-wrong-registration-order/example.exception.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter-wrong-registration-order/example.exception.ts new file mode 100644 index 000000000000..0e4f58314fa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter-wrong-registration-order/example.exception.ts @@ -0,0 +1,5 @@ +export class ExampleExceptionWrongRegistrationOrder extends Error { + constructor() { + super('Something went wrong in the example module!'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter-wrong-registration-order/example.filter.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter-wrong-registration-order/example.filter.ts new file mode 100644 index 000000000000..6ecdf88937aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter-wrong-registration-order/example.filter.ts @@ -0,0 +1,13 @@ +import { ArgumentsHost, BadRequestException, Catch } from '@nestjs/common'; +import { BaseExceptionFilter } from '@nestjs/core'; +import { ExampleExceptionWrongRegistrationOrder } from './example.exception'; + +@Catch(ExampleExceptionWrongRegistrationOrder) +export class ExampleExceptionFilterWrongRegistrationOrder extends BaseExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + if (exception instanceof ExampleExceptionWrongRegistrationOrder) { + return super.catch(new BadRequestException(exception.message), host); + } + return super.catch(exception, host); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter-wrong-registration-order/example.module.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter-wrong-registration-order/example.module.ts new file mode 100644 index 000000000000..c98a5757b01c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter-wrong-registration-order/example.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { APP_FILTER } from '@nestjs/core'; +import { ExampleController } from './example.controller'; +import { ExampleExceptionFilterWrongRegistrationOrder } from './example.filter'; + +@Module({ + imports: [], + controllers: [ExampleController], + providers: [ + { + provide: APP_FILTER, + useClass: ExampleExceptionFilterWrongRegistrationOrder, + }, + ], +}) +export class ExampleModuleGlobalFilterWrongRegistrationOrder {} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter/example.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter/example.controller.ts new file mode 100644 index 000000000000..53356e906130 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter/example.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get } from '@nestjs/common'; +import * as Sentry from '@sentry/nestjs'; +import { ExampleException } from './example.exception'; + +@Controller('example-module') +export class ExampleController { + constructor() {} + + @Get('/expected-exception') + getCaughtException(): string { + throw new ExampleException(); + } + + @Get('/unexpected-exception') + getUncaughtException(): string { + throw new Error(`This is an uncaught exception!`); + } + + @Get('/transaction') + testTransaction() { + Sentry.startSpan({ name: 'test-span' }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module/example.exception.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter/example.exception.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module/example.exception.ts rename to dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter/example.exception.ts diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module/example.filter.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter/example.filter.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module/example.filter.ts rename to dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter/example.filter.ts diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module/example.module.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter/example.module.ts similarity index 89% rename from dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module/example.module.ts rename to dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter/example.module.ts index fabd71c4df90..8052cb137aac 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module/example.module.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter/example.module.ts @@ -13,4 +13,4 @@ import { ExampleExceptionFilter } from './example.filter'; }, ], }) -export class ExampleModule {} +export class ExampleModuleGlobalFilter {} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-local-filter/example.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-local-filter/example.controller.ts new file mode 100644 index 000000000000..41d75d6eaf89 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-local-filter/example.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get, UseFilters } from '@nestjs/common'; +import { LocalExampleException } from './example.exception'; +import { LocalExampleExceptionFilter } from './example.filter'; + +@Controller('example-module-local-filter') +@UseFilters(LocalExampleExceptionFilter) +export class ExampleControllerLocalFilter { + constructor() {} + + @Get('/expected-exception') + getCaughtException() { + throw new LocalExampleException(); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-local-filter/example.exception.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-local-filter/example.exception.ts new file mode 100644 index 000000000000..85a64d26d75e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-local-filter/example.exception.ts @@ -0,0 +1,5 @@ +export class LocalExampleException extends Error { + constructor() { + super('Something went wrong in the example module with local filter!'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-local-filter/example.filter.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-local-filter/example.filter.ts new file mode 100644 index 000000000000..9b6797c95e44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-local-filter/example.filter.ts @@ -0,0 +1,13 @@ +import { ArgumentsHost, BadRequestException, Catch } from '@nestjs/common'; +import { BaseExceptionFilter } from '@nestjs/core'; +import { LocalExampleException } from './example.exception'; + +@Catch(LocalExampleException) +export class LocalExampleExceptionFilter extends BaseExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + if (exception instanceof LocalExampleException) { + return super.catch(new BadRequestException(exception.message), host); + } + return super.catch(exception, host); + } +} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-local-filter/example.module.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-local-filter/example.module.ts new file mode 100644 index 000000000000..91d229a553c1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-local-filter/example.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ExampleControllerLocalFilter } from './example.controller'; + +@Module({ + imports: [], + controllers: [ExampleControllerLocalFilter], + providers: [], +}) +export class ExampleModuleLocalFilter {} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module/example.controller.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module/example.controller.ts deleted file mode 100644 index b71179c195cb..000000000000 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module/example.controller.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { ExampleException } from './example.exception'; - -@Controller('example-module') -export class ExampleController { - constructor() {} - - @Get() - getTest(): string { - throw new ExampleException(); - } -} diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/main.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/main.ts index 3a7b5ded8645..71ce685f4d61 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/main.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/main.ts @@ -2,18 +2,13 @@ import './instrument'; // Import other modules -import { BaseExceptionFilter, HttpAdapterHost, NestFactory } from '@nestjs/core'; -import * as Sentry from '@sentry/nestjs'; +import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; const PORT = 3030; async function bootstrap() { const app = await NestFactory.create(AppModule); - - const { httpAdapter } = app.get(HttpAdapterHost); - Sentry.setupNestErrorHandler(app, new BaseExceptionFilter(httpAdapter)); - await app.listen(PORT); } diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts index 3711cbe8fd0f..8d5885f146df 100644 --- a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/errors.test.ts @@ -1,12 +1,129 @@ import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; -test('Does not handle expected exception if exception is thrown in module', async ({ baseURL }) => { +test('Sends unexpected exception to Sentry if thrown in module with global filter', async ({ baseURL }) => { + const errorEventPromise = waitForError('nestjs', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an uncaught exception!'; + }); + + const response = await fetch(`${baseURL}/example-module/unexpected-exception`); + 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 uncaught exception!'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/example-module/unexpected-exception', + }); + + expect(errorEvent.transaction).toEqual('GET /example-module/unexpected-exception'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); + +test('Sends unexpected exception to Sentry if thrown in module that was registered before Sentry', async ({ + baseURL, +}) => { + const errorEventPromise = waitForError('nestjs', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an uncaught exception!'; + }); + + const response = await fetch(`${baseURL}/example-module-wrong-order/unexpected-exception`); + 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 uncaught exception!'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/example-module-wrong-order/unexpected-exception', + }); + + expect(errorEvent.transaction).toEqual('GET /example-module-wrong-order/unexpected-exception'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); + +test('Does not send exception to Sentry if user-defined global exception filter already catches the exception', async ({ + baseURL, +}) => { + let errorEventOccurred = false; + + waitForError('nestjs', event => { + if (!event.type && event.exception?.values?.[0]?.value === 'Something went wrong in the example module!') { + errorEventOccurred = true; + } + + return event?.transaction === 'GET /example-module/expected-exception'; + }); + + const transactionEventPromise = waitForTransaction('nestjs', transactionEvent => { + return transactionEvent?.transaction === 'GET /example-module/expected-exception'; + }); + + const response = await fetch(`${baseURL}/example-module/expected-exception`); + expect(response.status).toBe(400); + + await transactionEventPromise; + + await new Promise(resolve => setTimeout(resolve, 10000)); + + expect(errorEventOccurred).toBe(false); +}); + +test('Does not send exception to Sentry if user-defined local exception filter already catches the exception', async ({ + baseURL, +}) => { + let errorEventOccurred = false; + + waitForError('nestjs', event => { + if ( + !event.type && + event.exception?.values?.[0]?.value === 'Something went wrong in the example module with local filter!' + ) { + errorEventOccurred = true; + } + + return event?.transaction === 'GET /example-module-local-filter/expected-exception'; + }); + + const transactionEventPromise = waitForTransaction('nestjs', transactionEvent => { + return transactionEvent?.transaction === 'GET /example-module-local-filter/expected-exception'; + }); + + const response = await fetch(`${baseURL}/example-module-local-filter/expected-exception`); + expect(response.status).toBe(400); + + await transactionEventPromise; + + await new Promise(resolve => setTimeout(resolve, 10000)); + + expect(errorEventOccurred).toBe(false); +}); + +test('Does not handle expected exception if exception is thrown in module registered before Sentry', async ({ + baseURL, +}) => { const errorEventPromise = waitForError('nestjs', event => { return !event.type && event.exception?.values?.[0]?.value === 'Something went wrong in the example module!'; }); - const response = await fetch(`${baseURL}/example-module`); + const response = await fetch(`${baseURL}/example-module-wrong-order/expected-exception`); expect(response.status).toBe(500); // should be 400 // should never arrive, but does because the exception is not handled properly @@ -19,10 +136,10 @@ test('Does not handle expected exception if exception is thrown in module', asyn method: 'GET', cookies: {}, headers: expect.any(Object), - url: 'http://localhost:3030/example-module', + url: 'http://localhost:3030/example-module-wrong-order/expected-exception', }); - expect(errorEvent.transaction).toEqual('GET /example-module'); + expect(errorEvent.transaction).toEqual('GET /example-module-wrong-order/expected-exception'); expect(errorEvent.contexts?.trace).toEqual({ trace_id: expect.any(String), diff --git a/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts new file mode 100644 index 000000000000..25375f5fd962 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction from module', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('nestjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /example-module/transaction' + ); + }); + + await fetch(`${baseURL}/example-module/transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: 'http://localhost:3030/example-module/transaction', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/example-module/transaction', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/example-module/transaction', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.route': '/example-module/transaction', + }, + op: 'http.server', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + origin: 'auto.http.otel.http', + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + spans: expect.arrayContaining([ + { + data: { + 'express.name': '/example-module/transaction', + 'express.type': 'request_handler', + 'http.route': '/example-module/transaction', + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'request_handler.express', + }, + op: 'request_handler.express', + description: '/example-module/transaction', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'auto.http.otel.express', + }, + { + data: { + 'sentry.origin': 'manual', + }, + description: 'test-span', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'manual', + }, + { + data: { + 'sentry.origin': 'manual', + }, + description: 'child-span', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'manual', + }, + { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.http.otel.nestjs', + 'sentry.op': 'handler.nestjs', + component: '@nestjs/core', + 'nestjs.version': expect.any(String), + 'nestjs.type': 'handler', + 'nestjs.callback': 'testTransaction', + }, + description: 'testTransaction', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + origin: 'auto.http.otel.nestjs', + op: 'handler.nestjs', + }, + ]), + transaction: 'GET /example-module/transaction', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json index 265ee010b8d5..39fcedf174da 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/package.json @@ -5,8 +5,8 @@ "scripts": { "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", "clean": "npx rimraf node_modules pnpm-lock.yaml", - "test:prod": "TEST_ENV=production __NEXT_EXPERIMENTAL_INSTRUMENTATION=1 playwright test", - "test:dev": "TEST_ENV=development __NEXT_EXPERIMENTAL_INSTRUMENTATION=1 playwright test", + "test:prod": "TEST_ENV=production playwright test", + "test:dev": "TEST_ENV=development playwright test", "test:build": "pnpm install && npx playwright install && pnpm build", "test:build-canary": "pnpm install && pnpm add next@canary && pnpm add react@beta && pnpm add react-dom@beta && npx playwright install && pnpm build", "test:build-latest": "pnpm install && pnpm add next@rc && pnpm add react@beta && pnpm add react-dom@beta && npx playwright install && pnpm build", @@ -17,7 +17,7 @@ "@types/node": "18.11.17", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "15.0.0-canary.63", + "next": "15.0.0-canary.77", "react": "beta", "react-dom": "beta", "typescript": "4.9.5" diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/.gitignore b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/.gitignore new file mode 100644 index 000000000000..4b56acfbebf4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/.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/node-nestjs-basic/.npmrc b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/.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/node-nestjs-basic/nest-cli.json b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/nest-cli.json new file mode 100644 index 000000000000..f9aa683b1ad5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/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/node-nestjs-basic/package.json b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/package.json new file mode 100644 index 000000000000..ec6510ac03ff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/package.json @@ -0,0 +1,48 @@ +{ + "name": "node-nestjs-basic", + "version": "0.0.1", + "private": true, + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test": "playwright test", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/schedule": "^4.1.0", + "@nestjs/platform-express": "^10.0.0", + "@sentry/nestjs": "latest || *", + "@sentry/types": "latest || *", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@nestjs/cli": "^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/node-nestjs-basic/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/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/node-nestjs-basic/src/app.controller.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.controller.ts new file mode 100644 index 000000000000..b54604d999cb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get('test-transaction') + testTransaction() { + return this.appService.testTransaction(); + } + + @Get('test-exception/:id') + async testException(@Param('id') id: string) { + return this.appService.testException(id); + } + + @Get('test-expected-exception/:id') + async testExpectedException(@Param('id') id: string) { + return this.appService.testExpectedException(id); + } + + @Get('test-span-decorator-async') + async testSpanDecoratorAsync() { + return { result: await this.appService.testSpanDecoratorAsync() }; + } + + @Get('test-span-decorator-sync') + async testSpanDecoratorSync() { + return { result: await this.appService.testSpanDecoratorSync() }; + } + + @Get('kill-test-cron') + async killTestCron() { + this.appService.killTestCron(); + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.module.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.module.ts new file mode 100644 index 000000000000..ceb7199a99cf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +@Module({ + imports: [ScheduleModule.forRoot()], + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.service.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.service.ts new file mode 100644 index 000000000000..3afb7b5147bd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.service.ts @@ -0,0 +1,67 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { Cron, SchedulerRegistry } from '@nestjs/schedule'; +import * as Sentry from '@sentry/nestjs'; +import { SentryCron, SentryTraced } from '@sentry/nestjs'; +import type { MonitorConfig } from '@sentry/types'; + +const monitorConfig: MonitorConfig = { + schedule: { + type: 'crontab', + value: '* * * * *', + }, +}; + +@Injectable() +export class AppService { + constructor(private schedulerRegistry: SchedulerRegistry) {} + + testTransaction() { + Sentry.startSpan({ name: 'test-span' }, () => { + Sentry.startSpan({ name: 'child-span' }, () => {}); + }); + } + + testException(id: string) { + throw new Error(`This is an exception with id ${id}`); + } + + testExpectedException(id: string) { + throw new HttpException(`This is an expected exception with id ${id}`, HttpStatus.FORBIDDEN); + } + + @SentryTraced('wait and return a string') + async wait() { + await new Promise(resolve => setTimeout(resolve, 500)); + return 'test'; + } + + async testSpanDecoratorAsync() { + return await this.wait(); + } + + @SentryTraced('return a string') + getString(): { result: string } { + return { result: 'test' }; + } + + async testSpanDecoratorSync() { + const returned = this.getString(); + // Will fail if getString() is async, because returned will be a Promise<> + return returned.result; + } + + /* + Actual cron schedule differs from schedule defined in config because Sentry + only supports minute granularity, but we don't want to wait (worst case) a + full minute for the tests to finish. + */ + @Cron('*/5 * * * * *', { name: 'test-cron-job' }) + @SentryCron('test-cron-slug', monitorConfig) + async testCron() { + console.log('Test cron!'); + } + + async killTestCron() { + this.schedulerRegistry.deleteCronJob('test-cron-job'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/instrument.ts new file mode 100644 index 000000000000..f1f4de865435 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/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/node-nestjs-basic/src/main.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/main.ts new file mode 100644 index 000000000000..3a7b5ded8645 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/main.ts @@ -0,0 +1,20 @@ +// Import this first +import './instrument'; + +// Import other modules +import { BaseExceptionFilter, HttpAdapterHost, NestFactory } from '@nestjs/core'; +import * as Sentry from '@sentry/nestjs'; +import { AppModule } from './app.module'; + +const PORT = 3030; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + const { httpAdapter } = app.get(HttpAdapterHost); + Sentry.setupNestErrorHandler(app, new BaseExceptionFilter(httpAdapter)); + + await app.listen(PORT); +} + +bootstrap(); diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/start-event-proxy.mjs new file mode 100644 index 000000000000..e9917b9273da --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nestjs', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/cron-decorator.test.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/cron-decorator.test.ts new file mode 100644 index 000000000000..c13623337343 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/cron-decorator.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem } from '@sentry-internal/test-utils'; + +test('Cron job triggers send of in_progress envelope', async ({ baseURL }) => { + const inProgressEnvelopePromise = waitForEnvelopeItem('nestjs', envelope => { + return envelope[0].type === 'check_in'; + }); + + const inProgressEnvelope = await inProgressEnvelopePromise; + + expect(inProgressEnvelope[1]).toEqual( + expect.objectContaining({ + check_in_id: expect.any(String), + monitor_slug: 'test-cron-slug', + status: 'in_progress', + environment: 'qa', + monitor_config: { + schedule: { + type: 'crontab', + value: '* * * * *', + }, + }, + contexts: { + trace: { + span_id: expect.any(String), + trace_id: expect.any(String), + }, + }, + }), + ); + + // kill cron so tests don't get stuck + await fetch(`${baseURL}/kill-test-cron`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/errors.test.ts new file mode 100644 index 000000000000..349b25b0eee9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/errors.test.ts @@ -0,0 +1,55 @@ +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', 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 expected exception to Sentry', async ({ baseURL }) => { + let errorEventOccurred = false; + + waitForError('nestjs', event => { + if (!event.type && event.exception?.values?.[0]?.value === 'This is an expected exception with id 123') { + errorEventOccurred = true; + } + + return event?.transaction === 'GET /test-expected-exception/:id'; + }); + + const transactionEventPromise = waitForTransaction('nestjs', transactionEvent => { + return transactionEvent?.transaction === 'GET /test-expected-exception/:id'; + }); + + const response = await fetch(`${baseURL}/test-expected-exception/123`); + expect(response.status).toBe(403); + + await transactionEventPromise; + + await new Promise(resolve => setTimeout(resolve, 10000)); + + expect(errorEventOccurred).toBe(false); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/span-decorator.test.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/span-decorator.test.ts new file mode 100644 index 000000000000..28c925727d89 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/span-decorator.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Transaction includes span and correct value for decorated async function', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nestjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-span-decorator-async' + ); + }); + + const response = await fetch(`${baseURL}/test-span-decorator-async`); + const body = await response.json(); + + expect(body.result).toEqual('test'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'manual', + 'sentry.op': 'wait and return a string', + }, + description: 'wait', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + op: 'wait and return a string', + origin: 'manual', + }), + ]), + ); +}); + +test('Transaction includes span and correct value for decorated sync function', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('nestjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-span-decorator-sync' + ); + }); + + const response = await fetch(`${baseURL}/test-span-decorator-sync`); + const body = await response.json(); + + expect(body.result).toEqual('test'); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.spans).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'manual', + 'sentry.op': 'return a string', + }, + description: 'getString', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + op: 'return a string', + origin: 'manual', + }), + ]), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/transactions.test.ts new file mode 100644 index 000000000000..ae5d8b63b16d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/transactions.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('nestjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: 'http://localhost:3030/test-transaction', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/test-transaction', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/test-transaction', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.route': '/test-transaction', + }, + op: 'http.server', + span_id: expect.any(String), + status: 'ok', + trace_id: expect.any(String), + origin: 'auto.http.otel.http', + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + spans: expect.arrayContaining([ + { + data: { + 'express.name': '/test-transaction', + 'express.type': 'request_handler', + 'http.route': '/test-transaction', + 'sentry.origin': 'auto.http.otel.express', + 'sentry.op': 'request_handler.express', + }, + op: 'request_handler.express', + description: '/test-transaction', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'auto.http.otel.express', + }, + { + data: { + 'sentry.origin': 'manual', + }, + description: 'test-span', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'manual', + }, + { + data: { + 'sentry.origin': 'manual', + }, + description: 'child-span', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'manual', + }, + { + span_id: expect.any(String), + trace_id: expect.any(String), + data: { + 'sentry.origin': 'auto.http.otel.nestjs', + 'sentry.op': 'handler.nestjs', + component: '@nestjs/core', + 'nestjs.version': expect.any(String), + 'nestjs.type': 'handler', + 'nestjs.callback': 'testTransaction', + }, + description: 'testTransaction', + parent_span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + status: 'ok', + origin: 'auto.http.otel.nestjs', + op: 'handler.nestjs', + }, + ]), + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tsconfig.build.json b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tsconfig.build.json new file mode 100644 index 000000000000..26c30d4eddf2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tsconfig.json new file mode 100644 index 000000000000..95f5641cf7f3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-basic/tsconfig.json @@ -0,0 +1,21 @@ +{ + "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 + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/.gitignore b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/.gitignore new file mode 100644 index 000000000000..4b56acfbebf4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/.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/node-nestjs-distributed-tracing/.npmrc b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/.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/node-nestjs-distributed-tracing/nest-cli.json b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/nest-cli.json new file mode 100644 index 000000000000..f9aa683b1ad5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/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/node-nestjs-distributed-tracing/package.json b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/package.json new file mode 100644 index 000000000000..ad61f9a77ad4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/package.json @@ -0,0 +1,47 @@ +{ + "name": "node-nestjs-distributed-tracing", + "version": "0.0.1", + "private": true, + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test": "playwright test", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@sentry/nestjs": "latest || *", + "@sentry/types": "latest || *", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@nestjs/cli": "^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/node-nestjs-distributed-tracing/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/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/node-nestjs-distributed-tracing/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/instrument.ts new file mode 100644 index 000000000000..b5ca047e497c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/instrument.ts @@ -0,0 +1,9 @@ +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, + tracePropagationTargets: ['http://localhost:3030', '/external-allowed'], +}); diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/main.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/main.ts new file mode 100644 index 000000000000..7e3f7e0e2b52 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/main.ts @@ -0,0 +1,23 @@ +// Import this first +import './instrument'; + +// Import other modules +import { BaseExceptionFilter, HttpAdapterHost, NestFactory } from '@nestjs/core'; +import * as Sentry from '@sentry/nestjs'; +import { TraceInitiatorModule } from './trace-initiator.module'; +import { TraceReceiverModule } from './trace-receiver.module'; + +const TRACE_INITIATOR_PORT = 3030; +const TRACE_RECEIVER_PORT = 3040; + +async function bootstrap() { + const trace_initiator_app = await NestFactory.create(TraceInitiatorModule); + const { httpAdapter } = trace_initiator_app.get(HttpAdapterHost); + Sentry.setupNestErrorHandler(trace_initiator_app, new BaseExceptionFilter(httpAdapter)); + await trace_initiator_app.listen(TRACE_INITIATOR_PORT); + + const trace_receiver_app = await NestFactory.create(TraceReceiverModule); + await trace_receiver_app.listen(TRACE_RECEIVER_PORT); +} + +bootstrap(); diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-initiator.controller.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-initiator.controller.ts new file mode 100644 index 000000000000..62e0c299a239 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-initiator.controller.ts @@ -0,0 +1,42 @@ +import { Controller, Get, Headers, Param } from '@nestjs/common'; +import { TraceInitiatorService } from './trace-initiator.service'; + +@Controller() +export class TraceInitiatorController { + constructor(private readonly traceInitiatorService: TraceInitiatorService) {} + + @Get('test-inbound-headers/:id') + testInboundHeaders(@Headers() headers, @Param('id') id: string) { + return this.traceInitiatorService.testInboundHeaders(headers, id); + } + + @Get('test-outgoing-http/:id') + async testOutgoingHttp(@Param('id') id: string) { + return this.traceInitiatorService.testOutgoingHttp(id); + } + + @Get('test-outgoing-fetch/:id') + async testOutgoingFetch(@Param('id') id: string) { + return this.traceInitiatorService.testOutgoingFetch(id); + } + + @Get('test-outgoing-fetch-external-allowed') + async testOutgoingFetchExternalAllowed() { + return this.traceInitiatorService.testOutgoingFetchExternalAllowed(); + } + + @Get('test-outgoing-fetch-external-disallowed') + async testOutgoingFetchExternalDisallowed() { + return this.traceInitiatorService.testOutgoingFetchExternalDisallowed(); + } + + @Get('test-outgoing-http-external-allowed') + async testOutgoingHttpExternalAllowed() { + return this.traceInitiatorService.testOutgoingHttpExternalAllowed(); + } + + @Get('test-outgoing-http-external-disallowed') + async testOutgoingHttpExternalDisallowed() { + return this.traceInitiatorService.testOutgoingHttpExternalDisallowed(); + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-initiator.module.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-initiator.module.ts new file mode 100644 index 000000000000..9256f29928ab --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-initiator.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TraceInitiatorController } from './trace-initiator.controller'; +import { TraceInitiatorService } from './trace-initiator.service'; + +@Module({ + imports: [], + controllers: [TraceInitiatorController], + providers: [TraceInitiatorService], +}) +export class TraceInitiatorModule {} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-initiator.service.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-initiator.service.ts new file mode 100644 index 000000000000..67c5333cedaf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-initiator.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { makeHttpRequest } from './utils'; + +@Injectable() +export class TraceInitiatorService { + constructor() {} + + testInboundHeaders(headers: Record, id: string) { + return { + headers, + id, + }; + } + + async testOutgoingHttp(id: string) { + const data = await makeHttpRequest(`http://localhost:3030/test-inbound-headers/${id}`); + + return data; + } + + async testOutgoingFetch(id: string) { + const response = await fetch(`http://localhost:3030/test-inbound-headers/${id}`); + const data = await response.json(); + + return data; + } + + async testOutgoingFetchExternalAllowed() { + const fetchResponse = await fetch('http://localhost:3040/external-allowed'); + + return fetchResponse.json(); + } + + async testOutgoingFetchExternalDisallowed() { + const fetchResponse = await fetch('http://localhost:3040/external-disallowed'); + + return fetchResponse.json(); + } + + async testOutgoingHttpExternalAllowed() { + return makeHttpRequest('http://localhost:3040/external-allowed'); + } + + async testOutgoingHttpExternalDisallowed() { + return makeHttpRequest('http://localhost:3040/external-disallowed'); + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-receiver.controller.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-receiver.controller.ts new file mode 100644 index 000000000000..2a1899f1097d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-receiver.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Headers } from '@nestjs/common'; +import { TraceReceiverService } from './trace-receiver.service'; + +@Controller() +export class TraceReceiverController { + constructor(private readonly traceReceiverService: TraceReceiverService) {} + + @Get('external-allowed') + externalAllowed(@Headers() headers) { + return this.traceReceiverService.externalAllowed(headers); + } + + @Get('external-disallowed') + externalDisallowed(@Headers() headers) { + return this.traceReceiverService.externalDisallowed(headers); + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-receiver.module.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-receiver.module.ts new file mode 100644 index 000000000000..2680b3071fb7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-receiver.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TraceReceiverController } from './trace-receiver.controller'; +import { TraceReceiverService } from './trace-receiver.service'; + +@Module({ + imports: [], + controllers: [TraceReceiverController], + providers: [TraceReceiverService], +}) +export class TraceReceiverModule {} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-receiver.service.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-receiver.service.ts new file mode 100644 index 000000000000..a40b28ad0778 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-receiver.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class TraceReceiverService { + externalAllowed(headers: Record) { + return { + headers, + route: 'external-allowed', + }; + } + + externalDisallowed(headers: Record) { + return { + headers, + route: 'external-disallowed', + }; + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/utils.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/utils.ts new file mode 100644 index 000000000000..27639ef26349 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/utils.ts @@ -0,0 +1,26 @@ +import * as http from 'http'; + +export function makeHttpRequest(url) { + return new Promise(resolve => { + const data = []; + + http + .request(url, httpRes => { + httpRes.on('data', chunk => { + data.push(chunk); + }); + httpRes.on('error', error => { + resolve({ error: error.message, url }); + }); + httpRes.on('end', () => { + try { + const json = JSON.parse(Buffer.concat(data).toString()); + resolve(json); + } catch { + resolve({ data: Buffer.concat(data).toString(), url }); + } + }); + }) + .end(); + }); +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/start-event-proxy.mjs new file mode 100644 index 000000000000..e9917b9273da --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nestjs', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/tests/propagation.test.ts b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/tests/propagation.test.ts new file mode 100644 index 000000000000..2922435c542b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/tests/propagation.test.ts @@ -0,0 +1,356 @@ +import crypto from 'crypto'; +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; +import { SpanJSON } from '@sentry/types'; + +test('Propagates trace for outgoing http requests', async ({ baseURL }) => { + const id = crypto.randomUUID(); + + const inboundTransactionPromise = waitForTransaction('nestjs', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === `/test-inbound-headers/${id}` + ); + }); + + const outboundTransactionPromise = waitForTransaction('nestjs', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === `/test-outgoing-http/${id}` + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-http/${id}`); + const data = await response.json(); + + const inboundTransaction = await inboundTransactionPromise; + const outboundTransaction = await outboundTransactionPromise; + + const traceId = outboundTransaction?.contexts?.trace?.trace_id; + const outgoingHttpSpan = outboundTransaction?.spans?.find(span => span.op === 'http.client') as SpanJSON | undefined; + + expect(outgoingHttpSpan).toBeDefined(); + + const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + + expect(traceId).toEqual(expect.any(String)); + + // data is passed through from the inbound request, to verify we have the correct headers set + const inboundHeaderSentryTrace = data.headers?.['sentry-trace']; + const inboundHeaderBaggage = data.headers?.['baggage']; + + expect(inboundHeaderSentryTrace).toEqual(`${traceId}-${outgoingHttpSpanId}-1`); + expect(inboundHeaderBaggage).toBeDefined(); + + const baggage = (inboundHeaderBaggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); + + expect(outboundTransaction.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: `http://localhost:3030/test-outgoing-http/${id}`, + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': `http://localhost:3030/test-outgoing-http/${id}`, + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': `/test-outgoing-http/${id}`, + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.route': '/test-outgoing-http/:id', + }, + op: 'http.server', + span_id: expect.any(String), + status: 'ok', + trace_id: traceId, + origin: 'auto.http.otel.http', + }); + + expect(inboundTransaction.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: `http://localhost:3030/test-inbound-headers/${id}`, + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': `http://localhost:3030/test-inbound-headers/${id}`, + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': `/test-inbound-headers/${id}`, + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.route': '/test-inbound-headers/:id', + }, + op: 'http.server', + parent_span_id: outgoingHttpSpanId, + span_id: expect.any(String), + status: 'ok', + trace_id: traceId, + origin: 'auto.http.otel.http', + }); +}); + +test('Propagates trace for outgoing fetch requests', async ({ baseURL }) => { + const id = crypto.randomUUID(); + + const inboundTransactionPromise = waitForTransaction('nestjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === `/test-inbound-headers/${id}` + ); + }); + + const outboundTransactionPromise = waitForTransaction('nestjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === `/test-outgoing-fetch/${id}` + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch/${id}`); + const data = await response.json(); + + const inboundTransaction = await inboundTransactionPromise; + const outboundTransaction = await outboundTransactionPromise; + + const traceId = outboundTransaction?.contexts?.trace?.trace_id; + const outgoingHttpSpan = outboundTransaction?.spans?.find(span => span.op === 'http.client') as SpanJSON | undefined; + + expect(outgoingHttpSpan).toBeDefined(); + + const outgoingHttpSpanId = outgoingHttpSpan?.span_id; + + expect(traceId).toEqual(expect.any(String)); + + // data is passed through from the inbound request, to verify we have the correct headers set + const inboundHeaderSentryTrace = data.headers?.['sentry-trace']; + const inboundHeaderBaggage = data.headers?.['baggage']; + + expect(inboundHeaderSentryTrace).toEqual(`${traceId}-${outgoingHttpSpanId}-1`); + expect(inboundHeaderBaggage).toBeDefined(); + + const baggage = (inboundHeaderBaggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); + + expect(outboundTransaction.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: `http://localhost:3030/test-outgoing-fetch/${id}`, + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': `http://localhost:3030/test-outgoing-fetch/${id}`, + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': `/test-outgoing-fetch/${id}`, + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.route': '/test-outgoing-fetch/:id', + }, + op: 'http.server', + span_id: expect.any(String), + status: 'ok', + trace_id: traceId, + origin: 'auto.http.otel.http', + }); + + expect(inboundTransaction.contexts?.trace).toEqual({ + data: expect.objectContaining({ + 'sentry.source': 'route', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: `http://localhost:3030/test-inbound-headers/${id}`, + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': `http://localhost:3030/test-inbound-headers/${id}`, + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': `/test-inbound-headers/${id}`, + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.route': '/test-inbound-headers/:id', + }), + op: 'http.server', + parent_span_id: outgoingHttpSpanId, + span_id: expect.any(String), + status: 'ok', + trace_id: traceId, + origin: 'auto.http.otel.http', + }); +}); + +test('Propagates trace for outgoing external http requests', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('nestjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === `/test-outgoing-http-external-allowed` + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-http-external-allowed`); + const data = await response.json(); + + const inboundTransaction = await inboundTransactionPromise; + + const traceId = inboundTransaction?.contexts?.trace?.trace_id; + const spanId = inboundTransaction?.spans?.find(span => span.op === 'http.client')?.span_id; + + expect(traceId).toEqual(expect.any(String)); + expect(spanId).toEqual(expect.any(String)); + + expect(data).toEqual({ + headers: expect.objectContaining({ + 'sentry-trace': `${traceId}-${spanId}-1`, + baggage: expect.any(String), + }), + route: 'external-allowed', + }); + + const baggage = (data.headers.baggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); +}); + +test('Does not propagate outgoing http requests not covered by tracePropagationTargets', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('nestjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === `/test-outgoing-http-external-disallowed` + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-http-external-disallowed`); + const data = await response.json(); + + const inboundTransaction = await inboundTransactionPromise; + + const traceId = inboundTransaction?.contexts?.trace?.trace_id; + const spanId = inboundTransaction?.spans?.find(span => span.op === 'http.client')?.span_id; + + expect(traceId).toEqual(expect.any(String)); + expect(spanId).toEqual(expect.any(String)); + + expect(data.route).toBe('external-disallowed'); + expect(data.headers?.['sentry-trace']).toBeUndefined(); + expect(data.headers?.baggage).toBeUndefined(); +}); + +test('Propagates trace for outgoing external fetch requests', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('nestjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === `/test-outgoing-fetch-external-allowed` + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch-external-allowed`); + const data = await response.json(); + + const inboundTransaction = await inboundTransactionPromise; + + const traceId = inboundTransaction?.contexts?.trace?.trace_id; + const spanId = inboundTransaction?.spans?.find(span => span.op === 'http.client')?.span_id; + + expect(traceId).toEqual(expect.any(String)); + expect(spanId).toEqual(expect.any(String)); + + expect(data).toEqual({ + headers: expect.objectContaining({ + 'sentry-trace': `${traceId}-${spanId}-1`, + baggage: expect.any(String), + }), + route: 'external-allowed', + }); + + const baggage = (data.headers.baggage || '').split(','); + expect(baggage).toEqual( + expect.arrayContaining([ + 'sentry-environment=qa', + `sentry-trace_id=${traceId}`, + expect.stringMatching(/sentry-public_key=/), + ]), + ); +}); + +test('Does not propagate outgoing fetch requests not covered by tracePropagationTargets', async ({ baseURL }) => { + const inboundTransactionPromise = waitForTransaction('nestjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent.contexts?.trace?.data?.['http.target'] === `/test-outgoing-fetch-external-disallowed` + ); + }); + + const response = await fetch(`${baseURL}/test-outgoing-fetch-external-disallowed`); + const data = await response.json(); + + const inboundTransaction = await inboundTransactionPromise; + + const traceId = inboundTransaction?.contexts?.trace?.trace_id; + const spanId = inboundTransaction?.spans?.find(span => span.op === 'http.client')?.span_id; + + expect(traceId).toEqual(expect.any(String)); + expect(spanId).toEqual(expect.any(String)); + + expect(data.route).toBe('external-disallowed'); + expect(data.headers?.['sentry-trace']).toBeUndefined(); + expect(data.headers?.baggage).toBeUndefined(); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/tsconfig.build.json b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/tsconfig.build.json new file mode 100644 index 000000000000..26c30d4eddf2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/tsconfig.json new file mode 100644 index 000000000000..95f5641cf7f3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/tsconfig.json @@ -0,0 +1,21 @@ +{ + "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 + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json index 8a1634725184..b02788c4761f 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/package.json @@ -1,5 +1,5 @@ { - "name": "node-otel-sdk-trace", + "name": "node-otel-sdk-node", "version": "1.0.0", "private": true, "scripts": { diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-event-proxy.mjs index 8c74fa842a1b..b97bfc4664dd 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-event-proxy.mjs +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-event-proxy.mjs @@ -2,5 +2,5 @@ import { startEventProxyServer } from '@sentry-internal/test-utils'; startEventProxyServer({ port: 3031, - proxyServerName: 'node-otel-sdk-trace', + proxyServerName: 'node-otel-sdk-node', }); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-otel-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-otel-proxy.mjs index 1cf9ef3e2c27..c24241310fbb 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-otel-proxy.mjs +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/start-otel-proxy.mjs @@ -2,5 +2,5 @@ import { startProxyServer } from '@sentry-internal/test-utils'; startProxyServer({ port: 3032, - proxyServerName: 'node-otel-sdk-trace-otel', + proxyServerName: 'node-otel-sdk-node-otel', }); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts index 9cb97a051476..7dbb66a4119d 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/errors.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForError } from '@sentry-internal/test-utils'; test('Sends correct error event', async ({ baseURL }) => { - const errorEventPromise = waitForError('node-otel-sdk-trace', event => { + const errorEventPromise = waitForError('node-otel-sdk-node', event => { return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; }); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts index f7fee0559a97..ebf500ffb09c 100644 --- a/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts +++ b/dev-packages/e2e-tests/test-applications/node-otel-sdk-node/tests/transactions.test.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; import { waitForPlainRequest, waitForTransaction } from '@sentry-internal/test-utils'; test('Sends an API route transaction', async ({ baseURL }) => { - const pageloadTransactionEventPromise = waitForTransaction('node-otel-sdk-trace', transactionEvent => { + const pageloadTransactionEventPromise = waitForTransaction('node-otel-sdk-node', transactionEvent => { return ( transactionEvent?.contexts?.trace?.op === 'http.server' && transactionEvent?.transaction === 'GET /test-transaction' @@ -10,7 +10,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { }); // Ensure we also send data to the OTLP endpoint - const otelPromise = waitForPlainRequest('node-otel-sdk-trace-otel', data => { + const otelPromise = waitForPlainRequest('node-otel-sdk-node-otel', data => { const json = JSON.parse(data) as any; return json.resourceSpans.length > 0; @@ -129,7 +129,7 @@ test('Sends an API route transaction', async ({ baseURL }) => { }); test('Sends an API route transaction for an errored route', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('node-otel-sdk-trace', transactionEvent => { + const transactionEventPromise = waitForTransaction('node-otel-sdk-node', transactionEvent => { return ( transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /test-exception/:id' && diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/.gitignore b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/.npmrc b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/.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/node-otel-without-tracing/package.json b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json new file mode 100644 index 000000000000..1683d4166af9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json @@ -0,0 +1,34 @@ +{ + "name": "node-otel-without-tracing", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc", + "start": "node dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "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", + "@sentry/core": "latest || *", + "@sentry/node": "latest || *", + "@sentry/opentelemetry": "latest || *", + "@sentry/types": "latest || *", + "@types/express": "4.17.17", + "@types/node": "18.15.1", + "express": "4.19.2", + "typescript": "4.9.5" + }, + "devDependencies": { + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/playwright.config.mjs new file mode 100644 index 000000000000..888e61cfb2dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/playwright.config.mjs @@ -0,0 +1,34 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig( + { + startCommand: `pnpm start`, + }, + { + webServer: [ + { + command: `node ./start-event-proxy.mjs`, + port: 3031, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: `node ./start-otel-proxy.mjs`, + port: 3032, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'pnpm start', + port: 3030, + stdout: 'pipe', + stderr: 'pipe', + env: { + PORT: 3030, + }, + }, + ], + }, +); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/app.ts b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/app.ts new file mode 100644 index 000000000000..c3fdfb9134a5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/app.ts @@ -0,0 +1,55 @@ +import './instrument'; + +// Other imports below +import * as Sentry from '@sentry/node'; +import express from 'express'; + +const app = express(); +const port = 3030; + +app.get('/test-success', function (req, res) { + res.send({ version: 'v1' }); +}); + +app.get('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get('/test-transaction', function (req, res) { + Sentry.withActiveSpan(null, async () => { + Sentry.startSpan({ name: 'test-transaction', op: 'e2e-test' }, () => { + Sentry.startSpan({ name: 'test-span' }, () => undefined); + }); + + await fetch('http://localhost:3030/test-success'); + + await Sentry.flush(); + + res.send({}); + }); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +Sentry.setupExpressErrorHandler(app); + +app.use(function onError(err: unknown, req: any, res: any, next: any) { + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); 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 new file mode 100644 index 000000000000..8100d27af965 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts @@ -0,0 +1,41 @@ +const { NodeTracerProvider, BatchSpanProcessor } = require('@opentelemetry/sdk-trace-node'); +const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http'); +const Sentry = require('@sentry/node'); +const { SentrySpanProcessor, SentryPropagator } = require('@sentry/opentelemetry'); +const { UndiciInstrumentation } = require('@opentelemetry/instrumentation-undici'); +const { registerInstrumentations } = require('@opentelemetry/instrumentation'); + +const sentryClient = Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: + process.env.E2E_TEST_DSN || + 'https://3b6c388182fb435097f41d181be2b2ba@o4504321058471936.ingest.sentry.io/4504321066008576', + includeLocalVariables: true, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + // Tracing is completely disabled + + // Custom OTEL setup + skipOpenTelemetrySetup: true, +}); + +// Create and configure NodeTracerProvider +const provider = new NodeTracerProvider({}); + +provider.addSpanProcessor( + new BatchSpanProcessor( + new OTLPTraceExporter({ + url: 'http://localhost:3032/', + }), + ), +); + +// Initialize the provider +provider.register({ + propagator: new SentryPropagator(), + contextManager: new Sentry.SentryContextManager(), +}); + +registerInstrumentations({ + instrumentations: [new UndiciInstrumentation()], +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/start-event-proxy.mjs new file mode 100644 index 000000000000..62073e9a9b6e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-otel-without-tracing', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/start-otel-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/start-otel-proxy.mjs new file mode 100644 index 000000000000..1e182efd3873 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/start-otel-proxy.mjs @@ -0,0 +1,6 @@ +import { startProxyServer } from '@sentry-internal/test-utils'; + +startProxyServer({ + port: 3032, + proxyServerName: 'node-otel-without-tracing-otel', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/errors.test.ts new file mode 100644 index 000000000000..28e63f02090c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/errors.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-otel-without-tracing', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + 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', + }); + + // This is unparametrized here because we do not have the express instrumentation + expect(errorEvent.transaction).toEqual('GET /test-exception/123'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); 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 new file mode 100644 index 000000000000..abc55344327c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts @@ -0,0 +1,173 @@ +import { expect, test } from '@playwright/test'; +import { waitForPlainRequest, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction to OTLP', async ({ baseURL }) => { + waitForTransaction('node-otel-without-tracing', transactionEvent => { + throw new Error('THIS SHOULD NEVER HAPPEN!'); + }); + + // Ensure we send data to the OTLP endpoint + const otelPromise = waitForPlainRequest('node-otel-without-tracing-otel', data => { + const json = JSON.parse(data) as any; + + const scopeSpans = json.resourceSpans?.[0]?.scopeSpans; + + const httpScope = scopeSpans?.find(scopeSpan => scopeSpan.scope.name === '@opentelemetry/instrumentation-http'); + + return ( + httpScope && + httpScope.spans.some(span => + span.attributes.some(attr => attr.key === 'http.target' && attr.value?.stringValue === '/test-transaction'), + ) + ); + }); + + await fetch(`${baseURL}/test-transaction`); + + const otelData = await otelPromise; + + expect(otelData).toBeDefined(); + + const json = JSON.parse(otelData); + expect(json.resourceSpans.length).toBe(1); + + const scopeSpans = json.resourceSpans?.[0]?.scopeSpans; + expect(scopeSpans).toBeDefined(); + + // Http server span & undici client spans are emitted + // 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 undiciScopes = scopeSpans?.filter( + scopeSpan => scopeSpan.scope.name === '@opentelemetry/instrumentation-undici', + ); + + expect(httpScopes.length).toBe(1); + + // Undici spans are emitted correctly + expect(undiciScopes.length).toBe(1); + expect(undiciScopes[0].spans.length).toBe(1); + + // 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'), + ); + + expect(httpSpans).toEqual([ + { + traceId: expect.any(String), + spanId: expect.any(String), + name: 'GET', + 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', + }, + }, + ], + droppedAttributesCount: 0, + events: [], + droppedEventsCount: 0, + status: { + code: 0, + }, + links: [], + droppedLinksCount: 0, + }, + ]); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tsconfig.json new file mode 100644 index 000000000000..d14f5822baf2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "types": ["node"], + "esModuleInterop": true, + "lib": ["es2018", "dom"], + "strict": true, + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/.gitignore b/dev-packages/e2e-tests/test-applications/nuxt-3/.gitignore new file mode 100644 index 000000000000..4a7f73a2ed0d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/.gitignore @@ -0,0 +1,24 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Logs +logs +*.log + +# Misc +.DS_Store +.fleet +.idea + +# Local env files +.env +.env.* +!.env.example diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/.npmrc b/dev-packages/e2e-tests/test-applications/nuxt-3/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/.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/nuxt-3/app.vue b/dev-packages/e2e-tests/test-applications/nuxt-3/app.vue new file mode 100644 index 000000000000..06f3020220dd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/app.vue @@ -0,0 +1,13 @@ + + 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 new file mode 100644 index 000000000000..84d8a7ac05ef --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/components/ErrorButton.vue @@ -0,0 +1,9 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts new file mode 100644 index 000000000000..87cff074ccd9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts @@ -0,0 +1,4 @@ +// https://nuxt.com/docs/api/configuration/nuxt-config +export default defineNuxtConfig({ + modules: ['@sentry/nuxt/module'], +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/package.json b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json new file mode 100644 index 000000000000..72acea9f33b6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/package.json @@ -0,0 +1,24 @@ +{ + "name": "nuxt-3", + "private": true, + "type": "module", + "scripts": { + "build": "nuxt build", + "dev": "nuxt dev", + "generate": "nuxt generate", + "preview": "nuxt preview", + "clean": "npx nuxi cleanup", + "test": "playwright test", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/nuxt": "latest || *", + "nuxt": "3.11.2" + }, + "devDependencies": { + "@nuxt/test-utils": "^3.13.1", + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils" + } +} 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 new file mode 100644 index 000000000000..d4054f7e8bee --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/client-error.vue @@ -0,0 +1,10 @@ + + + + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/pages/index.vue b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/index.vue new file mode 100644 index 000000000000..74513c5697f3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/pages/index.vue @@ -0,0 +1,3 @@ + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/playwright.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/playwright.config.ts new file mode 100644 index 000000000000..f270a5ad9b48 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/playwright.config.ts @@ -0,0 +1,16 @@ +import { fileURLToPath } from 'node:url'; +import type { ConfigOptions } from '@nuxt/test-utils/playwright'; +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const nuxtConfigOptions: ConfigOptions = { + nuxt: { + rootDir: fileURLToPath(new URL('.', import.meta.url)), + }, +}; + +const config = getPlaywrightConfig({ + startCommand: `pnpm preview`, + use: { ...nuxtConfigOptions }, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/public/favicon.ico b/dev-packages/e2e-tests/test-applications/nuxt-3/public/favicon.ico new file mode 100644 index 000000000000..18993ad91cfd Binary files /dev/null and b/dev-packages/e2e-tests/test-applications/nuxt-3/public/favicon.ico differ diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.client.config.ts new file mode 100644 index 000000000000..5253d08c90f0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/sentry.client.config.ts @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/nuxt'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nuxt-3/start-event-proxy.mjs new file mode 100644 index 000000000000..acf5943b0168 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nuxt-3', +}); 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 new file mode 100644 index 000000000000..cd0ae9051f71 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from '@nuxt/test-utils/playwright'; +import { waitForError } from '@sentry-internal/test-utils'; + +test.describe('client-side errors', async () => { + test('captures error thrown on click', async ({ page }) => { + const errorPromise = 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 error = await errorPromise; + + expect(error).toMatchObject({ + exception: { + values: [ + { + type: 'Error', + value: 'Error thrown from Nuxt-3 E2E test app', + mechanism: { + handled: false, + }, + }, + ], + }, + }); + expect(error.transaction).toEqual('/client-error'); + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-3/tsconfig.json new file mode 100644 index 000000000000..a746f2a70c28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tsconfig.json @@ -0,0 +1,4 @@ +{ + // https://nuxt.com/docs/guide/concepts/typescript + "extends": "./.nuxt/tsconfig.json" +} diff --git a/dev-packages/node-integration-tests/jest.setup.js b/dev-packages/node-integration-tests/jest.setup.js index 7c1837cab523..b0c26e5b05f2 100644 --- a/dev-packages/node-integration-tests/jest.setup.js +++ b/dev-packages/node-integration-tests/jest.setup.js @@ -1,2 +1,8 @@ +const { cleanupChildProcesses } = require('./utils/runner'); + // Increases test timeout from 5s to 45s jest.setTimeout(45000); + +afterEach(() => { + cleanupChildProcesses(); +}); diff --git a/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/before-send/scenario.ts b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/before-send/scenario.ts new file mode 100644 index 000000000000..ab22aa289892 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/before-send/scenario.ts @@ -0,0 +1,23 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +(async () => { + Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + beforeSend(event) { + return !event.type ? null : event; + }, + }); + + Sentry.captureException(new Error('this should get dropped by the event processor')); + + await Sentry.flush(); + + Sentry.captureException(new Error('this should get dropped by the event processor')); + Sentry.captureException(new Error('this should get dropped by the event processor')); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Sentry.flush(); +})(); diff --git a/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/before-send/test.ts b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/before-send/test.ts new file mode 100644 index 000000000000..363b8f268cd2 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/before-send/test.ts @@ -0,0 +1,32 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should record client report for beforeSend', done => { + createRunner(__dirname, 'scenario.ts') + .expect({ + client_report: { + discarded_events: [ + { + category: 'error', + quantity: 1, + reason: 'before_send', + }, + ], + }, + }) + .expect({ + client_report: { + discarded_events: [ + { + category: 'error', + quantity: 2, + reason: 'before_send', + }, + ], + }, + }) + .start(done); +}); diff --git a/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processors/scenario.ts b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processors/scenario.ts new file mode 100644 index 000000000000..2b188f8d71f3 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processors/scenario.ts @@ -0,0 +1,24 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +(async () => { + Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + }); + + Sentry.addEventProcessor(event => { + return !event.type ? null : event; + }); + + Sentry.captureException(new Error('this should get dropped by the event processor')); + + await Sentry.flush(); + + Sentry.captureException(new Error('this should get dropped by the event processor')); + Sentry.captureException(new Error('this should get dropped by the event processor')); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + Sentry.flush(); +})(); diff --git a/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processors/test.ts b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processors/test.ts new file mode 100644 index 000000000000..803f1c09bafe --- /dev/null +++ b/dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processors/test.ts @@ -0,0 +1,32 @@ +import { cleanupChildProcesses, createRunner } from '../../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should record client report for event processors', done => { + createRunner(__dirname, 'scenario.ts') + .expect({ + client_report: { + discarded_events: [ + { + category: 'error', + quantity: 1, + reason: 'event_processor', + }, + ], + }, + }) + .expect({ + client_report: { + discarded_events: [ + { + category: 'error', + quantity: 2, + reason: 'event_processor', + }, + ], + }, + }) + .start(done); +}); diff --git a/dev-packages/node-integration-tests/suites/client-reports/periodic-send/scenario.ts b/dev-packages/node-integration-tests/suites/client-reports/periodic-send/scenario.ts new file mode 100644 index 000000000000..ff14911469ca --- /dev/null +++ b/dev-packages/node-integration-tests/suites/client-reports/periodic-send/scenario.ts @@ -0,0 +1,13 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + transport: loggingTransport, + clientReportFlushInterval: 5000, + beforeSend(event) { + return !event.type ? null : event; + }, +}); + +Sentry.captureException(new Error('this should get dropped by before send')); diff --git a/dev-packages/node-integration-tests/suites/client-reports/periodic-send/test.ts b/dev-packages/node-integration-tests/suites/client-reports/periodic-send/test.ts new file mode 100644 index 000000000000..0364f3ea01f0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/client-reports/periodic-send/test.ts @@ -0,0 +1,21 @@ +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +afterAll(() => { + cleanupChildProcesses(); +}); + +test('should flush client reports automatically after the timeout interval', done => { + createRunner(__dirname, 'scenario.ts') + .expect({ + client_report: { + discarded_events: [ + { + category: 'error', + quantity: 1, + reason: 'before_send', + }, + ], + }, + }) + .start(done); +}); diff --git a/dev-packages/node-integration-tests/suites/express/setupExpressErrorHandler/server.js b/dev-packages/node-integration-tests/suites/express/setupExpressErrorHandler/server.js new file mode 100644 index 000000000000..0e73923cf88a --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/setupExpressErrorHandler/server.js @@ -0,0 +1,33 @@ +const { loggingTransport } = require('@sentry-internal/node-integration-tests'); +const Sentry = require('@sentry/node'); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + transport: loggingTransport, +}); + +// express must be required after Sentry is initialized +const express = require('express'); +const cors = require('cors'); +const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests'); + +const app = express(); + +app.use(cors()); + +app.get('/test1', (_req, _res) => { + throw new Error('error_1'); +}); + +app.get('/test2', (_req, _res) => { + throw new Error('error_2'); +}); + +Sentry.setupExpressErrorHandler(app, { + shouldHandleError: error => { + return error.message === 'error_2'; + }, +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/express/setupExpressErrorHandler/test.ts b/dev-packages/node-integration-tests/suites/express/setupExpressErrorHandler/test.ts new file mode 100644 index 000000000000..97ff6e3fa769 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/express/setupExpressErrorHandler/test.ts @@ -0,0 +1,30 @@ +import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; + +describe('express setupExpressErrorHandler', () => { + afterAll(() => { + cleanupChildProcesses(); + }); + + describe('CJS', () => { + test('allows to pass options to setupExpressErrorHandler', done => { + const runner = createRunner(__dirname, 'server.js') + .expect({ + event: { + exception: { + values: [ + { + value: 'error_2', + }, + ], + }, + }, + }) + .start(done); + + // this error is filtered & ignored + expect(() => runner.makeRequest('get', '/test1')).rejects.toThrow(); + // this error is actually captured + expect(() => runner.makeRequest('get', '/test2')).rejects.toThrow(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/express/tracing/withError/server.js b/dev-packages/node-integration-tests/suites/express/tracing/withError/server.js index 3b45591ec4df..890d26cda044 100644 --- a/dev-packages/node-integration-tests/suites/express/tracing/withError/server.js +++ b/dev-packages/node-integration-tests/suites/express/tracing/withError/server.js @@ -20,7 +20,7 @@ const app = express(); app.use(cors()); app.get('/test/:id1/:id2', (_req, res) => { - Sentry.captureMessage(new Error('error_1')); + Sentry.captureException(new Error('error_1')); res.send('Success'); }); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-noSampleRate/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/scenario.ts similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/requests/fetch-noSampleRate/scenario.ts rename to dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/scenario.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-noSampleRate/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts similarity index 82% rename from dev-packages/node-integration-tests/suites/tracing/requests/fetch-noSampleRate/test.ts rename to dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts index f69f4f54c56d..f9ad7f92d3f1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-noSampleRate/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-no-tracing/test.ts @@ -3,31 +3,27 @@ import { createRunner } from '../../../../utils/runner'; import { createTestServer } from '../../../../utils/server'; conditionalTest({ min: 18 })('outgoing fetch', () => { - test('outgoing fetch requests are correctly instrumented without tracesSampleRate', done => { - expect.assertions(15); + test('outgoing fetch requests are correctly instrumented with tracing disabled', done => { + expect.assertions(11); createTestServer(done) .get('/api/v0', headers => { expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['__requestUrl']).toBeUndefined(); }) .get('/api/v1', headers => { expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['__requestUrl']).toBeUndefined(); }) .get('/api/v2', headers => { expect(headers['baggage']).toBeUndefined(); expect(headers['sentry-trace']).toBeUndefined(); - expect(headers['__requestUrl']).toBeUndefined(); }) .get('/api/v3', headers => { expect(headers['baggage']).toBeUndefined(); expect(headers['sentry-trace']).toBeUndefined(); - expect(headers['__requestUrl']).toBeUndefined(); }) .start() .then(SERVER_URL => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled/scenario.ts deleted file mode 100644 index 4d47e16fd42f..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled/scenario.ts +++ /dev/null @@ -1,25 +0,0 @@ -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, - tracePropagationTargets: [/\/v0/, 'v1'], - integrations: [], - transport: loggingTransport, -}); - -async function run(): Promise { - await Sentry.startSpan({ name: 'test_span' }, async () => { - // Since fetch is lazy loaded, we need to wait a bit until it's fully instrumented - await new Promise(resolve => setTimeout(resolve, 100)); - await fetch(`${process.env.SERVER_URL}/api/v0`).then(res => res.text()); - await fetch(`${process.env.SERVER_URL}/api/v1`).then(res => res.text()); - await fetch(`${process.env.SERVER_URL}/api/v2`).then(res => res.text()); - await fetch(`${process.env.SERVER_URL}/api/v3`).then(res => res.text()); - }); -} - -// eslint-disable-next-line @typescript-eslint/no-floating-promises -run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled/test.ts deleted file mode 100644 index 40d05d2131eb..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled/test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { conditionalTest } from '../../../../utils'; -import { createRunner } from '../../../../utils/runner'; -import { createTestServer } from '../../../../utils/server'; - -conditionalTest({ min: 18 })('outgoing fetch', () => { - test('outgoing sampled fetch requests are correctly instrumented', done => { - expect.assertions(11); - - createTestServer(done) - .get('/api/v0', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v1', headers => { - expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); - expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); - expect(headers['baggage']).toEqual(expect.any(String)); - }) - .get('/api/v2', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .get('/api/v3', headers => { - expect(headers['baggage']).toBeUndefined(); - expect(headers['sentry-trace']).toBeUndefined(); - }) - .start() - .then(SERVER_URL => { - createRunner(__dirname, 'scenario.ts') - .withEnv({ SERVER_URL }) - .expect({ - transaction: { - // we're not too concerned with the actual transaction here since this is tested elsewhere - }, - }) - .start(done); - }); - }); -}); diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-noSampleRate/scenario.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/scenario.ts similarity index 100% rename from dev-packages/node-integration-tests/suites/tracing/requests/http-noSampleRate/scenario.ts rename to dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/scenario.ts diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-noSampleRate/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts similarity index 81% rename from dev-packages/node-integration-tests/suites/tracing/requests/http-noSampleRate/test.ts rename to dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts index b6766442683e..308e0c6676e2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-noSampleRate/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-no-tracing/test.ts @@ -1,31 +1,27 @@ import { createRunner } from '../../../../utils/runner'; import { createTestServer } from '../../../../utils/server'; -test('outgoing http requests are correctly instrumented without tracesSampleRate', done => { - expect.assertions(15); +test('outgoing http requests are correctly instrumented with tracing disabled', done => { + expect.assertions(11); createTestServer(done) .get('/api/v0', headers => { expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['__requestUrl']).toBeUndefined(); }) .get('/api/v1', headers => { expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); expect(headers['baggage']).toEqual(expect.any(String)); - expect(headers['__requestUrl']).toBeUndefined(); }) .get('/api/v2', headers => { expect(headers['baggage']).toBeUndefined(); expect(headers['sentry-trace']).toBeUndefined(); - expect(headers['__requestUrl']).toBeUndefined(); }) .get('/api/v3', headers => { expect(headers['baggage']).toBeUndefined(); expect(headers['sentry-trace']).toBeUndefined(); - expect(headers['__requestUrl']).toBeUndefined(); }) .start() .then(SERVER_URL => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts index 9f18f050b929..83d8774dbd46 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled-no-active-span/test.ts @@ -2,30 +2,26 @@ import { createRunner } from '../../../../utils/runner'; import { createTestServer } from '../../../../utils/server'; test('outgoing sampled http requests without active span are correctly instrumented', done => { - expect.assertions(15); + expect.assertions(11); createTestServer(done) .get('/api/v0', headers => { expect(headers['baggage']).toEqual(expect.any(String)); expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['__requestUrl']).toBeUndefined(); }) .get('/api/v1', headers => { expect(headers['baggage']).toEqual(expect.any(String)); expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})$/)); expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000'); - expect(headers['__requestUrl']).toBeUndefined(); }) .get('/api/v2', headers => { expect(headers['baggage']).toBeUndefined(); expect(headers['sentry-trace']).toBeUndefined(); - expect(headers['__requestUrl']).toBeUndefined(); }) .get('/api/v3', headers => { expect(headers['baggage']).toBeUndefined(); expect(headers['sentry-trace']).toBeUndefined(); - expect(headers['__requestUrl']).toBeUndefined(); }) .start() .then(SERVER_URL => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts index f3ad8bc5728e..fd939bc4458c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-sampled/test.ts @@ -2,30 +2,26 @@ import { createRunner } from '../../../../utils/runner'; import { createTestServer } from '../../../../utils/server'; test('outgoing sampled http requests are correctly instrumented', done => { - expect.assertions(15); + expect.assertions(11); createTestServer(done) .get('/api/v0', headers => { expect(headers['baggage']).toEqual(expect.any(String)); expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); - expect(headers['__requestUrl']).toBeUndefined(); }) .get('/api/v1', headers => { expect(headers['baggage']).toEqual(expect.any(String)); expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-1$/)); expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-1'); - expect(headers['__requestUrl']).toBeUndefined(); }) .get('/api/v2', headers => { expect(headers['baggage']).toBeUndefined(); expect(headers['sentry-trace']).toBeUndefined(); - expect(headers['__requestUrl']).toBeUndefined(); }) .get('/api/v3', headers => { expect(headers['baggage']).toBeUndefined(); expect(headers['sentry-trace']).toBeUndefined(); - expect(headers['__requestUrl']).toBeUndefined(); }) .start() .then(SERVER_URL => { diff --git a/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts b/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts index be4a2f542875..ed5d30631f31 100644 --- a/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/requests/http-unsampled/test.ts @@ -2,30 +2,26 @@ import { createRunner } from '../../../../utils/runner'; import { createTestServer } from '../../../../utils/server'; test('outgoing http requests are correctly instrumented when not sampled', done => { - expect.assertions(15); + expect.assertions(11); createTestServer(done) .get('/api/v0', headers => { expect(headers['baggage']).toEqual(expect.any(String)); expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); - expect(headers['__requestUrl']).toBeUndefined(); }) .get('/api/v1', headers => { expect(headers['baggage']).toEqual(expect.any(String)); expect(headers['sentry-trace']).toEqual(expect.stringMatching(/^([a-f0-9]{32})-([a-f0-9]{16})-0$/)); expect(headers['sentry-trace']).not.toEqual('00000000000000000000000000000000-0000000000000000-0'); - expect(headers['__requestUrl']).toBeUndefined(); }) .get('/api/v2', headers => { expect(headers['baggage']).toBeUndefined(); expect(headers['sentry-trace']).toBeUndefined(); - expect(headers['__requestUrl']).toBeUndefined(); }) .get('/api/v3', headers => { expect(headers['baggage']).toBeUndefined(); expect(headers['sentry-trace']).toBeUndefined(); - expect(headers['__requestUrl']).toBeUndefined(); }) .start() .then(SERVER_URL => { diff --git a/dev-packages/node-integration-tests/utils/runner.ts b/dev-packages/node-integration-tests/utils/runner.ts index 6e663cd13d75..ae0451f0d576 100644 --- a/dev-packages/node-integration-tests/utils/runner.ts +++ b/dev-packages/node-integration-tests/utils/runner.ts @@ -3,6 +3,7 @@ import { spawn, spawnSync } from 'child_process'; import { join } from 'path'; import { SDK_VERSION } from '@sentry/node'; import type { + ClientReport, Envelope, EnvelopeItemType, Event, @@ -46,6 +47,12 @@ export function assertSentryCheckIn(actual: SerializedCheckIn, expected: Partial }); } +export function assertSentryClientReport(actual: ClientReport, expected: Partial): void { + expect(actual).toMatchObject({ + ...expected, + }); +} + export function assertEnvelopeHeader(actual: Envelope[0], expected: Partial): void { expect(actual).toEqual({ event_id: expect.any(String), @@ -148,6 +155,9 @@ type Expected = } | { check_in: Partial | ((event: SerializedCheckIn) => void); + } + | { + client_report: Partial | ((event: ClientReport) => void); }; type ExpectedEnvelopeHeader = @@ -332,6 +342,17 @@ export function createRunner(...paths: string[]) { expectCallbackCalled(); } + + if ('client_report' in expected) { + const clientReport = item[1] as ClientReport; + if (typeof expected.client_report === 'function') { + expected.client_report(clientReport); + } else { + assertSentryClientReport(clientReport, expected.client_report); + } + + expectCallbackCalled(); + } } catch (e) { complete(e as Error); } diff --git a/packages/astro/src/integration/types.ts b/packages/astro/src/integration/types.ts index 6f182427ee47..f51a020bb290 100644 --- a/packages/astro/src/integration/types.ts +++ b/packages/astro/src/integration/types.ts @@ -70,7 +70,7 @@ type SourceMapsOptions = { telemetry?: boolean; /** - * A glob or an array of globs that specify the build artifacts and source maps that will uploaded to Sentry. + * A glob or an array of globs that specify the build artifacts and source maps that will be uploaded to Sentry. * * If this option is not specified, sensible defaults based on your `outDir`, `rootDir` and `adapter` * config will be used. Use this option to override these defaults, for instance if you have a diff --git a/packages/browser/src/client.ts b/packages/browser/src/client.ts index c301df98f7f6..177d787a438d 100644 --- a/packages/browser/src/client.ts +++ b/packages/browser/src/client.ts @@ -12,7 +12,7 @@ import type { SeverityLevel, UserFeedback, } from '@sentry/types'; -import { createClientReportEnvelope, dsnToString, getSDKSource, logger } from '@sentry/utils'; +import { getSDKSource, logger } from '@sentry/utils'; import { DEBUG_BUILD } from './debug-build'; import { eventFromException, eventFromMessage } from './eventbuilder'; @@ -118,30 +118,4 @@ export class BrowserClient extends BaseClient { event.platform = event.platform || 'javascript'; return super._prepareEvent(event, hint, scope); } - - /** - * Sends client reports as an envelope. - */ - private _flushOutcomes(): void { - const outcomes = this._clearOutcomes(); - - if (outcomes.length === 0) { - DEBUG_BUILD && logger.log('No outcomes to send'); - return; - } - - // This is really the only place where we want to check for a DSN and only send outcomes then - if (!this._dsn) { - DEBUG_BUILD && logger.log('No dsn provided, will not send outcomes'); - return; - } - - DEBUG_BUILD && logger.log('Sending outcomes:', outcomes); - - const envelope = createClientReportEnvelope(outcomes, this._options.tunnel && dsnToString(this._dsn)); - - // sendEnvelope should not throw - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.sendEnvelope(envelope); - } } diff --git a/packages/core/src/baseclient.ts b/packages/core/src/baseclient.ts index 9c4b43bd9c9d..5122b66d3267 100644 --- a/packages/core/src/baseclient.ts +++ b/packages/core/src/baseclient.ts @@ -36,7 +36,9 @@ import { addItemToEnvelope, checkOrSetAlreadyCaught, createAttachmentEnvelopeItem, + createClientReportEnvelope, dropUndefinedKeys, + dsnToString, isParameterizedString, isPlainObject, isPrimitive, @@ -871,6 +873,34 @@ export abstract class BaseClient implements Client { }); } + /** + * Sends client reports as an envelope. + */ + protected _flushOutcomes(): void { + DEBUG_BUILD && logger.log('Flushing outcomes...'); + + const outcomes = this._clearOutcomes(); + + if (outcomes.length === 0) { + DEBUG_BUILD && logger.log('No outcomes to send'); + return; + } + + // This is really the only place where we want to check for a DSN and only send outcomes then + if (!this._dsn) { + DEBUG_BUILD && logger.log('No dsn provided, will not send outcomes'); + return; + } + + DEBUG_BUILD && logger.log('Sending outcomes:', outcomes); + + const envelope = createClientReportEnvelope(outcomes, this._options.tunnel && dsnToString(this._dsn)); + + // sendEnvelope should not throw + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.sendEnvelope(envelope); + } + /** * @inheritDoc */ diff --git a/packages/feedback/src/core/components/Actor.test.ts b/packages/feedback/src/core/components/Actor.test.ts new file mode 100644 index 000000000000..27c138d5420d --- /dev/null +++ b/packages/feedback/src/core/components/Actor.test.ts @@ -0,0 +1,61 @@ +import { TRIGGER_LABEL } from '../../constants'; +import { getFeedback } from '../getFeedback'; +import { buildFeedbackIntegration } from '../integration'; +import { mockSdk } from '../mockSdk'; + +describe('Actor', () => { + it('renders the actor button', () => { + const feedbackIntegration = buildFeedbackIntegration({ + lazyLoadIntegration: jest.fn(), + }); + + const configuredIntegration = feedbackIntegration({}); + mockSdk({ + sentryOptions: { + integrations: [configuredIntegration], + }, + }); + + const feedback = getFeedback(); + expect(feedback).toBeDefined(); + + const actorComponent = feedback!.createWidget(); + + expect(actorComponent.el).toBeInstanceOf(HTMLButtonElement); + expect(actorComponent.el?.textContent).toBe(TRIGGER_LABEL); + }); + + it('renders the correct aria label for the button', () => { + const feedbackIntegration = buildFeedbackIntegration({ + lazyLoadIntegration: jest.fn(), + }); + + const configuredIntegration = feedbackIntegration({}); + mockSdk({ + sentryOptions: { + integrations: [configuredIntegration], + }, + }); + + const feedback = getFeedback(); + expect(feedback).toBeDefined(); + + // aria label is the same as trigger label when the trigger label isn't empty + const actorDefault = feedback!.createWidget({ triggerLabel: 'Button' }); + + expect(actorDefault.el?.textContent).toBe('Button'); + expect(actorDefault.el?.ariaLabel).toBe('Button'); + + // aria label is default text when trigger label is empty and aria isn't configured + const actorIcon = feedback!.createWidget({ triggerLabel: '' }); + + expect(actorIcon.el?.textContent).toBe(''); + expect(actorIcon.el?.ariaLabel).toBe(TRIGGER_LABEL); + + // aria label is the triggerAriaLabel if it's configured + const actorAria = feedback!.createWidget({ triggerLabel: 'Button', triggerAriaLabel: 'Aria' }); + + expect(actorAria.el?.textContent).toBe('Button'); + expect(actorAria.el?.ariaLabel).toBe('Aria'); + }); +}); diff --git a/packages/feedback/src/core/components/Actor.ts b/packages/feedback/src/core/components/Actor.ts index 6b2469e0313c..f31da8612a9f 100644 --- a/packages/feedback/src/core/components/Actor.ts +++ b/packages/feedback/src/core/components/Actor.ts @@ -1,9 +1,10 @@ -import { DOCUMENT } from '../../constants'; +import { DOCUMENT, TRIGGER_LABEL } from '../../constants'; import { createActorStyles } from './Actor.css'; import { FeedbackIcon } from './FeedbackIcon'; export interface ActorProps { triggerLabel: string; + triggerAriaLabel: string; shadow: ShadowRoot; } @@ -22,12 +23,12 @@ export interface ActorComponent { /** * The sentry-provided button to open the feedback modal */ -export function Actor({ triggerLabel, shadow }: ActorProps): ActorComponent { +export function Actor({ triggerLabel, triggerAriaLabel, shadow }: ActorProps): ActorComponent { const el = DOCUMENT.createElement('button'); el.type = 'button'; el.className = 'widget__actor'; el.ariaHidden = 'false'; - el.ariaLabel = triggerLabel; + el.ariaLabel = triggerAriaLabel || triggerLabel || TRIGGER_LABEL; el.appendChild(FeedbackIcon()); if (triggerLabel) { const label = DOCUMENT.createElement('span'); diff --git a/packages/feedback/src/core/integration.ts b/packages/feedback/src/core/integration.ts index 4e8caa85a135..e2194f43a1d5 100644 --- a/packages/feedback/src/core/integration.ts +++ b/packages/feedback/src/core/integration.ts @@ -99,6 +99,7 @@ export const buildFeedbackIntegration = ({ submitButtonLabel = SUBMIT_BUTTON_LABEL, successMessageText = SUCCESS_MESSAGE_TEXT, triggerLabel = TRIGGER_LABEL, + triggerAriaLabel = '', // FeedbackCallbacks onFormOpen, @@ -124,6 +125,7 @@ export const buildFeedbackIntegration = ({ themeLight, triggerLabel, + triggerAriaLabel, cancelButtonLabel, submitButtonLabel, confirmButtonLabel, @@ -258,7 +260,11 @@ export const buildFeedbackIntegration = ({ const _createActor = (optionOverrides: OverrideFeedbackConfiguration = {}): ActorComponent => { const mergedOptions = mergeOptions(_options, optionOverrides); const shadow = _createShadow(mergedOptions); - const actor = Actor({ triggerLabel: mergedOptions.triggerLabel, shadow }); + const actor = Actor({ + triggerLabel: mergedOptions.triggerLabel, + triggerAriaLabel: mergedOptions.triggerAriaLabel, + shadow, + }); _attachTo(actor.el, { ...mergedOptions, onFormOpen() { diff --git a/packages/google-cloud-serverless/test/integrations/private.pem b/packages/google-cloud-serverless/test/integrations/private.pem index 00a658fe7a7f..9d9ae7f01465 100644 --- a/packages/google-cloud-serverless/test/integrations/private.pem +++ b/packages/google-cloud-serverless/test/integrations/private.pem @@ -1,15 +1,15 @@ -----BEGIN RSA PRIVATE KEY----- -MIICXQIBAAKBgQDzU+jLTzW6154Joezxrd2+5pCNYP0HcaMoYqEyXfNRpkNE7wrQ -UEG830o4Qcaae2BhqZoujwSW7RkR6h0Fkd0WTR8h5J8rSGNHv/1jJoUUjP9iZ/5S -FAyIIyEYfDPqtnA4iF1QWO2lXWlEFSuZjwM/8jBmeGzoiw17akNThIw8NwIDAQAB -AoGATpboVloEAY/IdFX/QGOmfhTb1T3hG3lheBa695iOkO2BRo9qT7PMN6NqxlbA -PX7ht0lfCfCZS+HSOg4CR50/6WXHMSmwlvcjGuDIDKWjviQTTYE77MlVBQHw9WzY -PfiRBbtouyPGQtO4rk42zkIILC6exBZ1vKpRPOmTAnxrjCECQQD+56r6hYcS6GNp -NOWyv0eVFMBX4iNWAsRf9JVVvGDz2rVuhnkNiN73vfffDWvSXkCydL1jFmalgdQD -gm77UZQHAkEA9F+CauU0aZsJ1SthQ6H0sDQ+eNRUgnz4itnkSC2C20fZ3DaSpCMC -0go81CcZOhftNO730ILqiS67C3d3rqLqUQJBAP10ROHMmz4Fq7MUUcClyPtHIuk/ -hXskTTZL76DMKmrN8NDxDLSUf38+eJRkt+z4osPOp/E6eN3gdXr32nox50kCQCl8 -hXGMU+eR0IuF/88xkY7Qb8KnmWlFuhQohZ7TSyHbAttl0GNZJkNuRYFm2duI8FZK -M3wMnbCIZGy/7WuScOECQQCV+0yrf5dL1M2GHjJfwuTb00wRKalKQEH1v/kvE5vS -FmdN7BPK5Ra50MaecMNoYqu9rmtyWRBn93dcvKrL57nY +MIICXQIBAAKBgQCg50sVg2ZgE39e40dgdYnS8ExADVz4OM14tUVVHRBVOA0AcMFI +b1XBBKgcyNtsVAU/odyReckH9zhNL565EsOcKSXRmPd5SfFl8WojFLjpWNWXpoB7 +91dNpYyLOAohIoOSGi5gwn+m4RchElbPYzjsOEDK20vsYCUFERxAXZR/3QIDAQAB +AoGAOBkLu39pdPu3P5zb6Mxx9eIjo31FOaGMOZZxisAsTpnRJqMpMBjo+/ekqQx6 +O+V7Qvkqzml4ZleSAI8mtn3NRpjK5DiPzJ4f+pFd0QfcPeUvxyWEe8WNPr6SHbfd +20y/d1jQR7ATGrjOTlC0K3rPWvqZ94HieDOkInsvQ99WBR0CQQDpL/x38ugqHyKF +g63PcGd64MCoTUPb0lt3Chp3mgGEpAV/rY3N02XXKQ6z/kSeEIUOouw74ycZhdFC +dlhVcP7LAkEAsKUBvPooCB5Dc4O/GOxvhXLTMQaIzzhOsfHzVZzZFK7YK9tV4bIV +GQV8mswTWNboUpn7CaVQ5hWjbdN2Dty+9wJAYn7UY04E5pXUHRUru209KFf6yJwq +R5Wo8LUhzNcOQRqPAAks1n2ujJ1ZCooiLanIqhADPKCMCWnOpAYc9aoO1QJBAIwf +aICS47yE3Ta91JIdw91VF6h1KYNPhEVty3wnPqBEjiBEDh1J2aiOjKhyqAo59/LG +SLf/Fmxdz9Vn/+eMPkMCQQCrtJcDlpgrEHamuCibSA801D76O0merRvTBe2BzEUu +mnx3VIIgoB8/4r5tG+9TkudLutfDEghIXU7yWkbklVh7 -----END RSA PRIVATE KEY----- diff --git a/packages/nestjs/.eslintignore b/packages/nestjs/.eslintignore new file mode 100644 index 000000000000..3e89751310a5 --- /dev/null +++ b/packages/nestjs/.eslintignore @@ -0,0 +1,2 @@ +/*.d.ts +/*.d.ts.map diff --git a/packages/nestjs/.gitignore b/packages/nestjs/.gitignore new file mode 100644 index 000000000000..3e89751310a5 --- /dev/null +++ b/packages/nestjs/.gitignore @@ -0,0 +1,2 @@ +/*.d.ts +/*.d.ts.map diff --git a/packages/nestjs/README.md b/packages/nestjs/README.md index e336a856c03e..9e0192551afc 100644 --- a/packages/nestjs/README.md +++ b/packages/nestjs/README.md @@ -24,10 +24,9 @@ yarn add @sentry/nestjs ## Usage +Add an `instrument.ts` file: + ```typescript -// CJS Syntax -const Sentry = require('@sentry/nestjs'); -// ESM Syntax import * as Sentry from '@sentry/nestjs'; Sentry.init({ @@ -36,7 +35,46 @@ Sentry.init({ }); ``` -Note that it is necessary to initialize Sentry **before you import any package that may be instrumented by us**. +You need to require or import the `instrument.ts` file before requiring any other modules in your application. This is +necessary to ensure that Sentry can automatically instrument all modules in your application: + +```typescript +// Import this first! +import './instrument'; + +// Now import other modules +import * as Sentry from '@sentry/nestjs'; +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + await app.listen(3000); +} + +bootstrap(); +``` + +Then you can add the `SentryModule` as a root module: + +```typescript +import { Module } from '@nestjs/common'; +import { SentryModule } from '@sentry/nestjs/setup'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +@Module({ + imports: [ + SentryModule.forRoot(), + // ...other modules + ], + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} +``` + +The `SentryModule` needs to be registered before any module that should be instrumented by Sentry. ## SentryTraced diff --git a/packages/nestjs/package.json b/packages/nestjs/package.json index 7a8de8e14c47..dd1390c1f229 100644 --- a/packages/nestjs/package.json +++ b/packages/nestjs/package.json @@ -10,7 +10,9 @@ "node": ">=16" }, "files": [ - "/build" + "/build", + "/*.d.ts", + "/*.d.ts.map" ], "main": "build/cjs/nestjs/index.js", "module": "build/esm/nestjs/index.js", @@ -26,13 +28,16 @@ "types": "./build/types/index.d.ts", "default": "./build/cjs/index.js" } - } - }, - "typesVersions": { - "<4.9": { - "build/types/index.d.ts": [ - "build/types-ts3.8/index.d.ts" - ] + }, + "./setup": { + "import": { + "types": "./setup.d.ts", + "default": "./build/esm/setup.js" + }, + "require": { + "types": "./setup.d.ts", + "default": "./build/cjs/setup.js" + } } }, "publishConfig": { @@ -40,21 +45,31 @@ }, "dependencies": { "@sentry/core": "8.19.0", - "@sentry/node": "8.19.0" + "@sentry/node": "8.19.0", + "@sentry/types": "8.19.0", + "@sentry/utils": "8.19.0" + }, + "devDependencies": { + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" + }, + "peerDependencies": { + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" }, "scripts": { "build": "run-p build:transpile build:types", "build:dev": "yarn build", "build:transpile": "rollup -c rollup.npm.config.mjs", - "build:types": "run-s build:types:core build:types:downlevel", + "build:types": "run-s build:types:core build:types:setup", "build:types:core": "tsc -p tsconfig.types.json", - "build:types:downlevel": "yarn downlevel-dts build/types build/types-ts3.8 --to ts3.8", + "build:types:setup": "tsc -p tsconfig.setup-types.json", "build:watch": "run-p build:transpile:watch build:types:watch", "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", "build:types:watch": "tsc -p tsconfig.types.json --watch", "build:tarball": "npm pack", - "circularDepCheck": "madge --circular src/index.ts", + "circularDepCheck": "madge --circular src/index.ts && madge --circular src/setup.ts", "clean": "rimraf build coverage sentry-node-*.tgz", "fix": "eslint . --format stylish --fix", "lint": "eslint . --format stylish", diff --git a/packages/nestjs/rollup.npm.config.mjs b/packages/nestjs/rollup.npm.config.mjs index 84a06f2fb64a..0ce71546935c 100644 --- a/packages/nestjs/rollup.npm.config.mjs +++ b/packages/nestjs/rollup.npm.config.mjs @@ -1,3 +1,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; -export default makeNPMConfigVariants(makeBaseNPMConfig()); +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + entrypoints: ['src/index.ts', 'src/setup.ts'], + }), +); diff --git a/packages/nestjs/src/setup.ts b/packages/nestjs/src/setup.ts new file mode 100644 index 000000000000..b274b85ec43b --- /dev/null +++ b/packages/nestjs/src/setup.ts @@ -0,0 +1,159 @@ +import type { + ArgumentsHost, + CallHandler, + DynamicModule, + ExecutionContext, + NestInterceptor, + OnModuleInit, +} from '@nestjs/common'; +import { Catch } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import { Global, Module } from '@nestjs/common'; +import { APP_FILTER, APP_INTERCEPTOR, BaseExceptionFilter } from '@nestjs/core'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + captureException, + getClient, + getDefaultIsolationScope, + getIsolationScope, + spanToJSON, +} from '@sentry/core'; +import type { Span } from '@sentry/types'; +import { logger } from '@sentry/utils'; +import type { Observable } from 'rxjs'; + +/** + * Note: We cannot use @ syntax to add the decorators, so we add them directly below the classes as function wrappers. + */ + +/** + * Interceptor to add Sentry tracing capabilities to Nest.js applications. + */ +class SentryTracingInterceptor implements NestInterceptor { + /** + * Intercepts HTTP requests to set the transaction name for Sentry tracing. + */ + public intercept(context: ExecutionContext, next: CallHandler): Observable { + if (getIsolationScope() === getDefaultIsolationScope()) { + logger.warn('Isolation scope is still the default isolation scope, skipping setting transactionName.'); + return next.handle(); + } + + if (context.getType() === 'http') { + const req = context.switchToHttp().getRequest(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (req.route) { + // eslint-disable-next-line @sentry-internal/sdk/no-optional-chaining,@typescript-eslint/no-unsafe-member-access + getIsolationScope().setTransactionName(`${req.method?.toUpperCase() || 'GET'} ${req.route.path}`); + } + } + + return next.handle(); + } +} +Injectable()(SentryTracingInterceptor); +export { SentryTracingInterceptor }; + +/** + * Global filter to handle exceptions and report them to Sentry. + */ +class SentryGlobalFilter extends BaseExceptionFilter { + /** + * Catches exceptions and reports them to Sentry unless they are expected errors. + */ + public catch(exception: unknown, host: ArgumentsHost): void { + const status_code = (exception as { status?: number }).status; + + // don't report expected errors + if (status_code !== undefined && status_code >= 400 && status_code < 500) { + return super.catch(exception, host); + } + + captureException(exception); + return super.catch(exception, host); + } +} +Catch()(SentryGlobalFilter); +export { SentryGlobalFilter }; + +/** + * Service to set up Sentry performance tracing for Nest.js applications. + */ +class SentryService implements OnModuleInit { + /** + * Initializes the Sentry service and registers span attributes. + */ + public onModuleInit(): void { + // Sadly, NestInstrumentation has no requestHook, so we need to add the attributes here + // We register this hook in this method, because if we register it in the integration `setup`, + // it would always run even for users that are not even using Nest.js + const client = getClient(); + if (client) { + client.on('spanStart', span => { + addNestSpanAttributes(span); + }); + } + } +} +Injectable()(SentryService); +export { SentryService }; + +/** + * Set up a root module that can be injected in nest applications. + */ +class SentryModule { + /** + * Configures the module as the root module in a Nest.js application. + */ + public static forRoot(): DynamicModule { + return { + module: SentryModule, + providers: [ + SentryService, + { + provide: APP_FILTER, + useClass: SentryGlobalFilter, + }, + { + provide: APP_INTERCEPTOR, + useClass: SentryTracingInterceptor, + }, + ], + exports: [SentryService], + }; + } +} +Global()(SentryModule); +Module({ + providers: [ + SentryService, + { + provide: APP_FILTER, + useClass: SentryGlobalFilter, + }, + { + provide: APP_INTERCEPTOR, + useClass: SentryTracingInterceptor, + }, + ], + exports: [SentryService], +})(SentryModule); +export { SentryModule }; + +function addNestSpanAttributes(span: Span): void { + const attributes = spanToJSON(span).data || {}; + + // this is one of: app_creation, request_context, handler + const type = attributes['nestjs.type']; + + // If this is already set, or we have no nest.js span, no need to process again... + if (attributes[SEMANTIC_ATTRIBUTE_SENTRY_OP] || !type) { + return; + } + + span.setAttributes({ + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.otel.nestjs', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: `${type}.nestjs`, + }); +} diff --git a/packages/nestjs/tsconfig.setup-types.json b/packages/nestjs/tsconfig.setup-types.json new file mode 100644 index 000000000000..2ef9310f3edc --- /dev/null +++ b/packages/nestjs/tsconfig.setup-types.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "outDir": "./" + }, + + "//": "This type is built separately because it is for a subpath export, which has problems if it is not in the root", + "include": ["src/setup.ts"], + "exclude": [] +} diff --git a/packages/nestjs/tsconfig.types.json b/packages/nestjs/tsconfig.types.json index 65455f66bd75..6240cd92efaa 100644 --- a/packages/nestjs/tsconfig.types.json +++ b/packages/nestjs/tsconfig.types.json @@ -6,5 +6,8 @@ "declarationMap": true, "emitDeclarationOnly": true, "outDir": "build/types" - } + }, + + "//": "This is built separately in tsconfig.setup-types.json", + "exclude": ["src/setup.ts"] } diff --git a/packages/node/package.json b/packages/node/package.json index 2ef2e1499b9c..71f8c6bae267 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -91,7 +91,8 @@ "@sentry/core": "8.19.0", "@sentry/opentelemetry": "8.19.0", "@sentry/types": "8.19.0", - "@sentry/utils": "8.19.0" + "@sentry/utils": "8.19.0", + "import-in-the-middle": "^1.10.0" }, "devDependencies": { "@types/node": "^14.18.0" diff --git a/packages/node/src/integrations/node-fetch.ts b/packages/node/src/integrations/node-fetch.ts index 79b5aa10acad..093b314a6138 100644 --- a/packages/node/src/integrations/node-fetch.ts +++ b/packages/node/src/integrations/node-fetch.ts @@ -1,6 +1,12 @@ import type { Span } from '@opentelemetry/api'; -import { addBreadcrumb, defineIntegration } from '@sentry/core'; -import { addOpenTelemetryInstrumentation } from '@sentry/opentelemetry'; +import { trace } from '@opentelemetry/api'; +import { context, propagation } from '@opentelemetry/api'; +import { addBreadcrumb, defineIntegration, getCurrentScope, hasTracingEnabled } from '@sentry/core'; +import { + addOpenTelemetryInstrumentation, + generateSpanContextForPropagationContext, + getPropagationContextFromSpan, +} from '@sentry/opentelemetry'; import type { IntegrationFn, SanitizedRequestData } from '@sentry/types'; import { getSanitizedUrlString, logger, parseUrl } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; @@ -63,9 +69,49 @@ const _nativeNodeFetchIntegration = ((options: NodeFetchOptions = {}) => { } return new SentryNodeFetchInstrumentation({ - ignoreRequestHook: (request: { origin?: string }) => { - const url = request.origin; - return _ignoreOutgoingRequests && url && _ignoreOutgoingRequests(url); + ignoreRequestHook: (request: FetchRequest) => { + const url = getAbsoluteUrl(request.origin, request.path); + const tracingDisabled = !hasTracingEnabled(); + const shouldIgnore = _ignoreOutgoingRequests && url && _ignoreOutgoingRequests(url); + + 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 (tracingDisabled) { + 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; }, onRequest: ({ span }: { span: Span }) => { _updateSpan(span); @@ -141,3 +187,18 @@ function getBreadcrumbData(request: FetchRequest): Partial return {}; } } + +// Matching the behavior of the base instrumentation +function getAbsoluteUrl(origin: string, path: string = '/'): string { + const url = `${origin}`; + + if (origin.endsWith('/') && path.startsWith('/')) { + return `${url}${path.slice(1)}`; + } + + if (!origin.endsWith('/') && !path.startsWith('/')) { + return `${url}/${path.slice(1)}`; + } + + return `${url}${path}`; +} diff --git a/packages/node/src/integrations/tracing/express.ts b/packages/node/src/integrations/tracing/express.ts index 00c5735207d4..b8c50e0eb621 100644 --- a/packages/node/src/integrations/tracing/express.ts +++ b/packages/node/src/integrations/tracing/express.ts @@ -83,16 +83,18 @@ type ExpressMiddleware = ( next: (error: MiddlewareError) => void, ) => void; -/** - * An Express-compatible error handler. - */ -export function expressErrorHandler(options?: { +interface ExpressHandlerOptions { /** * Callback method deciding whether error should be captured and sent to Sentry * @param error Captured middleware error */ shouldHandleError?(this: void, error: MiddlewareError): boolean; -}): ExpressMiddleware { +} + +/** + * An Express-compatible error handler. + */ +export function expressErrorHandler(options?: ExpressHandlerOptions): ExpressMiddleware { return function sentryErrorMiddleware( error: MiddlewareError, _req: http.IncomingMessage, @@ -135,8 +137,11 @@ export function expressErrorHandler(options?: { * Setup an error handler for Express. * The error handler must be before any other middleware and after all controllers. */ -export function setupExpressErrorHandler(app: { use: (middleware: ExpressMiddleware) => unknown }): void { - app.use(expressErrorHandler()); +export function setupExpressErrorHandler( + app: { use: (middleware: ExpressMiddleware) => unknown }, + options?: ExpressHandlerOptions, +): void { + app.use(expressErrorHandler(options)); ensureIsWrapped(app.use, 'express'); } diff --git a/packages/node/src/sdk/client.ts b/packages/node/src/sdk/client.ts index cf1cb3c2023a..877b363d3b2a 100644 --- a/packages/node/src/sdk/client.ts +++ b/packages/node/src/sdk/client.ts @@ -6,12 +6,17 @@ import type { ServerRuntimeClientOptions } from '@sentry/core'; import { SDK_VERSION, ServerRuntimeClient, applySdkMetadata } from '@sentry/core'; import { logger } from '@sentry/utils'; import { isMainThread, threadId } from 'worker_threads'; +import { DEBUG_BUILD } from '../debug-build'; import type { NodeClientOptions } from '../types'; +const DEFAULT_CLIENT_REPORT_FLUSH_INTERVAL_MS = 60_000; // 60s was chosen arbitrarily + /** A client for using Sentry with Node & OpenTelemetry. */ export class NodeClient extends ServerRuntimeClient { public traceProvider: BasicTracerProvider | undefined; private _tracer: Tracer | undefined; + private _clientReportInterval: NodeJS.Timeout | undefined; + private _clientReportOnExitFlushListener: (() => void) | undefined; public constructor(options: NodeClientOptions) { const clientOptions: ServerRuntimeClientOptions = { @@ -44,9 +49,8 @@ export class NodeClient extends ServerRuntimeClient { return tracer; } - /** - * @inheritDoc - */ + // Eslint ignore explanation: This is already documented in super. + // eslint-disable-next-line jsdoc/require-jsdoc public async flush(timeout?: number): Promise { const provider = this.traceProvider; const spanProcessor = provider?.activeSpanProcessor; @@ -55,6 +59,60 @@ export class NodeClient extends ServerRuntimeClient { await spanProcessor.forceFlush(); } + if (this.getOptions().sendClientReports) { + this._flushOutcomes(); + } + return super.flush(timeout); } + + // Eslint ignore explanation: This is already documented in super. + // eslint-disable-next-line jsdoc/require-jsdoc + public close(timeout?: number | undefined): PromiseLike { + if (this._clientReportInterval) { + clearInterval(this._clientReportInterval); + } + + if (this._clientReportOnExitFlushListener) { + process.off('beforeExit', this._clientReportOnExitFlushListener); + } + + return super.close(timeout); + } + + /** + * Will start tracking client reports for this client. + * + * NOTICE: This method will create an interval that is periodically called and attach a `process.on('beforeExit')` + * hook. To clean up these resources, call `.close()` when you no longer intend to use the client. Not doing so will + * result in a memory leak. + */ + // The reason client reports need to be manually activated with this method instead of just enabling them in a + // constructor, is that if users periodically and unboundedly create new clients, we will create more and more + // intervals and beforeExit listeners, thus leaking memory. In these situations, users are required to call + // `client.close()` in order to dispose of the acquired resources. + // We assume that calling this method in Sentry.init() is a sensible default, because calling Sentry.init() over and + // over again would also result in memory leaks. + // Note: We have experimented with using `FinalizationRegisty` to clear the interval when the client is garbage + // collected, but it did not work, because the cleanup function never got called. + public startClientReportTracking(): void { + const clientOptions = this.getOptions(); + if (clientOptions.sendClientReports) { + this._clientReportOnExitFlushListener = () => { + this._flushOutcomes(); + }; + + this._clientReportInterval = setInterval( + () => { + DEBUG_BUILD && logger.log('Flushing client reports based on interval.'); + this._flushOutcomes(); + }, + clientOptions.clientReportFlushInterval ?? DEFAULT_CLIENT_REPORT_FLUSH_INTERVAL_MS, + ) + // Unref is critical for not preventing the process from exiting because the interval is active. + .unref(); + + process.on('beforeExit', this._clientReportOnExitFlushListener); + } + } } diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 7dd145854993..65a6f6768096 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -154,6 +154,8 @@ function _init( startSessionTracking(); } + client.startClientReportTracking(); + updateScopeFromEnvVariables(); if (options.spotlight) { @@ -228,6 +230,7 @@ function getClientOptions( transport: makeNodeTransport, dsn: process.env.SENTRY_DSN, environment: process.env.SENTRY_ENVIRONMENT, + sendClientReports: true, }); const overwriteOptions = dropUndefinedKeys({ diff --git a/packages/node/src/sdk/initOtel.ts b/packages/node/src/sdk/initOtel.ts index 947486ba26cb..03d8cea76fac 100644 --- a/packages/node/src/sdk/initOtel.ts +++ b/packages/node/src/sdk/initOtel.ts @@ -63,6 +63,7 @@ export function maybeInitializeEsmLoader(esmHookConfig?: EsmLoaderHookOptions): interface NodePreloadOptions { debug?: boolean; integrations?: string[]; + registerEsmLoaderHooks?: EsmLoaderHookOptions; } /** @@ -79,7 +80,7 @@ export function preloadOpenTelemetry(options: NodePreloadOptions = {}): void { } if (!isCjs()) { - maybeInitializeEsmLoader(); + maybeInitializeEsmLoader(options.registerEsmLoaderHooks); } // These are all integrations that we need to pre-load to ensure they are set up before any other code runs diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 9cf3047e6c0a..9604b31ddb22 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -5,8 +5,8 @@ import type { ClientOptions, Options, SamplingContext, Scope, Span, TracePropaga import type { NodeTransportOptions } from './transports'; export interface EsmLoaderHookOptions { - include?: string[]; - exclude?: string[]; + include?: Array; + exclude?: Array; } export interface BaseNodeOptions { @@ -109,6 +109,11 @@ export interface BaseNodeOptions { */ registerEsmLoaderHooks?: boolean | EsmLoaderHookOptions; + /** + * Configures in which interval client reports will be flushed. Defaults to `60_000` (milliseconds). + */ + clientReportFlushInterval?: number; + /** Callback that is executed when a fatal global error occurs. */ onFatalError?(this: void, error: Error): void; } diff --git a/packages/node/test/helpers/mockSdkInit.ts b/packages/node/test/helpers/mockSdkInit.ts index 0e1d23cfc73c..e482dac6ed08 100644 --- a/packages/node/test/helpers/mockSdkInit.ts +++ b/packages/node/test/helpers/mockSdkInit.ts @@ -17,7 +17,14 @@ export function resetGlobals(): void { export function mockSdkInit(options?: Partial) { resetGlobals(); - init({ dsn: PUBLIC_DSN, defaultIntegrations: false, ...options }); + init({ + dsn: PUBLIC_DSN, + defaultIntegrations: false, + // We are disabling client reports because we would be acquiring resources with every init call and that would leak + // memory every time we call init in the tests + sendClientReports: false, + ...options, + }); } export function cleanupOtel(_provider?: BasicTracerProvider): void { diff --git a/packages/nuxt/.eslintrc.js b/packages/nuxt/.eslintrc.js index c1f55c94aadf..d567b12530d0 100644 --- a/packages/nuxt/.eslintrc.js +++ b/packages/nuxt/.eslintrc.js @@ -14,6 +14,7 @@ module.exports = { files: ['src/vite/**', 'src/server/**'], rules: { '@sentry-internal/sdk/no-optional-chaining': 'off', + '@sentry-internal/sdk/no-nullish-coalescing': 'off', }, }, ], diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index c75fa334b8a5..a2f9d9d0d22e 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -102,21 +102,6 @@ Sentry.init({ ### 4. Server-side setup -Add a `sentry.server.config.(js|ts)` file to the root of your project: - -```javascript -import * as Sentry from '@sentry/nuxt'; - -Sentry.init({ - dsn: process.env.DSN, -}); -``` - -**Alternative Setup (ESM-compatible)** - -This setup makes sure Sentry is imported on the server before any other imports. As of now, this however leads to an -import-in-the-middle error ([related reproduction](https://github.com/getsentry/sentry-javascript-examples/pull/38)). - Add an `instrument.server.mjs` file to your `public` folder: ```javascript @@ -130,7 +115,8 @@ if (process.env.SENTRY_DSN) { } ``` -Add an import flag to the node options, so the file loads before any other imports: +Add an import flag to the `NODE_OPTIONS` of your preview script in the `package.json` file, so the file loads before any +other imports: ```json { @@ -140,12 +126,34 @@ Add an import flag to the node options, so the file loads before any other impor } ``` -### 5. Vite Setup - -todo: add vite setup +If you are getting an `import-in-the-middle` error message, add the package with a minimum version of `1.10.0` as a +dependency to your `package.json` +([issue reference](https://github.com/getsentry/sentry-javascript-examples/pull/38#issuecomment-2245259327)): ---- +```json +{ + "dependencies": { + "import-in-the-middle": "1.10.0" + } +} +``` ## Uploading Source Maps -todo: add source maps instructions +To upload source maps, you can use the `sourceMapsUploadOptions` option inside the `sentry` options of your +`nuxt.config.ts`: + +```javascript +// nuxt.config.ts +export default defineNuxtConfig({ + modules: ['@sentry/nuxt/module'], + sentry: { + debug: true, + sourceMapsUploadOptions: { + org: 'your-org-slug', + project: 'your-project-slug', + authToken: process.env.SENTRY_AUTH_TOKEN, + }, + }, +}); +``` diff --git a/packages/nuxt/src/client/sdk.ts b/packages/nuxt/src/client/sdk.ts index e07c3267c902..254498acbcbc 100644 --- a/packages/nuxt/src/client/sdk.ts +++ b/packages/nuxt/src/client/sdk.ts @@ -1,4 +1,8 @@ -import { init as initBrowser } from '@sentry/browser'; +import { + browserTracingIntegration, + getDefaultIntegrations as getBrowserDefaultIntegrations, + init as initBrowser, +} from '@sentry/browser'; import { applySdkMetadata } from '@sentry/core'; import type { Client } from '@sentry/types'; import type { SentryNuxtOptions } from '../common/types'; @@ -10,6 +14,7 @@ import type { SentryNuxtOptions } from '../common/types'; */ export function init(options: SentryNuxtOptions): Client | undefined { const sentryOptions = { + defaultIntegrations: [...getBrowserDefaultIntegrations(options), browserTracingIntegration()], ...options, }; diff --git a/packages/nuxt/src/common/types.ts b/packages/nuxt/src/common/types.ts index 4b924f81192a..0187e83170a7 100644 --- a/packages/nuxt/src/common/types.ts +++ b/packages/nuxt/src/common/types.ts @@ -2,3 +2,99 @@ import type { init } from '@sentry/vue'; // Omitting 'app' as the Nuxt SDK will add the app instance in the client plugin (users do not have to provide this) export type SentryNuxtOptions = Omit[0] & object, 'app'>; + +type SourceMapsOptions = { + /** + * Options for the Sentry Vite plugin to customize the source maps upload process. + * + * These options are always read from the `sentry` module options in the `nuxt.config.(js|ts). + * Do not define them in the `sentry.client.config.(js|ts)` or `sentry.server.config.(js|ts)` files. + */ + sourceMapsUploadOptions?: { + /** + * If this flag is `true`, and an auth token is detected, the Sentry integration will + * automatically generate and upload source maps to Sentry during a production build. + * + * @default true + */ + enabled?: boolean; + + /** + * The auth token to use when uploading source maps to Sentry. + * + * Instead of specifying this option, you can also set the `SENTRY_AUTH_TOKEN` environment variable. + * + * To create an auth token, follow this guide: + * @see https://docs.sentry.io/product/accounts/auth-tokens/#organization-auth-tokens + */ + authToken?: string; + + /** + * The organization slug of your Sentry organization. + * Instead of specifying this option, you can also set the `SENTRY_ORG` environment variable. + */ + org?: string; + + /** + * The project slug of your Sentry project. + * Instead of specifying this option, you can also set the `SENTRY_PROJECT` environment variable. + */ + project?: string; + + /** + * If this flag is `true`, the Sentry plugin will collect some telemetry data and send it to Sentry. + * It will not collect any sensitive or user-specific data. + * + * @default true + */ + telemetry?: boolean; + + /** + * Options related to sourcemaps + */ + sourcemaps?: { + /** + * A glob or an array of globs that specify the build artifacts and source maps that will be uploaded to Sentry. + * + * If this option is not specified, sensible defaults based on your adapter and nuxt.config.js + * setup will be used. Use this option to override these defaults, for instance if you have a + * customized build setup that diverges from Nuxt's defaults. + * + * The globbing patterns must follow the implementation of the `glob` package. + * @see https://www.npmjs.com/package/glob#glob-primer + */ + assets?: string | Array; + + /** + * A glob or an array of globs that specifies which build artifacts should not be uploaded to Sentry. + * + * @default [] - By default no files are ignored. Thus, all files matching the `assets` glob + * or the default value for `assets` are uploaded. + * + * The globbing patterns follow the implementation of the glob package. (https://www.npmjs.com/package/glob) + */ + ignore?: string | Array; + + /** + * A glob or an array of globs that specifies the build artifacts that should be deleted after the artifact + * upload to Sentry has been completed. + * + * @default [] - By default no files are deleted. + * + * The globbing patterns follow the implementation of the glob package. (https://www.npmjs.com/package/glob) + */ + filesToDeleteAfterUpload?: string | Array; + }; + }; +}; + +/** + * Build options for the Sentry module. These options are used during build-time by the Sentry SDK. + */ +export type SentryNuxtModuleOptions = SourceMapsOptions & { + /** + * Enable debug functionality of the SDK during build-time. + * Enabling this will give you, for example, logs about source maps. + */ + debug?: boolean; +}; diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 9c14abd6feea..4786fe1e4aa9 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -1,9 +1,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { addPlugin, addPluginTemplate, addServerPlugin, createResolver, defineNuxtModule } from '@nuxt/kit'; -import type { SentryNuxtOptions } from './common/types'; +import type { SentryNuxtModuleOptions } from './common/types'; +import { setupSourceMaps } from './vite/sourceMaps'; -export type ModuleOptions = SentryNuxtOptions; +export type ModuleOptions = SentryNuxtModuleOptions; export default defineNuxtModule({ meta: { @@ -14,7 +15,7 @@ export default defineNuxtModule({ }, }, defaults: {}, - setup(_moduleOptions, nuxt) { + setup(moduleOptions, nuxt) { const moduleDirResolver = createResolver(import.meta.url); const buildDirResolver = createResolver(nuxt.options.buildDir); @@ -47,6 +48,10 @@ export default defineNuxtModule({ addServerPlugin(moduleDirResolver.resolve('./runtime/plugins/sentry.server')); } + + if (clientConfigFile || serverConfigFile) { + setupSourceMaps(moduleOptions, nuxt); + } }, }); diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts new file mode 100644 index 000000000000..3518c45409e0 --- /dev/null +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -0,0 +1,52 @@ +import type { Nuxt } from '@nuxt/schema'; +import { sentryVitePlugin } from '@sentry/vite-plugin'; +import type { SentryNuxtModuleOptions } from '../common/types'; + +/** + * Setup source maps for Sentry inside the Nuxt module during build time. + */ +export function setupSourceMaps(moduleOptions: SentryNuxtModuleOptions, nuxt: Nuxt): void { + nuxt.hook('vite:extendConfig', async (viteInlineConfig, _env) => { + const sourceMapsUploadOptions = moduleOptions.sourceMapsUploadOptions || {}; + + if ((sourceMapsUploadOptions.enabled ?? true) && viteInlineConfig.mode !== 'development') { + const sentryPlugin = sentryVitePlugin({ + org: sourceMapsUploadOptions.org ?? process.env.SENTRY_ORG, + project: sourceMapsUploadOptions.project ?? process.env.SENTRY_PROJECT, + authToken: sourceMapsUploadOptions.authToken ?? process.env.SENTRY_AUTH_TOKEN, + telemetry: sourceMapsUploadOptions.telemetry ?? true, + sourcemaps: { + assets: sourceMapsUploadOptions.sourcemaps?.assets ?? undefined, + ignore: sourceMapsUploadOptions.sourcemaps?.ignore ?? undefined, + filesToDeleteAfterUpload: sourceMapsUploadOptions.sourcemaps?.filesToDeleteAfterUpload ?? undefined, + }, + _metaOptions: { + telemetry: { + metaFramework: 'nuxt', + }, + }, + debug: moduleOptions.debug ?? false, + }); + + viteInlineConfig.plugins = viteInlineConfig.plugins || []; + viteInlineConfig.plugins.push(sentryPlugin); + + const sourceMapsPreviouslyEnabled = viteInlineConfig.build?.sourcemap; + + if (moduleOptions.debug && !sourceMapsPreviouslyEnabled) { + // eslint-disable-next-line no-console + console.log('[Sentry]: Enabled source maps generation in the Vite build options.'); + if (!moduleOptions.sourceMapsUploadOptions?.sourcemaps?.filesToDeleteAfterUpload) { + // eslint-disable-next-line no-console + console.warn( + `[Sentry] We recommend setting the \`sourceMapsUploadOptions.sourcemaps.filesToDeleteAfterUpload\` option to clean up source maps after uploading. +[Sentry] Otherwise, source maps might be deployed to production, depending on your configuration`, + ); + } + } + + viteInlineConfig.build = viteInlineConfig.build || {}; + viteInlineConfig.build.sourcemap = true; + } + }); +} diff --git a/packages/nuxt/test/client/sdk.test.ts b/packages/nuxt/test/client/sdk.test.ts index 23766e9da0e3..83182bfc1c19 100644 --- a/packages/nuxt/test/client/sdk.test.ts +++ b/packages/nuxt/test/client/sdk.test.ts @@ -1,9 +1,9 @@ import * as SentryBrowser from '@sentry/browser'; -import { SDK_VERSION } from '@sentry/vue'; +import { type BrowserClient, SDK_VERSION, getClient } from '@sentry/vue'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { init } from '../../src/client'; -const vueInit = vi.spyOn(SentryBrowser, 'init'); +const browserInit = vi.spyOn(SentryBrowser, 'init'); describe('Nuxt Client SDK', () => { describe('init', () => { @@ -12,7 +12,7 @@ describe('Nuxt Client SDK', () => { }); it('Adds Nuxt metadata to the SDK options', () => { - expect(vueInit).not.toHaveBeenCalled(); + expect(browserInit).not.toHaveBeenCalled(); init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', @@ -31,8 +31,25 @@ describe('Nuxt Client SDK', () => { }, }; - expect(vueInit).toHaveBeenCalledTimes(1); - expect(vueInit).toHaveBeenLastCalledWith(expect.objectContaining(expectedMetadata)); + expect(browserInit).toHaveBeenCalledTimes(1); + expect(browserInit).toHaveBeenLastCalledWith(expect.objectContaining(expectedMetadata)); + }); + + describe('Automatically adds BrowserTracing integration', () => { + it.each([ + ['tracesSampleRate', { tracesSampleRate: 0 }], + ['tracesSampler', { tracesSampler: () => 1.0 }], + ['enableTracing', { enableTracing: true }], + ['no tracing option set', {}] /* enable "tracing without performance" by default */, + ])('adds a browserTracingIntegration if tracing is enabled via %s', (_, tracingOptions) => { + init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + ...tracingOptions, + }); + + const browserTracing = getClient()?.getIntegrationByName('BrowserTracing'); + expect(browserTracing).toBeDefined(); + }); }); it('returns client from init', () => { diff --git a/packages/opentelemetry/src/index.ts b/packages/opentelemetry/src/index.ts index e262247bce1e..ef57ab0fff3d 100644 --- a/packages/opentelemetry/src/index.ts +++ b/packages/opentelemetry/src/index.ts @@ -22,6 +22,7 @@ export { getDynamicSamplingContextFromSpan } from '@sentry/core'; export { isSentryRequestSpan } from './utils/isSentryRequest'; export { enhanceDscWithOpenTelemetryRootSpanName } from './utils/enhanceDscWithOpenTelemetryRootSpanName'; +export { generateSpanContextForPropagationContext } from './utils/generateSpanContextForPropagationContext'; export { getActiveSpan } from './utils/getActiveSpan'; export { startSpan, startSpanManual, startInactiveSpan, withActiveSpan, continueTrace } from './trace'; @@ -34,7 +35,7 @@ export { setupEventContextTrace } from './setupEventContextTrace'; export { setOpenTelemetryContextAsyncContextStrategy } from './asyncContextStrategy'; export { wrapContextManagerClass } from './contextManager'; -export { SentryPropagator } from './propagator'; +export { SentryPropagator, getPropagationContextFromSpan } from './propagator'; export { SentrySpanProcessor } from './spanProcessor'; export { SentrySampler, diff --git a/packages/opentelemetry/src/propagator.ts b/packages/opentelemetry/src/propagator.ts index 2fa125e86b86..40b8a8139b0d 100644 --- a/packages/opentelemetry/src/propagator.ts +++ b/packages/opentelemetry/src/propagator.ts @@ -1,8 +1,8 @@ -import type { Baggage, Context, Span, SpanContext, TextMapGetter, TextMapSetter } from '@opentelemetry/api'; +import type { Baggage, Context, Span, TextMapGetter, TextMapSetter } from '@opentelemetry/api'; import { INVALID_TRACEID } from '@opentelemetry/api'; import { context } from '@opentelemetry/api'; -import { TraceFlags, propagation, trace } from '@opentelemetry/api'; -import { TraceState, W3CBaggagePropagator, isTracingSuppressed } from '@opentelemetry/core'; +import { propagation, trace } from '@opentelemetry/api'; +import { W3CBaggagePropagator, isTracingSuppressed } from '@opentelemetry/core'; import { SEMATTRS_HTTP_URL } from '@opentelemetry/semantic-conventions'; import type { continueTrace } from '@sentry/core'; import { hasTracingEnabled } from '@sentry/core'; @@ -20,7 +20,6 @@ import { LRUMap, SENTRY_BAGGAGE_KEY_PREFIX, baggageHeaderToDynamicSamplingContext, - dynamicSamplingContextToSentryBaggageHeader, generateSentryTraceHeader, logger, parseBaggageHeader, @@ -33,11 +32,11 @@ import { SENTRY_TRACE_HEADER, SENTRY_TRACE_STATE_DSC, SENTRY_TRACE_STATE_PARENT_SPAN_ID, - SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, SENTRY_TRACE_STATE_URL, } from './constants'; import { DEBUG_BUILD } from './debug-build'; import { getScopesFromContext, setScopesOnContext } from './utils/contextData'; +import { generateSpanContextForPropagationContext } from './utils/generateSpanContextForPropagationContext'; import { getSamplingDecision } from './utils/getSamplingDecision'; import { setIsSetup } from './utils/setupCheck'; @@ -193,32 +192,6 @@ export class SentryPropagator extends W3CBaggagePropagator { } } -/** Exported for tests. */ -export function makeTraceState({ - parentSpanId, - dsc, - sampled, -}: { - parentSpanId?: string; - dsc?: Partial; - sampled?: boolean; -}): TraceState { - // We store the DSC as OTEL trace state on the span context - const dscString = dsc ? dynamicSamplingContextToSentryBaggageHeader(dsc) : undefined; - - // We _always_ set the parent span ID, even if it is empty - // If we'd set this to 'undefined' we could not know if the trace state was set, but there was no parentSpanId, - // vs the trace state was not set at all (in which case we want to do fallback handling) - // If `''`, it should be considered "no parent" - const traceStateBase = new TraceState().set(SENTRY_TRACE_STATE_PARENT_SPAN_ID, parentSpanId || ''); - - const traceStateWithDsc = dscString ? traceStateBase.set(SENTRY_TRACE_STATE_DSC, dscString) : traceStateBase; - - // We also specifically want to store if this is sampled to be not recording, - // or unsampled (=could be either sampled or not) - return sampled === false ? traceStateWithDsc.set(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, '1') : traceStateWithDsc; -} - function getInjectionData(context: Context): { dynamicSamplingContext: Partial | undefined; traceId: string | undefined; @@ -281,21 +254,7 @@ function getContextWithRemoteActiveSpan( ): Context { const propagationContext = propagationContextFromHeaders(sentryTrace, baggage); - // We store the DSC as OTEL trace state on the span context - const traceState = makeTraceState({ - parentSpanId: propagationContext.parentSpanId, - dsc: propagationContext.dsc, - sampled: propagationContext.sampled, - }); - - const spanContext: SpanContext = { - traceId: propagationContext.traceId, - spanId: propagationContext.parentSpanId || '', - isRemote: true, - traceFlags: propagationContext.sampled ? TraceFlags.SAMPLED : TraceFlags.NONE, - traceState, - }; - + const spanContext = generateSpanContextForPropagationContext(propagationContext); return trace.setSpanContext(ctx, spanContext); } diff --git a/packages/opentelemetry/src/trace.ts b/packages/opentelemetry/src/trace.ts index 356ba9a2688e..6ba41eec51e2 100644 --- a/packages/opentelemetry/src/trace.ts +++ b/packages/opentelemetry/src/trace.ts @@ -13,11 +13,12 @@ import { spanToJSON, } from '@sentry/core'; import type { Client, Scope, Span as SentrySpan } from '@sentry/types'; -import { continueTraceAsRemoteSpan, makeTraceState } from './propagator'; +import { continueTraceAsRemoteSpan } from './propagator'; import type { OpenTelemetryClient, OpenTelemetrySpanContext } from './types'; import { getContextFromScope, getScopesFromContext } from './utils/contextData'; import { getSamplingDecision } from './utils/getSamplingDecision'; +import { makeTraceState } from './utils/makeTraceState'; /** * Wraps a function with a transaction/span and finishes the span after the function is done. diff --git a/packages/opentelemetry/src/utils/generateSpanContextForPropagationContext.ts b/packages/opentelemetry/src/utils/generateSpanContextForPropagationContext.ts new file mode 100644 index 000000000000..d2aa470213f7 --- /dev/null +++ b/packages/opentelemetry/src/utils/generateSpanContextForPropagationContext.ts @@ -0,0 +1,27 @@ +import type { SpanContext } from '@opentelemetry/api'; +import { TraceFlags } from '@opentelemetry/api'; +import type { PropagationContext } from '@sentry/types'; +import { makeTraceState } from './makeTraceState'; + +/** + * Generates a SpanContext that represents a PropagationContext. + * This can be set on a `context` to make this a (virtual) active span. + */ +export function generateSpanContextForPropagationContext(propagationContext: PropagationContext): SpanContext { + // We store the DSC as OTEL trace state on the span context + const traceState = makeTraceState({ + parentSpanId: propagationContext.parentSpanId, + dsc: propagationContext.dsc, + sampled: propagationContext.sampled, + }); + + const spanContext: SpanContext = { + traceId: propagationContext.traceId, + spanId: propagationContext.parentSpanId || '', + isRemote: true, + traceFlags: propagationContext.sampled ? TraceFlags.SAMPLED : TraceFlags.NONE, + traceState, + }; + + return spanContext; +} diff --git a/packages/opentelemetry/src/utils/makeTraceState.ts b/packages/opentelemetry/src/utils/makeTraceState.ts new file mode 100644 index 000000000000..1b4fb4971efc --- /dev/null +++ b/packages/opentelemetry/src/utils/makeTraceState.ts @@ -0,0 +1,36 @@ +import { TraceState } from '@opentelemetry/core'; +import type { DynamicSamplingContext } from '@sentry/types'; +import { dynamicSamplingContextToSentryBaggageHeader } from '@sentry/utils'; +import { + SENTRY_TRACE_STATE_DSC, + SENTRY_TRACE_STATE_PARENT_SPAN_ID, + SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, +} from '../constants'; + +/** + * Generate a TraceState for the given data. + */ +export function makeTraceState({ + parentSpanId, + dsc, + sampled, +}: { + parentSpanId?: string; + dsc?: Partial; + sampled?: boolean; +}): TraceState { + // We store the DSC as OTEL trace state on the span context + const dscString = dsc ? dynamicSamplingContextToSentryBaggageHeader(dsc) : undefined; + + // We _always_ set the parent span ID, even if it is empty + // If we'd set this to 'undefined' we could not know if the trace state was set, but there was no parentSpanId, + // vs the trace state was not set at all (in which case we want to do fallback handling) + // If `''`, it should be considered "no parent" + const traceStateBase = new TraceState().set(SENTRY_TRACE_STATE_PARENT_SPAN_ID, parentSpanId || ''); + + const traceStateWithDsc = dscString ? traceStateBase.set(SENTRY_TRACE_STATE_DSC, dscString) : traceStateBase; + + // We also specifically want to store if this is sampled to be not recording, + // or unsampled (=could be either sampled or not) + return sampled === false ? traceStateWithDsc.set(SENTRY_TRACE_STATE_SAMPLED_NOT_RECORDING, '1') : traceStateWithDsc; +} diff --git a/packages/opentelemetry/test/integration/transactions.test.ts b/packages/opentelemetry/test/integration/transactions.test.ts index fe03fc8a1030..b8e7713cb4ca 100644 --- a/packages/opentelemetry/test/integration/transactions.test.ts +++ b/packages/opentelemetry/test/integration/transactions.test.ts @@ -16,9 +16,9 @@ import { logger } from '@sentry/utils'; import { TraceState } from '@opentelemetry/core'; import { SENTRY_TRACE_STATE_DSC } from '../../src/constants'; -import { makeTraceState } from '../../src/propagator'; import { SentrySpanProcessor } from '../../src/spanProcessor'; import { startInactiveSpan, startSpan } from '../../src/trace'; +import { makeTraceState } from '../../src/utils/makeTraceState'; import type { TestClientInterface } from '../helpers/TestClient'; import { cleanupOtel, getProvider, mockSdkInit } from '../helpers/mockSdkInit'; diff --git a/packages/opentelemetry/test/propagator.test.ts b/packages/opentelemetry/test/propagator.test.ts index d3ee43f4d199..16848352239a 100644 --- a/packages/opentelemetry/test/propagator.test.ts +++ b/packages/opentelemetry/test/propagator.test.ts @@ -11,9 +11,10 @@ import { suppressTracing } from '@opentelemetry/core'; import { getCurrentScope, withScope } from '@sentry/core'; import { SENTRY_BAGGAGE_HEADER, SENTRY_SCOPES_CONTEXT_KEY, SENTRY_TRACE_HEADER } from '../src/constants'; -import { SentryPropagator, makeTraceState } from '../src/propagator'; +import { SentryPropagator } from '../src/propagator'; import { getScopesFromContext } from '../src/utils/contextData'; import { getSamplingDecision } from '../src/utils/getSamplingDecision'; +import { makeTraceState } from '../src/utils/makeTraceState'; import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; describe('SentryPropagator', () => { diff --git a/packages/opentelemetry/test/trace.test.ts b/packages/opentelemetry/test/trace.test.ts index 5d9329650969..979d47acb437 100644 --- a/packages/opentelemetry/test/trace.test.ts +++ b/packages/opentelemetry/test/trace.test.ts @@ -20,7 +20,6 @@ import { withScope, } from '@sentry/core'; import type { Event, Scope } from '@sentry/types'; -import { makeTraceState } from '../src/propagator'; import { SEMATTRS_HTTP_METHOD } from '@opentelemetry/semantic-conventions'; import { continueTrace, startInactiveSpan, startSpan, startSpanManual } from '../src/trace'; @@ -28,6 +27,7 @@ import type { AbstractSpan } from '../src/types'; import { getActiveSpan } from '../src/utils/getActiveSpan'; import { getSamplingDecision } from '../src/utils/getSamplingDecision'; import { getSpanKind } from '../src/utils/getSpanKind'; +import { makeTraceState } from '../src/utils/makeTraceState'; import { spanHasAttributes, spanHasName } from '../src/utils/spanTypes'; import { cleanupOtel, mockSdkInit } from './helpers/mockSdkInit'; diff --git a/packages/replay-internal/src/integration.ts b/packages/replay-internal/src/integration.ts index 2b1ef71287ae..8f70e3099a97 100644 --- a/packages/replay-internal/src/integration.ts +++ b/packages/replay-internal/src/integration.ts @@ -226,7 +226,7 @@ export class Replay implements Integration { /** * Start a replay regardless of sampling rate. Calling this will always - * create a new session. Will throw an error if replay is already in progress. + * create a new session. Will log a message if replay is already in progress. * * Creates or loads a session, attaches listeners to varying events (DOM, * PerformanceObserver, Recording, Sentry SDK, etc) @@ -235,7 +235,6 @@ export class Replay implements Integration { if (!this._replay) { return; } - this._replay.start(); } @@ -265,13 +264,20 @@ export class Replay implements Integration { /** * If not in "session" recording mode, flush event buffer which will create a new replay. + * If replay is not enabled, a new session replay is started. * Unless `continueRecording` is false, the replay will continue to record and * behave as a "session"-based replay. * * Otherwise, queue up a flush. */ public flush(options?: SendBufferedReplayOptions): Promise { - if (!this._replay || !this._replay.isEnabled()) { + if (!this._replay) { + return Promise.resolve(); + } + + // assuming a session should be recorded in this case + if (!this._replay.isEnabled()) { + this._replay.start(); return Promise.resolve(); } diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index 1d3b1ef340c4..a0ef13276e1a 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -288,18 +288,20 @@ export class ReplayContainer implements ReplayContainerInterface { /** * Start a replay regardless of sampling rate. Calling this will always - * create a new session. Will throw an error if replay is already in progress. + * create a new session. Will log a message if replay is already in progress. * * Creates or loads a session, attaches listeners to varying events (DOM, * _performanceObserver, Recording, Sentry SDK, etc) */ public start(): void { if (this._isEnabled && this.recordingMode === 'session') { - throw new Error('Replay recording is already in progress'); + DEBUG_BUILD && logger.info('[Replay] Recording is already in progress'); + return; } if (this._isEnabled && this.recordingMode === 'buffer') { - throw new Error('Replay buffering is in progress, call `flush()` to save the replay'); + DEBUG_BUILD && logger.info('[Replay] Buffering is in progress, call `flush()` to save the replay'); + return; } logInfoNextTick('[Replay] Starting replay in session mode', this._options._experiments.traceInternals); @@ -335,7 +337,8 @@ export class ReplayContainer implements ReplayContainerInterface { */ public startBuffering(): void { if (this._isEnabled) { - throw new Error('Replay recording is already in progress'); + DEBUG_BUILD && logger.info('[Replay] Buffering is in progress, call `flush()` to save the replay'); + return; } logInfoNextTick('[Replay] Starting replay in buffer mode', this._options._experiments.traceInternals); diff --git a/packages/replay-internal/test/integration/flush.test.ts b/packages/replay-internal/test/integration/flush.test.ts index 31fd8a91a5e2..03dc3563e292 100644 --- a/packages/replay-internal/test/integration/flush.test.ts +++ b/packages/replay-internal/test/integration/flush.test.ts @@ -9,6 +9,7 @@ import * as SentryBrowserUtils from '@sentry-internal/browser-utils'; import * as SentryUtils from '@sentry/utils'; import { DEFAULT_FLUSH_MIN_DELAY, MAX_REPLAY_DURATION, WINDOW } from '../../src/constants'; +import type { Replay } from '../../src/integration'; import type { ReplayContainer } from '../../src/replay'; import { clearSession } from '../../src/session/clearSession'; import type { EventBuffer } from '../../src/types'; @@ -33,6 +34,7 @@ describe('Integration | flush', () => { const { record: mockRecord } = mockRrweb(); + let integration: Replay; let replay: ReplayContainer; let mockSendReplay: MockSendReplay; let mockFlush: MockFlush; @@ -45,7 +47,7 @@ describe('Integration | flush', () => { domHandler = handler; }); - ({ replay } = await mockSdk()); + ({ replay, integration } = await mockSdk()); mockSendReplay = vi.spyOn(SendReplay, 'sendReplay'); mockSendReplay.mockImplementation( @@ -484,4 +486,14 @@ describe('Integration | flush', () => { // Start again for following tests await replay.start(); }); + + /** + * Assuming the user wants to record a session + * when calling flush() without replay being enabled + */ + it('starts recording a session when replay is not enabled', () => { + integration.stop(); + integration.flush(); + expect(replay.isEnabled()).toBe(true); + }); }); diff --git a/packages/replay-internal/test/integration/start.test.ts b/packages/replay-internal/test/integration/start.test.ts index dff5df38b53d..063dc5babc7a 100644 --- a/packages/replay-internal/test/integration/start.test.ts +++ b/packages/replay-internal/test/integration/start.test.ts @@ -49,4 +49,24 @@ describe('Integration | start', () => { recordingPayloadHeader: { segment_id: 0 }, }); }); + + it('does not start recording once replay is already in progress', async () => { + const startRecordingSpy = vi.spyOn(replay, 'startRecording').mockImplementation(() => undefined); + + integration.start(); + replay.start(); + replay.start(); + + expect(startRecordingSpy).toHaveBeenCalledTimes(1); + }); + + it('does not start buffering once replay is already in progress', async () => { + const startRecordingSpy = vi.spyOn(replay, 'startRecording').mockImplementation(() => undefined); + + integration.startBuffering(); + replay.startBuffering(); + replay.startBuffering(); + + expect(startRecordingSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/solid/test/errorboundary.test.tsx b/packages/solid/test/errorboundary.test.tsx index ff907fc37af0..1ccfe3acccff 100644 --- a/packages/solid/test/errorboundary.test.tsx +++ b/packages/solid/test/errorboundary.test.tsx @@ -1,9 +1,10 @@ /* eslint-disable @typescript-eslint/unbound-method */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + import type * as SentryBrowser from '@sentry/browser'; import { createTransport, getCurrentScope, setCurrentClient } from '@sentry/core'; import { render } from '@solidjs/testing-library'; import userEvent from '@testing-library/user-event'; -import { vi } from 'vitest'; import { ErrorBoundary } from 'solid-js'; import { BrowserClient, withSentryErrorBoundary } from '../src'; diff --git a/packages/solid/test/sdk.test.ts b/packages/solid/test/sdk.test.ts index c912eda2809a..7177dd8c2a64 100644 --- a/packages/solid/test/sdk.test.ts +++ b/packages/solid/test/sdk.test.ts @@ -1,7 +1,8 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + import { SDK_VERSION } from '@sentry/browser'; import * as SentryBrowser from '@sentry/browser'; -import { vi } from 'vitest'; import { init as solidInit } from '../src/sdk'; const browserInit = vi.spyOn(SentryBrowser, 'init'); diff --git a/packages/solid/test/solidrouter.test.tsx b/packages/solid/test/solidrouter.test.tsx index 44268e6716ab..33267e1c849f 100644 --- a/packages/solid/test/solidrouter.test.tsx +++ b/packages/solid/test/solidrouter.test.tsx @@ -1,3 +1,5 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + import { spanToJSON } from '@sentry/browser'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -10,7 +12,6 @@ import { import type { MemoryHistory } from '@solidjs/router'; import { MemoryRouter, Navigate, Route, createMemoryHistory } from '@solidjs/router'; import { render } from '@solidjs/testing-library'; -import { vi } from 'vitest'; import { BrowserClient } from '../src'; import { solidRouterBrowserTracingIntegration, withSentryRouterRouting } from '../src/solidrouter'; diff --git a/packages/solid/tsconfig.test.json b/packages/solid/tsconfig.test.json index adecd5079938..da4c3e127129 100644 --- a/packages/solid/tsconfig.test.json +++ b/packages/solid/tsconfig.test.json @@ -5,7 +5,7 @@ "compilerOptions": { // should include all types from `./tsconfig.json` plus types for all test frameworks used - "types": ["vitest/globals", "vite/client", "@testing-library/jest-dom"], + "types": ["vite/client", "@testing-library/jest-dom"], // other package-specific, test-specific options "jsx": "preserve", diff --git a/packages/solid/vite.config.ts b/packages/solid/vite.config.ts index 416c98e877b3..c904f27887fe 100644 --- a/packages/solid/vite.config.ts +++ b/packages/solid/vite.config.ts @@ -6,6 +6,5 @@ export default { plugins: [solidPlugin({ hot: !process.env.VITEST })], test: { ...baseConfig.test, - environment: 'jsdom', }, }; diff --git a/packages/sveltekit/src/vite/sourceMaps.ts b/packages/sveltekit/src/vite/sourceMaps.ts index 4eda112eed31..7081e09e1c5d 100644 --- a/packages/sveltekit/src/vite/sourceMaps.ts +++ b/packages/sveltekit/src/vite/sourceMaps.ts @@ -108,8 +108,8 @@ export async function makeCustomSentryVitePlugins(options?: CustomSentryVitePlug // Modify the config to generate source maps config: config => { - const sourceMapsPreviouslyEnabled = !config.build?.sourcemap; - if (debug && sourceMapsPreviouslyEnabled) { + const sourceMapsPreviouslyNotEnabled = !config.build?.sourcemap; + if (debug && sourceMapsPreviouslyNotEnabled) { // eslint-disable-next-line no-console console.log('[Source Maps Plugin] Enabeling source map generation'); if (!mergedOptions.sourcemaps?.filesToDeleteAfterUpload) { diff --git a/packages/types/src/feedback/config.ts b/packages/types/src/feedback/config.ts index 2350545941be..977bf6ef7640 100644 --- a/packages/types/src/feedback/config.ts +++ b/packages/types/src/feedback/config.ts @@ -92,6 +92,11 @@ export interface FeedbackTextConfiguration { */ triggerLabel: string; + /** + * The aria label for the Feedback widget button that opens the dialog + */ + triggerAriaLabel: string; + /** * The label for the Feedback form cancel button that closes dialog */ diff --git a/packages/types/src/options.ts b/packages/types/src/options.ts index d6c407d60bd0..82123c01a380 100644 --- a/packages/types/src/options.ts +++ b/packages/types/src/options.ts @@ -31,8 +31,7 @@ export interface ClientOptions