diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts index 5d35a9f6fcc1..4b4a54ef24f1 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/src/stack.ts @@ -10,7 +10,7 @@ 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; +export const SAM_PORT = Number(process.env.SAM_PORT) || 7120; /** 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' { @@ -91,7 +91,9 @@ export class LocalLambdaStack extends Stack { } } - static async waitForStack(timeout = 60000, port = SAM_PORT) { + // Generous timeout: with `--warm-containers EAGER`, SAM boots every function container + // before the endpoint responds, which can take well over a minute on slow CI runners. + static async waitForStack(timeout = 180000, port = SAM_PORT) { const startTime = Date.now(); const maxWaitTime = timeout; diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/lambda-fixtures.ts b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/lambda-fixtures.ts index 4df52b322d26..0c15ef39d04e 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/lambda-fixtures.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless-layer/tests/lambda-fixtures.ts @@ -40,6 +40,8 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien 'local', 'start-lambda', '--debug', + '--port', + String(SAM_PORT), '--template', SAM_TEMPLATE_FILE, '--warm-containers', @@ -79,7 +81,8 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien removeDockerNetwork(); } }, - { scope: 'worker', auto: true }, + // Own timeout so slow SAM stack startup does not eat into the first test's budget. + { scope: 'worker', auto: true, timeout: 240_000 }, ], lambdaClient: async ({}, use) => { const lambdaClient = new LambdaClient({ diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts index 26db26e21659..a3ff3d1ce9a5 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/stack.ts @@ -9,7 +9,7 @@ import { execFileSync } from 'node:child_process'; const LAMBDA_FUNCTIONS_DIR = './src/lambda-functions-npm'; const LAMBDA_FUNCTION_TIMEOUT = 10; -export const SAM_PORT = 3001; +export const SAM_PORT = Number(process.env.SAM_PORT) || 7120; /** 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' { @@ -123,7 +123,9 @@ export class LocalLambdaStack extends Stack { } } - static async waitForStack(timeout = 60000, port = SAM_PORT) { + // Generous timeout: with `--warm-containers EAGER`, SAM boots every function container + // before the endpoint responds, which can take well over a minute on slow CI runners. + static async waitForStack(timeout = 180000, port = SAM_PORT) { const startTime = Date.now(); const maxWaitTime = timeout; diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts index 4df52b322d26..0c15ef39d04e 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/lambda-fixtures.ts @@ -40,6 +40,8 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien 'local', 'start-lambda', '--debug', + '--port', + String(SAM_PORT), '--template', SAM_TEMPLATE_FILE, '--warm-containers', @@ -79,7 +81,8 @@ export const test = base.extend<{ testEnvironment: LocalLambdaStack; lambdaClien removeDockerNetwork(); } }, - { scope: 'worker', auto: true }, + // Own timeout so slow SAM stack startup does not eat into the first test's budget. + { scope: 'worker', auto: true, timeout: 240_000 }, ], lambdaClient: async ({}, use) => { const lambdaClient = new LambdaClient({ diff --git a/packages/aws-serverless/package.json b/packages/aws-serverless/package.json index 910b1ba41a2e..32c1eed50390 100644 --- a/packages/aws-serverless/package.json +++ b/packages/aws-serverless/package.json @@ -48,6 +48,9 @@ "default": "./build/npm/esm/awslambda-auto.js" } }, + "./run-lambda-handler": { + "default": "./build/npm/run-lambda-handler.mjs" + }, "./dist/awslambda-auto": { "//": "@deprecated Use `./awslambda-auto` instead", "require": { @@ -118,6 +121,8 @@ "outputs": [ "{projectRoot}/build/npm/esm", "{projectRoot}/build/npm/cjs", + "{projectRoot}/build/npm/run-lambda-handler.mjs", + "{projectRoot}/build/npm/run-lambda-handler.mjs.map", "{projectRoot}/build/lambda-extension" ], "cache": true diff --git a/packages/aws-serverless/rollup.npm.config.mjs b/packages/aws-serverless/rollup.npm.config.mjs index 7f08d44dfcdb..0d1a5905ee67 100644 --- a/packages/aws-serverless/rollup.npm.config.mjs +++ b/packages/aws-serverless/rollup.npm.config.mjs @@ -1,6 +1,31 @@ import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sentry-internal/rollup-utils'; +// The handler shim (loaded by the AWS Lambda runtime via the redirected `_HANDLER`) is +// built as a standalone, ESM-only bundle: it uses top-level await to load the user's +// handler module, which cannot be expressed in the CJS variant. Relative imports are +// bundled into the file; bare imports (`@sentry/*`, node builtins) stay external and +// resolve against the installed package at runtime. +function makeHandlerShimConfig() { + const baseConfig = makeBaseNPMConfig({ + entrypoints: ['src/run-lambda-handler.ts'], + // Top-level await requires es2022. + esbuild: { target: 'es2022' }, + }); + + return { + ...baseConfig, + output: { + ...baseConfig.output, + dir: undefined, + file: 'build/npm/run-lambda-handler.mjs', + format: 'esm', + preserveModules: false, + }, + }; +} + export default [ + makeHandlerShimConfig(), ...makeNPMConfigVariants( makeBaseNPMConfig({ // TODO: `awslambda-auto.ts` is a file which the lambda layer uses to automatically init the SDK. Does it need to be diff --git a/packages/aws-serverless/scripts/buildLambdaLayer.ts b/packages/aws-serverless/scripts/buildLambdaLayer.ts index 9d590a96ae22..ea3f85bdf5d2 100644 --- a/packages/aws-serverless/scripts/buildLambdaLayer.ts +++ b/packages/aws-serverless/scripts/buildLambdaLayer.ts @@ -99,6 +99,7 @@ async function pruneNodeModules(): Promise { './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', + './build/aws/dist-serverless/nodejs/node_modules/@sentry/aws-serverless/build/npm/run-lambda-handler.mjs', ]; const { fileList } = await nodeFileTrace(entrypoints, { diff --git a/packages/aws-serverless/src/handlerResolution.ts b/packages/aws-serverless/src/handlerResolution.ts new file mode 100644 index 000000000000..f9d386c3de7b --- /dev/null +++ b/packages/aws-serverless/src/handlerResolution.ts @@ -0,0 +1,119 @@ +// The handler string parsing and module resolution logic in this file is ported from the +// AWS Lambda runtime interface client: +// https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/blob/main/src/UserFunction.js +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export interface ParsedHandler { + /** Directory portion of the handler string, e.g. `src/` for `src/index.handler`. */ + moduleRoot: string; + /** Module file name without extension, e.g. `index` for `src/index.handler`. */ + moduleName: string; + /** Export path within the module, e.g. `handler` or `nested.handler`. */ + functionPath: string; +} + +/** + * Parses a Lambda handler string (`/.`) the same way + * the AWS Lambda runtime interface client does: the module name is the part of the + * basename up to the first dot, the function path is everything after it (and may itself + * contain dots for nested exports). + * + * @see https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/blob/main/src/UserFunction.js + */ +export function parseHandlerString(handlerString: string): ParsedHandler | undefined { + const basename = path.basename(handlerString); + const moduleRoot = handlerString.substring(0, handlerString.length - basename.length); + + const match = basename.match(/^([^.]*)\.(.*)$/); + if (!match?.[1] || !match[2]) { + return undefined; + } + + return { moduleRoot, moduleName: match[1], functionPath: match[2] }; +} + +export interface ResolvedHandlerFile { + file: string; + format: 'cjs' | 'esm'; +} + +/** + * Resolves the handler module to a concrete file, mirroring the AWS Lambda runtime's + * probing order: extensionless file, then `.js` (ESM only when the nearest `package.json` + * declares `"type": "module"`), then `.mjs`, then `.cjs`. + * + * Extensionless files are always CJS: the runtime `require()`s them unconditionally, + * before its `"type": "module"` check (which only affects `.js` files), and Node cannot + * `import()` a file without an extension anyway. + */ +export function resolveHandlerFile( + taskRoot: string, + moduleRoot: string, + moduleName: string, +): ResolvedHandlerFile | undefined { + const basePath = path.resolve(taskRoot, moduleRoot, moduleName); + + if (isFile(basePath)) { + return { file: basePath, format: 'cjs' }; + } + if (isFile(`${basePath}.js`)) { + return { file: `${basePath}.js`, format: hasTypeModulePackageJson(path.dirname(basePath)) ? 'esm' : 'cjs' }; + } + if (isFile(`${basePath}.mjs`)) { + return { file: `${basePath}.mjs`, format: 'esm' }; + } + if (isFile(`${basePath}.cjs`)) { + return { file: `${basePath}.cjs`, format: 'cjs' }; + } + + return undefined; +} + +function isFile(filePath: string): boolean { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } +} + +/** + * Walks up from `dir` looking for the nearest `package.json`, mirroring the AWS runtime's + * ESM detection. The walk stops at the filesystem root or a `node_modules` boundary. + */ +function hasTypeModulePackageJson(dir: string): boolean { + let current = dir; + while (true) { + const packageJsonPath = path.join(current, 'package.json'); + if (isFile(packageJsonPath)) { + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as { type?: string }; + return packageJson.type === 'module'; + } catch { + return false; + } + } + + const parent = path.dirname(current); + if (parent === current || path.basename(current) === 'node_modules') { + return false; + } + current = parent; + } +} diff --git a/packages/aws-serverless/src/instrumentHandler.ts b/packages/aws-serverless/src/instrumentHandler.ts new file mode 100644 index 000000000000..48a8dcb6ac01 --- /dev/null +++ b/packages/aws-serverless/src/instrumentHandler.ts @@ -0,0 +1,195 @@ +// The span-handling logic in this file was ported from the vendored (and since removed) +// `@opentelemetry/instrumentation-aws-lambda`: +// https://github.com/open-telemetry/opentelemetry-js-contrib/blob/cc7eff47e2e7bad7678241b766753d5bd6dbc85f/packages/instrumentation-aws-lambda/src/instrumentation.ts +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { Span } from '@sentry/core'; +import { continueTrace, debug, SPAN_STATUS_ERROR, startSpanManual } from '@sentry/core'; +import { captureException } from '@sentry/node'; +import type { Callback, Context, Handler, StreamifyHandler } from 'aws-lambda'; +import { DEBUG_BUILD } from './debug-build'; +import { getRequestSpanOptions } from './requestSpanOptions'; +import { AWS_HANDLER_HIGHWATERMARK_SYMBOL, AWS_HANDLER_STREAMING_SYMBOL, isStreamingHandler, wrapHandler } from './sdk'; +import { getAwsTraceData, markEventUnhandled } from './utils'; + +const lambdaMaxInitInMilliseconds = 10_000; + +// The event truly is `any` from the runtime's point of view; the patched handlers only +// need the trace-header shape `getAwsTraceData` reads. +type LambdaEvent = Parameters[0]; + +/** + * Wraps a Lambda handler with full auto-instrumentation: a `function.aws.lambda` + * transaction that continues incoming traces, plus the scope, error-capture, timeout + * warning, and flushing behavior of {@link wrapHandler}. + * + * This is what the handler shim (`run-lambda-handler.mjs`) applies to the user's + * handler when the `AwsLambda` integration has redirected `_HANDLER`. + */ +export function instrumentHandler(original: T): T { + const lambdaStartTime = Date.now() - Math.floor(1000 * process.uptime()); + const patched = getPatchedHandler(original, lambdaStartTime); + + if (isStreamingHandler(original)) { + // Streaming handlers have special symbols that we need to copy over to the patched + // handler, so the runtime (and `wrapHandler`) treat it as a streaming handler. + const originalWithSymbols = original as unknown as Record; + const patchedWithSymbols = patched as unknown as Record; + patchedWithSymbols[AWS_HANDLER_STREAMING_SYMBOL] = originalWithSymbols[AWS_HANDLER_STREAMING_SYMBOL]; + patchedWithSymbols[AWS_HANDLER_HIGHWATERMARK_SYMBOL] = originalWithSymbols[AWS_HANDLER_HIGHWATERMARK_SYMBOL]; + return wrapHandler(patched as StreamifyHandler) as T; + } + + return wrapHandler(patched as Handler) as T; +} + +function getPatchedHandler(original: Handler | StreamifyHandler, lambdaStartTime: number): Handler | StreamifyHandler { + let requestHandledBefore = false; + let requestIsColdStart = true; + + function _onRequest(): void { + if (requestHandledBefore) { + // Non-first requests cannot be coldstart. + requestIsColdStart = false; + } else { + if (process.env.AWS_LAMBDA_INITIALIZATION_TYPE === 'provisioned-concurrency') { + // If sandbox environment is initialized with provisioned concurrency, + // even the first requests should not be considered as coldstart. + requestIsColdStart = false; + } else { + // Check whether it is proactive initialization or not: + // https://aaronstuyvenberg.com/posts/understanding-proactive-initialization + const passedTimeSinceHandlerLoad = Date.now() - lambdaStartTime; + const proactiveInitialization = passedTimeSinceHandlerLoad > lambdaMaxInitInMilliseconds; + + // If sandbox has been initialized proactively before the actual request, + // even the first requests should not be considered as coldstart. + requestIsColdStart = !proactiveInitialization; + } + requestHandledBefore = true; + } + } + + if (isStreamingHandler(original)) { + return function patchedStreamingHandler( + this: unknown, + event: LambdaEvent, + responseStream: Parameters[1], + context: Context, + ) { + _onRequest(); + const { 'sentry-trace': sentryTrace, baggage } = getAwsTraceData(event, context); + + return continueTrace({ sentryTrace, baggage }, () => + startSpanManual(getRequestSpanOptions(event, context, requestIsColdStart), span => { + let maybePromise: Promise | undefined; + try { + maybePromise = original.apply(this, [event, responseStream, context]) as Promise | undefined; + } catch (error) { + // Exception thrown synchronously before resolving the promise. + captureLambdaError(error as Error); + endSpan(span, error as Error); + throw error; + } + + return handlePromiseResult(span, maybePromise); + }), + ); + }; + } + + return function patchedHandler(this: unknown, event: LambdaEvent, context: Context, callback: Callback) { + _onRequest(); + + const { 'sentry-trace': sentryTrace, baggage } = getAwsTraceData(event, context); + + return continueTrace({ sentryTrace, baggage }, () => + startSpanManual(getRequestSpanOptions(event, context, requestIsColdStart), span => { + // Lambda seems to pass a callback even if handler is of Promise form, so we wrap all the time before calling + // the handler and see if the result is a Promise or not. In such a case, the callback is usually ignored. If + // the handler happened to both call the callback and complete a returned Promise, whichever happens first will + // win and the latter will be ignored. + const wrappedCallback = wrapCallback(callback, span); + + let maybePromise: Promise | undefined; + try { + maybePromise = original.apply(this, [event, context, wrappedCallback]) as Promise | undefined; + } catch (error) { + // Exception thrown synchronously before resolving callback / promise. + captureLambdaError(error as Error); + endSpan(span, error as Error); + throw error; + } + + return handlePromiseResult(span, maybePromise); + }), + ); + }; +} + +function captureLambdaError(err: Error | string): void { + captureException(err, scope => markEventUnhandled(scope, 'auto.function.aws_serverless.otel')); +} + +function handlePromiseResult(span: Span, maybePromise: Promise | undefined): Promise | undefined { + if (typeof maybePromise?.then === 'function') { + return maybePromise.then( + value => { + endSpan(span, undefined); + return value; + }, + (err: Error | string) => { + captureLambdaError(err); + endSpan(span, err); + throw err; + }, + ); + } + + // Handle synchronous return values by ending the span + endSpan(span, undefined); + return maybePromise; +} + +function wrapCallback(original: Callback, span: Span): Callback { + return function wrappedCallback(this: unknown, err, res) { + DEBUG_BUILD && debug.log('executing wrapped lambda callback function'); + if (err) { + captureLambdaError(err); + } + + endSpan(span, err); + return original.apply(this, [err, res]); + }; +} + +/** + * Sets the span status on error and ends the span. Unlike the old OTel-based + * instrumentation, this does not force-flush the tracer provider: the surrounding + * `wrapHandler` flushes the client before the invocation result is returned to the + * runtime, and `NodeClient.flush` force-flushes the tracer provider. + */ +function endSpan(span: Span, err: string | Error | null | undefined): void { + const errMessage = typeof err === 'string' ? err : err?.message; + if (errMessage) { + span.setStatus({ + code: SPAN_STATUS_ERROR, + message: errMessage, + }); + } + + span.end(); +} diff --git a/packages/aws-serverless/src/integration/awslambda.ts b/packages/aws-serverless/src/integration/awslambda.ts index 343d314370fb..8c48b3541f4b 100644 --- a/packages/aws-serverless/src/integration/awslambda.ts +++ b/packages/aws-serverless/src/integration/awslambda.ts @@ -1,7 +1,8 @@ import type { IntegrationFn } from '@sentry/core'; -import { defineIntegration, getCurrentScope, safeSetSpanJSONAttributes } from '@sentry/core'; -import { generateInstrumentOnce } from '@sentry/node'; -import { AwsLambdaInstrumentation } from './instrumentation-aws-lambda/instrumentation'; +import { debug, defineIntegration, getCurrentScope, safeSetSpanJSONAttributes } from '@sentry/core'; +import { createRequire } from 'node:module'; +import { DEBUG_BUILD } from '../debug-build'; +import { parseHandlerString, resolveHandlerFile } from '../handlerResolution'; interface AwsLambdaOptions { // TODO(v11): Remove this option since it's no longer used. @@ -12,9 +13,82 @@ interface AwsLambdaOptions { disableAwsContextPropagation?: boolean; } -export const instrumentAwsLambda = generateInstrumentOnce('AwsLambda', AwsLambdaInstrumentation, () => { - return {}; -}); +const SHIM_MODULE_ID = '@sentry/aws-serverless/run-lambda-handler'; + +function resolveShimFile(): string | undefined { + try { + // In the CJS build `require` exists; in the ESM build (and when running the TS source + // directly, e.g. in tests) we create one. Rollup converts `import.meta.url` to an + // equivalent for the CJS build, so both branches are always syntactically valid. + const resolve = typeof require === 'function' ? require.resolve : createRequire(import.meta.url).resolve; + return resolve(SHIM_MODULE_ID); + } catch (error) { + DEBUG_BUILD && debug.warn(`Could not resolve ${SHIM_MODULE_ID}, not instrumenting the Lambda handler.`, error); + return undefined; + } +} + +/** + * Redirects the Lambda runtime to a Sentry handler shim by rewriting the `_HANDLER` + * environment variable. The runtime reads `_HANDLER` only after all `--import`/`--require` + * preloads (and thus `Sentry.init()`) have run, so it loads the shim instead of the user's + * handler. The shim (`run-lambda-handler.mjs`) then loads the module referenced by + * `SENTRY_ORIGINAL_HANDLER`, wraps the user's handler and exports the wrapped version. + * + * Because the shim wraps the handler *value* (whatever the export resolves to), this also + * covers handlers that are re-exported, wrapped (e.g. middy), bundled, or streaming. + */ +export function redirectLambdaHandler(): void { + const taskRoot = process.env.LAMBDA_TASK_ROOT; + const handlerString = process.env._HANDLER; + + // _HANDLER and LAMBDA_TASK_ROOT are always defined in Lambda but guard bail out if in the future this changes. + if (!taskRoot || !handlerString) { + DEBUG_BUILD && + debug.log('Skipping lambda handler redirect: no _HANDLER or LAMBDA_TASK_ROOT.', { taskRoot, handlerString }); + return; + } + + // Already redirected (e.g. `init()` ran twice, or the user redirected manually). + if (process.env.SENTRY_ORIGINAL_HANDLER) { + DEBUG_BUILD && debug.log('Skipping lambda handler redirect: SENTRY_ORIGINAL_HANDLER is already set.'); + return; + } + + // The runtime rejects handler strings containing '..'; leave them untouched so it + // surfaces its own error. + if (handlerString.includes('..')) { + return; + } + + const parsedHandler = parseHandlerString(handlerString); + if (!parsedHandler) { + DEBUG_BUILD && debug.warn('Invalid handler definition, not instrumenting the Lambda handler.', { handlerString }); + return; + } + + // Validate that the handler module actually exists before redirecting, so a broken + // handler configuration still produces the runtime's original error message. + const resolvedFile = resolveHandlerFile(taskRoot, parsedHandler.moduleRoot, parsedHandler.moduleName); + if (!resolvedFile) { + DEBUG_BUILD && + debug.warn('Could not resolve the Lambda handler file, not instrumenting the Lambda handler.', { + handlerString, + }); + return; + } + + const shimFile = resolveShimFile(); + if (!shimFile) { + return; + } + + process.env.SENTRY_ORIGINAL_HANDLER = handlerString; + process.env._HANDLER = `${shimFile.replace(/\.mjs$/, '')}.handler`; + + DEBUG_BUILD && + debug.log('Redirected lambda handler.', { originalHandler: handlerString, newHandler: process.env._HANDLER }); +} const AWS_LAMBDA_CONTEXT_FIELDS = [ 'aws_request_id', @@ -27,11 +101,11 @@ const AWS_LAMBDA_CONTEXT_FIELDS = [ const AWS_CLOUDWATCH_CONTEXT_FIELDS = ['log_group', 'log_stream', 'url'] as const; -const _awsLambdaIntegration = ((options: AwsLambdaOptions = {}) => { +const _awsLambdaIntegration = ((_options: AwsLambdaOptions = {}) => { return { name: 'AwsLambda' as const, setupOnce() { - instrumentAwsLambda(options); + redirectLambdaHandler(); }, processSegmentSpan(span) { const { contexts } = getCurrentScope().getScopeData(); @@ -64,6 +138,6 @@ const _awsLambdaIntegration = ((options: AwsLambdaOptions = {}) => { }) satisfies IntegrationFn; /** - * Instrumentation for aws-sdk package + * Instrumentation for the AWS Lambda handler. */ export const awsLambdaIntegration = defineIntegration(_awsLambdaIntegration); diff --git a/packages/aws-serverless/src/integration/instrumentation-aws-lambda/instrumentation.ts b/packages/aws-serverless/src/integration/instrumentation-aws-lambda/instrumentation.ts deleted file mode 100644 index d466a10f0711..000000000000 --- a/packages/aws-serverless/src/integration/instrumentation-aws-lambda/instrumentation.ts +++ /dev/null @@ -1,520 +0,0 @@ -// Vendored and modified from: https://github.com/open-telemetry/opentelemetry-js-contrib/blob/cc7eff47e2e7bad7678241b766753d5bd6dbc85f/packages/instrumentation-aws-lambda/src/instrumentation.ts -// Modifications: -// - Added Sentry `wrapHandler` around the OTel patch handler. -// - Cancel init when handler string is invalid (TS) -// - Hardcoded package version and name -// - Added support for streaming handlers -/* eslint-disable */ -/* - * Copyright The OpenTelemetry Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { Attributes, TracerProvider } from '@opentelemetry/api'; -import { - InstrumentationBase, - InstrumentationNodeModuleDefinition, - InstrumentationNodeModuleFile, - isWrapped, - safeExecuteInTheMiddle, -} from '@opentelemetry/instrumentation'; -import { CLOUD_ACCOUNT_ID, FAAS_COLDSTART, URL_FULL } from '@sentry/conventions/attributes'; -import type { Span } from '@sentry/core'; -import { - continueTrace, - debug, - SDK_VERSION, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SPAN_KIND, - SPAN_STATUS_ERROR, - startInactiveSpan, - withActiveSpan, -} from '@sentry/core'; -import { captureException } from '@sentry/node'; -import type { Callback, Context, Handler, StreamifyHandler } from 'aws-lambda'; -import * as fs from 'fs'; -import * as path from 'path'; -import { ATTR_FAAS_EXECUTION, ATTR_FAAS_ID } from './semconv'; -import type { AwsLambdaInstrumentationConfig } from './types'; -import { wrapHandler } from '../../sdk'; -import { getAwsTraceData, markEventUnhandled } from '../../utils'; -import { DEBUG_BUILD } from '../../debug-build'; - -// OpenTelemetry package version was 0.54.0 at time of vendoring. -const PACKAGE_VERSION = SDK_VERSION; -const PACKAGE_NAME = '@sentry/instrumentation-aws-lambda'; - -export const lambdaMaxInitInMilliseconds = 10_000; -const AWS_HANDLER_STREAMING_SYMBOL = Symbol.for('aws.lambda.runtime.handler.streaming'); -const AWS_HANDLER_HIGHWATERMARK_SYMBOL = Symbol.for('aws.lambda.runtime.handler.streaming.highWaterMark'); -const AWS_HANDLER_STREAMING_RESPONSE = 'response'; - -type LambdaModule = Record; - -/** - * - */ -export class AwsLambdaInstrumentation extends InstrumentationBase { - declare private _traceForceFlusher?: () => Promise; - - constructor(config: AwsLambdaInstrumentationConfig = {}) { - super(PACKAGE_NAME, PACKAGE_VERSION, config); - } - - /** - * - */ - init() { - const taskRoot = process.env.LAMBDA_TASK_ROOT; - const handlerDef = this.getConfig().lambdaHandler ?? process.env._HANDLER; - - // _HANDLER and LAMBDA_TASK_ROOT are always defined in Lambda but guard bail out if in the future this changes. - if (!taskRoot || !handlerDef) { - DEBUG_BUILD && - debug.log('Skipping lambda instrumentation: no _HANDLER/lambdaHandler or LAMBDA_TASK_ROOT.', { - taskRoot, - handlerDef, - }); - return []; - } - - // Provide a temporary awslambda polyfill for CommonJS modules during loading - // This prevents ReferenceError when modules use awslambda.streamifyResponse at load time - // taken from https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/blob/main/src/UserFunction.js#L205C7-L211C9 - if (typeof globalThis.awslambda === 'undefined') { - (globalThis as any).awslambda = { - streamifyResponse: (handler: any, options: any) => { - handler[AWS_HANDLER_STREAMING_SYMBOL] = AWS_HANDLER_STREAMING_RESPONSE; - if (typeof options?.highWaterMark === 'number') { - handler[AWS_HANDLER_HIGHWATERMARK_SYMBOL] = parseInt(options.highWaterMark); - } - return handler; - }, - }; - } - - const handler = path.basename(handlerDef); - const moduleRoot = handlerDef.substring(0, handlerDef.length - handler.length); - - const [module, functionName] = handler.split('.', 2); - - if (!module || !functionName) { - DEBUG_BUILD && - debug.warn('Invalid handler definition', { - handler, - moduleRoot, - module, - }); - return []; - } - - // Lambda loads user function using an absolute path. - let filename = path.resolve(taskRoot, moduleRoot, module); - if (!filename.endsWith('.js')) { - // It's impossible to know in advance if the user has a js, mjs or cjs file. - // Check that the .js file exists otherwise fallback to the next known possibilities (.mjs, .cjs). - try { - fs.statSync(`${filename}.js`); - filename += '.js'; - } catch (e) { - try { - fs.statSync(`${filename}.mjs`); - // fallback to .mjs (ESM) - filename += '.mjs'; - } catch (e2) { - try { - fs.statSync(`${filename}.cjs`); - // fallback to .cjs (CommonJS) - filename += '.cjs'; - } catch (e3) { - DEBUG_BUILD && - debug.warn( - 'No handler file was able to resolved with one of the known extensions for the file', - filename, - ); - } - } - } - } - - DEBUG_BUILD && - debug.log('Instrumenting lambda handler', { - taskRoot, - handlerDef, - handler, - moduleRoot, - module, - filename, - functionName, - }); - - const lambdaStartTime = this.getConfig().lambdaStartTime || Date.now() - Math.floor(1000 * process.uptime()); - - return [ - new InstrumentationNodeModuleDefinition( - // NB: The patching infrastructure seems to match names backwards, this must be the filename, while - // InstrumentationNodeModuleFile must be the module name. - filename, - ['*'], - undefined, - undefined, - [ - new InstrumentationNodeModuleFile( - module, - ['*'], - (moduleExports: LambdaModule) => { - if (isWrapped(moduleExports[functionName])) { - this._unwrap(moduleExports, functionName); - } - this._wrap(moduleExports, functionName, this._getHandler(lambdaStartTime)); - return moduleExports; - }, - (moduleExports?: LambdaModule) => { - if (moduleExports == null) return; - this._unwrap(moduleExports, functionName); - }, - ), - ], - ), - ]; - } - - /** - * - */ - private _getHandler(handlerLoadStartTime: number) { - return (original: T): T => { - if (this._isStreamingHandler(original)) { - const patchedHandler = this._getPatchHandler(original, handlerLoadStartTime); - - // Streaming handlers have special symbols that we need to copy over to the patched handler. - (patchedHandler as unknown as Record)[AWS_HANDLER_STREAMING_SYMBOL] = ( - original as unknown as Record - )[AWS_HANDLER_STREAMING_SYMBOL]; - (patchedHandler as unknown as Record)[AWS_HANDLER_HIGHWATERMARK_SYMBOL] = ( - original as unknown as Record - )[AWS_HANDLER_HIGHWATERMARK_SYMBOL]; - - return wrapHandler(patchedHandler) as T; - } - - return wrapHandler(this._getPatchHandler(original, handlerLoadStartTime)) as T; - }; - } - - private _getPatchHandler(original: Handler, lambdaStartTime: number): Handler; - private _getPatchHandler(original: StreamifyHandler, lambdaStartTime: number): StreamifyHandler; - - /** - * - */ - private _getPatchHandler(original: Handler | StreamifyHandler, lambdaStartTime: number): Handler | StreamifyHandler { - DEBUG_BUILD && debug.log('patch handler function'); - const plugin = this; - - let requestHandledBefore = false; - let requestIsColdStart = true; - - /** - * - */ - function _onRequest(): void { - if (requestHandledBefore) { - // Non-first requests cannot be coldstart. - requestIsColdStart = false; - } else { - if (process.env.AWS_LAMBDA_INITIALIZATION_TYPE === 'provisioned-concurrency') { - // If sandbox environment is initialized with provisioned concurrency, - // even the first requests should not be considered as coldstart. - requestIsColdStart = false; - } else { - // Check whether it is proactive initialization or not: - // https://aaronstuyvenberg.com/posts/understanding-proactive-initialization - const passedTimeSinceHandlerLoad: number = Date.now() - lambdaStartTime; - const proactiveInitialization: boolean = passedTimeSinceHandlerLoad > lambdaMaxInitInMilliseconds; - - // If sandbox has been initialized proactively before the actual request, - // even the first requests should not be considered as coldstart. - requestIsColdStart = !proactiveInitialization; - } - requestHandledBefore = true; - } - } - - if (this._isStreamingHandler(original)) { - return function patchedStreamingHandler( - this: never, - // The event can be a user type, it truly is any. - event: any, - responseStream: Parameters[1], - context: Context, - ) { - _onRequest(); - const { 'sentry-trace': sentryTrace, baggage } = getAwsTraceData(event, context); - - return continueTrace({ sentryTrace, baggage }, () => { - const span = plugin._createSpanForRequest(event, context, requestIsColdStart); - - return withActiveSpan(span, () => { - const maybePromise = safeExecuteInTheMiddle( - () => original.apply(this, [event, responseStream, context]), - error => { - if (error != null) { - // Exception thrown synchronously before resolving promise. - plugin._captureError(error); - plugin._endSpan(span, error, () => {}); - } - }, - ) as Promise<{}> | undefined; - - return plugin._handlePromiseResult(span, maybePromise); - }); - }); - }; - } - - return function patchedHandler( - this: never, - // The event can be a user type, it truly is any. - event: any, - context: Context, - callback: Callback, - ) { - _onRequest(); - - const { 'sentry-trace': sentryTrace, baggage } = getAwsTraceData(event, context); - - return continueTrace({ sentryTrace, baggage }, () => { - const span = plugin._createSpanForRequest(event, context, requestIsColdStart); - - return withActiveSpan(span, () => { - // Lambda seems to pass a callback even if handler is of Promise form, so we wrap all the time before calling - // the handler and see if the result is a Promise or not. In such a case, the callback is usually ignored. If - // the handler happened to both call the callback and complete a returned Promise, whichever happens first will - // win and the latter will be ignored. - const wrappedCallback = plugin._wrapCallback(callback, span); - const maybePromise = safeExecuteInTheMiddle( - () => original.apply(this, [event, context, wrappedCallback]), - error => { - if (error != null) { - // Exception thrown synchronously before resolving callback / promise. - plugin._captureError(error); - plugin._endSpan(span, error, () => {}); - } - }, - ) as Promise<{}> | undefined; - - return plugin._handlePromiseResult(span, maybePromise); - }); - }); - }; - } - - private _createSpanForRequest(event: any, context: Context, requestIsColdStart: boolean): Span { - // The span is created within the surrounding `continueTrace`, so it continues the incoming trace. - return startInactiveSpan({ - name: context.functionName, - op: 'function.aws.lambda', - forceTransaction: true, - kind: SPAN_KIND.SERVER, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.otel.aws_lambda', - [ATTR_FAAS_EXECUTION]: context.awsRequestId, - [ATTR_FAAS_ID]: context.invokedFunctionArn, - [CLOUD_ACCOUNT_ID]: AwsLambdaInstrumentation._extractAccountId(context.invokedFunctionArn), - [FAAS_COLDSTART]: requestIsColdStart, - ...AwsLambdaInstrumentation._extractOtherEventFields(event), - }, - }); - } - - private _captureError(err: Error | string): void { - captureException(err, scope => markEventUnhandled(scope, 'auto.function.aws_serverless.otel')); - } - - private _handlePromiseResult(span: Span, maybePromise: Promise<{}> | undefined): Promise<{}> | undefined { - if (typeof maybePromise?.then === 'function') { - return maybePromise.then( - value => { - return new Promise(resolve => this._endSpan(span, undefined, () => resolve(value))); - }, - (err: Error | string) => { - this._captureError(err); - return new Promise((resolve, reject) => this._endSpan(span, err, () => reject(err))); - }, - ); - } - - // Handle synchronous return values by ending the span - this._endSpan(span, undefined, () => {}); - return maybePromise; - } - - private _isStreamingHandler( - handler: Handler | StreamifyHandler, - ): handler is StreamifyHandler { - return ( - (handler as unknown as Record)[AWS_HANDLER_STREAMING_SYMBOL] === AWS_HANDLER_STREAMING_RESPONSE - ); - } - - /** - * - */ - override setTracerProvider(tracerProvider: TracerProvider) { - super.setTracerProvider(tracerProvider); - this._traceForceFlusher = this._traceForceFlush(tracerProvider); - } - - /** - * - */ - private _traceForceFlush(tracerProvider: TracerProvider) { - if (!tracerProvider) return undefined; - - let currentProvider: any = tracerProvider; - - if (typeof currentProvider.getDelegate === 'function') { - currentProvider = currentProvider.getDelegate(); - } - - if (typeof currentProvider.forceFlush === 'function') { - return currentProvider.forceFlush.bind(currentProvider); - } - - return undefined; - } - - /** - * - */ - private _wrapCallback(original: Callback, span: Span): Callback { - const plugin = this; - return function wrappedCallback(this: never, err, res) { - DEBUG_BUILD && debug.log('executing wrapped lookup callback function'); - if (err) { - plugin._captureError(err); - } - - plugin._endSpan(span, err, () => { - DEBUG_BUILD && debug.log('executing original lookup callback function'); - return original.apply(this, [err, res]); - }); - }; - } - - /** - * - */ - private _endSpan(span: Span, err: string | Error | null | undefined, callback: () => void) { - let errMessage; - if (typeof err === 'string') { - errMessage = err; - } else if (err) { - errMessage = err.message; - } - if (errMessage) { - span.setStatus({ - code: SPAN_STATUS_ERROR, - message: errMessage, - }); - } - - span.end(); - - const flushers = []; - if (this._traceForceFlusher) { - flushers.push(this._traceForceFlusher()); - } else { - DEBUG_BUILD && - debug.log( - 'Spans may not be exported for the lambda function because we are not force flushing before callback.', - ); - } - const FORCE_FLUSH_TIMEOUT_MS = 2000; - let timeoutId: ReturnType; - const timeoutPromise = new Promise(resolve => { - timeoutId = setTimeout(resolve, FORCE_FLUSH_TIMEOUT_MS); - timeoutId.unref(); - }); - Promise.race([Promise.all(flushers), timeoutPromise]) - .catch(() => {}) - .finally(() => { - clearTimeout(timeoutId); - callback(); - }); - } - - /** - * - */ - private static _extractAccountId(arn: string): string | undefined { - const parts = arn.split(':'); - if (parts.length >= 5) { - return parts[4]; - } - return undefined; - } - - /** - * - */ - private static _extractOtherEventFields(event: any): Attributes { - const answer: Attributes = {}; - const fullUrl = this._extractFullUrl(event); - if (fullUrl) { - answer[URL_FULL] = fullUrl; - } - return answer; - } - - /** - * - */ - private static _extractFullUrl(event: any): string | undefined { - // API gateway encodes a lot of url information in various places to recompute this - if (!event.headers) { - return undefined; - } - // Helper function to deal with case variations (instead of making a tolower() copy of the headers) - /** - * - */ - function findAny(event: any, key1: string, key2: string): string | undefined { - return event.headers[key1] ?? event.headers[key2]; - } - const host = findAny(event, 'host', 'Host'); - const proto = findAny(event, 'x-forwarded-proto', 'X-Forwarded-Proto'); - const port = findAny(event, 'x-forwarded-port', 'X-Forwarded-Port'); - if (!(proto && host && (event.path || event.rawPath))) { - return undefined; - } - let answer = `${proto}://${host}`; - if (port) { - answer += `:${port}`; - } - answer += event.path ?? event.rawPath; - if (event.queryStringParameters) { - let first = true; - for (const key in event.queryStringParameters) { - answer += first ? '?' : '&'; - answer += encodeURIComponent(key); - answer += '='; - answer += encodeURIComponent(event.queryStringParameters[key]); - first = false; - } - } - return answer; - } -} diff --git a/packages/aws-serverless/src/integration/instrumentation-aws-lambda/types.ts b/packages/aws-serverless/src/integration/instrumentation-aws-lambda/types.ts deleted file mode 100644 index ab08ea5d20ad..000000000000 --- a/packages/aws-serverless/src/integration/instrumentation-aws-lambda/types.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Vendored from: https://github.com/open-telemetry/opentelemetry-js-contrib/blob/cc7eff47e2e7bad7678241b766753d5bd6dbc85f/packages/instrumentation-aws-lambda/src/types.ts -/* - * Copyright The OpenTelemetry Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { InstrumentationConfig } from '@opentelemetry/instrumentation'; - -export interface AwsLambdaInstrumentationConfig extends InstrumentationConfig { - lambdaHandler?: string; - lambdaStartTime?: number; -} diff --git a/packages/aws-serverless/src/requestSpanOptions.ts b/packages/aws-serverless/src/requestSpanOptions.ts new file mode 100644 index 000000000000..1cd782f83d54 --- /dev/null +++ b/packages/aws-serverless/src/requestSpanOptions.ts @@ -0,0 +1,102 @@ +// The attribute-extraction logic in this file was ported from the vendored (and since removed) +// `@opentelemetry/instrumentation-aws-lambda`: +// https://github.com/open-telemetry/opentelemetry-js-contrib/blob/cc7eff47e2e7bad7678241b766753d5bd6dbc85f/packages/instrumentation-aws-lambda/src/instrumentation.ts +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { CLOUD_ACCOUNT_ID, FAAS_COLDSTART, URL_FULL } from '@sentry/conventions/attributes'; +import type { SpanAttributes, StartSpanOptions } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_KIND } from '@sentry/core'; +import type { Context } from 'aws-lambda'; +import { ATTR_FAAS_EXECUTION, ATTR_FAAS_ID } from './semconv'; + +interface ApiGatewayLikeEvent { + headers?: Record; + path?: string; + rawPath?: string; + queryStringParameters?: Record; +} + +/** + * Builds the options for the `function.aws.lambda` transaction started for each invocation. + */ +export function getRequestSpanOptions(event: unknown, context: Context, requestIsColdStart: boolean): StartSpanOptions { + // The span is started within the surrounding `continueTrace`, so it continues the incoming trace. + return { + name: context.functionName, + op: 'function.aws.lambda', + forceTransaction: true, + kind: SPAN_KIND.SERVER, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.otel.aws_lambda', + [ATTR_FAAS_EXECUTION]: context.awsRequestId, + [ATTR_FAAS_ID]: context.invokedFunctionArn, + [CLOUD_ACCOUNT_ID]: extractAccountId(context.invokedFunctionArn), + [FAAS_COLDSTART]: requestIsColdStart, + ...extractOtherEventFields(event), + }, + }; +} + +function extractAccountId(arn: string): string | undefined { + const parts = arn.split(':'); + if (parts.length >= 5) { + return parts[4]; + } + return undefined; +} + +function extractOtherEventFields(event: unknown): SpanAttributes { + const answer: SpanAttributes = {}; + const fullUrl = extractFullUrl(event as ApiGatewayLikeEvent); + if (fullUrl) { + answer[URL_FULL] = fullUrl; + } + return answer; +} + +function extractFullUrl(event: ApiGatewayLikeEvent): string | undefined { + // API gateway encodes a lot of url information in various places to recompute this + const headers = event.headers; + if (!headers) { + return undefined; + } + // Helper function to deal with case variations (instead of making a tolower() copy of the headers) + function findAny(key1: string, key2: string): string | undefined { + return headers?.[key1] ?? headers?.[key2]; + } + const host = findAny('host', 'Host'); + const proto = findAny('x-forwarded-proto', 'X-Forwarded-Proto'); + const port = findAny('x-forwarded-port', 'X-Forwarded-Port'); + if (!(proto && host && (event.path || event.rawPath))) { + return undefined; + } + let answer = `${proto}://${host}`; + if (port) { + answer += `:${port}`; + } + answer += event.path ?? event.rawPath; + if (event.queryStringParameters) { + let first = true; + for (const [key, value] of Object.entries(event.queryStringParameters)) { + answer += first ? '?' : '&'; + answer += encodeURIComponent(key); + answer += '='; + answer += encodeURIComponent(value ?? ''); + first = false; + } + } + return answer; +} diff --git a/packages/aws-serverless/src/run-lambda-handler.ts b/packages/aws-serverless/src/run-lambda-handler.ts new file mode 100644 index 000000000000..a37a3f025443 --- /dev/null +++ b/packages/aws-serverless/src/run-lambda-handler.ts @@ -0,0 +1,53 @@ +// This is the handler shim the AWS Lambda runtime loads in place of the user's handler +// when the `AwsLambda` integration has redirected `_HANDLER` (see `integration/awslambda.ts`). +// +// It is built as a standalone, ESM-only `build/npm/run-lambda-handler.mjs` because it uses +// top-level await to load the user's handler module (which may itself be ESM) before +// exporting the instrumented handler. The runtime awaits the dynamic import of this file, +// resolves the `handler` export, and invokes it for every event. +import type { Handler } from 'aws-lambda'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { parseHandlerString, resolveHandlerFile } from './handlerResolution'; +import { instrumentHandler } from './instrumentHandler'; + +function fail(message: string): never { + throw new Error( + `[Sentry] Failed to load the Lambda handler: ${message} If this error persists, remove the Sentry integration and contact us at https://github.com/getsentry/sentry-javascript/issues`, + ); +} + +const taskRoot = process.env.LAMBDA_TASK_ROOT; +const originalHandlerString = process.env.SENTRY_ORIGINAL_HANDLER; + +if (!taskRoot || !originalHandlerString) { + fail('LAMBDA_TASK_ROOT or SENTRY_ORIGINAL_HANDLER is not set.'); +} + +const parsedHandler = parseHandlerString(originalHandlerString); +if (!parsedHandler) { + fail(`"${originalHandlerString}" is not a valid handler string.`); +} + +const resolvedFile = resolveHandlerFile(taskRoot, parsedHandler.moduleRoot, parsedHandler.moduleName); +if (!resolvedFile) { + fail(`Could not find module "${parsedHandler.moduleName}" for handler "${originalHandlerString}".`); +} + +const handlerModule: Record = + resolvedFile.format === 'esm' + ? ((await import(pathToFileURL(resolvedFile.file).href)) as Record) + : (createRequire(import.meta.url)(resolvedFile.file) as Record); + +// Mirrors the AWS runtime's nested handler resolution (`_resolveHandler` in +// https://github.com/aws/aws-lambda-nodejs-runtime-interface-client/blob/main/src/UserFunction.js), +// e.g. `index.nested.handler`. +const originalHandler = parsedHandler.functionPath + .split('.') + .reduce((nested, key) => (nested as Record | undefined)?.[key], handlerModule); + +if (typeof originalHandler !== 'function') { + fail(`"${parsedHandler.functionPath}" in module "${parsedHandler.moduleName}" is not a function.`); +} + +export const handler = instrumentHandler(originalHandler as Handler); diff --git a/packages/aws-serverless/src/sdk.ts b/packages/aws-serverless/src/sdk.ts index ddbfe828471f..ed24e5d22501 100644 --- a/packages/aws-serverless/src/sdk.ts +++ b/packages/aws-serverless/src/sdk.ts @@ -139,7 +139,7 @@ export const AWS_HANDLER_HIGHWATERMARK_SYMBOL = Symbol.for('aws.lambda.runtime.h export const AWS_HANDLER_STREAMING_SYMBOL = Symbol.for('aws.lambda.runtime.handler.streaming'); export const AWS_HANDLER_STREAMING_RESPONSE = 'response'; -function isStreamingHandler(handler: Handler | StreamifyHandler): handler is StreamifyHandler { +export function isStreamingHandler(handler: Handler | StreamifyHandler): handler is StreamifyHandler { return ( (handler as unknown as Record)[AWS_HANDLER_STREAMING_SYMBOL] === AWS_HANDLER_STREAMING_RESPONSE ); diff --git a/packages/aws-serverless/src/integration/instrumentation-aws-lambda/semconv.ts b/packages/aws-serverless/src/semconv.ts similarity index 80% rename from packages/aws-serverless/src/integration/instrumentation-aws-lambda/semconv.ts rename to packages/aws-serverless/src/semconv.ts index 34d1130b1cf9..2b6c92860e20 100644 --- a/packages/aws-serverless/src/integration/instrumentation-aws-lambda/semconv.ts +++ b/packages/aws-serverless/src/semconv.ts @@ -24,13 +24,17 @@ /** * The execution ID of the current function execution. * - * @deprecated Removed from the stable semantic conventions; not present in `@sentry/conventions`, so vendored here. + * Removed from the stable semantic conventions; not present in `@sentry/conventions`, so vendored here. + * + * todo(v11): migrate to `faas.invocation_id` */ export const ATTR_FAAS_EXECUTION = 'faas.execution'; /** * The unique ID of the single function that this runtime instance executes. * - * @deprecated Removed from the stable semantic conventions; not present in `@sentry/conventions`, so vendored here. + * Removed from the stable semantic conventions; not present in `@sentry/conventions`, so vendored here. + * + * todo(v11): migrate to `cloud.resource_id` */ export const ATTR_FAAS_ID = 'faas.id'; diff --git a/packages/aws-serverless/test/awslambda-integration.test.ts b/packages/aws-serverless/test/awslambda-integration.test.ts index 5e64abb626b0..a1a45fd656b9 100644 --- a/packages/aws-serverless/test/awslambda-integration.test.ts +++ b/packages/aws-serverless/test/awslambda-integration.test.ts @@ -1,6 +1,9 @@ import type { StreamedSpanJSON } from '@sentry/core'; -import { describe, expect, test, vi } from 'vitest'; -import { awsLambdaIntegration } from '../src/integration/awslambda'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { awsLambdaIntegration, redirectLambdaHandler } from '../src/integration/awslambda'; const mockGetScopeData = vi.fn(); @@ -14,12 +17,85 @@ vi.mock('@sentry/core', async () => { }; }); -vi.mock('@sentry/node', async () => { - const original = await vi.importActual('@sentry/node'); - return { - ...original, - generateInstrumentOnce: () => () => {}, - }; +describe('redirectLambdaHandler', () => { + let taskRoot: string; + const originalEnv = { ...process.env }; + + beforeEach(() => { + taskRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-lambda-test-')); + fs.writeFileSync(path.join(taskRoot, 'index.js'), 'exports.handler = async () => {};'); + delete process.env._HANDLER; + delete process.env.LAMBDA_TASK_ROOT; + delete process.env.SENTRY_ORIGINAL_HANDLER; + }); + + afterEach(() => { + fs.rmSync(taskRoot, { recursive: true, force: true }); + process.env = { ...originalEnv }; + }); + + test('redirects _HANDLER to the shim and stores the original handler', () => { + process.env.LAMBDA_TASK_ROOT = taskRoot; + process.env._HANDLER = 'index.handler'; + + redirectLambdaHandler(); + + expect(process.env.SENTRY_ORIGINAL_HANDLER).toBe('index.handler'); + expect(process.env._HANDLER).toMatch(/\/run-lambda-handler\.handler$/); + expect(process.env._HANDLER).not.toContain('..'); + }); + + test('does nothing when LAMBDA_TASK_ROOT or _HANDLER are not set', () => { + process.env._HANDLER = 'index.handler'; + + redirectLambdaHandler(); + + expect(process.env._HANDLER).toBe('index.handler'); + expect(process.env.SENTRY_ORIGINAL_HANDLER).toBeUndefined(); + }); + + test('does nothing when the handler file cannot be resolved', () => { + process.env.LAMBDA_TASK_ROOT = taskRoot; + process.env._HANDLER = 'missing.handler'; + + redirectLambdaHandler(); + + expect(process.env._HANDLER).toBe('missing.handler'); + expect(process.env.SENTRY_ORIGINAL_HANDLER).toBeUndefined(); + }); + + test('does nothing when the handler string is invalid', () => { + process.env.LAMBDA_TASK_ROOT = taskRoot; + process.env._HANDLER = 'no-function-name'; + + redirectLambdaHandler(); + + expect(process.env._HANDLER).toBe('no-function-name'); + expect(process.env.SENTRY_ORIGINAL_HANDLER).toBeUndefined(); + }); + + test('does not redirect twice', () => { + process.env.LAMBDA_TASK_ROOT = taskRoot; + process.env._HANDLER = 'index.handler'; + + redirectLambdaHandler(); + const redirectedHandler = process.env._HANDLER; + + redirectLambdaHandler(); + + expect(process.env._HANDLER).toBe(redirectedHandler); + expect(process.env.SENTRY_ORIGINAL_HANDLER).toBe('index.handler'); + }); + + test('setupOnce redirects the handler', () => { + process.env.LAMBDA_TASK_ROOT = taskRoot; + process.env._HANDLER = 'index.handler'; + + awsLambdaIntegration().setupOnce!(); + + expect(process.env._HANDLER).toMatch(/\/run-lambda-handler\.handler$/); + expect(process.env.SENTRY_ORIGINAL_HANDLER).toBe('index.handler'); + }); }); describe('awsLambdaIntegration processSegmentSpan', () => { diff --git a/packages/aws-serverless/test/instrumentation.test.ts b/packages/aws-serverless/test/instrumentation.test.ts deleted file mode 100644 index 3f9a35528c66..000000000000 --- a/packages/aws-serverless/test/instrumentation.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { SPAN_STATUS_ERROR } from '@sentry/core'; -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { AwsLambdaInstrumentation } from '../src/integration/instrumentation-aws-lambda/instrumentation'; - -function createMockTracerProvider(forceFlushImpl: () => Promise) { - return { - getTracer: () => ({ - startSpan: vi.fn(), - startActiveSpan: vi.fn(), - }), - forceFlush: forceFlushImpl, - }; -} - -describe('AwsLambdaInstrumentation', () => { - describe('_endSpan', () => { - afterEach(() => { - vi.useRealTimers(); - }); - - test('callback fires even when tracerProvider.forceFlush() never resolves', async () => { - vi.useFakeTimers(); - - const instrumentation = new AwsLambdaInstrumentation(); - - const hangingProvider = createMockTracerProvider(() => new Promise(() => {})); - instrumentation.setTracerProvider(hangingProvider as any); - - const mockSpan = { - end: vi.fn(), - recordException: vi.fn(), - setStatus: vi.fn(), - }; - - const callback = vi.fn(); - - (instrumentation as any)._endSpan(mockSpan, undefined, callback); - - // Advance past any reasonable timeout (e.g. 5s) — the callback should fire - // within a bounded time even if forceFlush() hangs forever. - await vi.advanceTimersByTimeAsync(5_000); - - expect(mockSpan.end).toHaveBeenCalled(); - expect(callback).toHaveBeenCalledTimes(1); - - vi.useRealTimers(); - }); - - test('callback fires promptly when tracerProvider.forceFlush() resolves', async () => { - const instrumentation = new AwsLambdaInstrumentation(); - - const normalProvider = createMockTracerProvider(() => Promise.resolve()); - instrumentation.setTracerProvider(normalProvider as any); - - const mockSpan = { - end: vi.fn(), - recordException: vi.fn(), - setStatus: vi.fn(), - }; - - const callback = vi.fn(); - - (instrumentation as any)._endSpan(mockSpan, undefined, callback); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(callback).toHaveBeenCalledTimes(1); - expect(mockSpan.end).toHaveBeenCalled(); - }); - - test('error status is set on span before flush attempt', async () => { - const instrumentation = new AwsLambdaInstrumentation(); - - const normalProvider = createMockTracerProvider(() => Promise.resolve()); - instrumentation.setTracerProvider(normalProvider as any); - - const mockSpan = { - end: vi.fn(), - setStatus: vi.fn(), - }; - - const error = new Error('lambda failure'); - const callback = vi.fn(); - - (instrumentation as any)._endSpan(mockSpan, error, callback); - - await new Promise(resolve => setTimeout(resolve, 10)); - - expect(mockSpan.setStatus).toHaveBeenCalledWith({ - code: SPAN_STATUS_ERROR, - message: 'lambda failure', - }); - expect(mockSpan.end).toHaveBeenCalled(); - expect(callback).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/packages/aws-serverless/tsconfig.types.json b/packages/aws-serverless/tsconfig.types.json index 03b57fcaa2a7..2f96809918e7 100644 --- a/packages/aws-serverless/tsconfig.types.json +++ b/packages/aws-serverless/tsconfig.types.json @@ -2,8 +2,10 @@ "extends": "./tsconfig.json", // 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", "scripts/**/*"], + // no need to build types for it. + // `run-lambda-handler.ts` is built as a standalone ESM-only artifact with no type + // output; its top-level await is not compilable under the repo-wide `module: commonjs`. + "exclude": ["src/index.awslambda.ts", "src/run-lambda-handler.ts", "scripts/**/*"], "compilerOptions": { "declaration": true,