Skip to content

Commit af9acac

Browse files
committed
Implement error handling and tracing for Google Cloud functions.
1 parent a863763 commit af9acac

File tree

6 files changed

+344
-4
lines changed

6 files changed

+344
-4
lines changed

packages/serverless/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@
2323
"tslib": "^1.9.3"
2424
},
2525
"devDependencies": {
26+
"@google-cloud/functions-framework": "^1.7.1",
2627
"@sentry-internal/eslint-config-sdk": "5.25.0",
2728
"@types/aws-lambda": "^8.10.62",
29+
"@types/express": "^4.17.2",
2830
"@types/node": "^14.6.4",
2931
"eslint": "7.6.0",
3032
"jest": "^24.7.1",
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { HttpFunction } from '@google-cloud/functions-framework/build/src/functions';
2+
import { captureException, flush, getCurrentHub, Scope, SDK_VERSION, startTransaction, withScope } from '@sentry/node';
3+
import { Transaction } from '@sentry/types';
4+
import { addExceptionMechanism, logger, stripUrlQueryAndFragment } from '@sentry/utils';
5+
import { Domain } from 'domain';
6+
import { Request, Response } from 'express'; // eslint-disable-line import/no-extraneous-dependencies
7+
8+
export interface HttpWrapperOptions {
9+
tracing: boolean;
10+
flushTimeout: number;
11+
}
12+
13+
/**
14+
* Add event processor that will override SDK details to point to the serverless SDK instead of Node,
15+
* as well as set correct mechanism type, which should be set to `handled: false`.
16+
* We do it like this, so that we don't introduce any side-effects in this module, which makes it tree-shakeable.
17+
* @param scope Scope that processor should be added to
18+
*/
19+
function addServerlessEventProcessor(scope: Scope): void {
20+
scope.addEventProcessor(event => {
21+
event.sdk = {
22+
...event.sdk,
23+
name: 'sentry.javascript.serverless',
24+
integrations: [...((event.sdk && event.sdk.integrations) || []), 'GCPFunction'],
25+
packages: [
26+
...((event.sdk && event.sdk.packages) || []),
27+
{
28+
name: 'npm:@sentry/serverless',
29+
version: SDK_VERSION,
30+
},
31+
],
32+
version: SDK_VERSION,
33+
};
34+
35+
addExceptionMechanism(event, {
36+
handled: false,
37+
});
38+
39+
return event;
40+
});
41+
}
42+
43+
/**
44+
* Capture exception.
45+
*
46+
* @param e exception to be captured
47+
*/
48+
function captureServerlessError(e: unknown): void {
49+
withScope(scope => {
50+
addServerlessEventProcessor(scope);
51+
captureException(e);
52+
});
53+
}
54+
55+
/**
56+
* Wraps an HTTP function handler adding it error capture and tracing capabilities.
57+
*
58+
* @param handler Handler
59+
* @param options Options
60+
* @returns Handler
61+
*/
62+
export function wrapHttp(fn: HttpFunction, wrapOptions: Partial<HttpWrapperOptions> = {}): HttpFunction {
63+
const options: HttpWrapperOptions = {
64+
tracing: true,
65+
flushTimeout: 2000,
66+
...wrapOptions,
67+
};
68+
const domain = require('domain');
69+
return function(req: Request, res: Response): ReturnType<HttpFunction> {
70+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
71+
const activeDomain = domain.active as Domain;
72+
const reqMethod = (req.method || '').toUpperCase();
73+
const reqUrl = req.url && stripUrlQueryAndFragment(req.url);
74+
let transaction: Transaction | undefined;
75+
76+
if (options.tracing) {
77+
transaction = startTransaction({
78+
name: `${reqMethod} ${reqUrl}`,
79+
op: 'gcp.function.http',
80+
});
81+
82+
// We put the transaction on the scope so users can attach children to it
83+
getCurrentHub().configureScope(scope => {
84+
scope.setSpan(transaction);
85+
});
86+
87+
// We also set __sentry_transaction on the response so people can grab the transaction there to add
88+
// spans to it later.
89+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
90+
(res as any).__sentry_transaction = transaction;
91+
}
92+
93+
activeDomain.on('error', captureServerlessError);
94+
// eslint-disable-next-line @typescript-eslint/unbound-method
95+
const _end = res.end;
96+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
97+
res.end = function(chunk?: any | (() => void), encoding?: string | (() => void), cb?: () => void): void {
98+
if (transaction) {
99+
transaction.setHttpStatus(res.statusCode);
100+
transaction.finish();
101+
}
102+
103+
activeDomain.off('error', captureServerlessError);
104+
flush(options.flushTimeout)
105+
.then(() => {
106+
_end.call(this, chunk, encoding, cb);
107+
})
108+
.then(null, e => {
109+
logger.error(e);
110+
});
111+
};
112+
113+
return fn(req, res);
114+
};
115+
}

packages/serverless/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// https://medium.com/unsplash/named-namespace-imports-7345212bbffb
22
import * as AWSLambda from './awslambda';
3-
export { AWSLambda };
3+
import * as GCPFunction from './gcpfunction';
4+
export { AWSLambda, GCPFunction };
45

56
export * from '@sentry/node';

packages/serverless/test/__mocks__/@sentry/node.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const fakeScope = {
1616
};
1717
export const fakeTransaction = {
1818
finish: jest.fn(),
19+
setHttpStatus: jest.fn(),
1920
};
2021
export const getCurrentHub = jest.fn(() => fakeHub);
2122
export const startTransaction = jest.fn(_ => fakeTransaction);
@@ -25,6 +26,7 @@ export const withScope = jest.fn(cb => cb(fakeScope));
2526
export const flush = jest.fn(() => Promise.resolve());
2627

2728
export const resetMocks = (): void => {
29+
fakeTransaction.setHttpStatus.mockClear();
2830
fakeTransaction.finish.mockClear();
2931
fakeParentScope.setSpan.mockClear();
3032
fakeHub.configureScope.mockClear();
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { HttpFunction } from '@google-cloud/functions-framework/build/src/functions';
2+
import { Event } from '@sentry/types';
3+
import * as domain from 'domain';
4+
import { Request, Response } from 'express';
5+
import { ServerResponse } from 'http';
6+
7+
import * as Sentry from '../src';
8+
9+
const { wrapHttp } = Sentry.GCPFunction;
10+
11+
/**
12+
* Why @ts-ignore some Sentry.X calls
13+
*
14+
* A hack-ish way to contain everything related to mocks in the same __mocks__ file.
15+
* Thanks to this, we don't have to do more magic than necessary. Just add and export desired method and assert on it.
16+
*/
17+
18+
describe('GCPFunction', () => {
19+
afterEach(() => {
20+
// @ts-ignore see "Why @ts-ignore" note
21+
Sentry.resetMocks();
22+
});
23+
24+
async function invokeHttp(fn: HttpFunction): Promise<void> {
25+
const req = {
26+
method: 'GET',
27+
host: '',
28+
cookies: {},
29+
query: {},
30+
url: '/path',
31+
headers: {},
32+
} as Request;
33+
const res = new ServerResponse(req) as Response;
34+
await new Promise<void>((resolve, _reject) => {
35+
const d = domain.create();
36+
// eslint-disable-next-line @typescript-eslint/unbound-method
37+
const _end = res.end;
38+
res.end = function(...args: any[]): void {
39+
resolve();
40+
_end.call(this, ...args);
41+
};
42+
d.on('error', () => {
43+
res.end();
44+
});
45+
d.run(fn, req, res);
46+
});
47+
}
48+
49+
describe('wrapHttp() options', () => {
50+
test('flushTimeout', async () => {
51+
expect.assertions(1);
52+
53+
const handler: HttpFunction = (_, res) => {
54+
res.end();
55+
};
56+
const wrappedHandler = wrapHttp(handler, { flushTimeout: 1337 });
57+
58+
await invokeHttp(wrappedHandler);
59+
expect(Sentry.flush).toBeCalledWith(1337);
60+
});
61+
62+
test('tracing enabled', async () => {
63+
expect.assertions(1);
64+
65+
const handler: HttpFunction = (_, res) => {
66+
res.end();
67+
};
68+
await invokeHttp(wrapHttp(handler, { tracing: true }));
69+
expect(Sentry.startTransaction).toBeCalled();
70+
});
71+
72+
test('tracing disabled', async () => {
73+
expect.assertions(1);
74+
75+
const handler: HttpFunction = (_, res) => {
76+
res.end();
77+
};
78+
await invokeHttp(wrapHttp(handler, { tracing: false }));
79+
expect(Sentry.startTransaction).not.toBeCalled();
80+
});
81+
});
82+
83+
describe('wrapHandler()', () => {
84+
test('successful execution', async () => {
85+
expect.assertions(5);
86+
87+
const handler: HttpFunction = (_, res) => {
88+
res.statusCode = 200;
89+
res.end();
90+
};
91+
const wrappedHandler = wrapHttp(handler);
92+
await invokeHttp(wrappedHandler);
93+
expect(Sentry.startTransaction).toBeCalledWith({ name: 'GET /path', op: 'gcp.function.http' });
94+
// @ts-ignore see "Why @ts-ignore" note
95+
expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction);
96+
// @ts-ignore see "Why @ts-ignore" note
97+
expect(Sentry.fakeTransaction.setHttpStatus).toBeCalledWith(200);
98+
// @ts-ignore see "Why @ts-ignore" note
99+
expect(Sentry.fakeTransaction.finish).toBeCalled();
100+
expect(Sentry.flush).toBeCalledWith(2000);
101+
});
102+
103+
test('capture error', async () => {
104+
expect.assertions(5);
105+
106+
const error = new Error('wat');
107+
const handler: HttpFunction = (_req, _res) => {
108+
process.nextTick(() => {
109+
throw error;
110+
});
111+
};
112+
const wrappedHandler = wrapHttp(handler);
113+
await invokeHttp(wrappedHandler);
114+
expect(Sentry.startTransaction).toBeCalledWith({ name: 'GET /path', op: 'gcp.function.http' });
115+
// @ts-ignore see "Why @ts-ignore" note
116+
expect(Sentry.fakeParentScope.setSpan).toBeCalledWith(Sentry.fakeTransaction);
117+
expect(Sentry.captureException).toBeCalledWith(error);
118+
// @ts-ignore see "Why @ts-ignore" note
119+
expect(Sentry.fakeTransaction.finish).toBeCalled();
120+
expect(Sentry.flush).toBeCalled();
121+
});
122+
});
123+
124+
test('enhance event with SDK info and correct mechanism value', async () => {
125+
expect.assertions(2);
126+
127+
const error = new Error('wat');
128+
const handler: HttpFunction = () => {
129+
process.nextTick(() => {
130+
throw error;
131+
});
132+
};
133+
const wrappedHandler = wrapHttp(handler);
134+
135+
const eventWithSomeData = {
136+
exception: {
137+
values: [{}],
138+
},
139+
sdk: {
140+
integrations: ['SomeIntegration'],
141+
packages: [
142+
{
143+
name: 'some:@random/package',
144+
version: '1337',
145+
},
146+
],
147+
},
148+
};
149+
// @ts-ignore see "Why @ts-ignore" note
150+
Sentry.fakeScope.addEventProcessor.mockImplementationOnce(cb => cb(eventWithSomeData));
151+
await invokeHttp(wrappedHandler);
152+
expect(eventWithSomeData).toEqual({
153+
exception: {
154+
values: [
155+
{
156+
mechanism: {
157+
handled: false,
158+
},
159+
},
160+
],
161+
},
162+
sdk: {
163+
name: 'sentry.javascript.serverless',
164+
integrations: ['SomeIntegration', 'GCPFunction'],
165+
packages: [
166+
{
167+
name: 'some:@random/package',
168+
version: '1337',
169+
},
170+
{
171+
name: 'npm:@sentry/serverless',
172+
version: '6.6.6',
173+
},
174+
],
175+
version: '6.6.6',
176+
},
177+
});
178+
179+
const eventWithoutAnyData: Event = {
180+
exception: {
181+
values: [{}],
182+
},
183+
};
184+
// @ts-ignore see "Why @ts-ignore" note
185+
Sentry.fakeScope.addEventProcessor.mockImplementationOnce(cb => cb(eventWithoutAnyData));
186+
await invokeHttp(wrappedHandler);
187+
expect(eventWithoutAnyData).toEqual({
188+
exception: {
189+
values: [
190+
{
191+
mechanism: {
192+
handled: false,
193+
},
194+
},
195+
],
196+
},
197+
sdk: {
198+
name: 'sentry.javascript.serverless',
199+
integrations: ['GCPFunction'],
200+
packages: [
201+
{
202+
name: 'npm:@sentry/serverless',
203+
version: '6.6.6',
204+
},
205+
],
206+
version: '6.6.6',
207+
},
208+
});
209+
});
210+
});

0 commit comments

Comments
 (0)