Skip to content

Commit 19b1040

Browse files
committed
Implement error handling and tracing for Google Cloud functions.
1 parent f250f72 commit 19b1040

File tree

6 files changed

+331
-4
lines changed

6 files changed

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

yarn.lock

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1545,6 +1545,16 @@
15451545
retry-request "^4.0.0"
15461546
teeny-request "^3.11.3"
15471547

1548+
"@google-cloud/functions-framework@^1.7.1":
1549+
version "1.7.1"
1550+
resolved "https://registry.yarnpkg.com/@google-cloud/functions-framework/-/functions-framework-1.7.1.tgz#d29a27744a6eb2f95d840b86135b97b0d804a49e"
1551+
integrity sha512-jjG7nH94Thij97EPW2oQN28pVPRN3UEGcsCRi6RdaaiSyK32X40LN4WHntKVmQPBhqH+I0magHMk1pSb0McH2g==
1552+
dependencies:
1553+
body-parser "^1.18.3"
1554+
express "^4.16.4"
1555+
minimist "^1.2.0"
1556+
on-finished "^2.3.0"
1557+
15481558
"@google-cloud/paginator@^0.2.0":
15491559
version "0.2.0"
15501560
resolved "https://registry.yarnpkg.com/@google-cloud/paginator/-/paginator-0.2.0.tgz#eab2e6aa4b81df7418f6c51e2071f64dab2c2fa5"
@@ -5047,7 +5057,7 @@ bn.js@^5.1.1:
50475057
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.1.2.tgz#c9686902d3c9a27729f43ab10f9d79c2004da7b0"
50485058
integrity sha512-40rZaf3bUNKTVYu9sIeeEGOg7g14Yvnj9kH7b50EiwX0Q7A6umbvfI5tvHaOERH0XigqKkfLkFQxzb4e6CIXnA==
50495059

5050-
[email protected], body-parser@^1.16.1:
5060+
[email protected], body-parser@^1.16.1, body-parser@^1.18.3:
50515061
version "1.19.0"
50525062
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
50535063
integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
@@ -9143,7 +9153,7 @@ expect@^24.9.0:
91439153
jest-message-util "^24.9.0"
91449154
jest-regex-util "^24.9.0"
91459155

9146-
express@^4.10.7, express@^4.16.3, express@^4.17.1:
9156+
express@^4.10.7, express@^4.16.3, express@^4.16.4, express@^4.17.1:
91479157
version "4.17.1"
91489158
resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
91499159
integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==
@@ -14183,7 +14193,7 @@ octokit-pagination-methods@^1.1.0:
1418314193
resolved "https://registry.yarnpkg.com/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4"
1418414194
integrity sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ==
1418514195

14186-
on-finished@~2.3.0:
14196+
on-finished@^2.3.0, on-finished@~2.3.0:
1418714197
version "2.3.0"
1418814198
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
1418914199
integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=

0 commit comments

Comments
 (0)