Skip to content

feat(tracing): GraphQL and Apollo Integrations #3953

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

Merged
merged 15 commits into from
Jun 10, 2022
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
2 changes: 2 additions & 0 deletions packages/node-integration-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@
"@types/mongodb": "^3.6.20",
"@types/mysql": "^2.15.21",
"@types/pg": "^8.6.5",
"apollo-server": "^3.6.7",
"cors": "^2.8.5",
"express": "^4.17.3",
"graphql": "^16.3.0",
"mongodb": "^3.7.3",
"mongodb-memory-server-global": "^7.6.3",
"mysql": "^2.18.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import * as Sentry from '@sentry/node';
import * as Tracing from '@sentry/tracing';
import { ApolloServer, gql } from 'apollo-server';

Sentry.init({
dsn: 'https://[email protected]/1337',
release: '1.0',
tracesSampleRate: 1.0,
integrations: [new Tracing.Integrations.GraphQL(), new Tracing.Integrations.Apollo()],
});

const typeDefs = gql`
type Query {
hello: String
}
`;

const resolvers = {
Query: {
hello: () => {
return 'Hello world!';
},
},
};

const server = new ApolloServer({
typeDefs,
resolvers,
});

const transaction = Sentry.startTransaction({ name: 'test_transaction', op: 'transaction' });

Sentry.configureScope(scope => {
scope.setSpan(transaction);
});

void (async () => {
// Ref: https://www.apollographql.com/docs/apollo-server/testing/testing/#testing-using-executeoperation
await server.executeOperation({
query: '{hello}',
});

transaction.finish();
})();
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { assertSentryTransaction, conditionalTest, getEnvelopeRequest, runServer } from '../../../utils';

// Node 10 is not supported by `graphql-js`
// Ref: https://github.com/graphql/graphql-js/blob/main/package.json
conditionalTest({ min: 12 })('GraphQL/Apollo Tests', () => {
test('should instrument GraphQL and Apollo Server.', async () => {
const url = await runServer(__dirname);
const envelope = await getEnvelopeRequest(url);

expect(envelope).toHaveLength(3);

const transaction = envelope[2];
const parentSpanId = (transaction as any)?.contexts?.trace?.span_id;
const graphqlSpanId = (transaction as any)?.spans?.[0].span_id;

expect(parentSpanId).toBeDefined();
expect(graphqlSpanId).toBeDefined();

assertSentryTransaction(transaction, {
transaction: 'test_transaction',
spans: [
{
description: 'execute',
op: 'db.graphql',
parent_span_id: parentSpanId,
},
{
description: 'Query.hello',
op: 'db.graphql.apollo',
parent_span_id: graphqlSpanId,
},
],
});
});
});
1 change: 0 additions & 1 deletion packages/tracing/src/hubextensions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable max-lines */
import { getMainCarrier, Hub } from '@sentry/hub';
import {
ClientOptions,
Expand Down
2 changes: 2 additions & 0 deletions packages/tracing/src/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export { Postgres } from './node/postgres';
export { Mysql } from './node/mysql';
export { Mongo } from './node/mongo';
export { Prisma } from './node/prisma';
export { GraphQL } from './node/graphql';
export { Apollo } from './node/apollo';

// TODO(v7): Remove this export
// Please see `src/index.ts` for more details.
Expand Down
101 changes: 101 additions & 0 deletions packages/tracing/src/integrations/node/apollo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Hub } from '@sentry/hub';
import { EventProcessor, Integration } from '@sentry/types';
import { fill, isThenable, loadModule, logger } from '@sentry/utils';

type ApolloResolverGroup = {
[key: string]: () => unknown;
};

type ApolloModelResolvers = {
[key: string]: ApolloResolverGroup;
};

/** Tracing integration for Apollo */
export class Apollo implements Integration {
/**
* @inheritDoc
*/
public static id: string = 'Apollo';

/**
* @inheritDoc
*/
public name: string = Apollo.id;

/**
* @inheritDoc
*/
public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
const pkg = loadModule<{
ApolloServerBase: {
prototype: {
constructSchema: () => unknown;
};
};
}>('apollo-server-core');

if (!pkg) {
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: () => unknown) {
return function (this: { config: { resolvers: ApolloModelResolvers[] } }) {
const resolvers = Array.isArray(this.config.resolvers) ? this.config.resolvers : [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;
}

wrapResolver(model, resolverGroupName, resolverName, getCurrentHub);
});
});

return model;
});

return orig.call(this);
};
});
}
}

/**
* Wrap a single resolver which can be a parent of other resolvers and/or db operations.
*/
function wrapResolver(
model: ApolloModelResolvers,
resolverGroupName: string,
resolverName: string,
getCurrentHub: () => Hub,
): void {
fill(model[resolverGroupName], resolverName, function (orig: () => unknown | Promise<unknown>) {
return function (this: unknown, ...args: unknown[]) {
const scope = getCurrentHub().getScope();
const parentSpan = scope?.getSpan();
const span = parentSpan?.startChild({
description: `${resolverGroupName}.${resolverName}`,
op: 'db.graphql.apollo',
});

const rv = orig.call(this, ...args);

if (isThenable(rv)) {
return rv.then((res: unknown) => {
span?.finish();
return res;
});
}

span?.finish();

return rv;
};
});
}
59 changes: 59 additions & 0 deletions packages/tracing/src/integrations/node/graphql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Hub } from '@sentry/hub';
import { EventProcessor, Integration } from '@sentry/types';
import { fill, isThenable, loadModule, logger } from '@sentry/utils';

/** Tracing integration for graphql package */
export class GraphQL implements Integration {
/**
* @inheritDoc
*/
public static id: string = 'GraphQL';

/**
* @inheritDoc
*/
public name: string = GraphQL.id;

/**
* @inheritDoc
*/
public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
const pkg = loadModule<{
[method: string]: (...args: unknown[]) => unknown;
}>('graphql/execution/execute.js');

if (!pkg) {
logger.error('GraphQL Integration was unable to require graphql/execution package.');
return;
}

fill(pkg, 'execute', function (orig: () => void | Promise<unknown>) {
return function (this: unknown, ...args: unknown[]) {
const scope = getCurrentHub().getScope();
const parentSpan = scope?.getSpan();

const span = parentSpan?.startChild({
description: 'execute',
op: 'db.graphql',
});

scope?.setSpan(span);

const rv = orig.call(this, ...args);

if (isThenable(rv)) {
return rv.then((res: unknown) => {
span?.finish();
scope?.setSpan(parentSpan);

return res;
});
}

span?.finish();
scope?.setSpan(parentSpan);
return rv;
};
});
}
}
103 changes: 103 additions & 0 deletions packages/tracing/test/integrations/apollo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { Hub, Scope } from '@sentry/hub';

import { Apollo } from '../../src/integrations/node/apollo';
import { Span } from '../../src/span';

type ApolloResolverGroup = {
[key: string]: () => any;
};

type ApolloModelResolvers = {
[key: string]: ApolloResolverGroup;
};

class ApolloServerBase {
config: {
resolvers: ApolloModelResolvers[];
};

constructor() {
this.config = {
resolvers: [
{
Query: {
res_1(..._args: unknown[]) {
return 'foo';
},
},
Mutation: {
res_2(..._args: unknown[]) {
return 'bar';
},
},
},
],
};

this.constructSchema();
}

public constructSchema(..._args: unknown[]) {
return null;
}
}

// mock for ApolloServer package
jest.mock('@sentry/utils', () => {
const actual = jest.requireActual('@sentry/utils');
return {
...actual,
loadModule() {
return {
ApolloServerBase,
};
},
};
});

describe('setupOnce', () => {
let scope = new Scope();
let parentSpan: Span;
let childSpan: Span;
let ApolloServer: ApolloServerBase;

beforeAll(() => {
new Apollo().setupOnce(
() => undefined,
() => new Hub(undefined, scope),
);

ApolloServer = new ApolloServerBase();
});

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', () => {
ApolloServer.config.resolvers[0]?.['Query']?.['res_1']?.();
expect(scope.getSpan).toBeCalled();
expect(parentSpan.startChild).toBeCalledWith({
description: 'Query.res_1',
op: 'db.graphql.apollo',
});
expect(childSpan.finish).toBeCalled();
});

it('should wrap another simple resolver', () => {
ApolloServer.config.resolvers[0]?.['Mutation']?.['res_2']?.();
expect(scope.getSpan).toBeCalled();
expect(parentSpan.startChild).toBeCalledWith({
description: 'Mutation.res_2',
op: 'db.graphql.apollo',
});
expect(childSpan.finish).toBeCalled();
});
});
Loading