@lewisf/serverless-sentry-lib
Version:
Serverless Sentry Lib - Automatically send errors and exceptions to Sentry (https://sentry.io)
431 lines (375 loc) • 12.6 kB
JavaScript
/**
* Raven SDK helper for AWS Lambda.
*/
;
/**
* Whether Raven was installed or not
* @type {boolean}
*/
let ravenInstalled = false;
/**
* Global variable for backward compatibility with old versions of this plugin.
*
* This should not be used. Import Raven yourself and use the local
* instead instead.
*
* @type {Raven}
*
* @example
* const Raven = require('raven');
* Raven.captureException(new Error("My Error"));
*/
global.sls_raven = null;
/**
* Assorted Helper Functions loosely mimicing [lodash](https://lodash.com/).
*/
class _ {
static extend(origin, add) {
// Don't do anything if add isn't an object
if (!add || !_.isObject(add)) {
return origin;
}
const keys = Object.keys(add);
let i = keys.length;
while (i--) {
origin[keys[i]] = add[keys[i]];
}
return origin;
}
static isObject(obj) {
return (typeof obj === "object");
}
static isError(obj) {
return (obj instanceof Error);
}
static isUndefined(obj) {
return (typeof obj === "undefined");
}
static isFunction(obj) {
return (typeof obj === "function");
}
static isNil(obj) {
return (obj === null || _.isUndefined(obj));
}
static get(obj, prop, defaultValue) {
return obj.hasOwnProperty(prop) ? obj[prop] : defaultValue;
}
}
/**
* Install Raven/Sentry support
*
* @param {Object} pluginConfig - Plugin configuration. This is NOT optional!
* @returns {undefined}
*/
function installRaven(pluginConfig) {
const Raven = pluginConfig.ravenClient;
if (!Raven) {
console.error("Raven client not found.");
}
// Check for local environment
const isLocalEnv = process.env.IS_OFFLINE || process.env.IS_LOCAL || !process.env.LAMBDA_TASK_ROOT;
if (pluginConfig.filterLocal && isLocalEnv) {
// Running locally.
console.warn("Sentry disabled in local environment");
delete process.env.SENTRY_DSN; // otherwise raven will start reporting nonetheless
Raven.config().install();
ravenInstalled = true;
return;
}
// We're merging the plugin config options with the Raven options. This
// allows us to control all aspects of Raven in a single location -
// our plugin configuration.
Raven.config(
process.env.SENTRY_DSN,
_.extend({
release: process.env.SENTRY_RELEASE,
environment: isLocalEnv ? "Local" : process.env.SENTRY_ENVIRONMENT,
tags: {
lambda: process.env.AWS_LAMBDA_FUNCTION_NAME,
version: process.env.AWS_LAMBDA_FUNCTION_VERSION,
memory_size: process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE,
log_group: process.env.AWS_LAMBDA_LOG_GROUP_NAME,
log_stream: process.env.AWS_LAMBDA_LOG_STREAM_NAME,
service_name: process.env.SERVERLESS_SERVICE,
stage: process.env.SERVERLESS_STAGE,
alias: process.env.SERVERLESS_ALIAS,
region: process.env.SERVERLESS_REGION || process.env.AWS_REGION
}
}, pluginConfig)
).install();
// Register this instance globally for backward compatibility
// with serverless-sentry-plugin 0.2.x/0.3.x
global.sls_raven = Raven;
ravenInstalled = true;
console.log("Raven installed.");
}
// Timers
let memoryWatch, timeoutWarning, timeoutError;
/**
* Insatll Watchdog timers
*
* @param {Object} pluginConfig
* @param {Object} lambdaContext
*/
function installTimers(pluginConfig, lambdaContext) {
const timeRemaining = lambdaContext.getRemainingTimeInMillis();
const memoryLimit = lambdaContext.memoryLimitInMB;
function timeoutWarningFunc(cb) {
const Raven = pluginConfig.ravenClient;
ravenInstalled && Raven.captureMessage("Function Execution Time Warning", {
level: "warning",
extra: {
TimeRemainingInMsec: lambdaContext.getRemainingTimeInMillis()
}
}, cb);
}
function timeoutErrorFunc(cb) {
const Raven = pluginConfig.ravenClient;
ravenInstalled && Raven.captureMessage("Function Timed Out", {
level: "error"
}, cb);
}
function memoryWatchFunc(cb) {
const used = process.memoryUsage().rss / 1048576;
const p = (used / memoryLimit);
if (p >= 0.75) {
const Raven = pluginConfig.ravenClient;
ravenInstalled && Raven.captureMessage("Low Memory Warning", {
level: "warning",
extra: {
MemoryLimitInMB: memoryLimit,
MemoryUsedInMB: Math.floor(used)
}
}, cb);
if (memoryWatch) {
clearTimeout(memoryWatch);
memoryWatch = null;
}
}
else {
memoryWatch = setTimeout(memoryWatchFunc, 500);
}
}
if (pluginConfig.captureTimeoutWarnings) {
// We schedule the warning at half the maximum execution time and
// the error a few milliseconds before the actual timeout happens.
timeoutWarning = setTimeout(timeoutWarningFunc, timeRemaining / 2);
timeoutError = setTimeout(timeoutErrorFunc, Math.max(timeRemaining - 500, 0));
}
if (pluginConfig.captureMemoryWarnings) {
// Schedule memory watch dog interval. Note that we're not using
// setInterval() here as we don't want invokes to be skipped.
memoryWatch = setTimeout(memoryWatchFunc, 500);
}
}
/**
* Stops and removes all timers
*/
function clearTimers() {
if (timeoutWarning) {
clearTimeout(timeoutWarning);
timeoutWarning = null;
}
if (timeoutError) {
clearTimeout(timeoutError);
timeoutError = null;
}
if (memoryWatch) {
clearTimeout(memoryWatch);
memoryWatch = null;
}
}
/**
* Wraps a given callback function with error logging
*
* @param {Object} pluginConfig
* @param {Function} cb - Callback function to wrap
* @returns {Function}
*/
function wrapCallback(pluginConfig, cb) {
return (err, data) => {
// Stop watchdog timers
clearTimers();
// If an error was thrown we'll report it to Sentry
if (err && pluginConfig.captureErrors) {
const Raven = pluginConfig.ravenClient;
ravenInstalled && Raven.captureException(err, {}, () => {
cb(err, data);
});
}
else {
cb(err, data);
}
};
}
/**
* Tries to convert any given value into a boolean `true`/`false`.
*
* @param {any} value - Value to parse
* @param {boolean} defaultValue - Default value to use if no valid value was passed
* @returns {boolean}
*/
function parseBoolean(value, defaultValue) {
const v = String(value).trim().toLowerCase();
if ([ "true", "t", "1", "yes", "y" ].includes(v)) {
return true;
}
else if ([ "false", "f", "0", "no", "n" ].includes(v)) {
return false;
}
else {
return defaultValue;
}
}
class RavenLambdaWrapper {
/**
* Wrap a Lambda Functions Handler
*
* @see http://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-handler.html
* @param {Object|Raven} pluginConfig - Raven client or an options object
* @param {boolean} [pluginConfig.ravenClient] - Raven client instance
* @param {boolean} [pluginConfig.autoBreadcrumbs] - Automatically create breadcrumbs (see Sentry Raven docs, default to `true`)
* @param {boolean} [pluginConfig.filterLocal] - don't report errors from local environments (defaults to `true`)
* @param {boolean} [pluginConfig.captureErrors] - capture Lambda errors (defaults to `true`)
* @param {boolean} [pluginConfig.captureUnhandledRejections] - capture unhandled exceptions (defaults to `true`)
* @param {boolean} [pluginConfig.captureMemoryWarnings] - monitor memory usage (defaults to `true`)
* @param {boolean} [pluginConfig.captureTimeoutWarnings] - monitor execution timeouts (defaults to `true`)
* @param {Function} handler - Original Lambda function handler
* @return {Function} - Wrapped Lambda function handler with Sentry instrumentation
*/
static handler(pluginConfig, handler) {
if (_.isObject(pluginConfig) &&
_.isFunction(pluginConfig.captureException) &&
_.isFunction(pluginConfig.captureMessage)) {
// Passed in the Raven client object directly
pluginConfig = { ravenClient: pluginConfig };
}
const pluginConfigDefaults = {
autoBreadcrumbs: parseBoolean(_.get(process.env, "SENTRY_AUTO_BREADCRUMBS"), true),
filterLocal: parseBoolean(_.get(process.env, "SENTRY_FILTER_LOCAL"), true),
captureErrors: parseBoolean(_.get(process.env, "SENTRY_CAPTURE_ERRORS"), true),
captureUnhandledRejections: parseBoolean(_.get(process.env, "SENTRY_CAPTURE_UNHANDLED"), true),
captureMemoryWarnings: parseBoolean(_.get(process.env, "SENTRY_CAPTURE_MEMORY"), true),
captureTimeoutWarnings: parseBoolean(_.get(process.env, "SENTRY_CAPTURE_TIMEOUTS"), true),
ravenClient: null
};
pluginConfig = _.extend(pluginConfigDefaults, pluginConfig);
if (!pluginConfig.ravenClient) {
pluginConfig.ravenClient = require("raven");
}
// Install raven (if that didn't happen already during a previous Lambda invocation)
if (process.env.SENTRY_DSN && !ravenInstalled) {
installRaven(pluginConfig);
}
// Create a new handler function wrapping the original one and hooking
// into all callbacks
return (event, context, callback) => {
let callbackCalled = false;
const wrappedCallback = () => {
callbackCalled = true;
callback(arguments);
};
if (!ravenInstalled) {
// Directly invoke the original handler
const maybeThennable = handler(event, context, wrappedCallback);
if (!callbackCalled && maybeThennable.then) {
return maybeThennable;
}
else {
return;
}
}
context.done = wrapCallback(pluginConfig, context.done.bind(context));
context.fail = err => context.done(err);
context.succeed = data => context.done(null, data);
callback = wrapCallback(pluginConfig, callback);
// Additional context to be stored with Raven events and messages
const ravenContext = {
extra: {
Event: event,
Context: context
},
tags: {}
};
// Depending on the endpoint type the identity information can be at
// event.requestContext.identity (AWS_PROXY) or at context.identity (AWS)
const identity =
!_.isNil(context.identity) ? context.identity :
(!_.isNil(event.requestContext) ? event.requestContext.identity : null);
if (!_.isNil(identity)) {
// Track the caller's Cognito identity
// id, username and ip_address are key fields in Sentry
ravenContext.user = {
id: identity.cognitoIdentityId || undefined,
username: identity.user || undefined,
ip_address: identity.sourceIp || undefined,
cognito_identity_pool_id: identity.cognitoIdentityPoolId,
cognito_authentication_type: identity.cognitoAuthenticationType,
user_agent: identity.userAgent
};
}
// Add additional tags for AWS_PROXY endpoints
if (!_.isNil(event.requestContext)) {
_.extend(ravenContext.tags, {
api_id: event.requestContext.apiId,
api_stage: event.requestContext.stage,
http_method: event.requestContext.httpMethod
});
}
// Callback triggered after logging unhandled exceptions or rejections.
// We rethrow the previous error to force stop the current Lambda execution.
const captureUnhandled = wrapCallback(pluginConfig, err => {
err._ravenHandled = true; // prevent recursion
throw err;
});
const Raven = pluginConfig.ravenClient;
return Raven.context(ravenContext, () => {
// This code runs within a raven context now. Unhandled exceptions will
// automatically be captured and reported.
// Monitor for timeouts and memory usage
// The timers will be removed in the wrappedCtx and wrappedCb below
installTimers(pluginConfig, context);
try {
if (pluginConfig.autoBreadcrumbs) {
// First breadcrumb is the invocation of the Lambda itself
const breadcrumb = {
message: process.env.AWS_LAMBDA_FUNCTION_NAME,
category: "lambda",
level: "info",
data: {}
};
if (event.requestContext) {
// Track HTTP request info as part of the breadcrumb
_.extend(breadcrumb.data, {
http_method: event.requestContext && event.requestContext.httpMethod,
host: event.headers && event.headers.Host,
path: event.path,
user_agent: event.headers && event.headers["User-Agent"]
});
}
Raven.captureBreadcrumb(breadcrumb);
}
// And finally invoke the original handler code
const maybeThennable = handler(event, context, wrappedCallback);
if (!callbackCalled && maybeThennable.then) {
return maybeThennable;
}
}
catch (err) {
// Catch and log synchronous exceptions thrown by the handler
captureUnhandled(err);
}
}, err => {
// Catch unhandled exceptions and rejections
if (!_.isObject(err) || err._ravenHandled) {
// This error is being rethrown. Pass it through...
throw err;
}
else {
captureUnhandled(err);
}
});
};
}
}
module.exports = RavenLambdaWrapper;