Skip to content

Commit 36463ce

Browse files
iker-barriocanalmarshall-leeHazAT
authored
feat(serverless): Zip serverless dependencies for AWS Lambda
* Implement extension for AWS Lambda for automatic integration. * AWS Lambda packages cached The packages to upload to the AWS Lambda layer are zipped. This zip file is cached. * AWS Lambda build workflow Created another workflow to build the AWS Lambda layer by running a script when changes something on @sentry/serverless * Merged AWS workflow to the main one Removed the AWS Lambda workflow, and added the build to the main workflow. Also, included the generated zip file to the artifacts. * Serverless package versions updated * Deleted publish script The publish functionality should not be here, deleted. * Serverless AWS integration docs minor change Referenced the docs. Also, updated `yarn.lock`. * Set default value in the parameter Instead of setting a default value at the beginning of the function, so that the undefined is avoided, it's now set in the parameter. * Deleted dependency `path-exists` * Fixed broken promise Fixed the broken promise, and removed the `path-exists` dependency from `package.json`. * ref(aws-lambda): WIP build script * fix(aws-lambda): Correct API function Using the function available in the same node version. Also, removed some logs. * ref: Revert yarn.lock * ci: Update * ref: Reduce imports * ref: Remove dependency in AWS Lambda build script * ref: Rename AWS Lambda initializer Be more specific in the initializer script and rename it to `awslambda-auto`. * doc: Remove optional step Co-authored-by: Vladimir Kochnev <[email protected]> Co-authored-by: Daniel Griesser <[email protected]>
1 parent 66a6a8c commit 36463ce

File tree

9 files changed

+319
-10
lines changed

9 files changed

+319
-10
lines changed

.github/workflows/build.yml

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ jobs:
2121
${{ github.workspace }}/packages/**/build
2222
${{ github.workspace }}/packages/**/dist
2323
${{ github.workspace }}/packages/**/esm
24-
key: ${{ runner.os }}-${{ github.sha }}
24+
${{ github.workspace }}/packages/serverless/dist-awslambda-layer/*.zip
25+
key: ${{ github.sha }}
2526
- name: Install
2627
run: yarn install
2728
- name: Build
@@ -43,7 +44,8 @@ jobs:
4344
${{ github.workspace }}/packages/**/build
4445
${{ github.workspace }}/packages/**/dist
4546
${{ github.workspace }}/packages/**/esm
46-
key: ${{ runner.os }}-${{ github.sha }}
47+
${{ github.workspace }}/packages/serverless/dist-awslambda-layer/*.zip
48+
key: ${{ github.sha }}
4749
- uses: andresz1/[email protected]
4850
with:
4951
github_token: ${{ secrets.GITHUB_TOKEN }}
@@ -64,7 +66,8 @@ jobs:
6466
${{ github.workspace }}/packages/**/build
6567
${{ github.workspace }}/packages/**/dist
6668
${{ github.workspace }}/packages/**/esm
67-
key: ${{ runner.os }}-${{ github.sha }}
69+
${{ github.workspace }}/packages/serverless/dist-awslambda-layer/*.zip
70+
key: ${{ github.sha }}
6871
- run: yarn install
6972
- name: Run Linter
7073
run: yarn lint
@@ -84,7 +87,8 @@ jobs:
8487
${{ github.workspace }}/packages/**/build
8588
${{ github.workspace }}/packages/**/dist
8689
${{ github.workspace }}/packages/**/esm
87-
key: ${{ runner.os }}-${{ github.sha }}
90+
${{ github.workspace }}/packages/serverless/dist-awslambda-layer/*.zip
91+
key: ${{ github.sha }}
8892
- run: yarn install
8993
- name: Unit Tests
9094
run: yarn test
@@ -105,9 +109,11 @@ jobs:
105109
${{ github.workspace }}/packages/**/build
106110
${{ github.workspace }}/packages/**/dist
107111
${{ github.workspace }}/packages/**/esm
108-
key: ${{ runner.os }}-${{ github.sha }}
112+
${{ github.workspace }}/packages/serverless/dist-awslambda-layer/*.zip
113+
key: ${{ github.sha }}
109114
- name: Pack
110115
run: yarn pack:changed
116+
- run: yarn install
111117
- name: Archive Artifacts
112118
uses: actions/upload-artifact@v2
113119
with:
@@ -117,6 +123,7 @@ jobs:
117123
${{ github.workspace }}/packages/integrations/build/**
118124
${{ github.workspace }}/packages/tracing/build/**
119125
${{ github.workspace }}/packages/**/*.tgz
126+
${{ github.workspace }}/packages/serverless/dist-awslambda-layer/*.zip
120127
121128
job_browserstack_test:
122129
name: BrowserStack
@@ -134,7 +141,8 @@ jobs:
134141
${{ github.workspace }}/packages/**/build
135142
${{ github.workspace }}/packages/**/dist
136143
${{ github.workspace }}/packages/**/esm
137-
key: ${{ runner.os }}-${{ github.sha }}
144+
${{ github.workspace }}/packages/serverless/dist-awslambda-layer/*.zip
145+
key: ${{ github.sha }}
138146
- run: yarn install
139147
- name: Integration Tests
140148
env:

packages/node/src/sdk.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ export function init(options: NodeOptions = {}): void {
8585
options.dsn = process.env.SENTRY_DSN;
8686
}
8787

88+
if (options.tracesSampleRate === undefined && process.env.SENTRY_TRACES_SAMPLE_RATE) {
89+
const tracesSampleRate = parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE);
90+
if (isFinite(tracesSampleRate)) {
91+
options.tracesSampleRate = tracesSampleRate;
92+
}
93+
}
94+
8895
if (options.release === undefined) {
8996
const global = getGlobalObject<Window>();
9097
// Prefer env var over global

packages/serverless/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
dist-awslambda-layer/

packages/serverless/README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ exports.handler = Sentry.AWSLambda.wrapHandler((event, context, callback) => {
4242
});
4343
```
4444

45-
If you also want to trace performance of all the incoming requests and also outgoing AWS service requests, just set the `tracesSampleRate` option.
45+
If you also want to trace performance of all the incoming requests and also outgoing AWS service requests, just set the `tracesSampleRate` option.
4646

4747
```javascript
4848
import * as Sentry from '@sentry/serverless';
@@ -53,6 +53,18 @@ Sentry.AWSLambda.init({
5353
});
5454
```
5555

56+
#### Integrate Sentry using internal extension
57+
58+
Another and much simpler way to integrate Sentry to your AWS Lambda function is to add an official layer.
59+
60+
1. Choose Layers -> Add Layer.
61+
2. Specify an ARN: `arn:aws:lambda:us-west-1:TODO:layer:TODO:VERSION`.
62+
3. Go to Environment variables and add:
63+
- `NODE_OPTIONS`: `-r @sentry/serverless/dist/awslambda-auto`.
64+
- `SENTRY_DSN`: `your dsn`.
65+
- `SENTRY_TRACES_SAMPLE_RATE`: a number between 0 and 1 representing the chance a transaction is sent to Sentry. For more information, see [docs](https://docs.sentry.io/platforms/node/guides/aws-lambda/configuration/options/#tracesSampleRate).
66+
67+
5668
### Google Cloud Functions
5769

5870
To use this SDK, call `Sentry.GCPFunction.init(options)` at the very beginning of your JavaScript file.

packages/serverless/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,22 @@
3333
"@types/node": "^14.6.4",
3434
"aws-sdk": "^2.765.0",
3535
"eslint": "7.6.0",
36+
"find-up": "^5.0.0",
3637
"google-gax": "^2.9.0",
3738
"jest": "^24.7.1",
3839
"nock": "^13.0.4",
40+
"npm-packlist": "^2.1.4",
3941
"npm-run-all": "^4.1.2",
4042
"prettier": "1.19.0",
43+
"read-pkg": "^5.2.0",
4144
"rimraf": "^2.6.3",
4245
"typescript": "3.7.5"
4346
},
4447
"scripts": {
45-
"build": "run-p build:es5 build:esm",
48+
"build": "run-p build:es5 build:esm build:awslambda-layer",
4649
"build:es5": "tsc -p tsconfig.build.json",
4750
"build:esm": "tsc -p tsconfig.esm.json",
51+
"build:awslambda-layer": "node scripts/build-awslambda-layer.js",
4852
"build:watch": "run-p build:watch:es5 build:watch:esm",
4953
"build:watch:es5": "tsc -p tsconfig.build.json -w --preserveWatchOutput",
5054
"build:watch:esm": "tsc -p tsconfig.esm.json -w --preserveWatchOutput",
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
const path = require('path');
2+
const process = require('process');
3+
const fs = require('fs');
4+
const childProcess = require('child_process');
5+
6+
const findUp = require('find-up');
7+
const packList = require('npm-packlist');
8+
const readPkg = require('read-pkg');
9+
10+
const serverlessPackage = require('../package.json');
11+
12+
// AWS Lambda layer are being uploaded as zip archive, whose content is then being unpacked to the /opt
13+
// directory in the lambda environment.
14+
//
15+
// So this script does the following: it builds a 'dist-awslambda-layer/nodejs/node_modules/@sentry/serverless'
16+
// directory with a special index.js and with all necessary @sentry packages symlinked as node_modules.
17+
// Then, this directory is compressed with zip.
18+
//
19+
// The tricky part about it is that one cannot just symlink the entire package directories into node_modules because
20+
// all the src/ contents and other unnecessary files will end up in the zip archive. So, we need to symlink only
21+
// individual files from package and it must be only those of them that are distributable.
22+
// There exists a `npm-packlist` library for such purpose. So we need to traverse all the dependencies,
23+
// execute `npm-packlist` on them and symlink the files into 'dist-awslambda-layer/.../@sentry/serverless/node_modules'.
24+
// I didn't find any way to achieve this goal using standard command-line tools so I have to write this script.
25+
//
26+
// Another, and much simpler way to assemble such zip bundle is install all the dependencies from npm registry and
27+
// just bundle the entire node_modules.
28+
// It's easier and looks more stable but it's inconvenient if one wants build a zip bundle out of current source tree.
29+
//
30+
// And yet another way is to bundle everything with webpack into a single file. I tried and it seems to be error-prone
31+
// so I think it's better to have a classic package directory with node_modules file structure.
32+
33+
/** Recursively traverse all the dependencies and collect all the info to the map */
34+
async function collectPackages(cwd, packages = {}) {
35+
const packageJson = await readPkg({ cwd });
36+
37+
packages[packageJson.name] = { cwd, packageJson };
38+
39+
if (!packageJson.dependencies) {
40+
return packages;
41+
}
42+
43+
await Promise.all(
44+
Object.keys(packageJson.dependencies).map(async dep => {
45+
// We are interested only in 'external' dependencies which are strictly upper than current directory.
46+
// Internal deps aka local node_modules folder of each package is handled differently.
47+
const searchPath = path.resolve(cwd, '..');
48+
const depPath = fs.realpathSync(
49+
await findUp(path.join('node_modules', dep),
50+
{ type: 'directory', cwd: searchPath })
51+
);
52+
if (packages[dep]) {
53+
if (packages[dep].cwd != depPath) {
54+
throw new Error(`${packageJson.name}'s dependenciy ${dep} maps to both ${packages[dep].cwd} and ${depPath}`);
55+
}
56+
return;
57+
}
58+
await collectPackages(depPath, packages);
59+
}),
60+
);
61+
62+
return packages;
63+
}
64+
65+
async function main() {
66+
const workDir = path.resolve(__dirname, '..'); // packages/serverless directory
67+
const packages = await collectPackages(workDir);
68+
69+
const dist = path.resolve(workDir, 'dist-awslambda-layer');
70+
const destRootRelative = 'nodejs/node_modules/@sentry/serverless';
71+
const destRoot = path.resolve(dist, destRootRelative);
72+
const destModulesRoot = path.resolve(destRoot, 'node_modules');
73+
74+
try {
75+
// Setting `force: true` ignores exceptions when paths don't exist.
76+
fs.rmSync(destRoot, { force: true, recursive: true, maxRetries: 1 });
77+
fs.mkdirSync(destRoot, { recursive: true });
78+
} catch (error) {
79+
// Ignore errors.
80+
}
81+
82+
await Promise.all(
83+
Object.entries(packages).map(async ([name, pkg]) => {
84+
const isRoot = name == serverlessPackage.name;
85+
const destPath = isRoot ? destRoot : path.resolve(destModulesRoot, name);
86+
87+
// Scan over the distributable files of the module and symlink each of them.
88+
const sourceFiles = await packList({ path: pkg.cwd });
89+
await Promise.all(
90+
sourceFiles.map(async filename => {
91+
const sourceFilename = path.resolve(pkg.cwd, filename);
92+
const destFilename = path.resolve(destPath, filename);
93+
94+
try {
95+
fs.mkdirSync(path.dirname(destFilename), { recursive: true });
96+
fs.symlinkSync(sourceFilename, destFilename);
97+
} catch (error) {
98+
// Ignore errors.
99+
}
100+
}),
101+
);
102+
103+
const sourceModulesRoot = path.resolve(pkg.cwd, 'node_modules');
104+
// `fs.constants.F_OK` indicates whether the file is visible to the current process, but it doesn't check
105+
// its permissions. For more information, refer to https://nodejs.org/api/fs.html#fs_file_access_constants.
106+
try {
107+
fs.accessSync(path.resolve(sourceModulesRoot), fs.constants.F_OK);
108+
} catch (error) {
109+
return;
110+
}
111+
112+
// Scan over local node_modules folder of the package and symlink its non-dev dependencies.
113+
const sourceModules = fs.readdirSync(sourceModulesRoot);
114+
await Promise.all(
115+
sourceModules.map(async sourceModule => {
116+
if (!pkg.packageJson.dependencies || !pkg.packageJson.dependencies[sourceModule]) {
117+
return;
118+
}
119+
120+
const sourceModulePath = path.resolve(sourceModulesRoot, sourceModule);
121+
const destModulePath = path.resolve(destPath, 'node_modules', sourceModule);
122+
123+
try {
124+
fs.mkdirSync(path.dirname(destModulePath), { recursive: true });
125+
fs.symlinkSync(sourceModulePath, destModulePath);
126+
} catch (error) {
127+
// Ignore errors.
128+
}
129+
}),
130+
);
131+
}),
132+
);
133+
134+
const version = serverlessPackage.version;
135+
const zipFilename = `sentry-node-serverless-${version}.zip`;
136+
137+
try {
138+
fs.unlinkSync(path.resolve(dist, zipFilename));
139+
} catch (error) {
140+
// If the ZIP file hasn't been previously created (e.g. running this script for the first time),
141+
// `unlinkSync` will try to delete a non-existing file. This error is ignored.
142+
}
143+
144+
try {
145+
childProcess.execSync(`zip -r ${zipFilename} ${destRootRelative}`, { cwd: dist });
146+
} catch (error) {
147+
// The child process timed out or had non-zero exit code.
148+
// The error contains the entire result from `childProcess.spawnSync`.
149+
console.log(error); // eslint-disable-line no-console
150+
}
151+
}
152+
153+
main().then(
154+
() => {
155+
process.exit(0);
156+
},
157+
err => {
158+
console.error(err); // eslint-disable-line no-console
159+
process.exit(-1);
160+
},
161+
);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import * as Sentry from './index';
2+
3+
const lambdaTaskRoot = process.env.LAMBDA_TASK_ROOT;
4+
if (lambdaTaskRoot) {
5+
const handlerString = process.env._HANDLER;
6+
if (!handlerString) {
7+
throw Error(`LAMBDA_TASK_ROOT is non-empty(${lambdaTaskRoot}) but _HANDLER is not set`);
8+
}
9+
Sentry.AWSLambda.init();
10+
Sentry.AWSLambda.tryPatchHandler(lambdaTaskRoot, handlerString);
11+
} else {
12+
throw Error('LAMBDA_TASK_ROOT environment variable is not set');
13+
}

packages/serverless/src/awslambda.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ import {
1010
} from '@sentry/node';
1111
import * as Sentry from '@sentry/node';
1212
import { Integration } from '@sentry/types';
13+
import { logger } from '@sentry/utils';
1314
// 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
1415
// eslint-disable-next-line import/no-unresolved
1516
import { Context, Handler } from 'aws-lambda';
17+
import { existsSync } from 'fs';
1618
import { hostname } from 'os';
19+
import { basename, resolve } from 'path';
1720
import { performance } from 'perf_hooks';
1821
import { types } from 'util';
1922

@@ -57,6 +60,62 @@ export function init(options: Sentry.NodeOptions = {}): void {
5760
Sentry.addGlobalEventProcessor(serverlessEventProcessor('AWSLambda'));
5861
}
5962

63+
/** */
64+
function tryRequire<T>(taskRoot: string, subdir: string, mod: string): T {
65+
const lambdaStylePath = resolve(taskRoot, subdir, mod);
66+
if (existsSync(lambdaStylePath) || existsSync(`${lambdaStylePath}.js`)) {
67+
// Lambda-style path
68+
return require(lambdaStylePath);
69+
}
70+
// Node-style path
71+
return require(require.resolve(mod, { paths: [taskRoot, subdir] }));
72+
}
73+
74+
/** */
75+
export function tryPatchHandler(taskRoot: string, handlerPath: string): void {
76+
type HandlerBag = HandlerModule | Handler | null | undefined;
77+
interface HandlerModule {
78+
[key: string]: HandlerBag;
79+
}
80+
81+
const handlerDesc = basename(handlerPath);
82+
const match = handlerDesc.match(/^([^.]*)\.(.*)$/);
83+
if (!match) {
84+
logger.error(`Bad handler ${handlerDesc}`);
85+
return;
86+
}
87+
88+
const [, handlerMod, handlerName] = match;
89+
90+
let obj: HandlerBag;
91+
try {
92+
const handlerDir = handlerPath.substring(0, handlerPath.indexOf(handlerDesc));
93+
obj = tryRequire(taskRoot, handlerDir, handlerMod);
94+
} catch (e) {
95+
logger.error(`Cannot require ${handlerPath} in ${taskRoot}`, e);
96+
return;
97+
}
98+
99+
let mod: HandlerBag;
100+
let functionName: string | undefined;
101+
handlerName.split('.').forEach(name => {
102+
mod = obj;
103+
obj = obj && (obj as HandlerModule)[name];
104+
functionName = name;
105+
});
106+
if (!obj) {
107+
logger.error(`${handlerPath} is undefined or not exported`);
108+
return;
109+
}
110+
if (typeof obj !== 'function') {
111+
logger.error(`${handlerPath} is not a function`);
112+
return;
113+
}
114+
115+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
116+
(mod as HandlerModule)[functionName!] = wrapHandler(obj as Handler);
117+
}
118+
60119
/**
61120
* Adds additional information from the environment and AWS Context to the Sentry Scope.
62121
*

0 commit comments

Comments
 (0)