Skip to content

Commit 80982fa

Browse files
committed
Implement error handling and tracing for Google Cloud functions.
1 parent ac2e261 commit 80982fa

File tree

7 files changed

+339
-5
lines changed

7 files changed

+339
-5
lines changed

packages/node/src/handlers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ function extractUserData(
141141
/**
142142
* Options deciding what parts of the request to use when enhancing an event
143143
*/
144-
interface ParseRequestOptions {
144+
export interface ParseRequestOptions {
145145
ip?: boolean;
146146
request?: boolean | string[];
147147
serverName?: boolean;

packages/serverless/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
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",
2829
"@types/node": "^14.6.4",
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// '@google-cloud/functions-framework/build/src/functions' import is expected to be type-only so it's erased in the final .js file.
2+
// When TypeScript compiler is upgraded, use `import type` syntax to explicitly assert that we don't want to load a module here.
3+
import { HttpFunction } from '@google-cloud/functions-framework/build/src/functions';
4+
import {
5+
captureException,
6+
flush,
7+
getCurrentHub,
8+
Handlers,
9+
Scope,
10+
SDK_VERSION,
11+
startTransaction,
12+
withScope,
13+
} from '@sentry/node';
14+
import { addExceptionMechanism, logger, stripUrlQueryAndFragment } from '@sentry/utils';
15+
import { Domain } from 'domain';
16+
17+
type Request = Parameters<HttpFunction>[0];
18+
type Response = Parameters<HttpFunction>[1];
19+
type ParseRequestOptions = Handlers.ParseRequestOptions;
20+
21+
export { HttpFunction, Request, Response };
22+
23+
export interface HttpWrapperOptions {
24+
flushTimeout: number;
25+
parseRequestOptions: ParseRequestOptions;
26+
}
27+
28+
const { parseRequest } = Handlers;
29+
30+
/**
31+
* Add event processor that will override SDK details to point to the serverless SDK instead of Node,
32+
* as well as set correct mechanism type, which should be set to `handled: false`.
33+
* We do it like this, so that we don't introduce any side-effects in this module, which makes it tree-shakeable.
34+
* @param scope Scope that processor should be added to
35+
*/
36+
function addServerlessEventProcessor(scope: Scope, req: Request, options: ParseRequestOptions): void {
37+
scope.addEventProcessor(event => {
38+
event.sdk = {
39+
...event.sdk,
40+
name: 'sentry.javascript.serverless',
41+
integrations: [...((event.sdk && event.sdk.integrations) || []), 'GCPFunction'],
42+
packages: [
43+
...((event.sdk && event.sdk.packages) || []),
44+
{
45+
name: 'npm:@sentry/serverless',
46+
version: SDK_VERSION,
47+
},
48+
],
49+
version: SDK_VERSION,
50+
};
51+
52+
addExceptionMechanism(event, {
53+
handled: false,
54+
});
55+
56+
return parseRequest(event, req, options);
57+
});
58+
}
59+
60+
/**
61+
* Capture exception.
62+
*
63+
* @param e exception to be captured
64+
*/
65+
function captureRequestError(e: unknown, req: Request, options: ParseRequestOptions): void {
66+
withScope(scope => {
67+
addServerlessEventProcessor(scope, req, options);
68+
captureException(e);
69+
});
70+
}
71+
72+
/**
73+
* Wraps an HTTP function handler adding it error capture and tracing capabilities.
74+
*
75+
* @param handler Handler
76+
* @param options Options
77+
* @returns Handler
78+
*/
79+
export function wrapHttp(fn: HttpFunction, wrapOptions: Partial<HttpWrapperOptions> = {}): HttpFunction {
80+
const options: HttpWrapperOptions = {
81+
flushTimeout: 2000,
82+
parseRequestOptions: {},
83+
...wrapOptions,
84+
};
85+
const domain = require('domain');
86+
return (req, res) => {
87+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
88+
const activeDomain = domain.active as Domain;
89+
const reqMethod = (req.method || '').toUpperCase();
90+
const reqUrl = req.url && stripUrlQueryAndFragment(req.url);
91+
92+
const transaction = startTransaction({
93+
name: `${reqMethod} ${reqUrl}`,
94+
op: 'gcp.function.http',
95+
});
96+
97+
// We put the transaction on the scope so users can attach children to it
98+
getCurrentHub().configureScope(scope => {
99+
scope.setSpan(transaction);
100+
});
101+
102+
// We also set __sentry_transaction on the response so people can grab the transaction there to add
103+
// spans to it later.
104+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
105+
(res as any).__sentry_transaction = transaction;
106+
107+
// functions-framework creates a domain for each incoming request so we take advantage of this fact and add an error handler.
108+
// BTW this is the only way to catch any exception occured during request lifecycle.
109+
activeDomain.on('error', err => {
110+
captureRequestError(err, req, options.parseRequestOptions);
111+
});
112+
113+
// eslint-disable-next-line @typescript-eslint/unbound-method
114+
const _end = res.end;
115+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
116+
res.end = function(chunk?: any | (() => void), encoding?: string | (() => void), cb?: () => void): void {
117+
transaction.setHttpStatus(res.statusCode);
118+
transaction.finish();
119+
120+
flush(options.flushTimeout)
121+
.then(() => {
122+
_end.call(this, chunk, encoding, cb);
123+
})
124+
.then(null, e => {
125+
logger.error(e);
126+
});
127+
};
128+
129+
return fn(req, res);
130+
};
131+
}

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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
const origSentry = jest.requireActual('@sentry/node');
2+
export const Handlers = origSentry.Handlers; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
13
export const SDK_VERSION = '6.6.6';
24
export const Severity = {
35
Warning: 'warning',
@@ -16,6 +18,7 @@ export const fakeScope = {
1618
};
1719
export const fakeTransaction = {
1820
finish: jest.fn(),
21+
setHttpStatus: jest.fn(),
1922
};
2023
export const getCurrentHub = jest.fn(() => fakeHub);
2124
export const startTransaction = jest.fn(_ => fakeTransaction);
@@ -25,6 +28,7 @@ export const withScope = jest.fn(cb => cb(fakeScope));
2528
export const flush = jest.fn(() => Promise.resolve());
2629

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

0 commit comments

Comments
 (0)