From a8449deaf0b6f066c32cca528b4d35dc71522a97 Mon Sep 17 00:00:00 2001 From: Onur Temizkan Date: Thu, 16 Feb 2023 12:06:13 +0000 Subject: [PATCH] feat(tracing): Support Apollo/GraphQL with NestJS (#7194) Co-authored-by: Abhijeet Prasad --- .../tracing/src/integrations/node/apollo.ts | 154 +++++++++++++----- .../test/integrations/apollo-nestjs.test.ts | 120 ++++++++++++++ 2 files changed, 230 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..3a076444af5e 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 { + useNestjs?: 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 = { + useNestjs: false, + }, + ) { + this._useNest = !!options.useNestjs; + } + /** * @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({ useNestjs: 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..117cfd6ab704 --- /dev/null +++ b/packages/tracing/test/integrations/apollo-nestjs.test.ts @@ -0,0 +1,120 @@ +/* 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[]; + resolversExplorerService = { + explore: () => this._resolvers, + }; + 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(); + } +} + +// 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({ + useNestjs: 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({ useNestjs: true }); + integration.setupOnce( + () => {}, + () => hub, + ); + + expect(loggerLogSpy).toBeCalledWith('Apollo Integration is skipped because of instrumenter configuration.'); + }); +});