Skip to content

Commit

Permalink
feat(tracing): Support Apollo/GraphQL with NestJS (#7194)
Browse files Browse the repository at this point in the history
Co-authored-by: Abhijeet Prasad <[email protected]>
  • Loading branch information
onurtemizkan and AbhiPrasad authored Feb 16, 2023
1 parent 79babe9 commit a8449de
Show file tree
Hide file tree
Showing 2 changed files with 230 additions and 44 deletions.
154 changes: 110 additions & 44 deletions packages/tracing/src/integrations/node/apollo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -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
*/
Expand All @@ -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;
});
}

/**
Expand Down
120 changes: 120 additions & 0 deletions packages/tracing/test/integrations/apollo-nestjs.test.ts
Original file line number Diff line number Diff line change
@@ -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.');
});
});

0 comments on commit a8449de

Please sign in to comment.