From 7ad5054da7f01cb70eca1a996acad03928d4d066 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Mon, 5 Aug 2024 10:04:41 +0200 Subject: [PATCH] ref(node): Split up nest integration into multiple files (#13172) The file implementing the nest integration in node got a bit annoying to work with, so splitting it up. --- packages/node/src/index.ts | 2 +- .../node/src/integrations/tracing/index.ts | 2 +- .../src/integrations/tracing/nest/helpers.ts | 34 +++ .../src/integrations/tracing/nest/nest.ts | 123 ++++++++++ .../sentry-nest-instrumentation.ts} | 222 +----------------- .../src/integrations/tracing/nest/types.ts | 57 +++++ .../test/integrations/tracing/nest.test.ts | 4 +- 7 files changed, 223 insertions(+), 221 deletions(-) create mode 100644 packages/node/src/integrations/tracing/nest/helpers.ts create mode 100644 packages/node/src/integrations/tracing/nest/nest.ts rename packages/node/src/integrations/tracing/{nest.ts => nest/sentry-nest-instrumentation.ts} (51%) create mode 100644 packages/node/src/integrations/tracing/nest/types.ts diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 3aa519c055d1..badd1f1a27bf 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -18,7 +18,7 @@ export { mongooseIntegration } from './integrations/tracing/mongoose'; export { mysqlIntegration } from './integrations/tracing/mysql'; export { mysql2Integration } from './integrations/tracing/mysql2'; export { redisIntegration } from './integrations/tracing/redis'; -export { nestIntegration, setupNestErrorHandler } from './integrations/tracing/nest'; +export { nestIntegration, setupNestErrorHandler } from './integrations/tracing/nest/nest'; export { postgresIntegration } from './integrations/tracing/postgres'; export { prismaIntegration } from './integrations/tracing/prisma'; export { hapiIntegration, setupHapiErrorHandler } from './integrations/tracing/hapi'; diff --git a/packages/node/src/integrations/tracing/index.ts b/packages/node/src/integrations/tracing/index.ts index bee4f06db8f5..886c11683674 100644 --- a/packages/node/src/integrations/tracing/index.ts +++ b/packages/node/src/integrations/tracing/index.ts @@ -11,7 +11,7 @@ import { instrumentMongo, mongoIntegration } from './mongo'; import { instrumentMongoose, mongooseIntegration } from './mongoose'; import { instrumentMysql, mysqlIntegration } from './mysql'; import { instrumentMysql2, mysql2Integration } from './mysql2'; -import { instrumentNest, nestIntegration } from './nest'; +import { instrumentNest, nestIntegration } from './nest/nest'; import { instrumentPostgres, postgresIntegration } from './postgres'; import { instrumentRedis, redisIntegration } from './redis'; diff --git a/packages/node/src/integrations/tracing/nest/helpers.ts b/packages/node/src/integrations/tracing/nest/helpers.ts new file mode 100644 index 000000000000..32eb3a0d5a39 --- /dev/null +++ b/packages/node/src/integrations/tracing/nest/helpers.ts @@ -0,0 +1,34 @@ +import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/core'; +import { addNonEnumerableProperty } from '@sentry/utils'; +import type { InjectableTarget } from './types'; + +const sentryPatched = 'sentryPatched'; + +/** + * Helper checking if a concrete target class is already patched. + * + * We already guard duplicate patching with isWrapped. However, isWrapped checks whether a file has been patched, whereas we use this check for concrete target classes. + * This check might not be necessary, but better to play it safe. + */ +export function isPatched(target: InjectableTarget): boolean { + if (target.sentryPatched) { + return true; + } + + addNonEnumerableProperty(target, sentryPatched, true); + return false; +} + +/** + * Returns span options for nest middleware spans. + */ +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getMiddlewareSpanOptions(target: InjectableTarget) { + return { + name: target.name, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nestjs', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.middleware.nestjs', + }, + }; +} diff --git a/packages/node/src/integrations/tracing/nest/nest.ts b/packages/node/src/integrations/tracing/nest/nest.ts new file mode 100644 index 000000000000..4f7f7a1f59d3 --- /dev/null +++ b/packages/node/src/integrations/tracing/nest/nest.ts @@ -0,0 +1,123 @@ +import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + captureException, + defineIntegration, + getClient, + getDefaultIsolationScope, + getIsolationScope, + spanToJSON, +} from '@sentry/core'; +import type { IntegrationFn, Span } from '@sentry/types'; +import { logger } from '@sentry/utils'; +import { generateInstrumentOnce } from '../../../otel/instrument'; +import { SentryNestInstrumentation } from './sentry-nest-instrumentation'; +import type { MinimalNestJsApp, NestJsErrorFilter } from './types'; + +const INTEGRATION_NAME = 'Nest'; + +const instrumentNestCore = generateInstrumentOnce('Nest-Core', () => { + return new NestInstrumentation(); +}); + +const instrumentNestCommon = generateInstrumentOnce('Nest-Common', () => { + return new SentryNestInstrumentation(); +}); + +export const instrumentNest = Object.assign( + (): void => { + instrumentNestCore(); + instrumentNestCommon(); + }, + { id: INTEGRATION_NAME }, +); + +const _nestIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + instrumentNest(); + }, + }; +}) satisfies IntegrationFn; + +/** + * Nest framework integration + * + * Capture tracing data for nest. + */ +export const nestIntegration = defineIntegration(_nestIntegration); + +/** + * Setup an error handler for Nest. + */ +export function setupNestErrorHandler(app: MinimalNestJsApp, baseFilter: NestJsErrorFilter): 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); + }); + } + + app.useGlobalInterceptors({ + intercept(context, next) { + 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(); + if (req.route) { + getIsolationScope().setTransactionName(`${req.method?.toUpperCase() || 'GET'} ${req.route.path}`); + } + } + + return next.handle(); + }, + }); + + const wrappedFilter = new Proxy(baseFilter, { + get(target, prop, receiver) { + if (prop === 'catch') { + const originalCatch = Reflect.get(target, prop, receiver); + + return (exception: unknown, host: unknown) => { + const status_code = (exception as { status?: number }).status; + + // don't report expected errors + if (status_code !== undefined) { + return originalCatch.apply(target, [exception, host]); + } + + captureException(exception); + return originalCatch.apply(target, [exception, host]); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + + app.useGlobalFilters(wrappedFilter); +} + +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/node/src/integrations/tracing/nest.ts b/packages/node/src/integrations/tracing/nest/sentry-nest-instrumentation.ts similarity index 51% rename from packages/node/src/integrations/tracing/nest.ts rename to packages/node/src/integrations/tracing/nest/sentry-nest-instrumentation.ts index b3d1b3547118..52c3a4ad6b40 100644 --- a/packages/node/src/integrations/tracing/nest.ts +++ b/packages/node/src/integrations/tracing/nest/sentry-nest-instrumentation.ts @@ -5,121 +5,14 @@ import { InstrumentationNodeModuleDefinition, InstrumentationNodeModuleFile, } from '@opentelemetry/instrumentation'; -import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'; -import { - SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - captureException, - defineIntegration, - getActiveSpan, - getClient, - getDefaultIsolationScope, - getIsolationScope, - spanToJSON, - startSpan, - startSpanManual, - withActiveSpan, -} from '@sentry/core'; -import type { IntegrationFn, Span } from '@sentry/types'; -import { addNonEnumerableProperty, logger } from '@sentry/utils'; -import { generateInstrumentOnce } from '../../otel/instrument'; - -interface MinimalNestJsExecutionContext { - getType: () => string; - - switchToHttp: () => { - // minimal request object - // according to official types, all properties are required but - // let's play it safe and assume they're optional - getRequest: () => { - route?: { - path?: string; - }; - method?: string; - }; - }; -} - -interface NestJsErrorFilter { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - catch(exception: any, host: any): void; -} - -interface MinimalNestJsApp { - useGlobalFilters: (arg0: NestJsErrorFilter) => void; - useGlobalInterceptors: (interceptor: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - intercept: (context: MinimalNestJsExecutionContext, next: { handle: () => any }) => any; - }) => void; -} - -const INTEGRATION_NAME = 'Nest'; +import { getActiveSpan, startSpan, startSpanManual, withActiveSpan } from '@sentry/core'; +import type { Span } from '@sentry/types'; +import { SDK_VERSION } from '@sentry/utils'; +import { getMiddlewareSpanOptions, isPatched } from './helpers'; +import type { InjectableTarget } from './types'; const supportedVersions = ['>=8.0.0 <11']; -const sentryPatched = 'sentryPatched'; - -/** - * A minimal interface for an Observable. - */ -export interface Observable { - subscribe(observer: (value: T) => void): void; -} - -/** - * A NestJS call handler. Used in interceptors to start the route execution. - */ -export interface CallHandler { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - handle(...args: any[]): Observable; -} - -/** - * Represents an injectable target class in NestJS. - */ -export interface InjectableTarget { - name: string; - sentryPatched?: boolean; - __SENTRY_INTERNAL__?: boolean; - prototype: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - use?: (req: unknown, res: unknown, next: () => void, ...args: any[]) => void; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - canActivate?: (...args: any[]) => boolean | Promise | Observable; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - transform?: (...args: any[]) => any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - intercept?: (context: unknown, next: CallHandler, ...args: any[]) => Observable; - }; -} - -/** - * Helper checking if a concrete target class is already patched. - * - * We already guard duplicate patching with isWrapped. However, isWrapped checks whether a file has been patched, whereas we use this check for concrete target classes. - * This check might not be necessary, but better to play it safe. - */ -export function isPatched(target: InjectableTarget): boolean { - if (target.sentryPatched) { - return true; - } - - addNonEnumerableProperty(target, sentryPatched, true); - return false; -} - -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -function getMiddlewareSpanOptions(target: InjectableTarget) { - return { - name: target.name, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'middleware.nestjs', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.middleware.nestjs', - }, - }; -} - /** * Custom instrumentation for nestjs. * @@ -285,108 +178,3 @@ export class SentryNestInstrumentation extends InstrumentationBase { }; } } - -const instrumentNestCore = generateInstrumentOnce('Nest-Core', () => { - return new NestInstrumentation(); -}); - -const instrumentNestCommon = generateInstrumentOnce('Nest-Common', () => { - return new SentryNestInstrumentation(); -}); - -export const instrumentNest = Object.assign( - (): void => { - instrumentNestCore(); - instrumentNestCommon(); - }, - { id: INTEGRATION_NAME }, -); - -const _nestIntegration = (() => { - return { - name: INTEGRATION_NAME, - setupOnce() { - instrumentNest(); - }, - }; -}) satisfies IntegrationFn; - -/** - * Nest framework integration - * - * Capture tracing data for nest. - */ -export const nestIntegration = defineIntegration(_nestIntegration); - -/** - * Setup an error handler for Nest. - */ -export function setupNestErrorHandler(app: MinimalNestJsApp, baseFilter: NestJsErrorFilter): 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); - }); - } - - app.useGlobalInterceptors({ - intercept(context, next) { - 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(); - if (req.route) { - getIsolationScope().setTransactionName(`${req.method?.toUpperCase() || 'GET'} ${req.route.path}`); - } - } - - return next.handle(); - }, - }); - - const wrappedFilter = new Proxy(baseFilter, { - get(target, prop, receiver) { - if (prop === 'catch') { - const originalCatch = Reflect.get(target, prop, receiver); - - return (exception: unknown, host: unknown) => { - const status_code = (exception as { status?: number }).status; - - // don't report expected errors - if (status_code !== undefined) { - return originalCatch.apply(target, [exception, host]); - } - - captureException(exception); - return originalCatch.apply(target, [exception, host]); - }; - } - return Reflect.get(target, prop, receiver); - }, - }); - - app.useGlobalFilters(wrappedFilter); -} - -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/node/src/integrations/tracing/nest/types.ts b/packages/node/src/integrations/tracing/nest/types.ts new file mode 100644 index 000000000000..2cdd1b6aefaf --- /dev/null +++ b/packages/node/src/integrations/tracing/nest/types.ts @@ -0,0 +1,57 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +interface MinimalNestJsExecutionContext { + getType: () => string; + + switchToHttp: () => { + // minimal request object + // according to official types, all properties are required but + // let's play it safe and assume they're optional + getRequest: () => { + route?: { + path?: string; + }; + method?: string; + }; + }; +} + +export interface NestJsErrorFilter { + catch(exception: any, host: any): void; +} + +export interface MinimalNestJsApp { + useGlobalFilters: (arg0: NestJsErrorFilter) => void; + useGlobalInterceptors: (interceptor: { + intercept: (context: MinimalNestJsExecutionContext, next: { handle: () => any }) => any; + }) => void; +} + +/** + * A minimal interface for an Observable. + */ +export interface Observable { + subscribe(observer: (value: T) => void): void; +} + +/** + * A NestJS call handler. Used in interceptors to start the route execution. + */ +export interface CallHandler { + handle(...args: any[]): Observable; +} + +/** + * Represents an injectable target class in NestJS. + */ +export interface InjectableTarget { + name: string; + sentryPatched?: boolean; + __SENTRY_INTERNAL__?: boolean; + prototype: { + use?: (req: unknown, res: unknown, next: () => void, ...args: any[]) => void; + canActivate?: (...args: any[]) => boolean | Promise | Observable; + transform?: (...args: any[]) => any; + intercept?: (context: unknown, next: CallHandler, ...args: any[]) => Observable; + }; +} diff --git a/packages/node/test/integrations/tracing/nest.test.ts b/packages/node/test/integrations/tracing/nest.test.ts index 3dc321f28008..3837e3e4ee3d 100644 --- a/packages/node/test/integrations/tracing/nest.test.ts +++ b/packages/node/test/integrations/tracing/nest.test.ts @@ -1,5 +1,5 @@ -import type { InjectableTarget } from '../../../src/integrations/tracing/nest'; -import { isPatched } from '../../../src/integrations/tracing/nest'; +import { isPatched } from '../../../src/integrations/tracing/nest/helpers'; +import type { InjectableTarget } from '../../../src/integrations/tracing/nest/types'; describe('Nest', () => { describe('isPatched', () => {