diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 0823005bcf08..9ccb0fb157cb 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -18,6 +18,7 @@ "dependencies": { "@sentry/types": "5.22.3", "@sentry/utils": "5.22.3", + "fs": "0.0.1-security", "localforage": "1.8.1", "tslib": "^1.9.3" }, diff --git a/packages/integrations/src/awslambda.ts b/packages/integrations/src/awslambda.ts new file mode 100644 index 000000000000..78c0d09c552b --- /dev/null +++ b/packages/integrations/src/awslambda.ts @@ -0,0 +1,257 @@ +import { EventProcessor, Hub, Integration, Scope } from '@sentry/types'; + +interface AWSLambdaContext { + getRemainingTimeInMillis: () => number; + callbackWaitsForEmptyEventLoop: boolean; + awsRequestId: string; + functionName: string; + functionVersion: string; + invokedFunctionArn: string; + logGroupName: string; + logStreamName: string; +} + +interface Module { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + exports: any; + id: string; + filename: string; + loaded: boolean; + parent: Module | null; + children: Module[]; + path: string; + paths: string[]; +} +/** + * NodeJS integration + * + * Provides a mechanism for NodeJS + * that raises an exception for handled, unhandled, timeout and similar times of error + * and captures the same in Sentry Dashboard + */ +export class AWSLambda implements Integration { + /** + * @inheritDoc + */ + public static id: string = 'AWSLambda'; + + /** + * @inheritDoc + */ + public name: string = AWSLambda.id; + + /** + * context. + */ + private _awsContext: AWSLambdaContext = {} as AWSLambdaContext; + + /** + * timeout flag. + */ + private _timeoutWarning?: boolean = false; + + /** + * flush time in milliseconds to set time for flush. + */ + private _flushTimeout: number = 2000; + + /** + * Assign Hub + */ + private _hub: Hub = {} as Hub; + + public constructor() { + // empty constructor + } + + /** + * @inheritDoc + */ + public setupOnce(_: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { + this._hub = getCurrentHub(); + } + + /** + * @inheritDoc + */ + public providedContext( + context: AWSLambdaContext, + timeoutWarning: boolean = false, + flushTimeout: number = 2000, + ): void { + if (context) { + this._awsContext = context; + } + if (flushTimeout) { + this._flushTimeout = flushTimeout; + } + if (timeoutWarning) { + this._timeoutWarning = timeoutWarning; + } + const flushTime = this._flushTimeout; + const lambdaBootstrap: Module | undefined = require.main; + + if (!this._awsContext.awsRequestId || !lambdaBootstrap) { + return; + } + /** configured time to timeout error and calculate execution time */ + const configuredTimeInMilliseconds = + this._awsContext.getRemainingTimeInMillis && this._awsContext.getRemainingTimeInMillis(); + + /** rapid runtime instance */ + let rapidRuntime; + let originalPostInvocationError = function(): void { + return; + }; + if (lambdaBootstrap.children && lambdaBootstrap.children.length) { + rapidRuntime = lambdaBootstrap.children[0].exports; + /** handler that is invoked in case of unhandled and handled exception */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + originalPostInvocationError = rapidRuntime.prototype.postInvocationError; + } + + const hub = this._hub; + + /** + * This function sets Additional Runtime Data which are displayed in Sentry Dashboard + * @param scope - holds additional event information + * @hidden + */ + const setAdditionalRuntimeData = (scope: Scope): void => { + scope.setContext('runtime', { + name: 'node', + version: global.process.version, + }); + }; + + /** + * This function sets Additional Lambda Parameters which are displayed in Sentry Dashboard + * @param scope - holds additional event information + * @hidden + */ + const setAdditionalLambdaParameters = (scope: Scope): void => { + const remainingTimeInMillisecond: number = + this._awsContext.getRemainingTimeInMillis && this._awsContext.getRemainingTimeInMillis(); + const executionTime: number = configuredTimeInMilliseconds - remainingTimeInMillisecond; + + scope.setContext('lambda', { + aws_request_id: this._awsContext.awsRequestId, + function_name: this._awsContext.functionName, + function_version: this._awsContext.functionVersion, + invoked_function_arn: this._awsContext.invokedFunctionArn, + execution_duration_in_millis: executionTime, + remaining_time_in_millis: remainingTimeInMillisecond, + }); + }; + + /** + * This variable use to generate cloud watch url + * process.env.AWS_REGION - this parameters given the AWS region + * process.env.AWS_LAMBDA_FUNCTION_NAME - this parameter provides the AWS Lambda Function name + */ + const cloudwatchUrl: string = `https://${process.env.AWS_REGION}.console.aws.amazon.com/cloudwatch/home?region=${process.env.AWS_REGION}#logsV2:log-groups/log-group/$252Faws$252Flambda$252F${process.env.AWS_LAMBDA_FUNCTION_NAME}`; + + /** + * This function sets Cloud Watch Logs data which are displayed in Sentry Dashboard + * @param scope - holds additional event information + * @hidden + */ + const setCloudwatchLogsData = (scope: Scope): void => { + scope.setContext('cloudwatch.logs', { + log_group: this._awsContext.logGroupName, + log_stream: this._awsContext.logStreamName, + url: cloudwatchUrl, + }); + }; + + /** + * This function sets tags which are displayed in Sentry Dashboard + * @param scope - holds additional event information + * @hidden + */ + const setTags = (scope: Scope): void => { + scope.setTag('runtime', `node${global.process.version}`); + scope.setTag('transaction', this._awsContext.functionName); + scope.setTag('runtime.name', 'node'); + scope.setTag('server_name', process.env._AWS_XRAY_DAEMON_ADDRESS || ''); + scope.setTag('url', `awslambda:///${this._awsContext.functionName}`); + scope.setTag('handled', 'no'); + scope.setTag('mechanism', 'awslambda'); + }; + + // timeout warning buffer for timeout error + const timeoutWarningBuffer: number = 1500; + const configuredTimeInSec = Math.floor(configuredTimeInMilliseconds / 1000); + const configuredTimeInMilli = configuredTimeInSec * 1000; + + /** check timeout flag and checking if configured Time In Milliseconds is greater than timeout Warning Buffer */ + if (this._timeoutWarning === true && configuredTimeInMilliseconds > timeoutWarningBuffer) { + this._awsContext.callbackWaitsForEmptyEventLoop = false; + + setTimeout(() => { + const error = new Error( + `WARNING : Function is expected to get timed out. Configured timeout duration = ${configuredTimeInSec + + 1} seconds.`, + ); + + // setting parameters in scope which will be displayed as additional data in Sentry dashboard + hub.withScope((scope: Scope) => { + setTags(scope); + // runtime + setAdditionalRuntimeData(scope); + // setting the lambda parameters + setAdditionalLambdaParameters(scope); + // setting the cloudwatch logs parameter + setCloudwatchLogsData(scope); + // setting the sys.argv parameter + scope.setExtra('sys.argv', process.argv); + /** capturing the exception and re-directing it to the Sentry Dashboard */ + hub.captureException(error); + }); + }, configuredTimeInMilli); + void hub.getClient()?.flush(flushTime); + } + + /** + * unhandled and handled exception + * @param error - holds the error captured in AWS Lambda Function + * @param id - holds event id value + * @param callback - callback function + */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + rapidRuntime.prototype.postInvocationError = async function( + error: Error, + id: string, + callback: () => void, + ): Promise { + // setting parameters in scope which will be displayed as additional data in Sentry dashboard + hub.withScope((scope: Scope) => { + setTags(scope); + // runtime + setAdditionalRuntimeData(scope); + // setting the lambda parameters + setAdditionalLambdaParameters(scope); + // setting the cloudwatch logs parameter + setCloudwatchLogsData(scope); + // setting the sys.argv parameter + scope.setExtra('sys.argv', process.argv); + /** capturing the exception and re-directing it to the Sentry Dashboard */ + hub.captureException(error); + }); + + /** capturing the exception and re-directing it to the Sentry Dashboard */ + const client = hub.getClient(); + if (client) { + await client.flush(flushTime); + } + + /** + * Here, we make sure the error has been captured by Sentry Dashboard + * and then re-raised the exception + */ + originalPostInvocationError.call(this, error, id, callback); + }; + } +} + +export const AWSLambdaIntegration = new AWSLambda(); diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index f1ba52e92026..eb6f8978b556 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -10,3 +10,4 @@ export { RewriteFrames } from './rewriteframes'; export { SessionTiming } from './sessiontiming'; export { Transaction } from './transaction'; export { Vue } from './vue'; +export { AWSLambda, AWSLambdaIntegration } from './awslambda'; diff --git a/packages/integrations/test/awsLambdaPrelude.ts b/packages/integrations/test/awsLambdaPrelude.ts new file mode 100644 index 000000000000..f70250d3b2cf --- /dev/null +++ b/packages/integrations/test/awsLambdaPrelude.ts @@ -0,0 +1,56 @@ +/** + * This function returns the client file. + * @param additionalLambdaPrelude - Holds the scenario, timeoutWarning and error string. + */ + +export function lambdaPreludeReplacer(additionalLambdaPrelude: { + scenario: string; + timeoutWarning: boolean; + error: string; +}): string { + const lambdaPrelude = ` +const http = require('http'); +const Sentry = require('@sentry/node'); +const { AWSLambdaIntegration } = require('@sentry/integrations'); +const { HTTPSTransport } = require('@sentry/node/dist/transports'); + +class testTransport extends HTTPSTransport { + constructor() { + super(...arguments); + } + async sendEvent(event) { + console.log('Event:', JSON.stringify(event)); + } +} + +// Configure the Sentry SDK. +Sentry.init({ + dsn: 'https://e16b3f19d01b4989b118f20dbcc11f87@o388065.ingest.sentry.io/5224330', + transport: testTransport, + integrations: [AWSLambdaIntegration], +}); + +const lambdaBootstrap = require.main; +let rapidRuntime = lambdaBootstrap.children[0].exports; +rapidRuntime.prototype = { + postInvocationError: function(error, id, callback) {}, +}; + +exports.handler = ${additionalLambdaPrelude.scenario} (event,callback) => { + const context = { + functionVersion: '$LATEST', + functionName: 'aws-lambda-unit-test', + awsRequestId: '95a31fc8-7490-4474-aa7d-951aca216381', + getRemainingTimeInMillis: function getRemainingTimeInMillis() { + return 3000; + }, + }; + AWSLambdaIntegration.providedContext(context, ${additionalLambdaPrelude.timeoutWarning}, 2000); + + ${additionalLambdaPrelude.error} +}; + +exports.handler(); +`; + return lambdaPrelude; +} diff --git a/packages/integrations/test/awslambda.test.ts b/packages/integrations/test/awslambda.test.ts new file mode 100644 index 000000000000..8e4eb3a614cc --- /dev/null +++ b/packages/integrations/test/awslambda.test.ts @@ -0,0 +1,351 @@ +import childProcess from 'child_process'; +import fs from 'fs'; + +import { lambdaPreludeReplacer as lambdaFixures } from './awsLambdaPrelude'; + +/** + * This function create a development package. + * @param remainingPrelude - holds remaining prelude data + * @param callback -return the development package code output. + * @hidden + */ +function runLambdaFunction(remainingPrelude: any, callback: any) { + const lambdaPrelude = lambdaFixures(remainingPrelude); + + const __packageDir: string = './test/testCase'; + const __setupJsonFile: string = './test/setup.json'; + const __buildDist: string = './dist'; + + if (!fs.existsSync(__buildDist)) { + throw Error('Build not found in specified directory.'); + } + + if (fs.existsSync(__packageDir)) { + childProcess.execSync('cd ./test/; rm -rf testCase; cd ..; cd ..;'); + } + + fs.mkdirSync(__packageDir); + + fs.appendFileSync(`${__packageDir}/index.js`, lambdaPrelude); + + if (fs.existsSync(__setupJsonFile)) { + const packageData = fs.readFileSync(__setupJsonFile); + + fs.appendFileSync(`${__packageDir}/package.json`, packageData); + + childProcess.execSync('cd ./test/testCase; npm install --prefix; cd ..; cd ..;'); + childProcess.execSync('cp -r build dist esm ./test/testCase/node_modules/@sentry/integrations/'); + + /** + * Check the async/sync scenario, run the development package code and return the package output. + */ + if (remainingPrelude.scenario === 'async') { + const event = childProcess.execSync('node ./test/testCase/index.js'); + + const eventParser = event.toString('utf-8'); + const mainEvent = eventParser.split('Event:'); + callback(null, mainEvent[1]); + } else { + childProcess.exec('node ./test/testCase/index.js', (error, res) => { + if (error !== null) { + const mainEventSync = res.split('Event:'); + callback(null, JSON.parse(mainEventSync[1])); + } + }); + } + } +} + +/** + * Unit test case suit + */ +describe('AWS Lambda serverless test suit ', () => { + const timeout: number = 3000; + /** + * Test case for handled exception for sync and async scenario with timeout warning true. + */ + test( + 'should be capture handled exception for async scenario timeoutWarning=true', + done => { + const remainingPrelude = { + error: ` + notDefinedFunction(); + `, + scenario: 'async', + timeoutWarning: true, + }; + runLambdaFunction(remainingPrelude, (err: any, _res: any) => { + if (err) { + throw new Error(err); + } else { + const res = JSON.parse(_res); + expect(res.exception.values[0].type).toBe('ReferenceError'); + expect(res.exception.values[0].value).toBe('notDefinedFunction is not defined'); + } + }); + done(); + }, + timeout, + ); + + test( + 'should be capture handled exception for sync scenario timeoutWarning=true', + done => { + const remainingPrelude = { + error: ` + notDefinedFunction(); + `, + scenario: '', + timeoutWarning: true, + }; + runLambdaFunction(remainingPrelude, (err: any, _res: any) => { + if (err) { + throw new Error(err); + } else { + expect('ReferenceError').toBe(_res.exception.values[0].type); + expect('notDefinedFunction is not defined').toBe(_res.exception.values[0].value); + } + }); + done(); + }, + timeout, + ); + + /** + * Test case for handled exception for sync and async scenario with timeout warning false. + */ + test( + 'should be capture handled exception for async scenario timeoutWarning=false', + done => { + const remainingPrelude = { + error: ` + notDefinedFunction(); + `, + scenario: 'async', + timeoutWarning: false, + }; + runLambdaFunction(remainingPrelude, (err: any, _res: any) => { + if (err) { + throw new Error(err); + } else { + const res = JSON.parse(_res); + expect(res.exception.values[0].type).toBe('ReferenceError'); + expect(res.exception.values[0].value).toBe('notDefinedFunction is not defined'); + } + }); + done(); + }, + timeout, + ); + + test( + 'should be capture handled exception for sync scenario timeoutWarning=false', + done => { + const remainingPrelude = { + error: ` + notDefinedFunction(); + `, + scenario: '', + timeoutWarning: false, + }; + runLambdaFunction(remainingPrelude, (err: any, _res: any) => { + if (err) { + throw new Error(err); + } else { + expect('ReferenceError').toBe(_res.exception.values[0].type); + expect('notDefinedFunction is not defined').toBe(_res.exception.values[0].value); + } + }); + done(); + }, + timeout, + ); + + /** + * Test case for unhandled exception for sync and async scenario with timeout warning true. + */ + test( + 'should be capture unhandled exception for async scenario timeoutWarning=true', + done => { + const remainingPrelude = { + error: ` + throw new Error('Dummy error'); + `, + scenario: 'async', + timeoutWarning: true, + }; + runLambdaFunction(remainingPrelude, (err: any, _res: any) => { + if (err) { + throw new Error(err); + } else { + const res = JSON.parse(_res); + expect(res.exception.values[0].type).toBe('Error'); + expect(res.exception.values[0].value).toBe('Dummy error'); + } + }); + done(); + }, + timeout, + ); + + test( + 'should be capture unhandled exception for sync scenario timeoutWarning=true', + done => { + const remainingPrelude = { + error: ` + throw new Error('Dummy error'); + `, + scenario: '', + timeoutWarning: true, + }; + runLambdaFunction(remainingPrelude, (err: any, _res: any) => { + if (err) { + throw new Error(err); + } else { + expect(_res.exception.values[0].type).toBe('Error'); + expect(_res.exception.values[0].value).toBe('Dummy error'); + } + }); + done(); + }, + timeout, + ); + + /** + * Test case for handled exception for sync and async scenario with timeout warning false. + */ + test( + 'should be capture unhandled exception for async scenario timeoutWarning=false', + done => { + const remainingPrelude = { + error: ` + throw new Error('Dummy error'); + `, + scenario: 'async', + timeoutWarning: false, + }; + runLambdaFunction(remainingPrelude, (err: any, _res: any) => { + if (err) { + throw new Error(err); + } else { + const res = JSON.parse(_res); + expect(res.exception.values[0].type).toBe('Error'); + expect(res.exception.values[0].value).toBe('Dummy error'); + } + }); + done(); + }, + timeout, + ); + + test( + 'should be capture unhandled exception for sync scenario timeoutWarning=false', + done => { + const remainingPrelude = { + error: ` + throw new Error('Dummy error'); + `, + scenario: '', + timeoutWarning: false, + }; + runLambdaFunction(remainingPrelude, (err: any, _res: any) => { + if (err) { + throw new Error(err); + } else { + expect(_res.exception.values[0].type).toBe('Error'); + expect(_res.exception.values[0].value).toBe('Dummy error'); + } + }); + done(); + }, + timeout, + ); + + /** + * Test case for timeout error for sync and async scenario. + */ + test( + 'should be timeout error for async scenario timeoutWarning=true', + done => { + const remainingPrelude = { + error: ` + let data = []; + let ip_address = "192.0.2.1"; // Dummy IP which does not exist + let url = "http://" + ip_address + "/api/test"; + // let url = 'http://dummy.restapiexample.com/api/v1/employees'; + const response = await new Promise((resolve) => { + // callback function for the get the data from provided URL + const req = http.get(url, function (res) { + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => { + resolve({ + statusCode: 200, + body: JSON.parse(data), + }); + }); + }); + // error is showing in the console. + req.on("error", (e) => { + console.log("Error is:- " + e); + }); + }); + return response; + `, + scenario: 'async', + timeoutWarning: true, + }; + runLambdaFunction(remainingPrelude, (err: any, _res: any) => { + if (err) { + throw new Error(err); + } else { + const event = _res.split('\nError is:- Error: connect ETIMEDOUT'); + + const res = JSON.parse(event[0]); + + expect(res.exception.values[0].type).toBe('Error'); + expect(res.exception.values[0].value).toBe( + 'WARNING : Function is expected to get timed out. Configured timeout duration = 4 seconds.', + ); + } + }); + done(); + }, + timeout, + ); + + test( + 'should be timeout error for sync scenario timeoutWarning=true', + done => { + const remainingPrelude = { + error: ` + let data = []; + let ip_address = "192.0.2.1"; // Dummy IP which does not exist + let url = "http://" + ip_address + "/api/test"; + http + .get(url, (res) => { + callback(null, res.statusCode); + }) + .on("error", (e) => { + callback(Error(e)); + }); + `, + scenario: '', + timeoutWarning: true, + }; + runLambdaFunction(remainingPrelude, (err: any, _res: any) => { + if (err) { + throw new Error(err); + } else { + expect(_res.exception.values[0].type).toBe('Error'); + expect(_res.exception.values[0].value).toBe( + 'WARNING : Function is expected to get timed out. Configured timeout duration = 4 seconds.', + ); + } + }); + done(); + }, + timeout, + ); +}); diff --git a/packages/integrations/test/setup.json b/packages/integrations/test/setup.json new file mode 100644 index 000000000000..06d11874d2c9 --- /dev/null +++ b/packages/integrations/test/setup.json @@ -0,0 +1,21 @@ +{ + "name": "AWSLAMBDA", + "version": "1.0.0", + "description": "Test case for AWS Lambda for NodeJS.", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "Sentry", + "license": "MIT", + "dependencies": { + "@sentry/integrations": "^5.21.4", + "@sentry/node": "^5.19.0", + "lru-cache": "^5.1.1", + "lru-map": "^1.6.1", + "middy": "^0.36.0", + "tslib": "^1.13.0", + "util": "^0.12.3" + } +}