Skip to content

Commit cafd6cb

Browse files
authored
feat(tracing): GraphQL and Apollo Integrations (#3953)
1 parent c7d69b6 commit cafd6cb

File tree

10 files changed

+675
-15
lines changed

10 files changed

+675
-15
lines changed

packages/node-integration-tests/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
"@types/mongodb": "^3.6.20",
2323
"@types/mysql": "^2.15.21",
2424
"@types/pg": "^8.6.5",
25+
"apollo-server": "^3.6.7",
2526
"cors": "^2.8.5",
2627
"express": "^4.17.3",
28+
"graphql": "^16.3.0",
2729
"mongodb": "^3.7.3",
2830
"mongodb-memory-server-global": "^7.6.3",
2931
"mysql": "^2.18.1",
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import * as Sentry from '@sentry/node';
2+
import * as Tracing from '@sentry/tracing';
3+
import { ApolloServer, gql } from 'apollo-server';
4+
5+
Sentry.init({
6+
dsn: 'https://[email protected]/1337',
7+
release: '1.0',
8+
tracesSampleRate: 1.0,
9+
integrations: [new Tracing.Integrations.GraphQL(), new Tracing.Integrations.Apollo()],
10+
});
11+
12+
const typeDefs = gql`
13+
type Query {
14+
hello: String
15+
}
16+
`;
17+
18+
const resolvers = {
19+
Query: {
20+
hello: () => {
21+
return 'Hello world!';
22+
},
23+
},
24+
};
25+
26+
const server = new ApolloServer({
27+
typeDefs,
28+
resolvers,
29+
});
30+
31+
const transaction = Sentry.startTransaction({ name: 'test_transaction', op: 'transaction' });
32+
33+
Sentry.configureScope(scope => {
34+
scope.setSpan(transaction);
35+
});
36+
37+
void (async () => {
38+
// Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation
39+
await server.executeOperation({
40+
query: '{hello}',
41+
});
42+
43+
transaction.finish();
44+
})();
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { assertSentryTransaction, conditionalTest, getEnvelopeRequest, runServer } from '../../../utils';
2+
3+
// Node 10 is not supported by `graphql-js`
4+
// Ref: https://github.com/graphql/graphql-js/blob/main/package.json
5+
conditionalTest({ min: 12 })('GraphQL/Apollo Tests', () => {
6+
test('should instrument GraphQL and Apollo Server.', async () => {
7+
const url = await runServer(__dirname);
8+
const envelope = await getEnvelopeRequest(url);
9+
10+
expect(envelope).toHaveLength(3);
11+
12+
const transaction = envelope[2];
13+
const parentSpanId = (transaction as any)?.contexts?.trace?.span_id;
14+
const graphqlSpanId = (transaction as any)?.spans?.[0].span_id;
15+
16+
expect(parentSpanId).toBeDefined();
17+
expect(graphqlSpanId).toBeDefined();
18+
19+
assertSentryTransaction(transaction, {
20+
transaction: 'test_transaction',
21+
spans: [
22+
{
23+
description: 'execute',
24+
op: 'db.graphql',
25+
parent_span_id: parentSpanId,
26+
},
27+
{
28+
description: 'Query.hello',
29+
op: 'db.graphql.apollo',
30+
parent_span_id: graphqlSpanId,
31+
},
32+
],
33+
});
34+
});
35+
});

packages/tracing/src/hubextensions.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable max-lines */
21
import { getMainCarrier, Hub } from '@sentry/hub';
32
import {
43
ClientOptions,

packages/tracing/src/integrations/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ export { Postgres } from './node/postgres';
33
export { Mysql } from './node/mysql';
44
export { Mongo } from './node/mongo';
55
export { Prisma } from './node/prisma';
6+
export { GraphQL } from './node/graphql';
7+
export { Apollo } from './node/apollo';
68

79
// TODO(v7): Remove this export
810
// Please see `src/index.ts` for more details.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { Hub } from '@sentry/hub';
2+
import { EventProcessor, Integration } from '@sentry/types';
3+
import { fill, isThenable, loadModule, logger } from '@sentry/utils';
4+
5+
type ApolloResolverGroup = {
6+
[key: string]: () => unknown;
7+
};
8+
9+
type ApolloModelResolvers = {
10+
[key: string]: ApolloResolverGroup;
11+
};
12+
13+
/** Tracing integration for Apollo */
14+
export class Apollo implements Integration {
15+
/**
16+
* @inheritDoc
17+
*/
18+
public static id: string = 'Apollo';
19+
20+
/**
21+
* @inheritDoc
22+
*/
23+
public name: string = Apollo.id;
24+
25+
/**
26+
* @inheritDoc
27+
*/
28+
public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
29+
const pkg = loadModule<{
30+
ApolloServerBase: {
31+
prototype: {
32+
constructSchema: () => unknown;
33+
};
34+
};
35+
}>('apollo-server-core');
36+
37+
if (!pkg) {
38+
logger.error('Apollo Integration was unable to require apollo-server-core package.');
39+
return;
40+
}
41+
42+
/**
43+
* Iterate over resolvers of the ApolloServer instance before schemas are constructed.
44+
*/
45+
fill(pkg.ApolloServerBase.prototype, 'constructSchema', function (orig: () => unknown) {
46+
return function (this: { config: { resolvers: ApolloModelResolvers[] } }) {
47+
const resolvers = Array.isArray(this.config.resolvers) ? this.config.resolvers : [this.config.resolvers];
48+
49+
this.config.resolvers = resolvers.map(model => {
50+
Object.keys(model).forEach(resolverGroupName => {
51+
Object.keys(model[resolverGroupName]).forEach(resolverName => {
52+
if (typeof model[resolverGroupName][resolverName] !== 'function') {
53+
return;
54+
}
55+
56+
wrapResolver(model, resolverGroupName, resolverName, getCurrentHub);
57+
});
58+
});
59+
60+
return model;
61+
});
62+
63+
return orig.call(this);
64+
};
65+
});
66+
}
67+
}
68+
69+
/**
70+
* Wrap a single resolver which can be a parent of other resolvers and/or db operations.
71+
*/
72+
function wrapResolver(
73+
model: ApolloModelResolvers,
74+
resolverGroupName: string,
75+
resolverName: string,
76+
getCurrentHub: () => Hub,
77+
): void {
78+
fill(model[resolverGroupName], resolverName, function (orig: () => unknown | Promise<unknown>) {
79+
return function (this: unknown, ...args: unknown[]) {
80+
const scope = getCurrentHub().getScope();
81+
const parentSpan = scope?.getSpan();
82+
const span = parentSpan?.startChild({
83+
description: `${resolverGroupName}.${resolverName}`,
84+
op: 'db.graphql.apollo',
85+
});
86+
87+
const rv = orig.call(this, ...args);
88+
89+
if (isThenable(rv)) {
90+
return rv.then((res: unknown) => {
91+
span?.finish();
92+
return res;
93+
});
94+
}
95+
96+
span?.finish();
97+
98+
return rv;
99+
};
100+
});
101+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { Hub } from '@sentry/hub';
2+
import { EventProcessor, Integration } from '@sentry/types';
3+
import { fill, isThenable, loadModule, logger } from '@sentry/utils';
4+
5+
/** Tracing integration for graphql package */
6+
export class GraphQL implements Integration {
7+
/**
8+
* @inheritDoc
9+
*/
10+
public static id: string = 'GraphQL';
11+
12+
/**
13+
* @inheritDoc
14+
*/
15+
public name: string = GraphQL.id;
16+
17+
/**
18+
* @inheritDoc
19+
*/
20+
public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
21+
const pkg = loadModule<{
22+
[method: string]: (...args: unknown[]) => unknown;
23+
}>('graphql/execution/execute.js');
24+
25+
if (!pkg) {
26+
logger.error('GraphQL Integration was unable to require graphql/execution package.');
27+
return;
28+
}
29+
30+
fill(pkg, 'execute', function (orig: () => void | Promise<unknown>) {
31+
return function (this: unknown, ...args: unknown[]) {
32+
const scope = getCurrentHub().getScope();
33+
const parentSpan = scope?.getSpan();
34+
35+
const span = parentSpan?.startChild({
36+
description: 'execute',
37+
op: 'db.graphql',
38+
});
39+
40+
scope?.setSpan(span);
41+
42+
const rv = orig.call(this, ...args);
43+
44+
if (isThenable(rv)) {
45+
return rv.then((res: unknown) => {
46+
span?.finish();
47+
scope?.setSpan(parentSpan);
48+
49+
return res;
50+
});
51+
}
52+
53+
span?.finish();
54+
scope?.setSpan(parentSpan);
55+
return rv;
56+
};
57+
});
58+
}
59+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/* eslint-disable @typescript-eslint/unbound-method */
2+
import { Hub, Scope } from '@sentry/hub';
3+
4+
import { Apollo } from '../../src/integrations/node/apollo';
5+
import { Span } from '../../src/span';
6+
7+
type ApolloResolverGroup = {
8+
[key: string]: () => any;
9+
};
10+
11+
type ApolloModelResolvers = {
12+
[key: string]: ApolloResolverGroup;
13+
};
14+
15+
class ApolloServerBase {
16+
config: {
17+
resolvers: ApolloModelResolvers[];
18+
};
19+
20+
constructor() {
21+
this.config = {
22+
resolvers: [
23+
{
24+
Query: {
25+
res_1(..._args: unknown[]) {
26+
return 'foo';
27+
},
28+
},
29+
Mutation: {
30+
res_2(..._args: unknown[]) {
31+
return 'bar';
32+
},
33+
},
34+
},
35+
],
36+
};
37+
38+
this.constructSchema();
39+
}
40+
41+
public constructSchema(..._args: unknown[]) {
42+
return null;
43+
}
44+
}
45+
46+
// mock for ApolloServer package
47+
jest.mock('@sentry/utils', () => {
48+
const actual = jest.requireActual('@sentry/utils');
49+
return {
50+
...actual,
51+
loadModule() {
52+
return {
53+
ApolloServerBase,
54+
};
55+
},
56+
};
57+
});
58+
59+
describe('setupOnce', () => {
60+
let scope = new Scope();
61+
let parentSpan: Span;
62+
let childSpan: Span;
63+
let ApolloServer: ApolloServerBase;
64+
65+
beforeAll(() => {
66+
new Apollo().setupOnce(
67+
() => undefined,
68+
() => new Hub(undefined, scope),
69+
);
70+
71+
ApolloServer = new ApolloServerBase();
72+
});
73+
74+
beforeEach(() => {
75+
scope = new Scope();
76+
parentSpan = new Span();
77+
childSpan = parentSpan.startChild();
78+
jest.spyOn(scope, 'getSpan').mockReturnValueOnce(parentSpan);
79+
jest.spyOn(scope, 'setSpan');
80+
jest.spyOn(parentSpan, 'startChild').mockReturnValueOnce(childSpan);
81+
jest.spyOn(childSpan, 'finish');
82+
});
83+
84+
it('should wrap a simple resolver', () => {
85+
ApolloServer.config.resolvers[0]?.['Query']?.['res_1']?.();
86+
expect(scope.getSpan).toBeCalled();
87+
expect(parentSpan.startChild).toBeCalledWith({
88+
description: 'Query.res_1',
89+
op: 'db.graphql.apollo',
90+
});
91+
expect(childSpan.finish).toBeCalled();
92+
});
93+
94+
it('should wrap another simple resolver', () => {
95+
ApolloServer.config.resolvers[0]?.['Mutation']?.['res_2']?.();
96+
expect(scope.getSpan).toBeCalled();
97+
expect(parentSpan.startChild).toBeCalledWith({
98+
description: 'Mutation.res_2',
99+
op: 'db.graphql.apollo',
100+
});
101+
expect(childSpan.finish).toBeCalled();
102+
});
103+
});

0 commit comments

Comments
 (0)