Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tracing): Support Apollo/GraphQL with NestJS #7194

Merged
merged 6 commits into from
Feb 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.');
});
});