Skip to content

Commit a8449de

Browse files
feat(tracing): Support Apollo/GraphQL with NestJS (#7194)
Co-authored-by: Abhijeet Prasad <[email protected]>
1 parent 79babe9 commit a8449de

File tree

2 files changed

+230
-44
lines changed

2 files changed

+230
-44
lines changed

packages/tracing/src/integrations/node/apollo.ts

Lines changed: 110 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { arrayify, fill, isThenable, loadModule, logger } from '@sentry/utils';
44

55
import { shouldDisableAutoInstrumentation } from './utils/node-utils';
66

7+
interface ApolloOptions {
8+
useNestjs?: boolean;
9+
}
10+
711
type ApolloResolverGroup = {
812
[key: string]: () => unknown;
913
};
@@ -24,6 +28,19 @@ export class Apollo implements Integration {
2428
*/
2529
public name: string = Apollo.id;
2630

31+
private readonly _useNest: boolean;
32+
33+
/**
34+
* @inheritDoc
35+
*/
36+
public constructor(
37+
options: ApolloOptions = {
38+
useNestjs: false,
39+
},
40+
) {
41+
this._useNest = !!options.useNestjs;
42+
}
43+
2744
/**
2845
* @inheritDoc
2946
*/
@@ -33,62 +50,111 @@ export class Apollo implements Integration {
3350
return;
3451
}
3552

36-
const pkg = loadModule<{
37-
ApolloServerBase: {
38-
prototype: {
39-
constructSchema: () => unknown;
53+
if (this._useNest) {
54+
const pkg = loadModule<{
55+
GraphQLFactory: {
56+
prototype: {
57+
create: (resolvers: ApolloModelResolvers[]) => unknown;
58+
};
4059
};
41-
};
42-
}>('apollo-server-core');
60+
}>('@nestjs/graphql');
4361

44-
if (!pkg) {
45-
__DEBUG_BUILD__ && logger.error('Apollo Integration was unable to require apollo-server-core package.');
46-
return;
47-
}
62+
if (!pkg) {
63+
__DEBUG_BUILD__ && logger.error('Apollo-NestJS Integration was unable to require @nestjs/graphql package.');
64+
return;
65+
}
66+
67+
/**
68+
* Iterate over resolvers of NestJS ResolversExplorerService before schemas are constructed.
69+
*/
70+
fill(
71+
pkg.GraphQLFactory.prototype,
72+
'mergeWithSchema',
73+
function (orig: (this: unknown, ...args: unknown[]) => unknown) {
74+
return function (
75+
this: { resolversExplorerService: { explore: () => ApolloModelResolvers[] } },
76+
...args: unknown[]
77+
) {
78+
fill(this.resolversExplorerService, 'explore', function (orig: () => ApolloModelResolvers[]) {
79+
return function (this: unknown) {
80+
const resolvers = arrayify(orig.call(this));
81+
82+
const instrumentedResolvers = instrumentResolvers(resolvers, getCurrentHub);
83+
84+
return instrumentedResolvers;
85+
};
86+
});
87+
88+
return orig.call(this, ...args);
89+
};
90+
},
91+
);
92+
} else {
93+
const pkg = loadModule<{
94+
ApolloServerBase: {
95+
prototype: {
96+
constructSchema: (config: unknown) => unknown;
97+
};
98+
};
99+
}>('apollo-server-core');
100+
101+
if (!pkg) {
102+
__DEBUG_BUILD__ && logger.error('Apollo Integration was unable to require apollo-server-core package.');
103+
return;
104+
}
105+
106+
/**
107+
* Iterate over resolvers of the ApolloServer instance before schemas are constructed.
108+
*/
109+
fill(pkg.ApolloServerBase.prototype, 'constructSchema', function (orig: (config: unknown) => unknown) {
110+
return function (this: {
111+
config: { resolvers?: ApolloModelResolvers[]; schema?: unknown; modules?: unknown };
112+
}) {
113+
if (!this.config.resolvers) {
114+
if (__DEBUG_BUILD__) {
115+
if (this.config.schema) {
116+
logger.warn(
117+
'Apollo integration is not able to trace `ApolloServer` instances constructed via `schema` property.' +
118+
'If you are using NestJS with Apollo, please use `Sentry.Integrations.Apollo({ useNestjs: true })` instead.',
119+
);
120+
logger.warn();
121+
} else if (this.config.modules) {
122+
logger.warn(
123+
'Apollo integration is not able to trace `ApolloServer` instances constructed via `modules` property.',
124+
);
125+
}
48126

49-
/**
50-
* Iterate over resolvers of the ApolloServer instance before schemas are constructed.
51-
*/
52-
fill(pkg.ApolloServerBase.prototype, 'constructSchema', function (orig: () => unknown) {
53-
return function (this: { config: { resolvers?: ApolloModelResolvers[]; schema?: unknown; modules?: unknown } }) {
54-
if (!this.config.resolvers) {
55-
if (__DEBUG_BUILD__) {
56-
if (this.config.schema) {
57-
logger.warn(
58-
'Apollo integration is not able to trace `ApolloServer` instances constructed via `schema` property.',
59-
);
60-
} else if (this.config.modules) {
61-
logger.warn(
62-
'Apollo integration is not able to trace `ApolloServer` instances constructed via `modules` property.',
63-
);
127+
logger.error('Skipping tracing as no resolvers found on the `ApolloServer` instance.');
64128
}
65129

66-
logger.error('Skipping tracing as no resolvers found on the `ApolloServer` instance.');
130+
return orig.call(this);
67131
}
68132

69-
return orig.call(this);
70-
}
133+
const resolvers = arrayify(this.config.resolvers);
71134

72-
const resolvers = arrayify(this.config.resolvers);
73-
74-
this.config.resolvers = resolvers.map(model => {
75-
Object.keys(model).forEach(resolverGroupName => {
76-
Object.keys(model[resolverGroupName]).forEach(resolverName => {
77-
if (typeof model[resolverGroupName][resolverName] !== 'function') {
78-
return;
79-
}
135+
this.config.resolvers = instrumentResolvers(resolvers, getCurrentHub);
80136

81-
wrapResolver(model, resolverGroupName, resolverName, getCurrentHub);
82-
});
83-
});
137+
return orig.call(this);
138+
};
139+
});
140+
}
141+
}
142+
}
84143

85-
return model;
86-
});
144+
function instrumentResolvers(resolvers: ApolloModelResolvers[], getCurrentHub: () => Hub): ApolloModelResolvers[] {
145+
return resolvers.map(model => {
146+
Object.keys(model).forEach(resolverGroupName => {
147+
Object.keys(model[resolverGroupName]).forEach(resolverName => {
148+
if (typeof model[resolverGroupName][resolverName] !== 'function') {
149+
return;
150+
}
87151

88-
return orig.call(this);
89-
};
152+
wrapResolver(model, resolverGroupName, resolverName, getCurrentHub);
153+
});
90154
});
91-
}
155+
156+
return model;
157+
});
92158
}
93159

94160
/**
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/* eslint-disable @typescript-eslint/unbound-method */
2+
import { Hub, Scope } from '@sentry/core';
3+
import { logger } from '@sentry/utils';
4+
5+
import { Apollo } from '../../src/integrations/node/apollo';
6+
import { Span } from '../../src/span';
7+
import { getTestClient } from '../testutils';
8+
9+
type ApolloResolverGroup = {
10+
[key: string]: () => unknown;
11+
};
12+
13+
type ApolloModelResolvers = {
14+
[key: string]: ApolloResolverGroup;
15+
};
16+
17+
class GraphQLFactory {
18+
_resolvers: ApolloModelResolvers[];
19+
resolversExplorerService = {
20+
explore: () => this._resolvers,
21+
};
22+
constructor() {
23+
this._resolvers = [
24+
{
25+
Query: {
26+
res_1(..._args: unknown[]) {
27+
return 'foo';
28+
},
29+
},
30+
Mutation: {
31+
res_2(..._args: unknown[]) {
32+
return 'bar';
33+
},
34+
},
35+
},
36+
];
37+
38+
this.mergeWithSchema();
39+
}
40+
41+
public mergeWithSchema(..._args: unknown[]) {
42+
return this.resolversExplorerService.explore();
43+
}
44+
}
45+
46+
// mock for @nestjs/graphql package
47+
jest.mock('@sentry/utils', () => {
48+
const actual = jest.requireActual('@sentry/utils');
49+
return {
50+
...actual,
51+
loadModule() {
52+
return {
53+
GraphQLFactory,
54+
};
55+
},
56+
};
57+
});
58+
59+
describe('setupOnce', () => {
60+
let scope = new Scope();
61+
let parentSpan: Span;
62+
let childSpan: Span;
63+
let GraphQLFactoryInstance: GraphQLFactory;
64+
65+
beforeAll(() => {
66+
new Apollo({
67+
useNestjs: true,
68+
}).setupOnce(
69+
() => undefined,
70+
() => new Hub(undefined, scope),
71+
);
72+
73+
GraphQLFactoryInstance = new GraphQLFactory();
74+
});
75+
76+
beforeEach(() => {
77+
scope = new Scope();
78+
parentSpan = new Span();
79+
childSpan = parentSpan.startChild();
80+
jest.spyOn(scope, 'getSpan').mockReturnValueOnce(parentSpan);
81+
jest.spyOn(scope, 'setSpan');
82+
jest.spyOn(parentSpan, 'startChild').mockReturnValueOnce(childSpan);
83+
jest.spyOn(childSpan, 'finish');
84+
});
85+
86+
it('should wrap a simple resolver', () => {
87+
GraphQLFactoryInstance._resolvers[0]?.['Query']?.['res_1']?.();
88+
expect(scope.getSpan).toBeCalled();
89+
expect(parentSpan.startChild).toBeCalledWith({
90+
description: 'Query.res_1',
91+
op: 'graphql.resolve',
92+
});
93+
expect(childSpan.finish).toBeCalled();
94+
});
95+
96+
it('should wrap another simple resolver', () => {
97+
GraphQLFactoryInstance._resolvers[0]?.['Mutation']?.['res_2']?.();
98+
expect(scope.getSpan).toBeCalled();
99+
expect(parentSpan.startChild).toBeCalledWith({
100+
description: 'Mutation.res_2',
101+
op: 'graphql.resolve',
102+
});
103+
expect(childSpan.finish).toBeCalled();
104+
});
105+
106+
it("doesn't attach when using otel instrumenter", () => {
107+
const loggerLogSpy = jest.spyOn(logger, 'log');
108+
109+
const client = getTestClient({ instrumenter: 'otel' });
110+
const hub = new Hub(client);
111+
112+
const integration = new Apollo({ useNestjs: true });
113+
integration.setupOnce(
114+
() => {},
115+
() => hub,
116+
);
117+
118+
expect(loggerLogSpy).toBeCalledWith('Apollo Integration is skipped because of instrumenter configuration.');
119+
});
120+
});

0 commit comments

Comments
 (0)