Skip to content

Commit 832d1ec

Browse files
committed
Implement error handling and tracing for Google Cloud functions.
1 parent 21f1a9c commit 832d1ec

File tree

11 files changed

+734
-5
lines changed

11 files changed

+734
-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: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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 {
4+
CloudEventFunction,
5+
CloudEventFunctionWithCallback,
6+
} from '@google-cloud/functions-framework/build/src/functions';
7+
import { flush, getCurrentHub, startTransaction } from '@sentry/node';
8+
import { logger } from '@sentry/utils';
9+
10+
import { captureEventError, getActiveDomain, WrapperOptions } from './general';
11+
12+
export type CloudEventFunctionWrapperOptions = WrapperOptions;
13+
14+
/**
15+
* Wraps an event function handler adding it error capture and tracing capabilities.
16+
*
17+
* @param fn Event handler
18+
* @param options Options
19+
* @returns Event handler
20+
*/
21+
export function wrapCloudEventFunction(
22+
fn: CloudEventFunction | CloudEventFunctionWithCallback,
23+
wrapOptions: Partial<CloudEventFunctionWrapperOptions> = {},
24+
): CloudEventFunctionWithCallback {
25+
const options: CloudEventFunctionWrapperOptions = {
26+
flushTimeout: 2000,
27+
...wrapOptions,
28+
};
29+
return (context, callback) => {
30+
const transaction = startTransaction({
31+
name: context.type || '<unknown>',
32+
op: 'gcp.function.cloud_event',
33+
});
34+
35+
// We put the transaction on the scope so users can attach children to it
36+
getCurrentHub().configureScope(scope => {
37+
scope.setSpan(transaction);
38+
});
39+
40+
const activeDomain = getActiveDomain();
41+
42+
activeDomain.on('error', err => {
43+
captureEventError(err, context);
44+
});
45+
46+
const newCallback = activeDomain.bind((...args: unknown[]) => {
47+
if (args[0] !== null && args[0] !== undefined) {
48+
captureEventError(args[0], context);
49+
}
50+
transaction.finish();
51+
52+
flush(options.flushTimeout)
53+
.then(() => {
54+
callback(...args);
55+
})
56+
.then(null, e => {
57+
logger.error(e);
58+
});
59+
});
60+
61+
if (fn.length > 1) {
62+
return (fn as CloudEventFunctionWithCallback)(context, newCallback);
63+
}
64+
65+
Promise.resolve()
66+
.then(() => (fn as CloudEventFunction)(context))
67+
.then(
68+
result => {
69+
newCallback(null, result);
70+
},
71+
err => {
72+
newCallback(err, undefined);
73+
},
74+
);
75+
};
76+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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 { EventFunction, EventFunctionWithCallback } from '@google-cloud/functions-framework/build/src/functions';
4+
import { flush, getCurrentHub, startTransaction } from '@sentry/node';
5+
import { logger } from '@sentry/utils';
6+
7+
import { captureEventError, getActiveDomain, WrapperOptions } from './general';
8+
9+
export type EventFunctionWrapperOptions = WrapperOptions;
10+
11+
/**
12+
* Wraps an event function handler adding it error capture and tracing capabilities.
13+
*
14+
* @param fn Event handler
15+
* @param options Options
16+
* @returns Event handler
17+
*/
18+
export function wrapEventFunction(
19+
fn: EventFunction | EventFunctionWithCallback,
20+
wrapOptions: Partial<EventFunctionWrapperOptions> = {},
21+
): EventFunctionWithCallback {
22+
const options: EventFunctionWrapperOptions = {
23+
flushTimeout: 2000,
24+
...wrapOptions,
25+
};
26+
return (data, context, callback) => {
27+
const transaction = startTransaction({
28+
name: context.eventType,
29+
op: 'gcp.function.event',
30+
});
31+
32+
// We put the transaction on the scope so users can attach children to it
33+
getCurrentHub().configureScope(scope => {
34+
scope.setSpan(transaction);
35+
});
36+
37+
const activeDomain = getActiveDomain();
38+
39+
activeDomain.on('error', err => {
40+
captureEventError(err, context);
41+
});
42+
43+
const newCallback = activeDomain.bind((...args: unknown[]) => {
44+
if (args[0] !== null && args[0] !== undefined) {
45+
captureEventError(args[0], context);
46+
}
47+
transaction.finish();
48+
49+
flush(options.flushTimeout)
50+
.then(() => {
51+
callback(...args);
52+
})
53+
.then(null, e => {
54+
logger.error(e);
55+
});
56+
});
57+
58+
if (fn.length > 2) {
59+
return (fn as EventFunctionWithCallback)(data, context, newCallback);
60+
}
61+
62+
Promise.resolve()
63+
.then(() => (fn as EventFunction)(data, context))
64+
.then(
65+
result => {
66+
newCallback(null, result);
67+
},
68+
err => {
69+
newCallback(err, undefined);
70+
},
71+
);
72+
};
73+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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 { Context } from '@google-cloud/functions-framework/build/src/functions';
4+
import { captureException, Scope, SDK_VERSION, withScope } from '@sentry/node';
5+
import { Context as SentryContext } from '@sentry/types';
6+
import { addExceptionMechanism } from '@sentry/utils';
7+
import * as domain from 'domain';
8+
import { hostname } from 'os';
9+
10+
export interface WrapperOptions {
11+
flushTimeout: number;
12+
}
13+
14+
/**
15+
* Capture exception with additional event information.
16+
*
17+
* @param e exception to be captured
18+
* @param context event context
19+
*/
20+
export function captureEventError(e: unknown, context: Context): void {
21+
withScope(scope => {
22+
addServerlessEventProcessor(scope);
23+
scope.setContext('runtime', {
24+
name: 'node',
25+
version: global.process.version,
26+
});
27+
scope.setTag('server_name', process.env.SENTRY_NAME || hostname());
28+
scope.setContext('gcp.function.context', { ...context } as SentryContext);
29+
captureException(e);
30+
});
31+
}
32+
33+
/**
34+
* Add event processor that will override SDK details to point to the serverless SDK instead of Node,
35+
* as well as set correct mechanism type, which should be set to `handled: false`.
36+
* We do it like this, so that we don't introduce any side-effects in this module, which makes it tree-shakeable.
37+
* @param scope Scope that processor should be added to
38+
*/
39+
export function addServerlessEventProcessor(scope: Scope): void {
40+
scope.addEventProcessor(event => {
41+
event.sdk = {
42+
...event.sdk,
43+
name: 'sentry.javascript.serverless',
44+
integrations: [...((event.sdk && event.sdk.integrations) || []), 'GCPFunction'],
45+
packages: [
46+
...((event.sdk && event.sdk.packages) || []),
47+
{
48+
name: 'npm:@sentry/serverless',
49+
version: SDK_VERSION,
50+
},
51+
],
52+
version: SDK_VERSION,
53+
};
54+
55+
addExceptionMechanism(event, {
56+
handled: false,
57+
});
58+
59+
return event;
60+
});
61+
}
62+
63+
/**
64+
* @returns Current active domain with a correct type.
65+
*/
66+
export function getActiveDomain(): domain.Domain {
67+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
68+
return (domain as any).active as domain.Domain;
69+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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 { captureException, flush, getCurrentHub, Handlers, startTransaction, withScope } from '@sentry/node';
5+
import { logger, stripUrlQueryAndFragment } from '@sentry/utils';
6+
7+
import { addServerlessEventProcessor, getActiveDomain, WrapperOptions } from './general';
8+
9+
type Request = Parameters<HttpFunction>[0];
10+
type Response = Parameters<HttpFunction>[1];
11+
type ParseRequestOptions = Handlers.ParseRequestOptions;
12+
13+
export interface HttpFunctionWrapperOptions extends WrapperOptions {
14+
parseRequestOptions: ParseRequestOptions;
15+
}
16+
17+
export { Request, Response };
18+
19+
const { parseRequest } = Handlers;
20+
21+
/**
22+
* Capture exception with additional request information.
23+
*
24+
* @param e exception to be captured
25+
* @param req incoming request
26+
* @param options request capture options
27+
*/
28+
function captureRequestError(e: unknown, req: Request, options: ParseRequestOptions): void {
29+
withScope(scope => {
30+
addServerlessEventProcessor(scope);
31+
scope.addEventProcessor(event => parseRequest(event, req, options));
32+
captureException(e);
33+
});
34+
}
35+
36+
/**
37+
* Wraps an HTTP function handler adding it error capture and tracing capabilities.
38+
*
39+
* @param fn HTTP Handler
40+
* @param options Options
41+
* @returns HTTP handler
42+
*/
43+
export function wrapHttpFunction(
44+
fn: HttpFunction,
45+
wrapOptions: Partial<HttpFunctionWrapperOptions> = {},
46+
): HttpFunction {
47+
const options: HttpFunctionWrapperOptions = {
48+
flushTimeout: 2000,
49+
parseRequestOptions: {},
50+
...wrapOptions,
51+
};
52+
return (req, res) => {
53+
const reqMethod = (req.method || '').toUpperCase();
54+
const reqUrl = req.url && stripUrlQueryAndFragment(req.url);
55+
56+
const transaction = startTransaction({
57+
name: `${reqMethod} ${reqUrl}`,
58+
op: 'gcp.function.http',
59+
});
60+
61+
// We put the transaction on the scope so users can attach children to it
62+
getCurrentHub().configureScope(scope => {
63+
scope.setSpan(transaction);
64+
});
65+
66+
// We also set __sentry_transaction on the response so people can grab the transaction there to add
67+
// spans to it later.
68+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
69+
(res as any).__sentry_transaction = transaction;
70+
71+
// functions-framework creates a domain for each incoming request so we take advantage of this fact and add an error handler.
72+
// BTW this is the only way to catch any exception occured during request lifecycle.
73+
getActiveDomain().on('error', err => {
74+
captureRequestError(err, req, options.parseRequestOptions);
75+
});
76+
77+
// eslint-disable-next-line @typescript-eslint/unbound-method
78+
const _end = res.end;
79+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
80+
res.end = function(chunk?: any | (() => void), encoding?: string | (() => void), cb?: () => void): void {
81+
transaction.setHttpStatus(res.statusCode);
82+
transaction.finish();
83+
84+
flush(options.flushTimeout)
85+
.then(() => {
86+
_end.call(this, chunk, encoding, cb);
87+
})
88+
.then(null, e => {
89+
logger.error(e);
90+
});
91+
};
92+
93+
return fn(req, res);
94+
};
95+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './http';
2+
export * from './events';
3+
export * from './cloud_events';
4+
export { init } from '@sentry/node';

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();

0 commit comments

Comments
 (0)