From 50120d90d605da8d61b341128cf74262b368cd52 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 15 Feb 2023 13:34:46 +0000 Subject: [PATCH 1/5] feat(tracing): Support Apollo/GraphQL with NestJS --- .../tracing/src/integrations/node/apollo.ts | 154 +++++++++++++----- .../test/integrations/apollo-nestjs.test.ts | 122 ++++++++++++++ 2 files changed, 232 insertions(+), 44 deletions(-) create mode 100644 packages/tracing/test/integrations/apollo-nestjs.test.ts diff --git a/packages/tracing/src/integrations/node/apollo.ts b/packages/tracing/src/integrations/node/apollo.ts index 41b136abff42..6cb004c22299 100644 --- a/packages/tracing/src/integrations/node/apollo.ts +++ b/packages/tracing/src/integrations/node/apollo.ts @@ -4,6 +4,10 @@ import { arrayify, fill, isThenable, loadModule, logger } from '@sentry/utils'; import { shouldDisableAutoInstrumentation } from './utils/node-utils'; +interface ApolloOptions { + nestjs?: boolean; +} + type ApolloResolverGroup = { [key: string]: () => unknown; }; @@ -24,6 +28,19 @@ export class Apollo implements Integration { */ public name: string = Apollo.id; + private readonly _useNest: boolean; + + /** + * @inheritDoc + */ + public constructor( + options: ApolloOptions = { + nestjs: false, + }, + ) { + this._useNest = !!options.nestjs; + } + /** * @inheritDoc */ @@ -33,62 +50,111 @@ export class Apollo implements Integration { return; } - const pkg = loadModule<{ - ApolloServerBase: { - prototype: { - constructSchema: () => unknown; + if (this._useNest) { + const pkg = loadModule<{ + GraphQLFactory: { + prototype: { + create: (resolvers: ApolloModelResolvers[]) => unknown; + }; }; - }; - }>('apollo-server-core'); + }>('@nestjs/graphql'); - if (!pkg) { - __DEBUG_BUILD__ && logger.error('Apollo Integration was unable to require apollo-server-core package.'); - return; - } + if (!pkg) { + __DEBUG_BUILD__ && logger.error('Apollo-NestJS Integration was unable to require @nestjs/graphql package.'); + return; + } + + /** + * Iterate over resolvers of NestJS ResolversExplorerService before schemas are constructed. + */ + fill( + pkg.GraphQLFactory.prototype, + 'mergeWithSchema', + function (orig: (this: unknown, ...args: unknown[]) => unknown) { + return function ( + this: { resolversExplorerService: { explore: () => ApolloModelResolvers[] } }, + ...args: unknown[] + ) { + fill(this.resolversExplorerService, 'explore', function (orig: () => ApolloModelResolvers[]) { + return function (this: unknown) { + const resolvers = arrayify(orig.call(this)); + + const instrumentedResolvers = instrumentResolvers(resolvers, getCurrentHub); + + return instrumentedResolvers; + }; + }); + + return orig.call(this, ...args); + }; + }, + ); + } else { + const pkg = loadModule<{ + ApolloServerBase: { + prototype: { + constructSchema: (config: unknown) => unknown; + }; + }; + }>('apollo-server-core'); + + if (!pkg) { + __DEBUG_BUILD__ && logger.error('Apollo Integration was unable to require apollo-server-core package.'); + return; + } + + /** + * Iterate over resolvers of the ApolloServer instance before schemas are constructed. + */ + fill(pkg.ApolloServerBase.prototype, 'constructSchema', function (orig: (config: unknown) => unknown) { + return function (this: { + config: { resolvers?: ApolloModelResolvers[]; schema?: unknown; modules?: unknown }; + }) { + if (!this.config.resolvers) { + if (__DEBUG_BUILD__) { + if (this.config.schema) { + logger.warn( + 'Apollo integration is not able to trace `ApolloServer` instances constructed via `schema` property.' + + 'If you are using NestJS with Apollo, please use `Sentry.Integrations.Apollo({ nestjs: true })` instead.', + ); + logger.warn(); + } else if (this.config.modules) { + logger.warn( + 'Apollo integration is not able to trace `ApolloServer` instances constructed via `modules` property.', + ); + } - /** - * Iterate over resolvers of the ApolloServer instance before schemas are constructed. - */ - fill(pkg.ApolloServerBase.prototype, 'constructSchema', function (orig: () => unknown) { - return function (this: { config: { resolvers?: ApolloModelResolvers[]; schema?: unknown; modules?: unknown } }) { - if (!this.config.resolvers) { - if (__DEBUG_BUILD__) { - if (this.config.schema) { - logger.warn( - 'Apollo integration is not able to trace `ApolloServer` instances constructed via `schema` property.', - ); - } else if (this.config.modules) { - logger.warn( - 'Apollo integration is not able to trace `ApolloServer` instances constructed via `modules` property.', - ); + logger.error('Skipping tracing as no resolvers found on the `ApolloServer` instance.'); } - logger.error('Skipping tracing as no resolvers found on the `ApolloServer` instance.'); + return orig.call(this); } - return orig.call(this); - } + const resolvers = arrayify(this.config.resolvers); - const resolvers = arrayify(this.config.resolvers); - - this.config.resolvers = resolvers.map(model => { - Object.keys(model).forEach(resolverGroupName => { - Object.keys(model[resolverGroupName]).forEach(resolverName => { - if (typeof model[resolverGroupName][resolverName] !== 'function') { - return; - } + this.config.resolvers = instrumentResolvers(resolvers, getCurrentHub); - wrapResolver(model, resolverGroupName, resolverName, getCurrentHub); - }); - }); + return orig.call(this); + }; + }); + } + } +} - return model; - }); +function instrumentResolvers(resolvers: ApolloModelResolvers[], getCurrentHub: () => Hub): ApolloModelResolvers[] { + return resolvers.map(model => { + Object.keys(model).forEach(resolverGroupName => { + Object.keys(model[resolverGroupName]).forEach(resolverName => { + if (typeof model[resolverGroupName][resolverName] !== 'function') { + return; + } - return orig.call(this); - }; + wrapResolver(model, resolverGroupName, resolverName, getCurrentHub); + }); }); - } + + return model; + }); } /** diff --git a/packages/tracing/test/integrations/apollo-nestjs.test.ts b/packages/tracing/test/integrations/apollo-nestjs.test.ts new file mode 100644 index 000000000000..9f4ca64ab6c9 --- /dev/null +++ b/packages/tracing/test/integrations/apollo-nestjs.test.ts @@ -0,0 +1,122 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { Hub, Scope } from '@sentry/core'; +import { logger } from '@sentry/utils'; + +import { Apollo } from '../../src/integrations/node/apollo'; +import { Span } from '../../src/span'; +import { getTestClient } from '../testutils'; + +type ApolloResolverGroup = { + [key: string]: () => unknown; +}; + +type ApolloModelResolvers = { + [key: string]: ApolloResolverGroup; +}; + +class GraphQLFactory { + _resolvers: ApolloModelResolvers[]; + + constructor() { + this._resolvers = [ + { + Query: { + res_1(..._args: unknown[]) { + return 'foo'; + }, + }, + Mutation: { + res_2(..._args: unknown[]) { + return 'bar'; + }, + }, + }, + ]; + + this.mergeWithSchema(); + } + + public mergeWithSchema(..._args: unknown[]) { + return this.resolversExplorerService.explore(); + } + + public resolversExplorerService = { + explore: () => this._resolvers, + }; +} + +// mock for @nestjs/graphql package +jest.mock('@sentry/utils', () => { + const actual = jest.requireActual('@sentry/utils'); + return { + ...actual, + loadModule() { + return { + GraphQLFactory, + }; + }, + }; +}); + +describe('setupOnce', () => { + let scope = new Scope(); + let parentSpan: Span; + let childSpan: Span; + let GraphQLFactoryInstance: GraphQLFactory; + + beforeAll(() => { + new Apollo({ + nestjs: true, + }).setupOnce( + () => undefined, + () => new Hub(undefined, scope), + ); + + GraphQLFactoryInstance = new GraphQLFactory(); + }); + + beforeEach(() => { + scope = new Scope(); + parentSpan = new Span(); + childSpan = parentSpan.startChild(); + jest.spyOn(scope, 'getSpan').mockReturnValueOnce(parentSpan); + jest.spyOn(scope, 'setSpan'); + jest.spyOn(parentSpan, 'startChild').mockReturnValueOnce(childSpan); + jest.spyOn(childSpan, 'finish'); + }); + + it('should wrap a simple resolver', () => { + GraphQLFactoryInstance._resolvers[0]?.['Query']?.['res_1']?.(); + expect(scope.getSpan).toBeCalled(); + expect(parentSpan.startChild).toBeCalledWith({ + description: 'Query.res_1', + op: 'graphql.resolve', + }); + expect(childSpan.finish).toBeCalled(); + }); + + it('should wrap another simple resolver', () => { + GraphQLFactoryInstance._resolvers[0]?.['Mutation']?.['res_2']?.(); + expect(scope.getSpan).toBeCalled(); + expect(parentSpan.startChild).toBeCalledWith({ + description: 'Mutation.res_2', + op: 'graphql.resolve', + }); + expect(childSpan.finish).toBeCalled(); + }); + + it("doesn't attach when using otel instrumenter", () => { + const loggerLogSpy = jest.spyOn(logger, 'log'); + + const client = getTestClient({ instrumenter: 'otel' }); + const hub = new Hub(client); + + const integration = new Apollo({ nestjs: true }); + integration.setupOnce( + () => {}, + () => hub, + ); + + expect(loggerLogSpy).toBeCalledWith('Apollo Integration is skipped because of instrumenter configuration.'); + }); +}); From f399df07f26a5070aef2067568a5768d72f5b951 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Wed, 15 Feb 2023 13:58:32 +0000 Subject: [PATCH 2/5] Fix linter --- packages/tracing/test/integrations/apollo-nestjs.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/tracing/test/integrations/apollo-nestjs.test.ts b/packages/tracing/test/integrations/apollo-nestjs.test.ts index 9f4ca64ab6c9..393011b2263c 100644 --- a/packages/tracing/test/integrations/apollo-nestjs.test.ts +++ b/packages/tracing/test/integrations/apollo-nestjs.test.ts @@ -16,7 +16,9 @@ type ApolloModelResolvers = { class GraphQLFactory { _resolvers: ApolloModelResolvers[]; - + resolversExplorerService = { + explore: () => this._resolvers, + }; constructor() { this._resolvers = [ { @@ -39,10 +41,6 @@ class GraphQLFactory { public mergeWithSchema(..._args: unknown[]) { return this.resolversExplorerService.explore(); } - - public resolversExplorerService = { - explore: () => this._resolvers, - }; } // mock for @nestjs/graphql package From 6200dfbc4e9570c19975e112ad24f7a457d6b499 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 16 Feb 2023 12:12:08 +0100 Subject: [PATCH 3/5] rename to 'useNestjs' --- packages/tracing/src/integrations/node/apollo.ts | 6 +++--- packages/tracing/test/integrations/apollo-nestjs.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/tracing/src/integrations/node/apollo.ts b/packages/tracing/src/integrations/node/apollo.ts index 6cb004c22299..58364cb11078 100644 --- a/packages/tracing/src/integrations/node/apollo.ts +++ b/packages/tracing/src/integrations/node/apollo.ts @@ -5,7 +5,7 @@ import { arrayify, fill, isThenable, loadModule, logger } from '@sentry/utils'; import { shouldDisableAutoInstrumentation } from './utils/node-utils'; interface ApolloOptions { - nestjs?: boolean; + useNestjs?: boolean; } type ApolloResolverGroup = { @@ -35,10 +35,10 @@ export class Apollo implements Integration { */ public constructor( options: ApolloOptions = { - nestjs: false, + useNestjs: false, }, ) { - this._useNest = !!options.nestjs; + this._useNest = !!options.useNestjs; } /** diff --git a/packages/tracing/test/integrations/apollo-nestjs.test.ts b/packages/tracing/test/integrations/apollo-nestjs.test.ts index 393011b2263c..953875222125 100644 --- a/packages/tracing/test/integrations/apollo-nestjs.test.ts +++ b/packages/tracing/test/integrations/apollo-nestjs.test.ts @@ -64,7 +64,7 @@ describe('setupOnce', () => { beforeAll(() => { new Apollo({ - nestjs: true, + useNestjs: true, }).setupOnce( () => undefined, () => new Hub(undefined, scope), From e4977630837068a3a465e54ac8487815b5de262a Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 16 Feb 2023 12:31:34 +0100 Subject: [PATCH 4/5] more accurate comment --- packages/tracing/src/integrations/node/apollo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tracing/src/integrations/node/apollo.ts b/packages/tracing/src/integrations/node/apollo.ts index 58364cb11078..3a076444af5e 100644 --- a/packages/tracing/src/integrations/node/apollo.ts +++ b/packages/tracing/src/integrations/node/apollo.ts @@ -115,7 +115,7 @@ export class Apollo implements Integration { if (this.config.schema) { logger.warn( 'Apollo integration is not able to trace `ApolloServer` instances constructed via `schema` property.' + - 'If you are using NestJS with Apollo, please use `Sentry.Integrations.Apollo({ nestjs: true })` instead.', + 'If you are using NestJS with Apollo, please use `Sentry.Integrations.Apollo({ useNestjs: true })` instead.', ); logger.warn(); } else if (this.config.modules) { From b6dc39b215e39f42554ed2b64cac7e16c121c38c Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 16 Feb 2023 12:44:48 +0100 Subject: [PATCH 5/5] fix test --- packages/tracing/test/integrations/apollo-nestjs.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tracing/test/integrations/apollo-nestjs.test.ts b/packages/tracing/test/integrations/apollo-nestjs.test.ts index 953875222125..117cfd6ab704 100644 --- a/packages/tracing/test/integrations/apollo-nestjs.test.ts +++ b/packages/tracing/test/integrations/apollo-nestjs.test.ts @@ -109,7 +109,7 @@ describe('setupOnce', () => { const client = getTestClient({ instrumenter: 'otel' }); const hub = new Hub(client); - const integration = new Apollo({ nestjs: true }); + const integration = new Apollo({ useNestjs: true }); integration.setupOnce( () => {}, () => hub,