Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -935,7 +935,7 @@ jobs:
matrix.test-application)
uses: oven-sh/setup-bun@v2
- name: Set up AWS SAM
if: matrix.test-application == 'aws-serverless'
if: matrix.test-application == 'aws-serverless' || matrix.test-application == 'aws-serverless-layer'
uses: aws-actions/setup-sam@v2
with:
use-installer: true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"name": "aws-serverless-layer",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"scripts": {
"test": "playwright test",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:pull-sam-image": "./pull-sam-image.sh",
"test:build": "pnpm test:pull-sam-image && pnpm install && npx rimraf node_modules/@sentry/aws-serverless/nodejs",
"test:assert": "pnpm test"
},
"//": "We just need the @sentry/aws-serverless layer zip file, not the NPM package",
"devDependencies": {
"@aws-sdk/client-lambda": "^3.863.0",
"@playwright/test": "~1.56.0",
"@sentry-internal/test-utils": "link:../../../test-utils",
"@sentry/aws-serverless": "link:../../../../packages/aws-serverless/build/aws/dist-serverless/",
"aws-cdk-lib": "^2.210.0",
"constructs": "^10.4.2",
"glob": "^11.0.3",
"rimraf": "^5.0.10"
},
"volta": {
"extends": "../../package.json"
},
"//": "We need to override this here again to ensure this is not overwritten by the test runner",
"pnpm": {
"overrides": {
"@sentry/aws-serverless": "link:../../../../packages/aws-serverless/build/aws/dist-serverless/"
}
},
"sentryTest": {
"variants": [
{
"build-command": "NODE_VERSION=22 pnpm test:build",
"assert-command": "NODE_VERSION=22 pnpm test:assert",
"label": "aws-serverless-layer (Node 22)"
},
{
"build-command": "NODE_VERSION=18 pnpm test:build",
"assert-command": "NODE_VERSION=18 pnpm test:assert",
"label": "aws-serverless-layer (Node 18)"
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { getPlaywrightConfig } from '@sentry-internal/test-utils';

export default getPlaywrightConfig(undefined, {
timeout: 60 * 1000 * 3, // 3 minutes
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/bin/bash

# Pull the Lambda Node docker image for SAM local. NODE_VERSION should be the major only (e.g. 20).
# Defaults to 20 to match the repo's Volta Node major (see root package.json "volta.node").

set -e

NODE_VERSION="${NODE_VERSION:-20}"

echo "Pulling Lambda Node $NODE_VERSION docker image..."
docker pull "public.ecr.aws/lambda/nodejs:${NODE_VERSION}"

echo "Successfully pulled Lambda Node $NODE_VERSION docker image"
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# SAM CLI expects this file in the project root; without it, `sam local start-lambda` logs
# OSError / missing file errors when run from the e2e temp directory.
# These values are placeholders — this app only uses `sam local`, not deploy.
version = 0.1

[default.deploy.parameters]
stack_name = "sentry-e2e-aws-serverless-layer-local"
region = "us-east-1"
confirm_changeset = false
capabilities = "CAPABILITY_IAM"
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { Stack, CfnResource, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as path from 'node:path';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as dns from 'node:dns/promises';
import { arch, platform } from 'node:process';
import { globSync } from 'glob';

const LAMBDA_FUNCTIONS_DIR = './src/lambda-functions-layer';
const LAMBDA_FUNCTION_TIMEOUT = 10;
const LAYER_DIR = './node_modules/@sentry/aws-serverless/';
export const SAM_PORT = 3001;

/** Match SAM / Docker to this machine so Apple Silicon does not mix arm64 images with an x86_64 template default. */
function samLambdaArchitecture(): 'arm64' | 'x86_64' {
return arch === 'arm64' ? 'arm64' : 'x86_64';
}

export class LocalLambdaStack extends Stack {
sentryLayer: CfnResource;

constructor(scope: Construct, id: string, props: StackProps, hostIp: string) {
console.log('[LocalLambdaStack] Creating local SAM Lambda Stack');
super(scope, id, props);

this.templateOptions.templateFormatVersion = '2010-09-09';
this.templateOptions.transforms = ['AWS::Serverless-2016-10-31'];

console.log('[LocalLambdaStack] Add Sentry Lambda layer containing the Sentry SDK to the SAM stack');

const [layerZipFile] = globSync('sentry-node-serverless-*.zip', { cwd: LAYER_DIR });

if (!layerZipFile) {
throw new Error(`[LocalLambdaStack] Could not find sentry-node-serverless zip file in ${LAYER_DIR}`);
}

this.sentryLayer = new CfnResource(this, 'SentryNodeServerlessSDK', {
type: 'AWS::Serverless::LayerVersion',
properties: {
ContentUri: path.join(LAYER_DIR, layerZipFile),
CompatibleRuntimes: ['nodejs18.x', 'nodejs20.x', 'nodejs22.x'],
CompatibleArchitectures: [samLambdaArchitecture()],
},
});

const dsn = `http://public@${hostIp}:3031/1337`;
console.log(`[LocalLambdaStack] Using Sentry DSN: ${dsn}`);

this.addLambdaFunctions(dsn);
}

private addLambdaFunctions(dsn: string) {
console.log(`[LocalLambdaStack] Add all Lambda functions defined in ${LAMBDA_FUNCTIONS_DIR} to the SAM stack`);

const lambdaDirs = fs
.readdirSync(LAMBDA_FUNCTIONS_DIR)
.filter(dir => fs.statSync(path.join(LAMBDA_FUNCTIONS_DIR, dir)).isDirectory());

for (const lambdaDir of lambdaDirs) {
const functionName = `Layer${lambdaDir}`;

if (!process.env.NODE_VERSION) {
throw new Error('[LocalLambdaStack] NODE_VERSION is not set');
}

new CfnResource(this, functionName, {
type: 'AWS::Serverless::Function',
properties: {
Architectures: [samLambdaArchitecture()],
CodeUri: path.join(LAMBDA_FUNCTIONS_DIR, lambdaDir),
Handler: 'index.handler',
Runtime: `nodejs${process.env.NODE_VERSION}.x`,
Timeout: LAMBDA_FUNCTION_TIMEOUT,
Layers: [{ Ref: this.sentryLayer.logicalId }],
Environment: {
Variables: {
SENTRY_DSN: dsn,
SENTRY_TRACES_SAMPLE_RATE: 1.0,
SENTRY_DEBUG: true,
NODE_OPTIONS: `--import=@sentry/aws-serverless/awslambda-auto`,
},
},
},
});

console.log(`[LocalLambdaStack] Added Lambda function: ${functionName}`);
}
}

static async waitForStack(timeout = 60000, port = SAM_PORT) {
const startTime = Date.now();
const maxWaitTime = timeout;

while (Date.now() - startTime < maxWaitTime) {
try {
const response = await fetch(`http://127.0.0.1:${port}/`);

if (response.ok || response.status === 404) {
console.log(`[LocalLambdaStack] SAM stack is ready`);
return;
}
} catch {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}

throw new Error(`[LocalLambdaStack] Failed to start SAM stack after ${timeout}ms`);
}
}

export async function getHostIp() {
if (process.env.GITHUB_ACTIONS) {
const host = await dns.lookup(os.hostname());
return host.address;
}

if (platform === 'darwin' || platform === 'win32') {
return 'host.docker.internal';
}

const host = await dns.lookup(os.hostname());
return host.address;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { startEventProxyServer } from '@sentry-internal/test-utils';

startEventProxyServer({
port: 3031,
proxyServerName: 'aws-serverless-layer',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { test as base, expect } from '@playwright/test';
import { App } from 'aws-cdk-lib';
import { LocalLambdaStack, SAM_PORT, getHostIp } from '../src/stack';
import { writeFileSync } from 'node:fs';
import { execSync, spawn } from 'node:child_process';
import { LambdaClient } from '@aws-sdk/client-lambda';

const DOCKER_NETWORK_NAME = 'lambda-test-network';
const SAM_TEMPLATE_FILE = 'sam.template.yml';

/** Major Node for SAM `--invoke-image`; default matches root `package.json` `volta.node` and `pull-sam-image.sh`. */
const DEFAULT_NODE_VERSION_MAJOR = '20';

const SAM_INSTALL_ERROR =
'You need to install sam, e.g. run `brew install aws-sam-cli`. Ensure `sam` is on your PATH when running tests.';

export { expect };

export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClient: LambdaClient }>({
testEnvironment: [
async ({}, use) => {
console.log('[testEnvironment fixture] Setting up AWS Lambda test infrastructure');

const nodeVersionMajor = process.env.NODE_VERSION?.trim() || DEFAULT_NODE_VERSION_MAJOR;
process.env.NODE_VERSION = nodeVersionMajor;

assertSamOnPath();

execSync('docker network prune -f');
createDockerNetwork();

const hostIp = await getHostIp();
const app = new App();

const stack = new LocalLambdaStack(app, 'LocalLambdaStack', {}, hostIp);
const template = app.synth().getStackByName('LocalLambdaStack').template;
writeFileSync(SAM_TEMPLATE_FILE, JSON.stringify(template, null, 2));

const args = [
'local',
'start-lambda',
'--debug',
'--template',
SAM_TEMPLATE_FILE,
'--warm-containers',
'EAGER',
'--docker-network',
DOCKER_NETWORK_NAME,
'--skip-pull-image',
'--invoke-image',
`public.ecr.aws/lambda/nodejs:${nodeVersionMajor}`,
];

console.log(`[testEnvironment fixture] Running SAM with args: ${args.join(' ')}`);

const samProcess = spawn('sam', args, {
stdio: process.env.DEBUG ? 'inherit' : 'ignore',
env: envForSamChild(),
});

try {
await LocalLambdaStack.waitForStack();

await use(stack);
} finally {
console.log('[testEnvironment fixture] Tearing down AWS Lambda test infrastructure');

samProcess.kill('SIGTERM');
await new Promise(resolve => {
samProcess.once('exit', resolve);
setTimeout(() => {
if (!samProcess.killed) {
samProcess.kill('SIGKILL');
}
resolve(void 0);
}, 5000);
});

removeDockerNetwork();
}
},
{ scope: 'worker', auto: true },
],
lambdaClient: async ({}, use) => {
const lambdaClient = new LambdaClient({
endpoint: `http://127.0.0.1:${SAM_PORT}`,
region: 'us-east-1',
credentials: {
accessKeyId: 'dummy',
secretAccessKey: 'dummy',
},
});

await use(lambdaClient);
},
});

/** Avoid forcing linux/amd64 on Apple Silicon when `DOCKER_DEFAULT_PLATFORM` is set globally. */
function envForSamChild(): NodeJS.ProcessEnv {
const env = { ...process.env };
if (process.arch === 'arm64') {
delete env.DOCKER_DEFAULT_PLATFORM;
}
return env;
}

function assertSamOnPath(): void {
try {
execSync('sam --version', { encoding: 'utf-8', stdio: 'pipe' });
} catch {
throw new Error(SAM_INSTALL_ERROR);
}
}

function createDockerNetwork() {
try {
execSync(`docker network create --driver bridge ${DOCKER_NETWORK_NAME}`);
} catch (error) {
const stderr = (error as { stderr?: Buffer }).stderr?.toString() ?? '';
if (stderr.includes('already exists')) {
console.log(`[testEnvironment fixture] Reusing existing docker network ${DOCKER_NETWORK_NAME}`);
return;
}
throw error;
}
}

function removeDockerNetwork() {
try {
execSync(`docker network rm ${DOCKER_NETWORK_NAME}`);
} catch (error) {
const stderr = (error as { stderr?: Buffer }).stderr?.toString() ?? '';
if (!stderr.includes('No such network')) {
console.warn(`[testEnvironment fixture] Failed to remove docker network ${DOCKER_NETWORK_NAME}: ${stderr}`);
}
}
}
Loading
Loading