diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/ExperimentalExtension/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/ExperimentalExtension/index.mjs new file mode 100644 index 000000000000..d4cd56b78c90 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-layer/ExperimentalExtension/index.mjs @@ -0,0 +1,16 @@ +import * as Sentry from '@sentry/aws-serverless'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + tracesSampleRate: 1, + debug: true, + _experiments: { + enableLambdaExtension: true, + }, +}); + +export const handler = async (event, context) => { + Sentry.startSpan({ name: 'manual-span', op: 'test' }, async () => { + return 'Hello, world!'; + }); +}; diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts index 4d68efb66b08..0439aba5f53c 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts @@ -242,4 +242,52 @@ test.describe('Lambda layer', () => { }), ); }); + + test('experimental extension works', async ({ lambdaClient }) => { + const transactionEventPromise = waitForTransaction('aws-serverless-lambda-sam', transactionEvent => { + return transactionEvent?.transaction === 'LayerExperimentalExtension'; + }); + + await lambdaClient.send( + new InvokeCommand({ + FunctionName: 'LayerExperimentalExtension', + Payload: JSON.stringify({}), + }), + ); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.transaction).toEqual('LayerExperimentalExtension'); + 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': '012345678912', + 'faas.execution': expect.any(String), + 'faas.id': 'arn:aws:lambda:us-east-1:012345678912:function:LayerExperimentalExtension', + '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(1); + + 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 1099cb6b6549..b353eebaa214 100644 --- a/dev-packages/rollup-utils/bundleHelpers.mjs +++ b/dev-packages/rollup-utils/bundleHelpers.mjs @@ -53,7 +53,7 @@ export function makeBaseBundleConfig(options) { }, }, context: 'window', - plugins: [rrwebBuildPlugin, markAsBrowserBuildPlugin], + plugins: [rrwebBuildPlugin, markAsBrowserBuildPlugin, licensePlugin], }; // used by `@sentry/wasm` & pluggable integrations from core/browser (bundles which need to be combined with a stand-alone SDK bundle) @@ -87,14 +87,23 @@ export function makeBaseBundleConfig(options) { // code to add after the CJS wrapper footer: '}(window));', }, - plugins: [rrwebBuildPlugin, markAsBrowserBuildPlugin], + plugins: [rrwebBuildPlugin, markAsBrowserBuildPlugin, licensePlugin], }; const workerBundleConfig = { output: { format: 'esm', }, - plugins: [commonJSPlugin, makeTerserPlugin()], + plugins: [commonJSPlugin, makeTerserPlugin(), licensePlugin], + // Don't bundle any of Node's core modules + external: builtinModules, + }; + + const awsLambdaExtensionBundleConfig = { + output: { + format: 'esm', + }, + plugins: [commonJSPlugin, makeIsDebugBuildPlugin(true), makeTerserPlugin()], // Don't bundle any of Node's core modules external: builtinModules, }; @@ -110,7 +119,7 @@ export function makeBaseBundleConfig(options) { strict: false, esModule: false, }, - plugins: [sucrasePlugin, nodeResolvePlugin, cleanupPlugin, licensePlugin], + plugins: [sucrasePlugin, nodeResolvePlugin, cleanupPlugin], treeshake: 'smallest', }; @@ -118,6 +127,7 @@ export function makeBaseBundleConfig(options) { standalone: standAloneBundleConfig, addon: addOnBundleConfig, 'node-worker': workerBundleConfig, + 'lambda-extension': awsLambdaExtensionBundleConfig, }; return deepMerge.all([sharedBundleConfig, bundleTypeConfigMap[bundleType], packageSpecificConfig || {}], { diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 62dad1c3a5a9..c1e6937c021b 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -79,7 +79,7 @@ }, "scripts": { "build": "run-p build:transpile build:types", - "build:layer": "yarn ts-node scripts/buildLambdaLayer.ts", + "build:layer": "rollup -c rollup.lambda-extension.config.mjs && yarn ts-node scripts/buildLambdaLayer.ts", "build:dev": "run-p build:transpile build:types", "build:transpile": "rollup -c rollup.npm.config.mjs && yarn build:layer", "build:types": "run-s build:types:core build:types:downlevel", diff --git a/packages/aws-serverless/rollup.lambda-extension.config.mjs b/packages/aws-serverless/rollup.lambda-extension.config.mjs new file mode 100644 index 000000000000..cf7f369d9175 --- /dev/null +++ b/packages/aws-serverless/rollup.lambda-extension.config.mjs @@ -0,0 +1,15 @@ +import { makeBaseBundleConfig } from '@sentry-internal/rollup-utils'; + +export default [ + makeBaseBundleConfig({ + bundleType: 'lambda-extension', + entrypoints: ['src/lambda-extension/index.ts'], + outputFileBase: 'index.mjs', + packageSpecificConfig: { + output: { + dir: 'build/aws/dist-serverless/sentry-extension', + sourcemap: false, + }, + }, + }), +]; diff --git a/packages/aws-serverless/scripts/buildLambdaLayer.ts b/packages/aws-serverless/scripts/buildLambdaLayer.ts index a918e6bbae18..c12d8bd70d77 100644 --- a/packages/aws-serverless/scripts/buildLambdaLayer.ts +++ b/packages/aws-serverless/scripts/buildLambdaLayer.ts @@ -45,6 +45,11 @@ async function buildLambdaLayer(): Promise { replaceSDKSource(); + fsForceMkdirSync('./build/aws/dist-serverless/extensions'); + fs.copyFileSync('./src/lambda-extension/sentry-extension', './build/aws/dist-serverless/extensions/sentry-extension'); + fs.chmodSync('./build/aws/dist-serverless/extensions/sentry-extension', 0o755); + fs.chmodSync('./build/aws/dist-serverless/sentry-extension/index.mjs', 0o755); + const zipFilename = `sentry-node-serverless-${version}.zip`; console.log(`Creating final layer zip file ${zipFilename}.`); // need to preserve the symlink above with -y diff --git a/packages/aws-serverless/src/init.ts b/packages/aws-serverless/src/init.ts index 269cc3fe27fb..9de744bedf34 100644 --- a/packages/aws-serverless/src/init.ts +++ b/packages/aws-serverless/src/init.ts @@ -1,10 +1,10 @@ import type { Integration, Options } from '@sentry/core'; -import { applySdkMetadata, getSDKSource } from '@sentry/core'; +import { applySdkMetadata, debug, getSDKSource } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node'; +import { DEBUG_BUILD } from './debug-build'; import { awsIntegration } from './integration/aws'; import { awsLambdaIntegration } from './integration/awslambda'; - /** * Get the default integrations for the AWSLambda SDK. */ @@ -14,18 +14,45 @@ export function getDefaultIntegrations(_options: Options): Integration[] { return [...getDefaultIntegrationsWithoutPerformance(), awsIntegration(), awsLambdaIntegration()]; } +export interface AwsServerlessOptions extends NodeOptions { + _experiments?: NodeOptions['_experiments'] & { + /** + * If proxying Sentry events through the Sentry Lambda extension should be enabled. + */ + enableLambdaExtension?: boolean; + }; +} + /** * Initializes the Sentry AWS Lambda SDK. * * @param options Configuration options for the SDK, @see {@link AWSLambdaOptions}. */ -export function init(options: NodeOptions = {}): NodeClient | undefined { +export function init(options: AwsServerlessOptions = {}): NodeClient | undefined { const opts = { defaultIntegrations: getDefaultIntegrations(options), ...options, }; - applySdkMetadata(opts, 'aws-serverless', ['aws-serverless'], getSDKSource()); + const sdkSource = getSDKSource(); + + if (opts._experiments?.enableLambdaExtension) { + if (sdkSource === 'aws-lambda-layer') { + if (!opts.tunnel) { + DEBUG_BUILD && debug.log('Proxying Sentry events through the Sentry Lambda extension'); + opts.tunnel = 'http://localhost:9000/envelope'; + } else { + DEBUG_BUILD && + debug.warn( + `Using a custom tunnel with the Sentry Lambda extension is not supported. Events will be tunnelled to ${opts.tunnel} and not through the extension.`, + ); + } + } else { + DEBUG_BUILD && debug.warn('The Sentry Lambda extension is only supported when using the AWS Lambda layer.'); + } + } + + applySdkMetadata(opts, 'aws-serverless', ['aws-serverless'], sdkSource); return initWithoutDefaultIntegrations(opts); } diff --git a/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts b/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts new file mode 100644 index 000000000000..ff2228fffabe --- /dev/null +++ b/packages/aws-serverless/src/lambda-extension/aws-lambda-extension.ts @@ -0,0 +1,145 @@ +import * as http from 'node:http'; +import { buffer } from 'node:stream/consumers'; +import { debug, dsnFromString, getEnvelopeEndpointWithUrlEncodedAuth } from '@sentry/core'; +import { DEBUG_BUILD } from './debug-build'; + +/** + * The Extension API Client. + */ +export class AwsLambdaExtension { + private readonly _baseUrl: string; + private _extensionId: string | null; + + public constructor() { + this._baseUrl = `http://${process.env.AWS_LAMBDA_RUNTIME_API}/2020-01-01/extension`; + this._extensionId = null; + } + + /** + * Register this extension as an external extension with AWS. + */ + public async register(): Promise { + const res = await fetch(`${this._baseUrl}/register`, { + method: 'POST', + body: JSON.stringify({ + events: ['INVOKE', 'SHUTDOWN'], + }), + headers: { + 'Content-Type': 'application/json', + 'Lambda-Extension-Name': 'sentry-extension', + }, + }); + + if (!res.ok) { + throw new Error(`Failed to register with the extension API: ${await res.text()}`); + } + + this._extensionId = res.headers.get('lambda-extension-identifier'); + } + + /** + * Advances the extension to the next event. + */ + public async next(): Promise { + if (!this._extensionId) { + throw new Error('Extension ID is not set'); + } + + const res = await fetch(`${this._baseUrl}/event/next`, { + headers: { + 'Lambda-Extension-Identifier': this._extensionId, + 'Content-Type': 'application/json', + }, + }); + + if (!res.ok) { + throw new Error(`Failed to advance to next event: ${await res.text()}`); + } + } + + /** + * Reports an error to the extension API. + * @param phase The phase of the extension. + * @param err The error to report. + */ + public async error(phase: 'init' | 'exit', err: Error): Promise { + if (!this._extensionId) { + throw new Error('Extension ID is not set'); + } + + const errorType = `Extension.${err.name || 'UnknownError'}`; + + const res = await fetch(`${this._baseUrl}/${phase}/error`, { + method: 'POST', + body: JSON.stringify({ + errorMessage: err.message || err.toString(), + errorType, + stackTrace: [err.stack], + }), + headers: { + 'Content-Type': 'application/json', + 'Lambda-Extension-Identifier': this._extensionId, + 'Lambda-Extension-Function-Error': errorType, + }, + }); + + if (!res.ok) { + DEBUG_BUILD && debug.error(`Failed to report error: ${await res.text()}`); + } + + throw err; + } + + /** + * Starts the Sentry tunnel. + */ + public startSentryTunnel(): void { + const server = http.createServer(async (req, res) => { + if (req.method === 'POST' && req.url?.startsWith('/envelope')) { + try { + const buf = await buffer(req); + // Extract the actual bytes from the Buffer by slicing its underlying ArrayBuffer + // This ensures we get only the data portion without any padding or offset + const envelopeBytes = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + const envelope = new TextDecoder().decode(envelopeBytes); + const piece = envelope.split('\n')[0]; + const header = JSON.parse(piece || '{}') as { dsn?: string }; + if (!header.dsn) { + throw new Error('DSN is not set'); + } + const dsn = dsnFromString(header.dsn); + if (!dsn) { + throw new Error('Invalid DSN'); + } + const upstreamSentryUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsn); + + fetch(upstreamSentryUrl, { + method: 'POST', + body: envelopeBytes, + }).catch(err => { + DEBUG_BUILD && debug.error('Error sending envelope to Sentry', err); + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({})); + } catch (e) { + DEBUG_BUILD && debug.error('Error tunneling to Sentry', e); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Error tunneling to Sentry' })); + } + } else { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + } + }); + + server.listen(9000, () => { + DEBUG_BUILD && debug.log('Sentry proxy listening on port 9000'); + }); + + server.on('error', err => { + DEBUG_BUILD && debug.error('Error starting Sentry proxy', err); + process.exit(1); + }); + } +} diff --git a/packages/aws-serverless/src/lambda-extension/debug-build.ts b/packages/aws-serverless/src/lambda-extension/debug-build.ts new file mode 100644 index 000000000000..60aa50940582 --- /dev/null +++ b/packages/aws-serverless/src/lambda-extension/debug-build.ts @@ -0,0 +1,8 @@ +declare const __DEBUG_BUILD__: boolean; + +/** + * This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code. + * + * ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking. + */ +export const DEBUG_BUILD = __DEBUG_BUILD__; diff --git a/packages/aws-serverless/src/lambda-extension/index.ts b/packages/aws-serverless/src/lambda-extension/index.ts new file mode 100644 index 000000000000..f465dae9741d --- /dev/null +++ b/packages/aws-serverless/src/lambda-extension/index.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import { debug } from '@sentry/core'; +import { AwsLambdaExtension } from './aws-lambda-extension'; +import { DEBUG_BUILD } from './debug-build'; + +async function main(): Promise { + const extension = new AwsLambdaExtension(); + + await extension.register(); + + extension.startSentryTunnel(); + + // eslint-disable-next-line no-constant-condition + while (true) { + await extension.next(); + } +} + +main().catch(err => { + DEBUG_BUILD && debug.error('Error in Lambda Extension', err); +}); diff --git a/packages/aws-serverless/src/lambda-extension/sentry-extension b/packages/aws-serverless/src/lambda-extension/sentry-extension new file mode 100644 index 000000000000..a6c355b4a615 --- /dev/null +++ b/packages/aws-serverless/src/lambda-extension/sentry-extension @@ -0,0 +1,8 @@ +#!/bin/bash +set -euo pipefail + +OWN_FILENAME="$(basename 0ドル)" +LAMBDA_EXTENSION_NAME="$OWN_FILENAME" # (external) extension name has to match the filename + +unset NODE_OPTIONS +exec "/opt/${LAMBDA_EXTENSION_NAME}/index.mjs" diff --git a/packages/aws-serverless/test/init.test.ts b/packages/aws-serverless/test/init.test.ts new file mode 100644 index 000000000000..b4aa7ddc0d2b --- /dev/null +++ b/packages/aws-serverless/test/init.test.ts @@ -0,0 +1,104 @@ +import { getSDKSource } from '@sentry/core'; +import { initWithoutDefaultIntegrations } from '@sentry/node'; +import { describe, expect, test, vi } from 'vitest'; +import type { AwsServerlessOptions } from '../src/init'; +import { init } from '../src/init'; + +vi.mock('@sentry/core', async importOriginal => ({ + ...(await importOriginal()), + getSDKSource: vi.fn(), +})); + +vi.mock('@sentry/node', async importOriginal => ({ + ...(await importOriginal()), + initWithoutDefaultIntegrations: vi.fn(), +})); + +const mockGetSDKSource = vi.mocked(getSDKSource); +const mockInitWithoutDefaultIntegrations = vi.mocked(initWithoutDefaultIntegrations); + +describe('init', () => { + describe('experimental Lambda extension support', () => { + test('should preserve user-provided tunnel option when Lambda extension is enabled', () => { + mockGetSDKSource.mockReturnValue('aws-lambda-layer'); + const options: AwsServerlessOptions = { + tunnel: 'https://custom-tunnel.example.com', + _experiments: { + enableLambdaExtension: true, + }, + }; + + init(options); + + expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( + expect.objectContaining({ + tunnel: 'https://custom-tunnel.example.com', + }), + ); + }); + + test('should set default tunnel when Lambda extension is enabled and SDK source is aws-lambda-layer', () => { + mockGetSDKSource.mockReturnValue('aws-lambda-layer'); + const options: AwsServerlessOptions = { + _experiments: { + enableLambdaExtension: true, + }, + }; + + init(options); + + expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( + expect.objectContaining({ + tunnel: 'http://localhost:9000/envelope', + }), + ); + }); + + test('should not set tunnel when Lambda extension is disabled', () => { + mockGetSDKSource.mockReturnValue('aws-lambda-layer'); + const options: AwsServerlessOptions = { + _experiments: { + enableLambdaExtension: false, + }, + }; + + init(options); + + expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( + expect.not.objectContaining({ + tunnel: expect.any(String), + }), + ); + }); + + test('should not set tunnel when SDK source is not aws-lambda-layer even with Lambda extension enabled', () => { + mockGetSDKSource.mockReturnValue('npm'); + const options: AwsServerlessOptions = { + _experiments: { + enableLambdaExtension: true, + }, + }; + + init(options); + + expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( + expect.not.objectContaining({ + tunnel: expect.any(String), + }), + ); + }); + + test('should not set tunnel when no experiments are provided', () => { + mockGetSDKSource.mockReturnValue('aws-lambda-layer'); + const options: AwsServerlessOptions = {}; + + init(options); + + expect(mockInitWithoutDefaultIntegrations).toHaveBeenCalledWith( + expect.not.objectContaining({ + tunnel: expect.any(String), + }), + ); + }); + }); +});

AltStyle によって変換されたページ (->オリジナル) /