Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AWS Lambda layer build #3110

Merged
merged 23 commits into from
Dec 18, 2020
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6c214f4
Implement extension for AWS Lambda for automatic integration.
marshall-lee Nov 9, 2020
776dbd2
AWS Lambda packages cached
iker-barriocanal Dec 9, 2020
16fabd8
AWS Lambda build workflow
iker-barriocanal Dec 9, 2020
04552f6
Merged AWS workflow to the main one
iker-barriocanal Dec 9, 2020
d961a50
Serverless package versions updated
iker-barriocanal Dec 10, 2020
cd54159
Deleted publish script
iker-barriocanal Dec 10, 2020
ea2be97
Serverless AWS integration docs minor change
iker-barriocanal Dec 10, 2020
43f533e
Set default value in the parameter
iker-barriocanal Dec 14, 2020
443df76
Deleted dependency `path-exists`
iker-barriocanal Dec 14, 2020
107a533
Merge branch 'master' into serverless/awslambda-automation
iker-barriocanal Dec 14, 2020
af71461
Fixed broken promise
iker-barriocanal Dec 14, 2020
f0a9269
Merge branch 'serverless/awslambda-automation' of github.com:getsentr…
iker-barriocanal Dec 14, 2020
94b33b5
ref(aws-lambda): WIP build script
iker-barriocanal Dec 16, 2020
2f6714f
Merge branch 'master' into serverless/awslambda-automation
HazAT Dec 16, 2020
6f6a961
fix(aws-lambda): Correct API function
iker-barriocanal Dec 16, 2020
2d688d8
Merge branch 'serverless/awslambda-automation' of github.com:getsentr…
iker-barriocanal Dec 16, 2020
c4962bf
ref: Revert yarn.lock
HazAT Dec 16, 2020
37dc118
ci: Update
HazAT Dec 16, 2020
48c786d
ref: Reduce imports
iker-barriocanal Dec 16, 2020
6727c3a
ref: Remove dependency in AWS Lambda build script
iker-barriocanal Dec 17, 2020
ffc7e38
ref: Rename AWS Lambda initializer
iker-barriocanal Dec 17, 2020
49a5e60
doc: Remove optional step
iker-barriocanal Dec 18, 2020
6f6e3e4
Merge branch 'master' into serverless/awslambda-automation
iker-barriocanal Dec 18, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/awslambda-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: 'AWS Lambda Build'
on:
push:
paths:
- ${{ github.workspace }}/packages/serverless/**

jobs:
job_build:
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
name: Build AWS Lambda layer
runs-on: ubuntu-latest
steps:
- name: Build
run: node packages/serverless/scripts/build-awslambda-layer.js
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jobs:
${{ github.workspace }}/packages/**/build
${{ github.workspace }}/packages/**/dist
${{ github.workspace }}/packages/**/esm
${{ github.workspace }}/packages/serverless/dist-awslambda-layer/*.zip
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
key: ${{ runner.os }}-${{ github.sha }}
- name: Install
run: yarn install
Expand Down
7 changes: 7 additions & 0 deletions packages/node/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ export function init(options: NodeOptions = {}): void {
options.dsn = process.env.SENTRY_DSN;
}

if (options.tracesSampleRate === undefined && process.env.SENTRY_TRACES_SAMPLE_RATE) {
const tracesSampleRate = parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE);
if (isFinite(tracesSampleRate)) {
options.tracesSampleRate = tracesSampleRate;
}
}

if (options.release === undefined) {
const global = getGlobalObject<Window>();
// Prefer env var over global
Expand Down
1 change: 1 addition & 0 deletions packages/serverless/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist-awslambda-layer/
13 changes: 13 additions & 0 deletions packages/serverless/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ Sentry.AWSLambda.init({
});
```

#### Integrate Sentry using internal extension

Another and much simpler way to integrate Sentry to your AWS Lambda function is to add an official layer.

1. Choose Layers -> Add Layer.
2. Specify an ARN: `arn:aws:lambda:us-west-1:TODO:layer:TODO:VERSION`.
3. *(optional)* Go to Environment variables and add:
- `NODE_OPTIONS`: `-r @sentry/serverless/dist/auto`.
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
- `SENTRY_DSN`: `your dsn`.
- `SENTRY_TRACES_SAMPLE_RATE`: `1.0` if you want to enable tracing.
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved

*NOTE*: Optional step 3 automatically enables the `@sentry/serverless` so you should remove `Sentry.init()` and `Sentry.AWSLambda.wrapHandler()` you had before in your function code.
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved

### Google Cloud Functions

To use this SDK, call `Sentry.GCPFunction.init(options)` at the very beginning of your JavaScript file.
Expand Down
9 changes: 8 additions & 1 deletion packages/serverless/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,25 @@
"@types/node": "^14.6.4",
"aws-sdk": "^2.765.0",
"eslint": "7.6.0",
"execa": "^4.1.0",
"find-up": "^5.0.0",
"fs-extra": "^9.0.1",
"google-gax": "^2.9.0",
"jest": "^24.7.1",
"nock": "^13.0.4",
"npm-packlist": "^2.1.4",
"npm-run-all": "^4.1.2",
"path-exists": "^4.0.0",
"prettier": "1.19.0",
"read-pkg": "^5.2.0",
"rimraf": "^2.6.3",
"typescript": "3.7.5"
},
"scripts": {
"build": "run-p build:es5 build:esm",
"build": "run-p build:es5 build:esm && yarn build:awslambda-layer",
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
"build:es5": "tsc -p tsconfig.build.json",
"build:esm": "tsc -p tsconfig.esm.json",
"build:awslambda-layer": "node scripts/build-awslambda-layer.js",
"build:watch": "run-p build:watch:es5 build:watch:esm",
"build:watch:es5": "tsc -p tsconfig.build.json -w --preserveWatchOutput",
"build:watch:esm": "tsc -p tsconfig.esm.json -w --preserveWatchOutput",
Expand Down
136 changes: 136 additions & 0 deletions packages/serverless/scripts/build-awslambda-layer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
const path = require('path');
const process = require('process');

const fs = require('fs-extra');
const execa = require('execa');
const findUp = require('find-up');
const packList = require('npm-packlist');
const pathExists = require('path-exists');
const readPkg = require('read-pkg');

// AWS Lambda layer are being uploaded as zip archive, whose content is then being unpacked to the /opt
// directory in the lambda environment.
//
// So this script does the following: it builds a 'dist-awslambda-layer/nodejs/node_modules/@sentry/serverless'
// directory with a special index.js and with all necessary @sentry packages symlinked as node_modules.
// Then, this directory is compressed with zip.
//
// The tricky part about it is that one cannot just symlink the entire package directories into node_modules because
// all the src/ contents and other unnecessary files will end up in the zip archive. So, we need to symlink only
// individual files from package and it must be only those of them that are distributable.
// There exists a `npm-packlist` library for such purpose. So we need to traverse all the dependencies,
// execute `npm-packlist` on them and symlink the files into 'dist-awslambda-layer/.../@sentry/serverless/node_modules'.
// I didn't find any way to achieve this goal using standard command-line tools so I have to write this script.
//
// Another, and much simpler way to assemble such zip bundle is install all the dependencies from npm registry and
// just bundle the entire node_modules.
// It's easier and looks more stable but it's inconvenient if one wants build a zip bundle out of current source tree.
//
// And yet another way is to bundle everything with webpack into a single file. I tried and it seems to be error-prone
// so I think it's better to have a classic package directory with node_modules file structure.

/** Recursively traverse all the dependencies and collect all the info to the map */
async function collectPackages(cwd, packages) {
packages = packages || {}; // eslint-disable-line no-param-reassign
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved

const packageJson = await readPkg({ cwd: cwd });
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved

packages[packageJson.name] = { cwd, packageJson };

if (!packageJson.dependencies) {
return packages;
}

await Promise.all(
Object.keys(packageJson.dependencies).map(async dep => {
// We are interested only in 'external' dependencies which are strictly upper than current directory.
// Internal deps aka local node_modules folder of each package is handled differently.
if (await pathExists(path.resolve(cwd, 'node_modules', dep))) {
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
return;
}

const searchPath = path.resolve(cwd, '..');
const depPath = await fs.realpath(
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
await findUp(path.join('node_modules', dep), { type: 'directory', cwd: searchPath }),
);
if (packages[dep]) {
if (packages[dep].cwd != depPath) {
throw new Error(`${packageJson.name}'s dependenciy ${dep} maps to both ${packages[dep].cwd} and ${depPath}`);
}
return;
}
await collectPackages(depPath, packages);
}),
);

return packages;
}

async function main() {
const workDir = path.resolve(__dirname, '..'); // packages/serverless directory
const packages = await collectPackages(workDir);

const dist = path.resolve(workDir, 'dist-awslambda-layer');
const destRootRelative = 'nodejs/node_modules/@sentry/serverless';
const destRoot = path.resolve(dist, destRootRelative);
const destModulesRoot = path.resolve(destRoot, 'node_modules');

await fs.remove(destRoot, { recursive: true });
await fs.mkdir(destRoot, { recursive: true });
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved

await Promise.all(
Object.entries(packages).map(async ([name, pkg]) => {
const isRoot = name == '@sentry/serverless';
const destPath = isRoot ? destRoot : path.resolve(destModulesRoot, name);

// Scan over the distributable files of the module and symlink each of them.
const sourceFiles = await packList({ path: pkg.cwd });
await Promise.all(
sourceFiles.map(async filename => {
const sourceFilename = path.resolve(pkg.cwd, filename);
const destFilename = path.resolve(destPath, filename);

await fs.mkdir(path.dirname(destFilename), { recursive: true });
await fs.symlink(sourceFilename, destFilename);
}),
);

const sourceModulesRoot = path.resolve(pkg.cwd, 'node_modules');
if (!(await pathExists(sourceModulesRoot))) {
return;
}

// Scan over local node_modules folder of the package and symlink its non-dev dependencies.
const sourceModules = await fs.readdir(sourceModulesRoot);
await Promise.all(
sourceModules.map(async sourceModule => {
if (!pkg.packageJson.dependencies || !pkg.packageJson.dependencies[sourceModule]) {
return;
}

const sourceModulePath = path.resolve(sourceModulesRoot, sourceModule);
const destModulePath = path.resolve(destPath, 'node_modules', sourceModule);

await fs.mkdir(path.dirname(destModulePath), { recursive: true });
await fs.symlink(sourceModulePath, destModulePath);
}),
);
}),
);

const version = packages['@sentry/serverless'].packageJson.version;
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
const zipFilename = `sentry-node-serverless-${version}.zip`;

await fs.remove(path.resolve(dist, zipFilename));
await execa('zip', ['-r', zipFilename, destRootRelative], { cwd: dist, shell: true });
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
}

main().then(
() => {
process.exit(0);
},
err => {
console.error(err); // eslint-disable-line no-console
process.exit(-1);
},
);
79 changes: 79 additions & 0 deletions packages/serverless/scripts/publish-awslambda-layer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
const path = require('path');
const process = require('process');

const fs = require('fs-extra');
const Lambda = require('aws-sdk/clients/lambda');
const readPkg = require('read-pkg');

// This scripts publishes current lambda layer zip bundle to AWS and sets layer permission to public.
// To run you'll probably need to set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables.
//
// The file dist-awslambda-layer/sentry-node-serverless-X.YY.zip MUST exist before publishing.
// You could get it using `node scripts/build-awslambda-layer.js` or just `yarn build`.

const allRegions = [
'us-east-1',
'us-east-2',
'us-west-1',
'us-west-2',
'ap-south-1',
'ap-southeast-1',
'ap-southeast-2',
'ap-northeast-1',
'ap-northeast-2',
'ca-central-1',
'eu-central-1',
'eu-west-1',
'eu-west-2',
'eu-west-3',
'sa-east-1',
];

const layerName = 'SentryNodeServerlessSDK';

async function main() {
const workDir = path.resolve(__dirname, '..');
const packageJson = await readPkg({ cwd: workDir });
const fileContents = await fs.readFile(
path.resolve(workDir, 'dist-awslambda-layer', `sentry-node-serverless-${packageJson.version}.zip`),
);
const regions = allRegions;

for (let i = 0; i < regions.length; i++) {
const region = regions[i];
const lambda = new Lambda({ region: region });

const result = await lambda
.publishLayerVersion({
Content: {
ZipFile: fileContents,
},
LayerName: layerName,
CompatibleRuntimes: ['nodejs10.x', 'nodejs12.x'],
LicenseInfo: 'BSD-3-Clause',
})
.promise();

await lambda
.addLayerVersionPermission({
LayerName: layerName,
VersionNumber: result.Version,
StatementId: 'public',
Action: 'lambda:GetLayerVersion',
Principal: '*',
})
.promise();

console.log(result.LayerVersionArn); // eslint-disable-line no-console
}
}

main().then(
() => {
process.exit(0);
},
err => {
console.error(err); // eslint-disable-line no-console
process.exit(-1);
},
);
13 changes: 13 additions & 0 deletions packages/serverless/src/auto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import * as Sentry from './index';

const lambdaTaskRoot = process.env.LAMBDA_TASK_ROOT;
if (lambdaTaskRoot) {
const handlerString = process.env._HANDLER;
if (!handlerString) {
throw Error(`LAMBDA_TASK_ROOT is non-empty(${lambdaTaskRoot}) but _HANDLER is not set`);
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
}
Sentry.AWSLambda.init();
Sentry.AWSLambda.tryPatchHandler(lambdaTaskRoot, handlerString);
} else {
throw Error('LAMBDA_TASK_ROOT environment variable is not set');
}
59 changes: 59 additions & 0 deletions packages/serverless/src/awslambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@ import {
} from '@sentry/node';
import * as Sentry from '@sentry/node';
import { Integration } from '@sentry/types';
import { logger } from '@sentry/utils';
// 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
// eslint-disable-next-line import/no-unresolved
import { Context, Handler } from 'aws-lambda';
import * as fs from 'fs';
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
import { hostname } from 'os';
import * as path from 'path';
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
import { performance } from 'perf_hooks';
import { types } from 'util';

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

/** */
function tryRequire<T>(taskRoot: string, subdir: string, mod: string): T {
const lambdaStylePath = path.resolve(taskRoot, subdir, mod);
if (fs.existsSync(lambdaStylePath) || fs.existsSync(`${lambdaStylePath}.js`)) {
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
// Lambda-style path
return require(lambdaStylePath);
}
// Node-style path
return require(require.resolve(mod, { paths: [taskRoot, subdir] }));
}

/** */
export function tryPatchHandler(taskRoot: string, handlerPath: string): void {
type HandlerBag = HandlerModule | Handler | null | undefined;
interface HandlerModule {
[key: string]: HandlerBag;
}

const handlerDesc = path.basename(handlerPath);
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
const match = handlerDesc.match(/^([^.]*)\.(.*)$/);
if (!match) {
logger.error(`Bad handler ${handlerDesc}`);
return;
}

const [, handlerMod, handlerName] = match;

let obj: HandlerBag;
try {
const handlerDir = handlerPath.substring(0, handlerPath.indexOf(handlerDesc));
obj = tryRequire(taskRoot, handlerDir, handlerMod);
} catch (e) {
logger.error(`Cannot require ${handlerPath} in ${taskRoot}`, e);
return;
}

let mod: HandlerBag;
let functionName: string | undefined;
handlerName.split('.').forEach(name => {
mod = obj;
obj = obj && (obj as HandlerModule)[name];
functionName = name;
});
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
if (!obj) {
logger.error(`${handlerPath} is undefined or not exported`);
return;
}
if (typeof obj !== 'function') {
logger.error(`${handlerPath} is not a function`);
return;
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
(mod as HandlerModule)[functionName!] = wrapHandler(obj as Handler);
iker-barriocanal marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Adds additional information from the environment and AWS Context to the Sentry Scope.
*
Expand Down