From 231fc006d3d2aeda38570ab606b1443197242751 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Fri, 19 Jul 2024 10:28:23 +0200 Subject: [PATCH 01/17] feat(express): Allow to pass options to `setupExpressErrorHandler` (#12952) Allows to pass this through to the underlying `expressErrorHandler`. See https://github.com/getsentry/sentry-javascript/discussions/12715#discussioncomment-9965427 --- .../setupExpressErrorHandler/server.js | 33 +++++++++++++++++++ .../express/setupExpressErrorHandler/test.ts | 30 +++++++++++++++++ .../express/tracing/withError/server.js | 2 +- .../node/src/integrations/tracing/express.ts | 19 +++++++---- 4 files changed, 76 insertions(+), 8 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/express/setupExpressErrorHandler/server.js create mode 100644 dev-packages/node-integration-tests/suites/express/setupExpressErrorHandler/test.ts 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/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'); } From a49b2b9754b4576dbe40cbd9bed508029bb00263 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Fri, 19 Jul 2024 11:22:59 +0200 Subject: [PATCH 02/17] chore(repo): Add internal blank issue template (#12986) With the new issues UI (beta), our previous way of skipping issue templates for internally created issues doesn't work anymore. So we add a new template that essentially mimics a blank issue. --- .github/ISSUE_TEMPLATE/internal.yml | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/internal.yml 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 From 81e20c3ac05a5974079901416a967021d3324b88 Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Mon, 22 Jul 2024 13:31:11 +0200 Subject: [PATCH 03/17] feat(node): Allow to pass `registerEsmLoaderHooks` to preload (#12998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As mentioned in here: https://github.com/getsentry/sentry-javascript/issues/12912#issuecomment-2241308316 there is no way today to exclude/include esm modules when preloading today. This PR adds the option to pass `registerEsmLoaderHooks` as option to `preloadOpenTelemetry`, which allows to exclude/include packages there as well. Users can then write their own custom `preload` script and configure this there, if wanted. ## Naming I chose to use the same option naming here than for `init`, although the semantics are a bit different - here we can't actually disable the wrapping (because that's the only reason to even call this). We can also use a different name if we want, but I thought this would maybe be easier to understand that this is the same thing 🤔 --- packages/node/src/sdk/initOtel.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 849d1cfcf13b28105819630afd7eaa53a534b428 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Mon, 22 Jul 2024 15:52:08 +0200 Subject: [PATCH 04/17] feat(node): Send client reports (#12951) --- .../drop-reasons/before-send/scenario.ts | 23 +++++++ .../drop-reasons/before-send/test.ts | 32 ++++++++++ .../drop-reasons/event-processors/scenario.ts | 24 +++++++ .../drop-reasons/event-processors/test.ts | 32 ++++++++++ .../client-reports/periodic-send/scenario.ts | 13 ++++ .../client-reports/periodic-send/test.ts | 21 ++++++ .../node-integration-tests/utils/runner.ts | 21 ++++++ packages/browser/src/client.ts | 28 +------- packages/core/src/baseclient.ts | 30 +++++++++ packages/node/src/sdk/client.ts | 64 ++++++++++++++++++- packages/node/src/sdk/index.ts | 3 + packages/node/src/types.ts | 5 ++ packages/node/test/helpers/mockSdkInit.ts | 9 ++- packages/types/src/options.ts | 3 +- 14 files changed, 275 insertions(+), 33 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/client-reports/drop-reasons/before-send/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/client-reports/drop-reasons/before-send/test.ts create mode 100644 dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processors/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/client-reports/drop-reasons/event-processors/test.ts create mode 100644 dev-packages/node-integration-tests/suites/client-reports/periodic-send/scenario.ts create mode 100644 dev-packages/node-integration-tests/suites/client-reports/periodic-send/test.ts 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/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/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/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/types.ts b/packages/node/src/types.ts index 9cf3047e6c0a..dbdbdd1b1178 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -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/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 Date: Mon, 22 Jul 2024 16:55:18 +0200 Subject: [PATCH 05/17] test(nuxt): Add Nuxt 3 E2E test for client-side (#13002) Adding E2E test app with `@nuxt/test-utils` based on the [Nuxt docs](https://nuxt.com/docs/getting-started/testing#testing-in-a-browser). --- .github/workflows/build.yml | 1 + .../test-applications/nuxt-3/.gitignore | 24 ++++++++++++++ .../e2e-tests/test-applications/nuxt-3/.npmrc | 2 ++ .../test-applications/nuxt-3/app.vue | 13 ++++++++ .../nuxt-3/components/ErrorButton.vue | 9 ++++++ .../test-applications/nuxt-3/nuxt.config.ts | 4 +++ .../test-applications/nuxt-3/package.json | 24 ++++++++++++++ .../nuxt-3/pages/client-error.vue | 10 ++++++ .../test-applications/nuxt-3/pages/index.vue | 3 ++ .../nuxt-3/playwright.config.ts | 16 ++++++++++ .../nuxt-3/public/favicon.ico | Bin 0 -> 4286 bytes .../nuxt-3/sentry.client.config.ts | 9 ++++++ .../nuxt-3/start-event-proxy.mjs | 6 ++++ .../nuxt-3/tests/errors.client.test.ts | 30 ++++++++++++++++++ .../test-applications/nuxt-3/tsconfig.json | 4 +++ 15 files changed, 155 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/app.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/components/ErrorButton.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/nuxt.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/pages/client-error.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/pages/index.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/public/favicon.ico create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/sentry.client.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.client.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-3/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3f8cd2ac44ac..5ee7211b5a71 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1016,6 +1016,7 @@ jobs: 'node-exports-test-app', 'node-koa', 'node-connect', + 'nuxt-3', 'vue-3', 'webpack-4', 'webpack-5' 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 0000000000000000000000000000000000000000..18993ad91cfd43e03b074dd0b5cc3f37ab38e49c GIT binary patch literal 4286 zcmeHLOKuuL5PjK%MHWVi6lD zOGiREbCw`xmFozJ^aNatJY>w+g ze6a2@u~m#^BZm@8wco9#Crlli0uLb^3E$t2-WIc^#(?t)*@`UpuofJ(Uyh@F>b3Ph z$D^m8Xq~pTkGJ4Q`Q2)te3mgkWYZ^Ijq|hkiP^9`De={bQQ%heZC$QU2UpP(-tbl8 zPWD2abEew;oat@w`uP3J^YpsgT%~jT(Dk%oU}sa$7|n6hBjDj`+I;RX(>)%lm_7N{+B7Mu%H?422lE%MBJH!!YTN2oT7xr>>N-8OF$C&qU^ z>vLsa{$0X%q1fjOe3P1mCv#lN{xQ4_*HCSAZjTb1`}mlc+9rl8$B3OP%VT@mch_~G z7Y+4b{r>9e=M+7vSI;BgB?ryZDY4m>&wcHSn81VH1N~`0gvwH{ z8dv#hG|OK`>1;j7tM#B)Z7zDN?{6=dUal}$e { + 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" +} From 5a709dfb649df226dc265445bfe7191c418621bb Mon Sep 17 00:00:00 2001 From: Francesco Novy Date: Tue, 23 Jul 2024 10:07:23 +0200 Subject: [PATCH 06/17] fix(node): Do not emit fetch spans when tracing is disabled (#13003) Ensure that TWP works with fetch, but we do not emit a span. Otherwise, if a user does not use our tracing functionality but adds their own fetch instrumentation, they get spans twice. The benefit we have with fetch is that fetch is instrumented via diagnostics channel, which means that this can be instrumented multiple times. So if we instrument it, on the plus side this does not interfere with a user instrumenting it again themselves if they want. However, with the previous implementation our integration would always emit spans. If a user is using our own sampler, this is "OK" because it will sample it out. But if a user does not use our sampler, the spans we emit there get sent unintentionally. With this change, we ensure to ignore requests if tracing is disabled. In order to make TWP work, we instead manually inject the propagator headers into the fetch request in this scenario. Part of https://github.com/getsentry/sentry-javascript/issues/12984 Fixes https://github.com/getsentry/sentry-javascript/issues/12969 --- .../node-otel-sdk-node/package.json | 2 +- .../node-otel-sdk-node/start-event-proxy.mjs | 2 +- .../node-otel-sdk-node/start-otel-proxy.mjs | 2 +- .../node-otel-sdk-node/tests/errors.test.ts | 2 +- .../tests/transactions.test.ts | 6 +- .../node-otel-without-tracing/.gitignore | 1 + .../node-otel-without-tracing/.npmrc | 2 + .../node-otel-without-tracing/package.json | 34 ++++ .../playwright.config.mjs | 34 ++++ .../node-otel-without-tracing/src/app.ts | 55 ++++++ .../src/instrument.ts | 41 +++++ .../start-event-proxy.mjs | 6 + .../start-otel-proxy.mjs | 6 + .../tests/errors.test.ts | 30 +++ .../tests/transactions.test.ts | 173 ++++++++++++++++++ .../node-otel-without-tracing/tsconfig.json | 10 + .../node-integration-tests/jest.setup.js | 6 + .../scenario.ts | 0 .../test.ts | 8 +- .../requests/fetch-sampled/scenario.ts | 25 --- .../tracing/requests/fetch-sampled/test.ts | 40 ---- .../scenario.ts | 0 .../test.ts | 8 +- .../http-sampled-no-active-span/test.ts | 6 +- .../tracing/requests/http-sampled/test.ts | 6 +- .../tracing/requests/http-unsampled/test.ts | 6 +- packages/node/src/integrations/node-fetch.ts | 71 ++++++- packages/opentelemetry/src/index.ts | 3 +- packages/opentelemetry/src/propagator.ts | 51 +----- packages/opentelemetry/src/trace.ts | 3 +- ...enerateSpanContextForPropagationContext.ts | 27 +++ .../opentelemetry/src/utils/makeTraceState.ts | 36 ++++ .../test/integration/transactions.test.ts | 2 +- .../opentelemetry/test/propagator.test.ts | 3 +- packages/opentelemetry/test/trace.test.ts | 2 +- 35 files changed, 554 insertions(+), 155 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-without-tracing/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-without-tracing/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-without-tracing/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-without-tracing/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-without-tracing/src/instrument.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-without-tracing/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-without-tracing/start-otel-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-otel-without-tracing/tsconfig.json rename dev-packages/node-integration-tests/suites/tracing/requests/{fetch-noSampleRate => fetch-no-tracing}/scenario.ts (100%) rename dev-packages/node-integration-tests/suites/tracing/requests/{fetch-noSampleRate => fetch-no-tracing}/test.ts (82%) delete mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled/scenario.ts delete mode 100644 dev-packages/node-integration-tests/suites/tracing/requests/fetch-sampled/test.ts rename dev-packages/node-integration-tests/suites/tracing/requests/{http-noSampleRate => http-no-tracing}/scenario.ts (100%) rename dev-packages/node-integration-tests/suites/tracing/requests/{http-noSampleRate => http-no-tracing}/test.ts (81%) create mode 100644 packages/opentelemetry/src/utils/generateSpanContextForPropagationContext.ts create mode 100644 packages/opentelemetry/src/utils/makeTraceState.ts 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/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/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/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/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'; From f867cc06fb38c1a0f523369210a17684bda223d2 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 23 Jul 2024 13:01:45 +0200 Subject: [PATCH 07/17] feat(nuxt): Automatically add BrowserTracing (#13005) Add `BrowserTracing` when `tracesSampleRate` is set. --- .../nuxt-3/sentry.client.config.ts | 1 - packages/nuxt/src/client/sdk.ts | 7 ++++- packages/nuxt/test/client/sdk.test.ts | 27 +++++++++++++++---- 3 files changed, 28 insertions(+), 7 deletions(-) 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 index 755a0f43e919..5253d08c90f0 100644 --- 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 @@ -5,5 +5,4 @@ Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, - integrations: [Sentry.browserTracingIntegration()], }); 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/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', () => { From b577079540dffe358697b94a458ccb3b19b5ecaf Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 23 Jul 2024 16:12:35 +0200 Subject: [PATCH 08/17] feat(nestjs): Change nest sdk setup (#12920) - Adds a new nest root module that can be used to setup the Nest SDK as a replacement for the existing setup (with a function). Instead of calling `setupNestErrorHandler` in the main.ts file, users can now add `SentryModule.forRoot()` (feedback about the name is definitely welcome) as an import in their main app module. This approach is much more native to nest than what we used so far. This root module is introduced in the setup.ts file. - This root module is exported with a submodule export `@sentry/nestjs/setup`, because the SDK now depends on nestjs directly and without this the nest instrumentation does not work anymore, since nest gets imported before Sentry.init gets called, which disables the otel nest instrumentation. - Judging from the e2e tests it seems that this new approach also resolves some issues the previous implementation had, specifically [this issue](https://github.com/getsentry/sentry-javascript/issues/12351) seems to be resolved. The e2e test that was in place, just documented the current (wrong) behavior. So I updated the test to reflect the new (correct) behavior. - I updated all the test applications to use the new approach but kept a copy of the nestjs-basic and nestjs-distributed-tracing with the old setup (now named node-nestjs-basic and node-nestjs-distributed-tracing respectively) so we can still verify that the old setup (which a lot of people use) still keeps working going forward. - Updated/New tests in this PR: - Sends unexpected exception to Sentry if thrown in Submodule - Does not send expected exception to Sentry if thrown in Submodule and caught by a global exception filter - Does not send expected exception to Sentry if thrown in Submodule and caught by a local exception filter - Sends expected exception to Sentry if thrown from submodule registered before Sentry - To accomodate the new tests I added several submodules in the nestjs-with-submodules test-application. These are overall similarly but have important distinctions: - example-module-local-filter: Submodule with a local filter registered using `@UseFilters` on the controller. - example-module-global-filter: Submodule with a global filter registered using APP_FILTER in the submodule definition. - example-module-global-filter-wrong-registration-order: Also has a global filter set with APP_FILTER, but is registered in the root module as first submodule, even before the SentryIntegration is initialized. This case does not work properly in the new setup (Sentry should be set first), so this module is used for tests documenting this behavior. - Also set "moduleResolution": "Node16" in the nestjs-basic sample app to ensure our submodule-export workaround works in both, default and sub-path-export-compatible TS configs as was suggested [here](https://github.com/getsentry/sentry-javascript/pull/12948#discussion_r1682499929). --- .github/workflows/build.yml | 2 + .../nestjs-basic/src/app.module.ts | 3 +- .../nestjs-basic/src/main.ts | 7 +- .../nestjs-basic/tsconfig.json | 3 +- .../nestjs-distributed-tracing/src/main.ts | 7 +- .../src/trace-initiator.module.ts | 3 +- .../nestjs-with-submodules/src/app.module.ts | 12 +- .../example.controller.ts | 17 + .../example.exception.ts | 5 + .../example.filter.ts | 13 + .../example.module.ts | 16 + .../example.controller.ts | 25 ++ .../example.exception.ts | 0 .../example.filter.ts | 0 .../example.module.ts | 2 +- .../example.controller.ts | 14 + .../example.exception.ts | 5 + .../example.filter.ts | 13 + .../example.module.ts | 9 + .../src/example-module/example.controller.ts | 12 - .../nestjs-with-submodules/src/main.ts | 7 +- .../tests/errors.test.ts | 127 ++++++- .../tests/transactions.test.ts | 123 ++++++ .../node-nestjs-basic/.gitignore | 56 +++ .../node-nestjs-basic/.npmrc | 2 + .../node-nestjs-basic/nest-cli.json | 8 + .../node-nestjs-basic/package.json | 48 +++ .../node-nestjs-basic/playwright.config.mjs | 7 + .../node-nestjs-basic/src/app.controller.ts | 37 ++ .../node-nestjs-basic/src/app.module.ts | 11 + .../node-nestjs-basic/src/app.service.ts | 67 ++++ .../node-nestjs-basic/src/instrument.ts | 8 + .../node-nestjs-basic/src/main.ts | 20 + .../node-nestjs-basic/start-event-proxy.mjs | 6 + .../tests/cron-decorator.test.ts | 34 ++ .../node-nestjs-basic/tests/errors.test.ts | 55 +++ .../tests/span-decorator.test.ts | 72 ++++ .../tests/transactions.test.ts | 123 ++++++ .../node-nestjs-basic/tsconfig.build.json | 4 + .../node-nestjs-basic/tsconfig.json | 21 ++ .../.gitignore | 56 +++ .../node-nestjs-distributed-tracing/.npmrc | 2 + .../nest-cli.json | 8 + .../package.json | 47 +++ .../playwright.config.mjs | 7 + .../src/instrument.ts | 9 + .../src/main.ts | 23 ++ .../src/trace-initiator.controller.ts | 42 +++ .../src/trace-initiator.module.ts | 10 + .../src/trace-initiator.service.ts | 47 +++ .../src/trace-receiver.controller.ts | 17 + .../src/trace-receiver.module.ts | 10 + .../src/trace-receiver.service.ts | 18 + .../src/utils.ts | 26 ++ .../start-event-proxy.mjs | 6 + .../tests/propagation.test.ts | 356 ++++++++++++++++++ .../tsconfig.build.json | 4 + .../tsconfig.json | 21 ++ packages/nestjs/.eslintignore | 2 + packages/nestjs/.gitignore | 2 + packages/nestjs/README.md | 46 ++- packages/nestjs/package.json | 39 +- packages/nestjs/rollup.npm.config.mjs | 6 +- packages/nestjs/src/setup.ts | 159 ++++++++ packages/nestjs/tsconfig.setup-types.json | 14 + packages/nestjs/tsconfig.types.json | 5 +- yarn.lock | 26 ++ 67 files changed, 1953 insertions(+), 59 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter-wrong-registration-order/example.controller.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter-wrong-registration-order/example.exception.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter-wrong-registration-order/example.filter.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter-wrong-registration-order/example.module.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-global-filter/example.controller.ts rename dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/{example-module => example-module-global-filter}/example.exception.ts (100%) rename dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/{example-module => example-module-global-filter}/example.filter.ts (100%) rename dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/{example-module => example-module-global-filter}/example.module.ts (89%) create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-local-filter/example.controller.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-local-filter/example.exception.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-local-filter/example.filter.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module-local-filter/example.module.ts delete mode 100644 dev-packages/e2e-tests/test-applications/nestjs-with-submodules/src/example-module/example.controller.ts create mode 100644 dev-packages/e2e-tests/test-applications/nestjs-with-submodules/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/nest-cli.json create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.controller.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.module.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/app.service.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/instrument.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/src/main.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/cron-decorator.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/span-decorator.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/tsconfig.build.json create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-basic/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/.npmrc create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/nest-cli.json create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/instrument.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/main.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-initiator.controller.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-initiator.module.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-initiator.service.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-receiver.controller.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-receiver.module.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/trace-receiver.service.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/src/utils.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/tests/propagation.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/tsconfig.build.json create mode 100644 dev-packages/e2e-tests/test-applications/node-nestjs-distributed-tracing/tsconfig.json create mode 100644 packages/nestjs/.eslintignore create mode 100644 packages/nestjs/.gitignore create mode 100644 packages/nestjs/src/setup.ts create mode 100644 packages/nestjs/tsconfig.setup-types.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5ee7211b5a71..87d34d1731d4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1010,6 +1010,8 @@ jobs: 'generic-ts3.8', 'node-fastify', 'node-hapi', + 'node-nestjs-basic', + 'node-nestjs-distributed-tracing', 'nestjs-basic', 'nestjs-distributed-tracing', 'nestjs-with-submodules', 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/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/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/yarn.lock b/yarn.lock index ebb196a902b2..2a6b1991eb11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5973,6 +5973,15 @@ semver "^7.3.5" tar "^6.1.11" +"@nestjs/common@^10.3.10": + version "10.3.10" + resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.3.10.tgz#d8825d55a50a04e33080c9188e6a5b03235d19f2" + integrity sha512-H8k0jZtxk1IdtErGDmxFRy0PfcOAUg41Prrqpx76DQusGGJjsaovs1zjXVD1rZWaVYchfT1uczJ6L4Kio10VNg== + dependencies: + uid "2.0.2" + iterare "1.2.1" + tslib "2.6.3" + "@nestjs/common@^10.3.7": version "10.3.7" resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.3.7.tgz#38ab5ff92277cf1f26f4749c264524e76962cfff" @@ -5982,6 +5991,18 @@ iterare "1.2.1" tslib "2.6.2" +"@nestjs/core@^10.3.10": + version "10.3.10" + resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-10.3.10.tgz#508090c3ca36488a8e24a9e5939c2f37426e48f4" + integrity sha512-ZbQ4jovQyzHtCGCrzK5NdtW1SYO2fHSsgSY1+/9WdruYCUra+JDkWEXgZ4M3Hv480Dl3OXehAmY1wCOojeMyMQ== + dependencies: + uid "2.0.2" + "@nuxtjs/opencollective" "0.3.2" + fast-safe-stringify "2.1.1" + iterare "1.2.1" + path-to-regexp "3.2.0" + tslib "2.6.3" + "@nestjs/core@^10.3.3": version "10.3.3" resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-10.3.3.tgz#f957068ddda59252b7c36fcdb07a0fb323b52bcf" @@ -31897,6 +31918,11 @@ tslib@2.6.2, tslib@^2.6.2: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== +tslib@2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" + integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== + tslib@^1.11.1, tslib@^1.8.1, tslib@^1.9.0: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" From 1a5e3e350b43280b38663683bf0f6ef87042cf9e Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Tue, 23 Jul 2024 16:44:59 +0200 Subject: [PATCH 09/17] test(e2e): Remove experimental intrumentation flag for Next.js `onRequestError` hook (#13020) --- .../e2e-tests/test-applications/nextjs-15/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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" From edea28708342ede175a987aed0fea553be22b66c Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Tue, 23 Jul 2024 13:01:50 -0400 Subject: [PATCH 10/17] chore(google-cloud): Update private key used for tests (#13023) --- .../test/integrations/private.pem | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) 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----- From 65042b59fcb7c27e59cb638209c95317ad50ccbe Mon Sep 17 00:00:00 2001 From: Catherine Lee <55311782+c298lee@users.noreply.github.com> Date: Tue, 23 Jul 2024 15:09:15 -0400 Subject: [PATCH 11/17] feat(feedback): Trigger button aria label configuration (#13008) Adds `triggerAriaLabel` to configure the aria label of the trigger button. The aria label is set to the first value that's non-empty: 1. `triggerAriaLabel` 2. `triggerLabel` 3. TRIGGER_LABEL ("Report a Bug") Closes https://github.com/getsentry/sentry-javascript/issues/12505 --- .../src/core/components/Actor.test.ts | 61 +++++++++++++++++++ .../feedback/src/core/components/Actor.ts | 7 ++- packages/feedback/src/core/integration.ts | 8 ++- packages/types/src/feedback/config.ts | 5 ++ 4 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 packages/feedback/src/core/components/Actor.test.ts 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/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 */ From 04a26a4bdfc5b9bd7db965171383167d9a776e3a Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Wed, 24 Jul 2024 01:12:02 +0200 Subject: [PATCH 12/17] feat(node): Extend ESM hooks options for iitm v1.10.0 (#13016) `import-in-the-middle@1.10.0` supports regular expressions for `include` and `exclude`. Is there a risk here that users might not have the latest version of `import-in-the-middle` in their dependencies? --- packages/node/package.json | 3 ++- packages/node/src/types.ts | 4 ++-- yarn.lock | 8 ++++---- 3 files changed, 8 insertions(+), 7 deletions(-) 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/types.ts b/packages/node/src/types.ts index dbdbdd1b1178..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 { diff --git a/yarn.lock b/yarn.lock index 2a6b1991eb11..5c6f6c6c75a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20493,10 +20493,10 @@ import-in-the-middle@1.7.1: cjs-module-lexer "^1.2.2" module-details-from-path "^1.0.3" -import-in-the-middle@^1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-1.8.1.tgz#8b51c2cc631b64e53e958d7048d2d9463ce628f8" - integrity sha512-yhRwoHtiLGvmSozNOALgjRPFI6uYsds60EoMqqnXyyv+JOIW/BrrLejuTGBt+bq0T5tLzOHrN0T7xYTm4Qt/ng== +import-in-the-middle@^1.10.0, import-in-the-middle@^1.8.1: + version "1.10.0" + resolved "https://registry.yarnpkg.com/import-in-the-middle/-/import-in-the-middle-1.10.0.tgz#f15b0841950ded8d899b635058da5646256949b1" + integrity sha512-Z1jumVdF2GwnnYfM0a/y2ts7mZbwFMgt5rRuVmLgobgahC6iKgN5MBuXjzfTIOUpq5LSU10vJIPpVKe0X89fIw== dependencies: acorn "^8.8.2" acorn-import-attributes "^1.9.5" From b2dded81f0b288eb2abba241cd30b226677c1118 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Wed, 24 Jul 2024 02:55:11 -0400 Subject: [PATCH 13/17] test(solid): Switch to explicit vitest imports (#13028) As per https://vitest.dev/config/#globals > By default, vitest does not provide global APIs for explicitness I think we should follow vitest defaults here and explicitly import in the APIs that we need. This refactors our Solid SDK tests to do so. ref https://github.com/getsentry/sentry-javascript/issues/11084 This change also removes `environment: 'jsdom'` from the vite config as it seems nothing needs this for solid. This should means that our tests are not polluted with jsdom globals, and that future writers have to explicitly opt-in to the behaviour. --- packages/solid/test/errorboundary.test.tsx | 3 ++- packages/solid/test/sdk.test.ts | 3 ++- packages/solid/test/solidrouter.test.tsx | 3 ++- packages/solid/tsconfig.test.json | 2 +- packages/solid/vite.config.ts | 1 - 5 files changed, 7 insertions(+), 5 deletions(-) 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', }, }; From 4bdd9794460d9a0ab1f14202931ee4ef07c21f2d Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 24 Jul 2024 10:21:20 +0200 Subject: [PATCH 14/17] feat(replay): Improve public Replay APIs (#13000) --- packages/replay-internal/src/integration.ts | 12 ++++++++--- packages/replay-internal/src/replay.ts | 11 ++++++---- .../test/integration/flush.test.ts | 14 ++++++++++++- .../test/integration/start.test.ts | 20 +++++++++++++++++++ 4 files changed, 49 insertions(+), 8 deletions(-) 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); + }); }); From aaaedbc916ab02f1017ed86fab057bc3513052c9 Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 24 Jul 2024 11:50:09 +0200 Subject: [PATCH 15/17] docs(nuxt): Add readme docs for server-side setup (ESM) (#13019) Specify how Nuxt is set up on the server-side. Nuxt v3+ runs with ESM. --------- Co-authored-by: Andrei <168741329+andreiborza@users.noreply.github.com> --- packages/nuxt/README.md | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index c75fa334b8a5..2bc000b81a90 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,6 +126,18 @@ Add an import flag to the node options, so the file loads before any other impor } ``` +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" + } +} +``` + ### 5. Vite Setup todo: add vite setup From ea07ec747899e1575664e2816e8806c3e0c5930c Mon Sep 17 00:00:00 2001 From: Sigrid Huemer <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 24 Jul 2024 12:58:33 +0200 Subject: [PATCH 16/17] feat(nuxt): Setup source maps with vite config (#13018) Closes https://github.com/getsentry/sentry-javascript/issues/13017 --- packages/astro/src/integration/types.ts | 2 +- packages/nuxt/.eslintrc.js | 1 + packages/nuxt/README.md | 24 ++++-- packages/nuxt/src/common/types.ts | 96 +++++++++++++++++++++++ packages/nuxt/src/module.ts | 11 ++- packages/nuxt/src/vite/sourceMaps.ts | 52 ++++++++++++ packages/sveltekit/src/vite/sourceMaps.ts | 4 +- yarn.lock | 37 ++++----- 8 files changed, 193 insertions(+), 34 deletions(-) create mode 100644 packages/nuxt/src/vite/sourceMaps.ts 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/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 2bc000b81a90..a2f9d9d0d22e 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -138,12 +138,22 @@ dependency to your `package.json` } ``` -### 5. Vite Setup - -todo: add vite setup - ---- - ## 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/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/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/yarn.lock b/yarn.lock index 5c6f6c6c75a7..72d28fc8f600 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5973,15 +5973,6 @@ semver "^7.3.5" tar "^6.1.11" -"@nestjs/common@^10.3.10": - version "10.3.10" - resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.3.10.tgz#d8825d55a50a04e33080c9188e6a5b03235d19f2" - integrity sha512-H8k0jZtxk1IdtErGDmxFRy0PfcOAUg41Prrqpx76DQusGGJjsaovs1zjXVD1rZWaVYchfT1uczJ6L4Kio10VNg== - dependencies: - uid "2.0.2" - iterare "1.2.1" - tslib "2.6.3" - "@nestjs/common@^10.3.7": version "10.3.7" resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.3.7.tgz#38ab5ff92277cf1f26f4749c264524e76962cfff" @@ -5991,16 +5982,13 @@ iterare "1.2.1" tslib "2.6.2" -"@nestjs/core@^10.3.10": +"@nestjs/common@^8.0.0 || ^9.0.0 || ^10.0.0": version "10.3.10" - resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-10.3.10.tgz#508090c3ca36488a8e24a9e5939c2f37426e48f4" - integrity sha512-ZbQ4jovQyzHtCGCrzK5NdtW1SYO2fHSsgSY1+/9WdruYCUra+JDkWEXgZ4M3Hv480Dl3OXehAmY1wCOojeMyMQ== + resolved "https://registry.yarnpkg.com/@nestjs/common/-/common-10.3.10.tgz#d8825d55a50a04e33080c9188e6a5b03235d19f2" + integrity sha512-H8k0jZtxk1IdtErGDmxFRy0PfcOAUg41Prrqpx76DQusGGJjsaovs1zjXVD1rZWaVYchfT1uczJ6L4Kio10VNg== dependencies: uid "2.0.2" - "@nuxtjs/opencollective" "0.3.2" - fast-safe-stringify "2.1.1" iterare "1.2.1" - path-to-regexp "3.2.0" tslib "2.6.3" "@nestjs/core@^10.3.3": @@ -6015,6 +6003,18 @@ path-to-regexp "3.2.0" tslib "2.6.2" +"@nestjs/core@^8.0.0 || ^9.0.0 || ^10.0.0": + version "10.3.10" + resolved "https://registry.yarnpkg.com/@nestjs/core/-/core-10.3.10.tgz#508090c3ca36488a8e24a9e5939c2f37426e48f4" + integrity sha512-ZbQ4jovQyzHtCGCrzK5NdtW1SYO2fHSsgSY1+/9WdruYCUra+JDkWEXgZ4M3Hv480Dl3OXehAmY1wCOojeMyMQ== + dependencies: + uid "2.0.2" + "@nuxtjs/opencollective" "0.3.2" + fast-safe-stringify "2.1.1" + iterare "1.2.1" + path-to-regexp "3.2.0" + tslib "2.6.3" + "@nestjs/platform-express@^10.3.3": version "10.3.3" resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-10.3.3.tgz#c1484d30d1e7666c4c8d0d7cde31cfc0b9d166d7" @@ -31918,7 +31918,7 @@ tslib@2.6.2, tslib@^2.6.2: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== -tslib@2.6.3: +tslib@2.6.3, tslib@^2.2.0: version "2.6.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== @@ -31933,11 +31933,6 @@ tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3 resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.2.tgz#1b6f07185c881557b0ffa84b111a0106989e8338" integrity sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA== -tslib@^2.2.0: - version "2.6.3" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" - integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== - tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" From 84f6fee34a6b9882ca8781d5441e75a8ca9593ae Mon Sep 17 00:00:00 2001 From: s1gr1d Date: Wed, 24 Jul 2024 13:25:26 +0200 Subject: [PATCH 17/17] meta(changelog): Update changelog for 8.20.0 --- CHANGELOG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) 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)