Skip to content

Commit 8a29275

Browse files
authored
feat: Introduce @sentry/serverless with AWSLambda support (#2886)
* feat: Introduce @sentry/serverless package with AWS Lambda support * ref: Provide more direct CloudWatch Logs url * fix: Set mechanism.handled to false * feat: Add AWSLambda to integrations table in SDK info field * feat: Intercept callback(err) calls as exceptions * ref: Target ES2018 as Node10+ already supports all its features * ref: Always return async function from handler and handle sync resolution locally * misc: Remove package-lock.json from root and add it to gitignore * misc: Readme codereview updates * ref: Clean up eslint and typescript configs * misc: Ignore serverless on Travis with Node <10 * fix: Handle edgecase where someone writes async handler with callback argument * misc: Ignore serverless builds on pre v10 node
1 parent 2e761fe commit 8a29275

File tree

15 files changed

+386
-5
lines changed

15 files changed

+386
-5
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# dependencies
22
node_modules/
33
packages/*/package-lock.json
4+
package-lock.json
45

56
# build and test
67
build/

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"packages/minimal",
3333
"packages/node",
3434
"packages/react",
35+
"packages/serverless",
3536
"packages/tracing",
3637
"packages/types",
3738
"packages/typescript",

packages/serverless/.eslintrc.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
module.exports = {
2+
root: true,
3+
env: {
4+
es6: true,
5+
node: true,
6+
},
7+
parserOptions: {
8+
ecmaVersion: 2018,
9+
},
10+
extends: ['@sentry-internal/sdk'],
11+
ignorePatterns: ['dist/**', 'esm/**'],
12+
overrides: [
13+
{
14+
files: ['*.ts', '*.d.ts'],
15+
parserOptions: {
16+
project: './tsconfig.json',
17+
},
18+
},
19+
],
20+
rules: {
21+
'@typescript-eslint/no-var-requires': 'off',
22+
'@sentry-internal/sdk/no-async-await': 'off',
23+
},
24+
};

packages/serverless/.npmignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
*
2+
!/dist/**/*
3+
!/esm/**/*
4+
*.tsbuildinfo

packages/serverless/LICENSE

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
The MIT License (MIT)
2+
3+
Copyright (c) 2020, Sentry
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6+
7+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8+
9+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

packages/serverless/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<p align="center">
2+
<a href="https://sentry.io" target="_blank" align="center">
3+
<img src="https://sentry-brand.storage.googleapis.com/sentry-logo-black.png" width="280">
4+
</a>
5+
<br />
6+
</p>
7+
8+
# Official Sentry SDK for Serverless environments
9+
10+
## Links
11+
12+
- [Official SDK Docs](https://docs.sentry.io/)
13+
- [TypeDoc](http://getsentry.github.io/sentry-javascript/)
14+
15+
## General
16+
17+
This package is a wrapper around `@sentry/node`, with added functionality related to various Serverless solutions. All
18+
methods available in `@sentry/node` can be imported from `@sentry/serverless`.
19+
20+
Currently supported environment:
21+
22+
*AWS Lambda*
23+
24+
To use this SDK, call `Sentry.init(options)` at the very beginning of your JavaScript file.
25+
26+
```javascript
27+
import * as Sentry from '@sentry/serverless';
28+
29+
Sentry.init({
30+
dsn: '__DSN__',
31+
// ...
32+
});
33+
34+
// async (recommended)
35+
exports.handler = Sentry.AWSLambda.wrapHandler(async (event, context) => {
36+
throw new Error('oh, hello there!');
37+
});
38+
39+
// sync
40+
exports.handler = Sentry.AWSLambda.wrapHandler((event, context, callback) => {
41+
throw new Error('oh, hello there!');
42+
});
43+
```

packages/serverless/package.json

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
{
2+
"name": "@sentry/serverless",
3+
"version": "5.22.3",
4+
"description": "Offical Sentry SDK for various serverless solutions",
5+
"repository": "git://github.com/getsentry/sentry-javascript.git",
6+
"homepage": "https://github.com/getsentry/sentry-javascript/tree/master/packages/serverless",
7+
"author": "Sentry",
8+
"license": "MIT",
9+
"engines": {
10+
"node": ">=10"
11+
},
12+
"main": "dist/index.js",
13+
"module": "esm/index.js",
14+
"types": "dist/index.d.ts",
15+
"publishConfig": {
16+
"access": "public"
17+
},
18+
"dependencies": {
19+
"@sentry/minimal": "5.22.3",
20+
"@sentry/node": "5.22.3",
21+
"@sentry/types": "5.22.3",
22+
"@sentry/utils": "5.22.3",
23+
"@types/aws-lambda": "^8.10.62",
24+
"@types/node": "^14.6.4",
25+
"tslib": "^1.9.3"
26+
},
27+
"devDependencies": {
28+
"@sentry-internal/eslint-config-sdk": "5.22.3",
29+
"eslint": "7.6.0",
30+
"npm-run-all": "^4.1.2",
31+
"prettier": "1.19.0",
32+
"rimraf": "^2.6.3",
33+
"typescript": "3.7.5"
34+
},
35+
"scripts": {
36+
"build": "run-p build:es5 build:esm",
37+
"build:es5": "tsc -p tsconfig.build.json",
38+
"build:esm": "tsc -p tsconfig.esm.json",
39+
"build:watch": "run-p build:watch:es5 build:watch:esm",
40+
"build:watch:es5": "tsc -p tsconfig.build.json -w --preserveWatchOutput",
41+
"build:watch:esm": "tsc -p tsconfig.esm.json -w --preserveWatchOutput",
42+
"clean": "rimraf dist coverage build esm",
43+
"link:yarn": "yarn link",
44+
"lint": "run-s lint:prettier lint:eslint",
45+
"lint:prettier": "prettier --check \"{src,test}/**/*.ts\"",
46+
"lint:eslint": "eslint . --cache --cache-location '../../eslintcache/' --format stylish",
47+
"fix": "run-s fix:eslint fix:prettier",
48+
"fix:prettier": "prettier --write \"{src,test}/**/*.ts\"",
49+
"fix:eslint": "eslint . --format stylish --fix",
50+
"test": "jest --passWithNoTests",
51+
"test:watch": "jest --watch --passWithNoTests",
52+
"pack": "npm pack"
53+
},
54+
"sideEffects": false
55+
}

packages/serverless/src/awslambda.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { captureException, captureMessage, flush, Scope, SDK_VERSION, Severity, withScope } from '@sentry/node';
2+
import { addExceptionMechanism } from '@sentry/utils';
3+
// NOTE: I have no idea how to fix this right now, and don't want to waste more time, as it builds just fine — Kamil
4+
// eslint-disable-next-line import/no-unresolved
5+
import { Callback, Context, Handler } from 'aws-lambda';
6+
import { hostname } from 'os';
7+
import { performance } from 'perf_hooks';
8+
import { types } from 'util';
9+
10+
const { isPromise } = types;
11+
12+
// https://www.npmjs.com/package/aws-lambda-consumer
13+
type SyncHandler<T extends Handler> = (
14+
event: Parameters<T>[0],
15+
context: Parameters<T>[1],
16+
callback: Parameters<T>[2],
17+
) => void;
18+
19+
export type AsyncHandler<T extends Handler> = (
20+
event: Parameters<T>[0],
21+
context: Parameters<T>[1],
22+
) => Promise<NonNullable<Parameters<Parameters<T>[2]>[1]>>;
23+
24+
interface WrapperOptions {
25+
flushTimeout: number;
26+
rethrowAfterCapture: boolean;
27+
callbackWaitsForEmptyEventLoop: boolean;
28+
captureTimeoutWarning: boolean;
29+
timeoutWarning: number;
30+
}
31+
32+
/**
33+
* Add event processor that will override SDK details to point to the serverless SDK instead of Node,
34+
* as well as set correct mechanism type, which should be set to `handled: false`.
35+
* We do it like this, so that we don't introduce any side-effects in this module, which makes it tree-shakeable.
36+
* @param scope Scope that processor should be added to
37+
*/
38+
function addServerlessEventProcessor(scope: Scope): void {
39+
scope.addEventProcessor(event => {
40+
event.sdk = {
41+
...event.sdk,
42+
name: 'sentry.javascript.serverless',
43+
integrations: [...((event.sdk && event.sdk.integrations) || []), 'AWSLambda'],
44+
packages: [
45+
...((event.sdk && event.sdk.packages) || []),
46+
{
47+
name: 'npm:@sentry/serverless',
48+
version: SDK_VERSION,
49+
},
50+
],
51+
version: SDK_VERSION,
52+
};
53+
54+
addExceptionMechanism(event, {
55+
handled: false,
56+
});
57+
58+
return event;
59+
});
60+
}
61+
62+
/**
63+
* Adds additional information from the environment and AWS Context to the Sentry Scope.
64+
*
65+
* @param scope Scope that should be enhanced
66+
* @param context AWS Lambda context that will be used to extract some part of the data
67+
*/
68+
function enhanceScopeWithEnvironmentData(scope: Scope, context: Context): void {
69+
scope.setTransactionName(context.functionName);
70+
71+
scope.setTag('server_name', process.env._AWS_XRAY_DAEMON_ADDRESS || process.env.SENTRY_NAME || hostname());
72+
scope.setTag('url', `awslambda:///${context.functionName}`);
73+
74+
scope.setContext('runtime', {
75+
name: 'node',
76+
version: global.process.version,
77+
});
78+
79+
scope.setContext('aws.lambda', {
80+
aws_request_id: context.awsRequestId,
81+
function_name: context.functionName,
82+
function_version: context.functionVersion,
83+
invoked_function_arn: context.invokedFunctionArn,
84+
execution_duration_in_millis: performance.now(),
85+
remaining_time_in_millis: context.getRemainingTimeInMillis(),
86+
'sys.argv': process.argv,
87+
});
88+
89+
scope.setContext('aws.cloudwatch.logs', {
90+
log_group: context.logGroupName,
91+
log_stream: context.logStreamName,
92+
url: `https://console.aws.amazon.com/cloudwatch/home?region=${
93+
process.env.AWS_REGION
94+
}#logsV2:log-groups/log-group/${encodeURIComponent(context.logGroupName)}/log-events/${encodeURIComponent(
95+
context.logStreamName,
96+
)}`,
97+
});
98+
}
99+
100+
/**
101+
* Capture, flush the result down the network stream and await the response.
102+
*
103+
* @param e exception to be captured
104+
* @param options WrapperOptions
105+
*/
106+
function captureExceptionAsync(e: unknown, context: Context, options: Partial<WrapperOptions>): Promise<boolean> {
107+
withScope(scope => {
108+
addServerlessEventProcessor(scope);
109+
enhanceScopeWithEnvironmentData(scope, context);
110+
captureException(e);
111+
});
112+
return flush(options.flushTimeout);
113+
}
114+
115+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
116+
export const wrapHandler = <TEvent = any, TResult = any>(
117+
handler: Handler,
118+
handlerOptions: Partial<WrapperOptions> = {},
119+
): Handler => {
120+
const options = {
121+
flushTimeout: 2000,
122+
rethrowAfterCapture: true,
123+
callbackWaitsForEmptyEventLoop: false,
124+
captureTimeoutWarning: true,
125+
timeoutWarningLimit: 500,
126+
...handlerOptions,
127+
};
128+
let timeoutWarningTimer: NodeJS.Timeout;
129+
130+
return async (event: TEvent, context: Context, callback: Callback<TResult>) => {
131+
context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop;
132+
133+
// In seconds. You cannot go any more granular than this in AWS Lambda.
134+
const configuredTimeout = Math.ceil(context.getRemainingTimeInMillis() / 1000);
135+
const configuredTimeoutMinutes = Math.floor(configuredTimeout / 60);
136+
const configuredTimeoutSeconds = configuredTimeout % 60;
137+
138+
const humanReadableTimeout =
139+
configuredTimeoutMinutes > 0
140+
? `${configuredTimeoutMinutes}m${configuredTimeoutSeconds}s`
141+
: `${configuredTimeoutSeconds}s`;
142+
143+
// When `callbackWaitsForEmptyEventLoop` is set to false, which it should when using `captureTimeoutWarning`,
144+
// we don't have a guarantee that this message will be delivered. Because of that, we don't flush it.
145+
if (options.captureTimeoutWarning) {
146+
const timeoutWarningDelay = context.getRemainingTimeInMillis() - options.timeoutWarningLimit;
147+
148+
timeoutWarningTimer = setTimeout(() => {
149+
withScope(scope => {
150+
addServerlessEventProcessor(scope);
151+
enhanceScopeWithEnvironmentData(scope, context);
152+
scope.setTag('timeout', humanReadableTimeout);
153+
captureMessage(`Possible function timeout: ${context.functionName}`, Severity.Warning);
154+
});
155+
}, timeoutWarningDelay);
156+
}
157+
158+
const callbackWrapper = <TResult>(
159+
callback: Callback<TResult>,
160+
resolve: (value?: unknown) => void,
161+
reject: (reason?: unknown) => void,
162+
): Callback<TResult> => {
163+
return (...args) => {
164+
clearTimeout(timeoutWarningTimer);
165+
if (args[0] === null || args[0] === undefined) {
166+
resolve(callback(...args));
167+
} else {
168+
captureExceptionAsync(args[0], context, options).then(
169+
() => reject(callback(...args)),
170+
() => reject(callback(...args)),
171+
);
172+
}
173+
};
174+
};
175+
176+
try {
177+
// AWSLambda is like Express. It makes a distinction about handlers based on it's last argument
178+
// async (event) => async handler
179+
// async (event, context) => async handler
180+
// (event, context, callback) => sync handler
181+
const isSyncHandler = handler.length === 3;
182+
const handlerRv = isSyncHandler
183+
? await new Promise((resolve, reject) => {
184+
const rv = (handler as SyncHandler<Handler<TEvent, TResult>>)(
185+
event,
186+
context,
187+
callbackWrapper(callback, resolve, reject),
188+
);
189+
190+
// This should never happen, but still can if someone writes a handler as
191+
// `async (event, context, callback) => {}`
192+
if (isPromise(rv)) {
193+
((rv as unknown) as Promise<TResult>).then(resolve, reject);
194+
}
195+
})
196+
: await (handler as AsyncHandler<Handler<TEvent, TResult>>)(event, context);
197+
clearTimeout(timeoutWarningTimer);
198+
return handlerRv;
199+
} catch (e) {
200+
clearTimeout(timeoutWarningTimer);
201+
await captureExceptionAsync(e, context, options);
202+
if (options.rethrowAfterCapture) {
203+
throw e;
204+
}
205+
}
206+
};
207+
};

packages/serverless/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// https://medium.com/unsplash/named-namespace-imports-7345212bbffb
2+
import * as AWSLambda from './awslambda';
3+
export { AWSLambda };
4+
5+
export * from '@sentry/node';

packages/serverless/test/.gitkeep

Whitespace-only changes.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"include": ["src/**/*"],
4+
"compilerOptions": {
5+
"baseUrl": ".",
6+
"outDir": "dist",
7+
"target": "ES2018"
8+
}
9+
}

packages/serverless/tsconfig.esm.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "../../tsconfig.esm.json",
3+
"include": ["src/**/*"],
4+
"compilerOptions": {
5+
"baseUrl": ".",
6+
"outDir": "esm",
7+
"target": "ES2018"
8+
}
9+
}

packages/serverless/tsconfig.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"extends": "./tsconfig.build.json",
3+
"include": ["src/**/*.ts", "test/**/*.ts"]
4+
}

0 commit comments

Comments
 (0)