Skip to content

Commit

Permalink
Implement error handling and tracing for Google Cloud functions.
Browse files Browse the repository at this point in the history
  • Loading branch information
marshall-lee authored and kamilogorek committed Oct 12, 2020
1 parent 9443027 commit b4a29be
Show file tree
Hide file tree
Showing 11 changed files with 734 additions and 5 deletions.
2 changes: 1 addition & 1 deletion packages/node/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ function extractUserData(
/**
* Options deciding what parts of the request to use when enhancing an event
*/
interface ParseRequestOptions {
export interface ParseRequestOptions {
ip?: boolean;
request?: boolean | string[];
serverName?: boolean;
Expand Down
1 change: 1 addition & 0 deletions packages/serverless/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"tslib": "^1.9.3"
},
"devDependencies": {
"@google-cloud/functions-framework": "^1.7.1",
"@sentry-internal/eslint-config-sdk": "5.25.0",
"@types/aws-lambda": "^8.10.62",
"@types/node": "^14.6.4",
Expand Down
76 changes: 76 additions & 0 deletions packages/serverless/src/gcpfunction/cloud_events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// '@google-cloud/functions-framework/build/src/functions' import is expected to be type-only so it's erased in the final .js file.
// When TypeScript compiler is upgraded, use `import type` syntax to explicitly assert that we don't want to load a module here.
import {
CloudEventFunction,
CloudEventFunctionWithCallback,
} from '@google-cloud/functions-framework/build/src/functions';
import { flush, getCurrentHub, startTransaction } from '@sentry/node';
import { logger } from '@sentry/utils';

import { captureEventError, getActiveDomain, WrapperOptions } from './general';

export type CloudEventFunctionWrapperOptions = WrapperOptions;

/**
* Wraps an event function handler adding it error capture and tracing capabilities.
*
* @param fn Event handler
* @param options Options
* @returns Event handler
*/
export function wrapCloudEventFunction(
fn: CloudEventFunction | CloudEventFunctionWithCallback,
wrapOptions: Partial<CloudEventFunctionWrapperOptions> = {},
): CloudEventFunctionWithCallback {
const options: CloudEventFunctionWrapperOptions = {
flushTimeout: 2000,
...wrapOptions,
};
return (context, callback) => {
const transaction = startTransaction({
name: context.type || '<unknown>',
op: 'gcp.function.cloud_event',
});

// We put the transaction on the scope so users can attach children to it
getCurrentHub().configureScope(scope => {
scope.setSpan(transaction);
});

const activeDomain = getActiveDomain();

activeDomain.on('error', err => {
captureEventError(err, context);
});

const newCallback = activeDomain.bind((...args: unknown[]) => {
if (args[0] !== null && args[0] !== undefined) {
captureEventError(args[0], context);
}
transaction.finish();

flush(options.flushTimeout)
.then(() => {
callback(...args);
})
.then(null, e => {
logger.error(e);
});
});

if (fn.length > 1) {
return (fn as CloudEventFunctionWithCallback)(context, newCallback);
}

Promise.resolve()
.then(() => (fn as CloudEventFunction)(context))
.then(
result => {
newCallback(null, result);
},
err => {
newCallback(err, undefined);
},
);
};
}
73 changes: 73 additions & 0 deletions packages/serverless/src/gcpfunction/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// '@google-cloud/functions-framework/build/src/functions' import is expected to be type-only so it's erased in the final .js file.
// When TypeScript compiler is upgraded, use `import type` syntax to explicitly assert that we don't want to load a module here.
import { EventFunction, EventFunctionWithCallback } from '@google-cloud/functions-framework/build/src/functions';
import { flush, getCurrentHub, startTransaction } from '@sentry/node';
import { logger } from '@sentry/utils';

import { captureEventError, getActiveDomain, WrapperOptions } from './general';

export type EventFunctionWrapperOptions = WrapperOptions;

/**
* Wraps an event function handler adding it error capture and tracing capabilities.
*
* @param fn Event handler
* @param options Options
* @returns Event handler
*/
export function wrapEventFunction(
fn: EventFunction | EventFunctionWithCallback,
wrapOptions: Partial<EventFunctionWrapperOptions> = {},
): EventFunctionWithCallback {
const options: EventFunctionWrapperOptions = {
flushTimeout: 2000,
...wrapOptions,
};
return (data, context, callback) => {
const transaction = startTransaction({
name: context.eventType,
op: 'gcp.function.event',
});

// We put the transaction on the scope so users can attach children to it
getCurrentHub().configureScope(scope => {
scope.setSpan(transaction);
});

const activeDomain = getActiveDomain();

activeDomain.on('error', err => {
captureEventError(err, context);
});

const newCallback = activeDomain.bind((...args: unknown[]) => {
if (args[0] !== null && args[0] !== undefined) {
captureEventError(args[0], context);
}
transaction.finish();

flush(options.flushTimeout)
.then(() => {
callback(...args);
})
.then(null, e => {
logger.error(e);
});
});

if (fn.length > 2) {
return (fn as EventFunctionWithCallback)(data, context, newCallback);
}

Promise.resolve()
.then(() => (fn as EventFunction)(data, context))
.then(
result => {
newCallback(null, result);
},
err => {
newCallback(err, undefined);
},
);
};
}
69 changes: 69 additions & 0 deletions packages/serverless/src/gcpfunction/general.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// '@google-cloud/functions-framework/build/src/functions' import is expected to be type-only so it's erased in the final .js file.
// When TypeScript compiler is upgraded, use `import type` syntax to explicitly assert that we don't want to load a module here.
import { Context } from '@google-cloud/functions-framework/build/src/functions';
import { captureException, Scope, SDK_VERSION, withScope } from '@sentry/node';
import { Context as SentryContext } from '@sentry/types';
import { addExceptionMechanism } from '@sentry/utils';
import * as domain from 'domain';
import { hostname } from 'os';

export interface WrapperOptions {
flushTimeout: number;
}

/**
* Capture exception with additional event information.
*
* @param e exception to be captured
* @param context event context
*/
export function captureEventError(e: unknown, context: Context): void {
withScope(scope => {
addServerlessEventProcessor(scope);
scope.setContext('runtime', {
name: 'node',
version: global.process.version,
});
scope.setTag('server_name', process.env.SENTRY_NAME || hostname());
scope.setContext('gcp.function.context', { ...context } as SentryContext);
captureException(e);
});
}

/**
* Add event processor that will override SDK details to point to the serverless SDK instead of Node,
* as well as set correct mechanism type, which should be set to `handled: false`.
* We do it like this, so that we don't introduce any side-effects in this module, which makes it tree-shakeable.
* @param scope Scope that processor should be added to
*/
export function addServerlessEventProcessor(scope: Scope): void {
scope.addEventProcessor(event => {
event.sdk = {
...event.sdk,
name: 'sentry.javascript.serverless',
integrations: [...((event.sdk && event.sdk.integrations) || []), 'GCPFunction'],
packages: [
...((event.sdk && event.sdk.packages) || []),
{
name: 'npm:@sentry/serverless',
version: SDK_VERSION,
},
],
version: SDK_VERSION,
};

addExceptionMechanism(event, {
handled: false,
});

return event;
});
}

/**
* @returns Current active domain with a correct type.
*/
export function getActiveDomain(): domain.Domain {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
return (domain as any).active as domain.Domain;
}
95 changes: 95 additions & 0 deletions packages/serverless/src/gcpfunction/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// '@google-cloud/functions-framework/build/src/functions' import is expected to be type-only so it's erased in the final .js file.
// When TypeScript compiler is upgraded, use `import type` syntax to explicitly assert that we don't want to load a module here.
import { HttpFunction } from '@google-cloud/functions-framework/build/src/functions';
import { captureException, flush, getCurrentHub, Handlers, startTransaction, withScope } from '@sentry/node';
import { logger, stripUrlQueryAndFragment } from '@sentry/utils';

import { addServerlessEventProcessor, getActiveDomain, WrapperOptions } from './general';

type Request = Parameters<HttpFunction>[0];
type Response = Parameters<HttpFunction>[1];
type ParseRequestOptions = Handlers.ParseRequestOptions;

export interface HttpFunctionWrapperOptions extends WrapperOptions {
parseRequestOptions: ParseRequestOptions;
}

export { Request, Response };

const { parseRequest } = Handlers;

/**
* Capture exception with additional request information.
*
* @param e exception to be captured
* @param req incoming request
* @param options request capture options
*/
function captureRequestError(e: unknown, req: Request, options: ParseRequestOptions): void {
withScope(scope => {
addServerlessEventProcessor(scope);
scope.addEventProcessor(event => parseRequest(event, req, options));
captureException(e);
});
}

/**
* Wraps an HTTP function handler adding it error capture and tracing capabilities.
*
* @param fn HTTP Handler
* @param options Options
* @returns HTTP handler
*/
export function wrapHttpFunction(
fn: HttpFunction,
wrapOptions: Partial<HttpFunctionWrapperOptions> = {},
): HttpFunction {
const options: HttpFunctionWrapperOptions = {
flushTimeout: 2000,
parseRequestOptions: {},
...wrapOptions,
};
return (req, res) => {
const reqMethod = (req.method || '').toUpperCase();
const reqUrl = req.url && stripUrlQueryAndFragment(req.url);

const transaction = startTransaction({
name: `${reqMethod} ${reqUrl}`,
op: 'gcp.function.http',
});

// We put the transaction on the scope so users can attach children to it
getCurrentHub().configureScope(scope => {
scope.setSpan(transaction);
});

// We also set __sentry_transaction on the response so people can grab the transaction there to add
// spans to it later.
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
(res as any).__sentry_transaction = transaction;

// functions-framework creates a domain for each incoming request so we take advantage of this fact and add an error handler.
// BTW this is the only way to catch any exception occured during request lifecycle.
getActiveDomain().on('error', err => {
captureRequestError(err, req, options.parseRequestOptions);
});

// eslint-disable-next-line @typescript-eslint/unbound-method
const _end = res.end;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
res.end = function(chunk?: any | (() => void), encoding?: string | (() => void), cb?: () => void): void {
transaction.setHttpStatus(res.statusCode);
transaction.finish();

flush(options.flushTimeout)
.then(() => {
_end.call(this, chunk, encoding, cb);
})
.then(null, e => {
logger.error(e);
});
};

return fn(req, res);
};
}
4 changes: 4 additions & 0 deletions packages/serverless/src/gcpfunction/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './http';
export * from './events';
export * from './cloud_events';
export { init } from '@sentry/node';
3 changes: 2 additions & 1 deletion packages/serverless/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// https://medium.com/unsplash/named-namespace-imports-7345212bbffb
import * as AWSLambda from './awslambda';
export { AWSLambda };
import * as GCPFunction from './gcpfunction';
export { AWSLambda, GCPFunction };

export * from '@sentry/node';
4 changes: 4 additions & 0 deletions packages/serverless/test/__mocks__/@sentry/node.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const origSentry = jest.requireActual('@sentry/node');
export const Handlers = origSentry.Handlers; // eslint-disable-line @typescript-eslint/no-unsafe-member-access
export const SDK_VERSION = '6.6.6';
export const Severity = {
Warning: 'warning',
Expand All @@ -16,6 +18,7 @@ export const fakeScope = {
};
export const fakeTransaction = {
finish: jest.fn(),
setHttpStatus: jest.fn(),
};
export const getCurrentHub = jest.fn(() => fakeHub);
export const startTransaction = jest.fn(_ => fakeTransaction);
Expand All @@ -25,6 +28,7 @@ export const withScope = jest.fn(cb => cb(fakeScope));
export const flush = jest.fn(() => Promise.resolve());

export const resetMocks = (): void => {
fakeTransaction.setHttpStatus.mockClear();
fakeTransaction.finish.mockClear();
fakeParentScope.setSpan.mockClear();
fakeHub.configureScope.mockClear();
Expand Down
Loading

0 comments on commit b4a29be

Please sign in to comment.