diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/.npmrc b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/package.json b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/package.json new file mode 100644 index 000000000000..7a25061dde1c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/package.json @@ -0,0 +1,23 @@ +{ + "name": "aws-lambda-layer-esm", + "version": "1.0.0", + "private": true, + "scripts": { + "start": "node src/run.mjs", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "//": "Link from local Lambda layer build", + "dependencies": { + "@sentry/aws-serverless": "link:../../../../packages/aws-serverless/build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless" + }, + "devDependencies": { + "@sentry-internal/test-utils": "link:../../../test-utils", + "@playwright/test": "~1.53.2" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/playwright.config.ts b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/playwright.config.ts new file mode 100644 index 000000000000..174593c307df --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/playwright.config.ts @@ -0,0 +1,3 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +export default getPlaywrightConfig(); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/lambda-function.mjs b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/lambda-function.mjs new file mode 100644 index 000000000000..a9cdd48c1197 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/lambda-function.mjs @@ -0,0 +1,21 @@ +import * as Sentry from '@sentry/aws-serverless'; + +import * as http from 'node:http'; + +async function handle() { + await Sentry.startSpan({ name: 'manual-span', op: 'test' }, async () => { + await new Promise(resolve => { + http.get('http://example.com', res => { + res.on('data', d => { + process.stdout.write(d); + }); + + res.on('end', () => { + resolve(); + }); + }); + }); + }); +} + +export { handle }; diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run-lambda.mjs b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run-lambda.mjs new file mode 100644 index 000000000000..c30903f9883d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run-lambda.mjs @@ -0,0 +1,8 @@ +import { handle } from './lambda-function.mjs'; + +const event = {}; +const context = { + invokedFunctionArn: 'arn:aws:lambda:us-east-1:123453789012:function:my-lambda', + functionName: 'my-lambda', +}; +await handle(event, context); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run.mjs b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run.mjs new file mode 100644 index 000000000000..4bcd5886a865 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/src/run.mjs @@ -0,0 +1,17 @@ +import child_process from 'node:child_process'; + +child_process.execSync('node ./src/run-lambda.mjs', { + stdio: 'inherit', + env: { + ...process.env, + // On AWS, LAMBDA_TASK_ROOT is usually /var/task but for testing, we set it to the CWD to correctly apply our handler + LAMBDA_TASK_ROOT: process.cwd(), + _HANDLER: 'src/lambda-function.handle', + + NODE_OPTIONS: '--import @sentry/aws-serverless/awslambda-auto', + SENTRY_DSN: 'http://public@localhost:3031/1337', + SENTRY_TRACES_SAMPLE_RATE: '1.0', + SENTRY_DEBUG: 'true', + }, + cwd: process.cwd(), +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/start-event-proxy.mjs new file mode 100644 index 000000000000..03fc10269998 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'aws-lambda-layer-esm', +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/tests/basic.test.ts b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/tests/basic.test.ts new file mode 100644 index 000000000000..2e02fda6486e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-lambda-layer-esm/tests/basic.test.ts @@ -0,0 +1,72 @@ +import * as child_process from 'child_process'; +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Lambda layer SDK bundle sends events', async ({ request }) => { + const transactionEventPromise = waitForTransaction('aws-lambda-layer-esm', transactionEvent => { + return transactionEvent?.transaction === 'my-lambda'; + }); + + // Waiting for 1s here because attaching the listener for events in `waitForTransaction` is not synchronous + // Since in this test, we don't start a browser via playwright, we don't have the usual delays (page.goto, etc) + // which are usually enough for us to never have noticed this race condition before. + // This is a workaround but probably sufficient as long as we only experience it in this test. + await new Promise(resolve => + setTimeout(() => { + resolve(); + }, 1000), + ); + + child_process.execSync('pnpm start', { + stdio: 'ignore', + }); + + const transactionEvent = await transactionEventPromise; + + // shows the SDK sent a transaction + expect(transactionEvent.transaction).toEqual('my-lambda'); // name should be the function name + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.sample_rate': 1, + 'sentry.source': 'custom', + 'sentry.origin': 'auto.otel.aws-lambda', + 'sentry.op': 'function.aws.lambda', + 'cloud.account.id': '123453789012', + 'faas.id': 'arn:aws:lambda:us-east-1:123453789012:function:my-lambda', + 'faas.coldstart': true, + 'otel.kind': 'SERVER', + }, + op: 'function.aws.lambda', + origin: 'auto.otel.aws-lambda', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + expect(transactionEvent.spans).toHaveLength(2); + + // shows that the Otel Http instrumentation is working + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'http.client', + 'sentry.origin': 'auto.http.otel.http', + url: 'http://example.com/', + }), + description: 'GET http://example.com/', + op: 'http.client', + }), + ); + + // shows that the manual span creation is working + expect(transactionEvent.spans).toContainEqual( + expect.objectContaining({ + data: expect.objectContaining({ + 'sentry.op': 'test', + 'sentry.origin': 'manual', + }), + description: 'manual-span', + op: 'test', + }), + ); +}); diff --git a/dev-packages/rollup-utils/bundleHelpers.mjs b/dev-packages/rollup-utils/bundleHelpers.mjs index f80b0b7c2e50..1099cb6b6549 100644 --- a/dev-packages/rollup-utils/bundleHelpers.mjs +++ b/dev-packages/rollup-utils/bundleHelpers.mjs @@ -90,32 +90,6 @@ export function makeBaseBundleConfig(options) { plugins: [rrwebBuildPlugin, markAsBrowserBuildPlugin], }; - // used by `@sentry/aws-serverless`, when creating the lambda layer - const awsLambdaBundleConfig = { - output: { - format: 'cjs', - }, - plugins: [ - jsonPlugin, - commonJSPlugin, - // Temporary fix for the lambda layer SDK bundle. - // This is necessary to apply to our lambda layer bundle because calling `new ImportInTheMiddle()` will throw an - // that `ImportInTheMiddle` is not a constructor. Instead we modify the code to call `new ImportInTheMiddle.default()` - // TODO: Remove this plugin once the weird import-in-the-middle exports are fixed, released and we use the respective - // version in our SDKs. See: https://github.com/getsentry/sentry-javascript/issues/12009#issuecomment-2126211967 - { - name: 'aws-serverless-lambda-layer-fix', - transform: code => { - if (code.includes('ImportInTheMiddle')) { - return code.replaceAll(/new\s+(ImportInTheMiddle.*)\(/gm, 'new $1.default('); - } - }, - }, - ], - // Don't bundle any of Node's core modules - external: builtinModules, - }; - const workerBundleConfig = { output: { format: 'esm', @@ -143,7 +117,6 @@ export function makeBaseBundleConfig(options) { const bundleTypeConfigMap = { standalone: standAloneBundleConfig, addon: addOnBundleConfig, - 'aws-lambda': awsLambdaBundleConfig, 'node-worker': workerBundleConfig, }; diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 0bca7574c42c..17433e6101e5 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -15,6 +15,7 @@ "/build/loader-hook.mjs" ], "main": "build/npm/cjs/index.js", + "module": "build/npm/esm/index.js", "types": "build/npm/types/index.d.ts", "exports": { "./package.json": "./package.json", @@ -73,10 +74,11 @@ "@types/aws-lambda": "^8.10.62" }, "devDependencies": { - "@types/node": "^18.19.1" + "@types/node": "^18.19.1", + "@vercel/nft": "^0.29.4" }, "scripts": { - "build": "run-p build:transpile build:types build:bundle", + "build": "run-p build:transpile build:types", "build:bundle": "yarn build:layer", "build:layer": "yarn ts-node scripts/buildLambdaLayer.ts", "build:dev": "run-p build:transpile build:types", diff --git a/packages/aws-serverless/rollup.aws.config.mjs b/packages/aws-serverless/rollup.aws.config.mjs deleted file mode 100644 index d9f0720886ef..000000000000 --- a/packages/aws-serverless/rollup.aws.config.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import { makeBaseBundleConfig, makeBaseNPMConfig, makeBundleConfigVariants } from '@sentry-internal/rollup-utils'; - -export default [ - // The SDK - ...makeBundleConfigVariants( - makeBaseBundleConfig({ - // this automatically sets it to be CJS - bundleType: 'aws-lambda', - entrypoints: ['src/index.ts'], - licenseTitle: '@sentry/aws-serverless', - outputFileBase: () => 'index', - packageSpecificConfig: { - output: { - dir: 'build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/cjs', - sourcemap: false, - }, - }, - }), - // We only need one copy of the SDK, and we pick the minified one because there's a cap on how big a lambda function - // plus its dependencies can be, and we might as well take up as little of that space as is necessary. We'll rename - // it to be `index.js` in the build script, since it's standing in for the index file of the npm package. - { variants: ['.debug.min.js'] }, - ), - makeBaseNPMConfig({ - entrypoints: ['src/awslambda-auto.ts'], - packageSpecificConfig: { - // Normally `makeNPMConfigVariants` sets both of these values for us, but we don't actually want the ESM variant, - // and the directory structure is different than normal, so we have to do it ourselves. - output: { - format: 'cjs', - dir: 'build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/cjs', - sourcemap: false, - }, - // We only want `awslambda-auto.js`, not the modules that it imports, because they're all included in the bundle - // we generate above - external: ['./index'], - }, - }), -]; diff --git a/packages/aws-serverless/scripts/buildLambdaLayer.ts b/packages/aws-serverless/scripts/buildLambdaLayer.ts index 52c3b50009c5..ad39948f5b0a 100644 --- a/packages/aws-serverless/scripts/buildLambdaLayer.ts +++ b/packages/aws-serverless/scripts/buildLambdaLayer.ts @@ -1,6 +1,8 @@ /* eslint-disable no-console */ +import { nodeFileTrace } from '@vercel/nft'; import * as childProcess from 'child_process'; import * as fs from 'fs'; +import * as path from 'path'; import { version } from '../package.json'; /** @@ -11,21 +13,20 @@ function run(cmd: string, options?: childProcess.ExecSyncOptions): string { return String(childProcess.execSync(cmd, { stdio: 'inherit', ...options })); } +/** + * Build the AWS lambda layer by first installing the local package into `build/aws/dist-serverless/nodejs`. + * Then, prune the node_modules directory to remove unused files by first getting all necessary files with + * `@vercel/nft` and then deleting all other files inside `node_modules`. + * Finally, create a zip file of the layer. + */ async function buildLambdaLayer(): Promise { - // Create the main SDK bundle - run('yarn rollup --config rollup.aws.config.mjs'); - - // We build a minified bundle, but it's standing in for the regular `index.js` file listed in `package.json`'s `main` - // property, so we have to rename it so it's findable. - fs.renameSync( - 'build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/cjs/index.debug.min.js', - 'build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/cjs/index.js', - ); + console.log('Building Lambda layer.'); + console.log('Installing local @sentry/aws-serverless into build/aws/dist-serverless/nodejs.'); + run('npm install . --prefix ./build/aws/dist-serverless/nodejs --install-links --silent'); - // We're creating a bundle for the SDK, but still using it in a Node context, so we need to copy in `package.json`, - // purely for its `main` property. - console.log('Copying `package.json` into lambda layer.'); - fs.copyFileSync('package.json', 'build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/package.json'); + await pruneNodeModules(); + fs.rmSync('./build/aws/dist-serverless/nodejs/package.json', { force: true }); + fs.rmSync('./build/aws/dist-serverless/nodejs/package-lock.json', { force: true }); // The layer also includes `awslambda-auto.js`, a helper file which calls `Sentry.init()` and wraps the lambda // handler. It gets run when Node is launched inside the lambda, using the environment variable @@ -61,3 +62,79 @@ function fsForceMkdirSync(path: string): void { fs.rmSync(path, { recursive: true, force: true }); fs.mkdirSync(path); } + +async function pruneNodeModules(): Promise { + const entrypoints = [ + './build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/esm/index.js', + './build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/cjs/index.js', + './build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/cjs/awslambda-auto.js', + './build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/esm/awslambda-auto.js', + ]; + + const { fileList } = await nodeFileTrace(entrypoints); + + const allFiles = getAllFiles('./build/aws/dist-serverless/nodejs/node_modules'); + + const filesToDelete = allFiles.filter(file => !fileList.has(file)); + console.log(`Removing ${filesToDelete.length} unused files from node_modules.`); + + for (const file of filesToDelete) { + try { + fs.unlinkSync(file); + } catch { + console.error(`Error deleting ${file}`); + } + } + + console.log('Cleaning up empty directories.'); + + removeEmptyDirs('./build/aws/dist-serverless/nodejs/node_modules'); +} + +function removeEmptyDirs(dir: string): void { + try { + const entries = fs.readdirSync(dir); + + for (const entry of entries) { + const fullPath = path.join(dir, entry); + const stat = fs.statSync(fullPath); + if (stat.isDirectory()) { + removeEmptyDirs(fullPath); + } + } + + const remainingEntries = fs.readdirSync(dir); + + if (remainingEntries.length === 0) { + fs.rmdirSync(dir); + } + } catch { + // Directory might not exist or might not be empty, that's ok + } +} + +function getAllFiles(dir: string): string[] { + const files: string[] = []; + + function walkDirectory(currentPath: string): void { + try { + const entries = fs.readdirSync(currentPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name); + const relativePath = path.relative(process.cwd(), fullPath); + + if (entry.isDirectory()) { + walkDirectory(fullPath); + } else { + files.push(relativePath); + } + } + } catch { + console.log(`Skipping directory ${currentPath}`); + } + } + + walkDirectory(dir); + return files; +} diff --git a/packages/aws-serverless/tsconfig.json b/packages/aws-serverless/tsconfig.json index a2731860dfa0..fd68e15254db 100644 --- a/packages/aws-serverless/tsconfig.json +++ b/packages/aws-serverless/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.json", - "include": ["src/**/*"], + "include": ["src/**/*", "scripts/**/*"], "compilerOptions": { // package-specific options diff --git a/packages/aws-serverless/tsconfig.types.json b/packages/aws-serverless/tsconfig.types.json index 4c51bd21e64b..03b57fcaa2a7 100644 --- a/packages/aws-serverless/tsconfig.types.json +++ b/packages/aws-serverless/tsconfig.types.json @@ -3,7 +3,7 @@ // We don't ship this in the npm package (it exists purely for controlling what ends up in the AWS lambda layer), so // no need to build types for it - "exclude": ["src/index.awslambda.ts"], + "exclude": ["src/index.awslambda.ts", "scripts/**/*"], "compilerOptions": { "declaration": true,