From 6390ae75fa875070530262bd52a0f0514ce0b5d6 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 2 Nov 2026 14:43:20 -0500 Subject: [PATCH 01/19] feat(node,aws-serverless): Answer managed tunnel requests on Lambda Lambda runs no HTTP server, so `serveTunnelIntegration` redirects `_HANDLER` to a shim that answers tunnel events before the user's handler, when `init` runs from a preload. `wrapHandler` in `@sentry/aws-serverless` answers them too. The handler resolution moves to `@sentry/node`, and its loader stays out of the main entry. --- .../scripts/consistentExports.ts | 6 + .../src/integration/awslambda.ts | 62 +----- .../aws-serverless/src/run-lambda-handler.ts | 5 +- packages/aws-serverless/src/sdk.ts | 21 +- packages/node/package.json | 7 +- packages/node/rollup.npm.config.mjs | 22 ++ packages/node/src/index.ts | 9 + packages/node/src/integrations/serveTunnel.ts | 38 +++- .../src/lambda}/handlerResolution.ts | 0 packages/node/src/lambda/loadHandler.ts | 48 +++++ packages/node/src/lambda/redirectHandler.ts | 87 ++++++++ packages/node/src/lambda/tunnel.ts | 201 ++++++++++++++++++ packages/node/src/run-lambda-handler.ts | 11 + .../tanstackstart-react/src/server/sdk.ts | 4 +- 14 files changed, 452 insertions(+), 69 deletions(-) rename packages/{aws-serverless/src => node/src/lambda}/handlerResolution.ts (100%) create mode 100644 packages/node/src/lambda/loadHandler.ts create mode 100644 packages/node/src/lambda/redirectHandler.ts create mode 100644 packages/node/src/lambda/tunnel.ts create mode 100644 packages/node/src/run-lambda-handler.ts diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 3c880974fe0b..cc65ee6f4a38 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -22,6 +22,12 @@ const NODE_EXPORTS_IGNORE = [ '_INTERNAL_normalizeCollectionInterval', // Only needed by framework SDKs that enable the managed tunnel after `init` '_INTERNAL_installTunnelHooks', + // Lambda helpers only needed by `@sentry/aws-serverless` + '_INTERNAL_answerLambdaTunnelEvent', + '_INTERNAL_answerStreamingLambdaTunnelEvent', + '_INTERNAL_parseLambdaHandlerString', + '_INTERNAL_redirectLambdaHandler', + '_INTERNAL_resolveLambdaHandlerFile', // not exported by bun 'nativeNodeFetchIntegration', ]; diff --git a/packages/aws-serverless/src/integration/awslambda.ts b/packages/aws-serverless/src/integration/awslambda.ts index 94ddb92d00f6..7ddfcc01b796 100644 --- a/packages/aws-serverless/src/integration/awslambda.ts +++ b/packages/aws-serverless/src/integration/awslambda.ts @@ -1,8 +1,8 @@ import type { IntegrationFn } from '@sentry/core'; import { debug, defineIntegration, getCurrentScope, safeSetSpanJSONAttributes } from '@sentry/core'; +import { _INTERNAL_redirectLambdaHandler } from '@sentry/node'; import { createRequire } from 'node:module'; import { DEBUG_BUILD } from '../debug-build'; -import { parseHandlerString, resolveHandlerFile } from '../handlerResolution'; const SHIM_MODULE_ID = '@sentry/aws-serverless/run-lambda-handler'; @@ -20,65 +20,11 @@ function resolveShimFile(): string | 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. + * Redirects the Lambda runtime to the Sentry handler shim (`run-lambda-handler.mjs`), which wraps the user's handler + * with the full Lambda instrumentation. See `_INTERNAL_redirectLambdaHandler` in `@sentry/node`. */ 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 }); + _INTERNAL_redirectLambdaHandler(resolveShimFile); } const AWS_LAMBDA_CONTEXT_FIELDS = [ diff --git a/packages/aws-serverless/src/run-lambda-handler.ts b/packages/aws-serverless/src/run-lambda-handler.ts index a37a3f025443..d4b077850e51 100644 --- a/packages/aws-serverless/src/run-lambda-handler.ts +++ b/packages/aws-serverless/src/run-lambda-handler.ts @@ -8,7 +8,10 @@ import type { Handler } from 'aws-lambda'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; -import { parseHandlerString, resolveHandlerFile } from './handlerResolution'; +import { + _INTERNAL_parseLambdaHandlerString as parseHandlerString, + _INTERNAL_resolveLambdaHandlerFile as resolveHandlerFile, +} from '@sentry/node'; import { instrumentHandler } from './instrumentHandler'; function fail(message: string): never { diff --git a/packages/aws-serverless/src/sdk.ts b/packages/aws-serverless/src/sdk.ts index 539389f4d576..1f93ecd908fc 100644 --- a/packages/aws-serverless/src/sdk.ts +++ b/packages/aws-serverless/src/sdk.ts @@ -1,6 +1,14 @@ import type { Scope } from '@sentry/core'; import { debug } from '@sentry/core'; -import { captureException, captureMessage, flush, getCurrentScope, withScope } from '@sentry/node'; +import { + _INTERNAL_answerLambdaTunnelEvent, + _INTERNAL_answerStreamingLambdaTunnelEvent, + captureException, + captureMessage, + flush, + getCurrentScope, + withScope, +} from '@sentry/node'; import type { Context, Handler, StreamifyHandler } from 'aws-lambda'; import { performance } from 'perf_hooks'; import { types } from 'util'; @@ -192,6 +200,12 @@ export function wrapHandler( : (handler as AsyncHandler>); return async (event: TEvent, context: Context) => { + // Lambda runs no HTTP server, so tunnel requests are answered here, before the handler. + const tunnelResult = _INTERNAL_answerLambdaTunnelEvent(event); + if (tunnelResult) { + return tunnelResult as Promise; + } + context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop; timeoutWarningTimer = setupTimeoutWarning(context, options); @@ -244,6 +258,11 @@ function wrapStreamingHandler( responseStream: Parameters>[1], context: Context, ): Promise => { + const tunnelAnswer = _INTERNAL_answerStreamingLambdaTunnelEvent(event, responseStream); + if (tunnelAnswer) { + return tunnelAnswer as Promise; + } + context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop; timeoutWarningTimer = setupTimeoutWarning(context, options); diff --git a/packages/node/package.json b/packages/node/package.json index acf5a4cb0565..c98fd0d41d75 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -32,6 +32,9 @@ "default": "./build/import-hook.mjs" } }, + "./run-lambda-handler": { + "default": "./build/run-lambda-handler.mjs" + }, "./vite": { "import": { "types": "./build/types/bundler-plugin/vite.d.ts", @@ -118,7 +121,9 @@ "{projectRoot}/build/cjs", "{projectRoot}/build/npm/esm", "{projectRoot}/build/npm/cjs", - "{projectRoot}/build/import-hook.mjs" + "{projectRoot}/build/import-hook.mjs", + "{projectRoot}/build/run-lambda-handler.mjs", + "{projectRoot}/build/run-lambda-handler.mjs.map" ] } } diff --git a/packages/node/rollup.npm.config.mjs b/packages/node/rollup.npm.config.mjs index 45f7f66a6df4..265ddb183a99 100644 --- a/packages/node/rollup.npm.config.mjs +++ b/packages/node/rollup.npm.config.mjs @@ -12,7 +12,29 @@ const [localVariablesWorkerConfig, getLocalVariablesBase64Code] = createWorkerCo 'build/esm/integrations/local-variables', ); +// The Lambda handler shim `init` redirects `_HANDLER` to for the managed tunnel. Standalone and ESM-only, since it +// uses top-level await to load the user's handler module. Bare imports stay external. +function makeLambdaHandlerShimConfig() { + 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/run-lambda-handler.mjs', + format: 'esm', + preserveModules: false, + }, + }; +} + export default [ + makeLambdaHandlerShimConfig(), // The `@sentry/node/import` entry (`node --import @sentry/node/import app.js`), which registers // the orchestrion diagnostics-channel injection before the app loads. ...makeOrchestrionLoader('./build'), diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 0e0250f6c432..545383060c07 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -190,6 +190,15 @@ export { wrapMcpServerWithSentry, } from '@sentry/core/server'; export type { TunnelRequestLike } from '@sentry/core/server'; +export { + answerStreamingTunnelEvent as _INTERNAL_answerStreamingLambdaTunnelEvent, + answerTunnelEvent as _INTERNAL_answerLambdaTunnelEvent, +} from './lambda/tunnel'; +export { + parseHandlerString as _INTERNAL_parseLambdaHandlerString, + resolveHandlerFile as _INTERNAL_resolveLambdaHandlerFile, +} from './lambda/handlerResolution'; +export { redirectLambdaHandler as _INTERNAL_redirectLambdaHandler } from './lambda/redirectHandler'; export { installTunnelHooks as _INTERNAL_installTunnelHooks, serveTunnelIntegration } from './integrations/serveTunnel'; export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; diff --git a/packages/node/src/integrations/serveTunnel.ts b/packages/node/src/integrations/serveTunnel.ts index 9a799638563b..3b0909cf22f1 100644 --- a/packages/node/src/integrations/serveTunnel.ts +++ b/packages/node/src/integrations/serveTunnel.ts @@ -1,24 +1,50 @@ +import type { IntegrationFn, ServerTunnelOptions } from '@sentry/core'; +import { defineIntegration } from '@sentry/core'; import { _INTERNAL_instrumentTunnelServers as instrumentTunnelServers, - makeServeTunnelIntegration, + serveTunnelIntegration as originalServeTunnelIntegration, } from '@sentry/server-utils'; +import { redirectLambdaHandlerForTunnel } from '../lambda/redirectHandler'; /** - * Lets every `node:http` server answer managed tunnel requests before the app sees them. Nothing is answered until the - * tunnel is on. For framework SDKs that enable the tunnel after `init`, like TanStack Start's `tunnelRoute`. + * Lets every `node:http` server, and the Lambda handler, answer managed tunnel requests before the app sees them. + * Nothing is answered until the tunnel is on. For framework SDKs that enable the tunnel after `init`, like TanStack + * Start's `tunnelRoute`. */ export function installTunnelHooks(): void { instrumentTunnelServers(); + redirectLambdaHandlerForTunnelIfOnLambda(); } +function redirectLambdaHandlerForTunnelIfOnLambda(): void { + // Lambda runs no HTTP server, so its handler is wrapped to answer tunnel requests. + if (process.env.LAMBDA_TASK_ROOT) { + redirectLambdaHandlerForTunnel(); + } +} + +const _serveTunnelIntegration = ((options: ServerTunnelOptions = {}) => { + return { + ...originalServeTunnelIntegration(options), + afterAllSetup() { + // After every `setupOnce`, so the redirect of `@sentry/aws-serverless`, whose handler answers tunnel requests + // too, goes first and this one stays out of it. + if (options.intercept !== false) { + redirectLambdaHandlerForTunnelIfOnLambda(); + } + }, + }; +}) satisfies IntegrationFn; + /** * Lets your server forward the events of a browser SDK set up with `tunnel: true` to Sentry. * * With this integration on the server and `tunnel: true` in the browser `init`, the browser sends its events to your * own server instead of Sentry, where ad-blockers can't drop them, and your server forwards them to Sentry. * - * Every `node:http` server answers the tunnel requests before the app sees them. To serve them from your own code - * instead, set `intercept: false` and call `handleTunnelRequestIfMatched`. + * Every `node:http` server answers the tunnel requests before the app sees them, and so does the Lambda handler when + * `init` runs from a preload. To serve them from your own code instead, set `intercept: false` and call + * `handleTunnelRequestIfMatched`. * * By default only envelopes for the server's own DSN are forwarded. * @@ -37,4 +63,4 @@ export function installTunnelHooks(): void { * }); * ``` */ -export const serveTunnelIntegration = makeServeTunnelIntegration(); +export const serveTunnelIntegration = defineIntegration(_serveTunnelIntegration); diff --git a/packages/aws-serverless/src/handlerResolution.ts b/packages/node/src/lambda/handlerResolution.ts similarity index 100% rename from packages/aws-serverless/src/handlerResolution.ts rename to packages/node/src/lambda/handlerResolution.ts diff --git a/packages/node/src/lambda/loadHandler.ts b/packages/node/src/lambda/loadHandler.ts new file mode 100644 index 000000000000..6dd8ad6a9bd0 --- /dev/null +++ b/packages/node/src/lambda/loadHandler.ts @@ -0,0 +1,48 @@ +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { parseHandlerString, resolveHandlerFile } from './handlerResolution'; + +/** + * Loads the handler the Lambda runtime would have loaded, from `SENTRY_ORIGINAL_HANDLER`. For the handler shims + * `_HANDLER` is redirected to, which run it as their own handler. + */ +export async function loadOriginalLambdaHandler(): Promise<(...args: unknown[]) => unknown> { + const taskRoot = process.env.LAMBDA_TASK_ROOT; + const originalHandlerString = process.env.SENTRY_ORIGINAL_HANDLER; + if (!taskRoot || !originalHandlerString) { + failToLoad('LAMBDA_TASK_ROOT or SENTRY_ORIGINAL_HANDLER is not set.'); + } + + const parsedHandler = parseHandlerString(originalHandlerString); + if (!parsedHandler) { + failToLoad(`"${originalHandlerString}" is not a valid handler string.`); + } + + const resolvedFile = resolveHandlerFile(taskRoot, parsedHandler.moduleRoot, parsedHandler.moduleName); + if (!resolvedFile) { + failToLoad(`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') { + failToLoad(`"${parsedHandler.functionPath}" in module "${parsedHandler.moduleName}" is not a function.`); + } + + return originalHandler as (...args: unknown[]) => unknown; +} + +function failToLoad(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`, + ); +} diff --git a/packages/node/src/lambda/redirectHandler.ts b/packages/node/src/lambda/redirectHandler.ts new file mode 100644 index 000000000000..4b539f5e8b8f --- /dev/null +++ b/packages/node/src/lambda/redirectHandler.ts @@ -0,0 +1,87 @@ +import { debug } from '@sentry/core'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DEBUG_BUILD } from '../debug-build'; +import { parseHandlerString, resolveHandlerFile } from './handlerResolution'; + +/** + * 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(resolveShimFile: () => string | undefined): 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 }); +} + +/** + * On Lambda, redirects the handler to the shim that answers managed tunnel requests before it, since Lambda runs no + * HTTP server. Only takes effect when `init` runs from a preload (`NODE_OPTIONS=--import`), before the runtime loads + * the handler. + */ +export function redirectLambdaHandlerForTunnel(): void { + redirectLambdaHandler(() => { + // The shim sits at `build/run-lambda-handler.mjs`, next to the `build/esm` and `build/cjs` this file is built into. + // Rollup converts `import.meta.url` for the CJS build. + const shimFile = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../run-lambda-handler.mjs'); + if (!fs.existsSync(shimFile)) { + DEBUG_BUILD && debug.warn(`Could not find ${shimFile}, not answering tunnel requests.`); + return undefined; + } + + return shimFile; + }); +} diff --git a/packages/node/src/lambda/tunnel.ts b/packages/node/src/lambda/tunnel.ts new file mode 100644 index 000000000000..fc4944b2b4f0 --- /dev/null +++ b/packages/node/src/lambda/tunnel.ts @@ -0,0 +1,201 @@ +import { debug } from '@sentry/core'; +import { + _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, + _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, +} from '@sentry/core/server'; +import { DEBUG_BUILD } from '../debug-build'; + +/** + * The parts of an API Gateway (REST and HTTP API), function URL or ALB event the tunnel reads. + */ +interface HttpEvent { + version?: string; + httpMethod?: string; + path?: string; + rawPath?: string; + rawQueryString?: string; + requestContext?: { http?: { method?: string }; elb?: unknown }; + headers?: Record | null; + multiValueHeaders?: Record | null; + body?: string | null; + isBase64Encoded?: boolean; +} + +/** + * The result shape API Gateway, function URLs and ALB accept from a handler. + */ +interface HttpResult { + statusCode: number; + headers?: Record; + multiValueHeaders?: Record; + body: string; + isBase64Encoded: boolean; +} + +/** + * The streaming response helper the Lambda runtime provides. + */ +declare const awslambda: + | { + HttpResponseStream: { + from( + stream: unknown, + metadata: { statusCode: number; headers: Record }, + ): { + end(chunk?: Uint8Array): void; + }; + }; + } + | undefined; + +/** + * Answers a managed tunnel request that came in as an HTTP event, before the handler runs. Lambda runs no HTTP server, + * so the handler invocation is the only place to do it. Returns `undefined` for every other event. + * + * It never throws. Until a request is claimed, a failure hands the event to the handler. Once claimed, it ends in a + * 502 instead. + */ +export function answerTunnelEvent(event: unknown): Promise | undefined { + const request = toTunnelRequest(event); + if (!request) { + return undefined; + } + + const handled = handleServedTunnelRequest(request); + if (!handled) { + return undefined; + } + + const httpEvent = event as HttpEvent; + + return handled + .then(response => toHttpResult(response, !!httpEvent.multiValueHeaders)) + .catch(() => ({ statusCode: 502, body: '', isBase64Encoded: false })); +} + +/** + * Answers a managed tunnel request for a streaming handler, writing the answer to its response stream. Returns + * `undefined` for every other event, or when the runtime has no `awslambda.HttpResponseStream`. + */ +export function answerStreamingTunnelEvent(event: unknown, responseStream: unknown): Promise | undefined { + if (typeof awslambda === 'undefined' || !awslambda?.HttpResponseStream) { + return undefined; + } + + const request = toTunnelRequest(event); + const handled = request && handleServedTunnelRequest(request); + if (!handled) { + return undefined; + } + + return handled + .then(async response => { + const body = new Uint8Array(await response.arrayBuffer()); + const stream = awslambda.HttpResponseStream.from(responseStream, { + statusCode: response.status, + headers: Object.fromEntries(response.headers), + }); + stream.end(body); + }) + .catch(error => { + DEBUG_BUILD && debug.error('Sentry tunnel: failed to answer a streaming tunnel request', error); + }); +} + +/** + * Builds a `Request` from an HTTP event that targets the tunnel, or returns `undefined`. Matches on the method and + * path first, so no other event's body gets decoded. + */ +function toTunnelRequest(event: unknown): Request | undefined { + try { + if (!event || typeof event !== 'object') { + return undefined; + } + + const httpEvent = event as HttpEvent; + const method = httpEvent.requestContext?.http?.method ?? httpEvent.httpMethod; + const path = + typeof httpEvent.rawPath === 'string' + ? `${httpEvent.rawPath}${httpEvent.rawQueryString ? `?${httpEvent.rawQueryString}` : ''}` + : httpEvent.path; + if (!method || !path || !isServedTunnelRequest({ method, url: path })) { + return undefined; + } + + const headers = new Headers(); + for (const [name, value] of Object.entries(httpEvent.headers ?? {})) { + if (value !== undefined) { + headers.set(name, value); + } + } + for (const [name, values] of Object.entries(httpEvent.multiValueHeaders ?? {})) { + if (values?.length && !headers.has(name)) { + headers.set(name, values.join(', ')); + } + } + + // API Gateway base64-encodes binary bodies, like a compressed replay envelope. + const body = httpEvent.body + ? httpEvent.isBase64Encoded + ? Uint8Array.from(atob(httpEvent.body), char => char.charCodeAt(0)) + : new TextEncoder().encode(httpEvent.body) + : new Uint8Array(0); + // The tunnel needs the length, which an event doesn't always carry. + headers.set('content-length', String(body.length)); + + return new Request(`https://${headers.get('host') || 'localhost'}${path}`, { method, headers, body }); + } catch (error) { + DEBUG_BUILD && debug.error('Sentry tunnel: failed to read a Lambda event', error); + + return undefined; + } +} + +async function toHttpResult(response: Response, multiValueHeaders: boolean): Promise { + const headers = Object.fromEntries(response.headers); + + return { + statusCode: response.status, + ...(multiValueHeaders + ? { multiValueHeaders: Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, [value]])) } + : { headers }), + body: await response.text(), + isBase64Encoded: false, + }; +} + +// The symbols the Lambda runtime reads off a streaming handler (`awslambda.streamifyResponse`). +const HANDLER_STREAMING_SYMBOL = Symbol.for('aws.lambda.runtime.handler.streaming'); +const HANDLER_HIGHWATERMARK_SYMBOL = Symbol.for('aws.lambda.runtime.handler.streaming.highWaterMark'); + +type LambdaHandler = (...args: unknown[]) => unknown; + +/** + * Wraps a Lambda handler so it answers managed tunnel requests before the handler runs, and passes every other event + * to it unchanged. Streaming handlers keep the symbols the runtime reads off them. + */ +export function wrapLambdaHandlerForTunnel(handler: LambdaHandler): LambdaHandler { + const handlerSymbols = handler as unknown as Record; + if (handlerSymbols[HANDLER_STREAMING_SYMBOL] === 'response') { + const streamingHandler = function (this: unknown, event: unknown, responseStream: unknown, ...rest: unknown[]) { + return answerStreamingTunnelEvent(event, responseStream) ?? handler.call(this, event, responseStream, ...rest); + }; + const streamingSymbols = streamingHandler as unknown as Record; + streamingSymbols[HANDLER_STREAMING_SYMBOL] = handlerSymbols[HANDLER_STREAMING_SYMBOL]; + streamingSymbols[HANDLER_HIGHWATERMARK_SYMBOL] = handlerSymbols[HANDLER_HIGHWATERMARK_SYMBOL]; + + return streamingHandler; + } + + // Keeps the handler's arity, which the runtime reads to tell a callback handler from an async one (and which the + // Node.js 24 runtime rejects). + if (handler.length > 2) { + return function (this: unknown, event: unknown, context: unknown, callback: unknown) { + return answerTunnelEvent(event) ?? handler.call(this, event, context, callback); + }; + } + + return function (this: unknown, event: unknown, context: unknown) { + return answerTunnelEvent(event) ?? handler.call(this, event, context); + }; +} diff --git a/packages/node/src/run-lambda-handler.ts b/packages/node/src/run-lambda-handler.ts new file mode 100644 index 000000000000..bbc3b0152713 --- /dev/null +++ b/packages/node/src/run-lambda-handler.ts @@ -0,0 +1,11 @@ +// The handler shim the AWS Lambda runtime loads in place of the user's handler when `init` redirected `_HANDLER` +// for the managed tunnel (see `lambda/redirectHandler.ts`). Lambda runs no HTTP server, so the handler invocation is +// the only place to answer tunnel requests before the app. +// +// It is built as a standalone, ESM-only `build/run-lambda-handler.mjs`, because it uses top-level await to load the +// user's handler module, which may itself be ESM. `@sentry/aws-serverless` has its own shim with the full Lambda +// instrumentation, and `init` doesn't redirect when that one is in place. +import { loadOriginalLambdaHandler } from './lambda/loadHandler'; +import { wrapLambdaHandlerForTunnel } from './lambda/tunnel'; + +export const handler = wrapLambdaHandlerForTunnel(await loadOriginalLambdaHandler()); diff --git a/packages/tanstackstart-react/src/server/sdk.ts b/packages/tanstackstart-react/src/server/sdk.ts index 987f1105ebc5..8d4d8fdf32f9 100644 --- a/packages/tanstackstart-react/src/server/sdk.ts +++ b/packages/tanstackstart-react/src/server/sdk.ts @@ -26,8 +26,8 @@ export function init(options: NodeOptions): NodeClient | undefined { ]; const client = initNodeSdk(sentryOptions); - // `tunnelRoute` enables the tunnel when the server entry loads, after this preload, so the servers must be ready to - // answer it already. + // `tunnelRoute` enables the tunnel when the server entry loads, after this preload, so the servers and the Lambda + // handler must be ready to answer it already. installTunnelHooks(); return client; From e74ae1c23b283784a53a43d59afcabdfbc9a5d64 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 2 Nov 2026 15:08:57 -0500 Subject: [PATCH 02/19] test(bundler-tests): Classify the `@sentry/node/run-lambda-handler` entry --- dev-packages/bundler-tests/tests/runtime-entries.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-packages/bundler-tests/tests/runtime-entries.test.ts b/dev-packages/bundler-tests/tests/runtime-entries.test.ts index 156102cc6765..73a5ccc89360 100644 --- a/dev-packages/bundler-tests/tests/runtime-entries.test.ts +++ b/dev-packages/bundler-tests/tests/runtime-entries.test.ts @@ -53,6 +53,7 @@ const ESM: Format[] = ['esm']; const CASES: Case[] = [ { specifier: '@sentry/node', conditions: NODE, formats: BOTH }, { specifier: '@sentry/node/import', conditions: NODE, formats: ESM }, + { specifier: '@sentry/node/run-lambda-handler', conditions: NODE, formats: ESM }, { specifier: '@sentry/bun', conditions: ['bun', 'node'], formats: BOTH }, { specifier: '@sentry/deno', conditions: ['deno', 'node'], formats: ESM }, { specifier: '@sentry/vercel-edge', conditions: ['edge-light'], formats: BOTH }, From ccb7436bc60d240e5dbe181de63b4ec1f825fbc1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 2 Nov 2026 23:58:05 -0500 Subject: [PATCH 03/19] ref(node): Name the Lambda tunnel event handlers handle*TunnelEvent --- .../node-exports-test-app/scripts/consistentExports.ts | 4 ++-- packages/aws-serverless/src/sdk.ts | 8 ++++---- packages/node/src/index.ts | 4 ++-- packages/node/src/lambda/tunnel.ts | 10 +++++----- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index cc65ee6f4a38..872712e9b43a 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -23,8 +23,8 @@ const NODE_EXPORTS_IGNORE = [ // Only needed by framework SDKs that enable the managed tunnel after `init` '_INTERNAL_installTunnelHooks', // Lambda helpers only needed by `@sentry/aws-serverless` - '_INTERNAL_answerLambdaTunnelEvent', - '_INTERNAL_answerStreamingLambdaTunnelEvent', + '_INTERNAL_handleLambdaTunnelEvent', + '_INTERNAL_handleStreamingLambdaTunnelEvent', '_INTERNAL_parseLambdaHandlerString', '_INTERNAL_redirectLambdaHandler', '_INTERNAL_resolveLambdaHandlerFile', diff --git a/packages/aws-serverless/src/sdk.ts b/packages/aws-serverless/src/sdk.ts index 1f93ecd908fc..37fcf3266c1c 100644 --- a/packages/aws-serverless/src/sdk.ts +++ b/packages/aws-serverless/src/sdk.ts @@ -1,8 +1,8 @@ import type { Scope } from '@sentry/core'; import { debug } from '@sentry/core'; import { - _INTERNAL_answerLambdaTunnelEvent, - _INTERNAL_answerStreamingLambdaTunnelEvent, + _INTERNAL_handleLambdaTunnelEvent, + _INTERNAL_handleStreamingLambdaTunnelEvent, captureException, captureMessage, flush, @@ -201,7 +201,7 @@ export function wrapHandler( return async (event: TEvent, context: Context) => { // Lambda runs no HTTP server, so tunnel requests are answered here, before the handler. - const tunnelResult = _INTERNAL_answerLambdaTunnelEvent(event); + const tunnelResult = _INTERNAL_handleLambdaTunnelEvent(event); if (tunnelResult) { return tunnelResult as Promise; } @@ -258,7 +258,7 @@ function wrapStreamingHandler( responseStream: Parameters>[1], context: Context, ): Promise => { - const tunnelAnswer = _INTERNAL_answerStreamingLambdaTunnelEvent(event, responseStream); + const tunnelAnswer = _INTERNAL_handleStreamingLambdaTunnelEvent(event, responseStream); if (tunnelAnswer) { return tunnelAnswer as Promise; } diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 545383060c07..87894cda94f9 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -191,8 +191,8 @@ export { } from '@sentry/core/server'; export type { TunnelRequestLike } from '@sentry/core/server'; export { - answerStreamingTunnelEvent as _INTERNAL_answerStreamingLambdaTunnelEvent, - answerTunnelEvent as _INTERNAL_answerLambdaTunnelEvent, + handleStreamingTunnelEvent as _INTERNAL_handleStreamingLambdaTunnelEvent, + handleTunnelEvent as _INTERNAL_handleLambdaTunnelEvent, } from './lambda/tunnel'; export { parseHandlerString as _INTERNAL_parseLambdaHandlerString, diff --git a/packages/node/src/lambda/tunnel.ts b/packages/node/src/lambda/tunnel.ts index fc4944b2b4f0..1b0efd095618 100644 --- a/packages/node/src/lambda/tunnel.ts +++ b/packages/node/src/lambda/tunnel.ts @@ -55,7 +55,7 @@ declare const awslambda: * It never throws. Until a request is claimed, a failure hands the event to the handler. Once claimed, it ends in a * 502 instead. */ -export function answerTunnelEvent(event: unknown): Promise | undefined { +export function handleTunnelEvent(event: unknown): Promise | undefined { const request = toTunnelRequest(event); if (!request) { return undefined; @@ -77,7 +77,7 @@ export function answerTunnelEvent(event: unknown): Promise | undefin * Answers a managed tunnel request for a streaming handler, writing the answer to its response stream. Returns * `undefined` for every other event, or when the runtime has no `awslambda.HttpResponseStream`. */ -export function answerStreamingTunnelEvent(event: unknown, responseStream: unknown): Promise | undefined { +export function handleStreamingTunnelEvent(event: unknown, responseStream: unknown): Promise | undefined { if (typeof awslambda === 'undefined' || !awslambda?.HttpResponseStream) { return undefined; } @@ -178,7 +178,7 @@ export function wrapLambdaHandlerForTunnel(handler: LambdaHandler): LambdaHandle const handlerSymbols = handler as unknown as Record; if (handlerSymbols[HANDLER_STREAMING_SYMBOL] === 'response') { const streamingHandler = function (this: unknown, event: unknown, responseStream: unknown, ...rest: unknown[]) { - return answerStreamingTunnelEvent(event, responseStream) ?? handler.call(this, event, responseStream, ...rest); + return handleStreamingTunnelEvent(event, responseStream) ?? handler.call(this, event, responseStream, ...rest); }; const streamingSymbols = streamingHandler as unknown as Record; streamingSymbols[HANDLER_STREAMING_SYMBOL] = handlerSymbols[HANDLER_STREAMING_SYMBOL]; @@ -191,11 +191,11 @@ export function wrapLambdaHandlerForTunnel(handler: LambdaHandler): LambdaHandle // Node.js 24 runtime rejects). if (handler.length > 2) { return function (this: unknown, event: unknown, context: unknown, callback: unknown) { - return answerTunnelEvent(event) ?? handler.call(this, event, context, callback); + return handleTunnelEvent(event) ?? handler.call(this, event, context, callback); }; } return function (this: unknown, event: unknown, context: unknown) { - return answerTunnelEvent(event) ?? handler.call(this, event, context); + return handleTunnelEvent(event) ?? handler.call(this, event, context); }; } From 47a5dd958903ce74a828d50413d1d0bb9ff3b721 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 3 Nov 2026 00:13:12 -0500 Subject: [PATCH 04/19] ref(node): Build the Lambda tunnel integration from the server-utils factory --- .../test/awslambda-integration.test.ts | 26 +++++++++++++ packages/node/src/integrations/serveTunnel.ts | 19 +--------- packages/node/src/lambda/redirectHandler.ts | 38 +++++++++++++++++-- 3 files changed, 62 insertions(+), 21 deletions(-) diff --git a/packages/aws-serverless/test/awslambda-integration.test.ts b/packages/aws-serverless/test/awslambda-integration.test.ts index a1a45fd656b9..cc4f55f1ca24 100644 --- a/packages/aws-serverless/test/awslambda-integration.test.ts +++ b/packages/aws-serverless/test/awslambda-integration.test.ts @@ -2,6 +2,7 @@ import type { StreamedSpanJSON } from '@sentry/core'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { serveTunnelIntegration } from '@sentry/node'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { awsLambdaIntegration, redirectLambdaHandler } from '../src/integration/awslambda'; @@ -96,6 +97,31 @@ describe('redirectLambdaHandler', () => { expect(process.env._HANDLER).toMatch(/\/run-lambda-handler\.handler$/); expect(process.env.SENTRY_ORIGINAL_HANDLER).toBe('index.handler'); }); + + test("takes over from @sentry/node's tunnel-only handler when that redirected first", () => { + process.env.LAMBDA_TASK_ROOT = taskRoot; + process.env._HANDLER = 'index.handler'; + + serveTunnelIntegration().setupOnce!(); + const tunnelHandler = process.env._HANDLER; + awsLambdaIntegration().setupOnce!(); + + expect(tunnelHandler).toMatch(/node\/build\/run-lambda-handler\.handler$/); + expect(process.env._HANDLER).toMatch(/aws-serverless\/build\/npm\/run-lambda-handler\.handler$/); + expect(process.env.SENTRY_ORIGINAL_HANDLER).toBe('index.handler'); + }); + + test("leaves the handler alone when @sentry/node's tunnel comes after", () => { + process.env.LAMBDA_TASK_ROOT = taskRoot; + process.env._HANDLER = 'index.handler'; + + awsLambdaIntegration().setupOnce!(); + const awsHandler = process.env._HANDLER; + serveTunnelIntegration().setupOnce!(); + + expect(process.env._HANDLER).toBe(awsHandler); + expect(process.env.SENTRY_ORIGINAL_HANDLER).toBe('index.handler'); + }); }); describe('awsLambdaIntegration processSegmentSpan', () => { diff --git a/packages/node/src/integrations/serveTunnel.ts b/packages/node/src/integrations/serveTunnel.ts index 3b0909cf22f1..1f44a38c9873 100644 --- a/packages/node/src/integrations/serveTunnel.ts +++ b/packages/node/src/integrations/serveTunnel.ts @@ -1,8 +1,6 @@ -import type { IntegrationFn, ServerTunnelOptions } from '@sentry/core'; -import { defineIntegration } from '@sentry/core'; import { _INTERNAL_instrumentTunnelServers as instrumentTunnelServers, - serveTunnelIntegration as originalServeTunnelIntegration, + makeServeTunnelIntegration, } from '@sentry/server-utils'; import { redirectLambdaHandlerForTunnel } from '../lambda/redirectHandler'; @@ -23,19 +21,6 @@ function redirectLambdaHandlerForTunnelIfOnLambda(): void { } } -const _serveTunnelIntegration = ((options: ServerTunnelOptions = {}) => { - return { - ...originalServeTunnelIntegration(options), - afterAllSetup() { - // After every `setupOnce`, so the redirect of `@sentry/aws-serverless`, whose handler answers tunnel requests - // too, goes first and this one stays out of it. - if (options.intercept !== false) { - redirectLambdaHandlerForTunnelIfOnLambda(); - } - }, - }; -}) satisfies IntegrationFn; - /** * Lets your server forward the events of a browser SDK set up with `tunnel: true` to Sentry. * @@ -63,4 +48,4 @@ const _serveTunnelIntegration = ((options: ServerTunnelOptions = {}) => { * }); * ``` */ -export const serveTunnelIntegration = defineIntegration(_serveTunnelIntegration); +export const serveTunnelIntegration = makeServeTunnelIntegration(redirectLambdaHandlerForTunnelIfOnLambda); diff --git a/packages/node/src/lambda/redirectHandler.ts b/packages/node/src/lambda/redirectHandler.ts index 4b539f5e8b8f..c98fb931b9b1 100644 --- a/packages/node/src/lambda/redirectHandler.ts +++ b/packages/node/src/lambda/redirectHandler.ts @@ -28,6 +28,7 @@ export function redirectLambdaHandler(resolveShimFile: () => string | undefined) // Already redirected (e.g. `init()` ran twice, or the user redirected manually). if (process.env.SENTRY_ORIGINAL_HANDLER) { + replaceTunnelShim(handlerString, resolveShimFile); DEBUG_BUILD && debug.log('Skipping lambda handler redirect: SENTRY_ORIGINAL_HANDLER is already set.'); return; } @@ -61,7 +62,7 @@ export function redirectLambdaHandler(resolveShimFile: () => string | undefined) } process.env.SENTRY_ORIGINAL_HANDLER = handlerString; - process.env._HANDLER = `${shimFile.replace(/\.mjs$/, '')}.handler`; + process.env._HANDLER = toHandlerString(shimFile); DEBUG_BUILD && debug.log('Redirected lambda handler.', { originalHandler: handlerString, newHandler: process.env._HANDLER }); @@ -74,9 +75,7 @@ export function redirectLambdaHandler(resolveShimFile: () => string | undefined) */ export function redirectLambdaHandlerForTunnel(): void { redirectLambdaHandler(() => { - // The shim sits at `build/run-lambda-handler.mjs`, next to the `build/esm` and `build/cjs` this file is built into. - // Rollup converts `import.meta.url` for the CJS build. - const shimFile = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../run-lambda-handler.mjs'); + const shimFile = getTunnelShimFile(); if (!fs.existsSync(shimFile)) { DEBUG_BUILD && debug.warn(`Could not find ${shimFile}, not answering tunnel requests.`); return undefined; @@ -85,3 +84,34 @@ export function redirectLambdaHandlerForTunnel(): void { return shimFile; }); } + +/** + * A shim with the full Lambda instrumentation, like `@sentry/aws-serverless`'s, replaces the tunnel-only one when that + * one redirected first. It answers tunnel requests too, so the order the integrations set up in doesn't matter. + */ +function replaceTunnelShim(handlerString: string, resolveShimFile: () => string | undefined): void { + const tunnelShimHandler = toHandlerString(getTunnelShimFile()); + if (handlerString !== tunnelShimHandler) { + return; + } + + const shimFile = resolveShimFile(); + if (shimFile && toHandlerString(shimFile) !== tunnelShimHandler) { + process.env._HANDLER = toHandlerString(shimFile); + } +} + +/** + * The tunnel-only shim, at `build/run-lambda-handler.mjs` next to the `build/esm` and `build/cjs` this file is built + * into. Rollup converts `import.meta.url` for the CJS build. + */ +function getTunnelShimFile(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../run-lambda-handler.mjs'); +} + +/** + * The `_HANDLER` value that loads a shim's `handler` export. + */ +function toHandlerString(shimFile: string): string { + return `${shimFile.replace(/\.mjs$/, '')}.handler`; +} From f77208bd516ed52fe83b794e04a0a571abb1fed7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Tue, 3 Nov 2026 00:17:04 -0500 Subject: [PATCH 05/19] fix(node): Recognize the tunnel-only Lambda shim by a marker instead of its path --- .../test/awslambda-integration.test.ts | 13 ++++++ packages/node/src/lambda/redirectHandler.ts | 43 ++++++++----------- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/packages/aws-serverless/test/awslambda-integration.test.ts b/packages/aws-serverless/test/awslambda-integration.test.ts index cc4f55f1ca24..fe69de1638d1 100644 --- a/packages/aws-serverless/test/awslambda-integration.test.ts +++ b/packages/aws-serverless/test/awslambda-integration.test.ts @@ -28,6 +28,7 @@ describe('redirectLambdaHandler', () => { delete process.env._HANDLER; delete process.env.LAMBDA_TASK_ROOT; delete process.env.SENTRY_ORIGINAL_HANDLER; + delete process.env.SENTRY_LAMBDA_TUNNEL_ONLY_SHIM; }); afterEach(() => { @@ -111,6 +112,18 @@ describe('redirectLambdaHandler', () => { expect(process.env.SENTRY_ORIGINAL_HANDLER).toBe('index.handler'); }); + test('takes over from the tunnel-only handler of another copy of @sentry/node', () => { + process.env.LAMBDA_TASK_ROOT = taskRoot; + process.env.SENTRY_ORIGINAL_HANDLER = 'index.handler'; + process.env._HANDLER = '/opt/other-copy/@sentry/node/build/run-lambda-handler.handler'; + process.env.SENTRY_LAMBDA_TUNNEL_ONLY_SHIM = '1'; + + awsLambdaIntegration().setupOnce!(); + + expect(process.env._HANDLER).toMatch(/aws-serverless\/build\/npm\/run-lambda-handler\.handler$/); + expect(process.env.SENTRY_LAMBDA_TUNNEL_ONLY_SHIM).toBeUndefined(); + }); + test("leaves the handler alone when @sentry/node's tunnel comes after", () => { process.env.LAMBDA_TASK_ROOT = taskRoot; process.env._HANDLER = 'index.handler'; diff --git a/packages/node/src/lambda/redirectHandler.ts b/packages/node/src/lambda/redirectHandler.ts index c98fb931b9b1..4ea158468c71 100644 --- a/packages/node/src/lambda/redirectHandler.ts +++ b/packages/node/src/lambda/redirectHandler.ts @@ -15,7 +15,7 @@ import { parseHandlerString, resolveHandlerFile } from './handlerResolution'; * 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(resolveShimFile: () => string | undefined): void { +export function redirectLambdaHandler(resolveShimFile: () => string | undefined, tunnelOnly = false): void { const taskRoot = process.env.LAMBDA_TASK_ROOT; const handlerString = process.env._HANDLER; @@ -28,7 +28,9 @@ export function redirectLambdaHandler(resolveShimFile: () => string | undefined) // Already redirected (e.g. `init()` ran twice, or the user redirected manually). if (process.env.SENTRY_ORIGINAL_HANDLER) { - replaceTunnelShim(handlerString, resolveShimFile); + if (!tunnelOnly) { + replaceTunnelOnlyShim(resolveShimFile); + } DEBUG_BUILD && debug.log('Skipping lambda handler redirect: SENTRY_ORIGINAL_HANDLER is already set.'); return; } @@ -62,7 +64,11 @@ export function redirectLambdaHandler(resolveShimFile: () => string | undefined) } process.env.SENTRY_ORIGINAL_HANDLER = handlerString; - process.env._HANDLER = toHandlerString(shimFile); + process.env._HANDLER = `${shimFile.replace(/\.mjs$/, '')}.handler`; + if (tunnelOnly) { + // A marker rather than the shim's path, so a full shim recognizes it even from another copy of this package. + process.env.SENTRY_LAMBDA_TUNNEL_ONLY_SHIM = '1'; + } DEBUG_BUILD && debug.log('Redirected lambda handler.', { originalHandler: handlerString, newHandler: process.env._HANDLER }); @@ -75,43 +81,30 @@ export function redirectLambdaHandler(resolveShimFile: () => string | undefined) */ export function redirectLambdaHandlerForTunnel(): void { redirectLambdaHandler(() => { - const shimFile = getTunnelShimFile(); + // The shim sits at `build/run-lambda-handler.mjs`, next to the `build/esm` and `build/cjs` this file is built into. + // Rollup converts `import.meta.url` for the CJS build. + const shimFile = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../run-lambda-handler.mjs'); if (!fs.existsSync(shimFile)) { DEBUG_BUILD && debug.warn(`Could not find ${shimFile}, not answering tunnel requests.`); return undefined; } return shimFile; - }); + }, true); } /** * A shim with the full Lambda instrumentation, like `@sentry/aws-serverless`'s, replaces the tunnel-only one when that * one redirected first. It answers tunnel requests too, so the order the integrations set up in doesn't matter. */ -function replaceTunnelShim(handlerString: string, resolveShimFile: () => string | undefined): void { - const tunnelShimHandler = toHandlerString(getTunnelShimFile()); - if (handlerString !== tunnelShimHandler) { +function replaceTunnelOnlyShim(resolveShimFile: () => string | undefined): void { + if (!process.env.SENTRY_LAMBDA_TUNNEL_ONLY_SHIM) { return; } const shimFile = resolveShimFile(); - if (shimFile && toHandlerString(shimFile) !== tunnelShimHandler) { - process.env._HANDLER = toHandlerString(shimFile); + if (shimFile) { + process.env._HANDLER = `${shimFile.replace(/\.mjs$/, '')}.handler`; + delete process.env.SENTRY_LAMBDA_TUNNEL_ONLY_SHIM; } } - -/** - * The tunnel-only shim, at `build/run-lambda-handler.mjs` next to the `build/esm` and `build/cjs` this file is built - * into. Rollup converts `import.meta.url` for the CJS build. - */ -function getTunnelShimFile(): string { - return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../run-lambda-handler.mjs'); -} - -/** - * The `_HANDLER` value that loads a shim's `handler` export. - */ -function toHandlerString(shimFile: string): string { - return `${shimFile.replace(/\.mjs$/, '')}.handler`; -} From 6d18ceb950e903ebc91bb5594182caf04032bf54 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 5 Nov 2026 10:46:30 -0500 Subject: [PATCH 06/19] test(aws-serverless): Answer managed tunnel requests on a real Lambda runtime Covers the aws-serverless and @sentry/node handler shims for async, callback and streaming handlers, the API Gateway, ALB and base64 event shapes, and a failed forward. --- .../TunnelAsync/index.mjs | 1 + .../TunnelAsync/instrument.mjs | 7 + .../TunnelNodeAsync/index.mjs | 1 + .../TunnelNodeAsync/instrument.mjs | 7 + .../TunnelNodeCallback/index.js | 3 + .../TunnelNodeCallback/instrument.mjs | 7 + .../TunnelNodeStreaming/index.mjs | 4 + .../TunnelNodeStreaming/instrument.mjs | 7 + .../TunnelNodeStreamingUnreachable/index.mjs | 4 + .../instrument.mjs | 8 + .../TunnelStreaming/index.mjs | 4 + .../TunnelStreaming/instrument.mjs | 7 + .../aws-serverless/src/stack.ts | 5 +- .../aws-serverless/tests/tunnel.test.ts | 159 ++++++++++++++++++ 14 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelAsync/index.mjs create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelAsync/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeAsync/index.mjs create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeAsync/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeCallback/index.js create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeCallback/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreaming/index.mjs create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreaming/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingUnreachable/index.mjs create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingUnreachable/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreaming/index.mjs create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreaming/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelAsync/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelAsync/index.mjs new file mode 100644 index 000000000000..914944147e85 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelAsync/index.mjs @@ -0,0 +1 @@ +export const handler = async () => ({ statusCode: 200, body: 'app' }); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelAsync/instrument.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelAsync/instrument.mjs new file mode 100644 index 000000000000..b513b159600c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelAsync/instrument.mjs @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/aws-serverless'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + debug: true, + integrations: [Sentry.serveTunnelIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeAsync/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeAsync/index.mjs new file mode 100644 index 000000000000..914944147e85 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeAsync/index.mjs @@ -0,0 +1 @@ +export const handler = async () => ({ statusCode: 200, body: 'app' }); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeAsync/instrument.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeAsync/instrument.mjs new file mode 100644 index 000000000000..52cd2539a0eb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeAsync/instrument.mjs @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + debug: true, + integrations: [Sentry.serveTunnelIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeCallback/index.js b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeCallback/index.js new file mode 100644 index 000000000000..2dabb97238dc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeCallback/index.js @@ -0,0 +1,3 @@ +exports.handler = (_event, _context, callback) => { + callback(null, { statusCode: 200, body: 'app' }); +}; diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeCallback/instrument.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeCallback/instrument.mjs new file mode 100644 index 000000000000..52cd2539a0eb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeCallback/instrument.mjs @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + debug: true, + integrations: [Sentry.serveTunnelIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreaming/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreaming/index.mjs new file mode 100644 index 000000000000..680bc8969deb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreaming/index.mjs @@ -0,0 +1,4 @@ +export const handler = awslambda.streamifyResponse(async (_event, responseStream) => { + responseStream.write('app'); + responseStream.end(); +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreaming/instrument.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreaming/instrument.mjs new file mode 100644 index 000000000000..52cd2539a0eb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreaming/instrument.mjs @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + debug: true, + integrations: [Sentry.serveTunnelIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingUnreachable/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingUnreachable/index.mjs new file mode 100644 index 000000000000..680bc8969deb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingUnreachable/index.mjs @@ -0,0 +1,4 @@ +export const handler = awslambda.streamifyResponse(async (_event, responseStream) => { + responseStream.write('app'); + responseStream.end(); +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingUnreachable/instrument.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingUnreachable/instrument.mjs new file mode 100644 index 000000000000..dfb6c804a050 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingUnreachable/instrument.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + // Nothing listens on this port, so forwarding a tunnel request fails after the tunnel claimed it. + dsn: process.env.SENTRY_DSN.replace(':3031/', ':3032/'), + debug: true, + integrations: [Sentry.serveTunnelIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreaming/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreaming/index.mjs new file mode 100644 index 000000000000..680bc8969deb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreaming/index.mjs @@ -0,0 +1,4 @@ +export const handler = awslambda.streamifyResponse(async (_event, responseStream) => { + responseStream.write('app'); + responseStream.end(); +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreaming/instrument.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreaming/instrument.mjs new file mode 100644 index 000000000000..b513b159600c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreaming/instrument.mjs @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/aws-serverless'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + debug: true, + integrations: [Sentry.serveTunnelIntegration()], +}); 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 6b0b980ae5c5..6de922abc3b8 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 @@ -123,7 +123,10 @@ export class LocalLambdaStack extends Stack { SENTRY_DSN: dsn, SENTRY_TRACES_SAMPLE_RATE: 1.0, SENTRY_DEBUG: true, - NODE_OPTIONS: `--import=@sentry/aws-serverless/awslambda-auto`, + // A function with its own `instrument.mjs` initializes the SDK itself, e.g. to add integrations. + NODE_OPTIONS: fs.existsSync(path.join(lambdaPath, 'instrument.mjs')) + ? '--import=/var/task/instrument.mjs' + : '--import=@sentry/aws-serverless/awslambda-auto', }, }, }, diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts new file mode 100644 index 000000000000..a2e7249d0eaa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts @@ -0,0 +1,159 @@ +import { InvokeCommand } from '@aws-sdk/client-lambda'; +import type { LambdaClient } from '@aws-sdk/client-lambda'; +import { waitForError } from '@sentry-internal/test-utils'; +import { getTunnelPath } from '@sentry/aws-serverless'; +import { getHostIp } from '../src/stack'; +import { expect, test } from './lambda-fixtures'; + +interface InvokeResult { + functionError?: string; + payload: string; +} + +async function invoke(lambdaClient: LambdaClient, functionName: string, event: unknown): Promise { + const result = await lambdaClient.send( + new InvokeCommand({ FunctionName: functionName, Payload: JSON.stringify(event) }), + ); + + return { functionError: result.FunctionError, payload: Buffer.from(result.Payload ?? []).toString('utf8') }; +} + +async function getDsn(port = 3031): Promise { + return `http://public@${await getHostIp()}:${port}/1337`; +} + +function makeEnvelope(dsn: string, marker: string): string { + const eventId = crypto.randomUUID().replace(/-/g, ''); + + return [ + JSON.stringify({ dsn, event_id: eventId, sent_at: new Date().toISOString() }), + JSON.stringify({ type: 'event' }), + JSON.stringify({ event_id: eventId, message: marker, level: 'info' }), + ].join('\n'); +} + +type EventShape = 'apiGatewayV2' | 'apiGatewayV1' | 'alb'; + +/** + * The event API Gateway (HTTP API or function URL, and REST), or an ALB, hands the handler for a browser POSTing an + * envelope to the tunnel. + */ +function makeHttpEvent(shape: EventShape, path: string, envelope: string, base64 = false): Record { + const body = base64 ? Buffer.from(envelope).toString('base64') : envelope; + const contentType = 'application/x-sentry-envelope'; + + if (shape === 'apiGatewayV2') { + return { + version: '2.0', + rawPath: path, + rawQueryString: '', + headers: { 'content-type': contentType }, + requestContext: { http: { method: 'POST' } }, + body, + isBase64Encoded: base64, + }; + } + + if (shape === 'apiGatewayV1') { + return { httpMethod: 'POST', path, headers: { 'content-type': contentType }, body, isBase64Encoded: base64 }; + } + + return { + httpMethod: 'POST', + path, + multiValueHeaders: { 'content-type': [contentType] }, + requestContext: { elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:us-east-1:012345678912:targetgroup/t' } }, + body, + isBase64Encoded: base64, + }; +} + +test.describe('managed tunnel', () => { + for (const functionName of ['NpmTunnelAsync', 'NpmTunnelNodeAsync', 'NpmTunnelNodeCallback']) { + test(`${functionName} answers tunnel requests before the handler`, async ({ lambdaClient }) => { + const dsn = await getDsn(); + const marker = `${functionName}-${crypto.randomUUID()}`; + const forwarded = waitForError('aws-serverless', event => event.message === marker); + + const result = await invoke( + lambdaClient, + functionName, + makeHttpEvent('apiGatewayV2', getTunnelPath(dsn)!, makeEnvelope(dsn, marker)), + ); + + expect(result.functionError).toBeUndefined(); + expect(JSON.parse(result.payload)).toMatchObject({ statusCode: 200, isBase64Encoded: false }); + expect(JSON.parse(result.payload).body).not.toBe('app'); + await forwarded; + }); + + test(`${functionName} passes other events to the handler`, async ({ lambdaClient }) => { + const result = await invoke( + lambdaClient, + functionName, + makeHttpEvent('apiGatewayV2', '/other', 'not an envelope'), + ); + + expect(JSON.parse(result.payload)).toEqual({ statusCode: 200, body: 'app' }); + }); + } + + for (const [shape, base64] of [ + ['apiGatewayV1', false], + ['alb', false], + ['apiGatewayV2', true], + ] as const) { + test(`reads ${base64 ? 'a base64 body from ' : ''}an ${shape} event`, async ({ lambdaClient }) => { + const dsn = await getDsn(); + const marker = `${shape}-${base64}-${crypto.randomUUID()}`; + const forwarded = waitForError('aws-serverless', event => event.message === marker); + + const result = await invoke( + lambdaClient, + 'NpmTunnelAsync', + makeHttpEvent(shape, getTunnelPath(dsn)!, makeEnvelope(dsn, marker), base64), + ); + + const response = JSON.parse(result.payload); + expect(response.statusCode).toBe(200); + // An ALB event with multi-value headers needs multi-value headers back. + expect(response).toHaveProperty(shape === 'alb' ? 'multiValueHeaders' : 'headers'); + await forwarded; + }); + } + + for (const functionName of ['NpmTunnelStreaming', 'NpmTunnelNodeStreaming']) { + test(`${functionName} streams the tunnel answer before the handler`, async ({ lambdaClient }) => { + const dsn = await getDsn(); + const marker = `${functionName}-${crypto.randomUUID()}`; + const forwarded = waitForError('aws-serverless', event => event.message === marker); + + const result = await invoke( + lambdaClient, + functionName, + makeHttpEvent('apiGatewayV2', getTunnelPath(dsn)!, makeEnvelope(dsn, marker)), + ); + + expect(result.functionError).toBeUndefined(); + expect(result.payload).not.toContain('app'); + await forwarded; + }); + } + + test('ends the stream with the error answer when forwarding a tunnel request fails', async ({ lambdaClient }) => { + // The function's DSN points at a port nothing listens on. + const dsn = await getDsn(3032); + const startedAt = Date.now(); + + const result = await invoke( + lambdaClient, + 'NpmTunnelNodeStreamingUnreachable', + makeHttpEvent('apiGatewayV2', getTunnelPath(dsn)!, makeEnvelope(dsn, 'unreachable')), + ); + + // An unended stream would hold the invocation until the 10s function timeout. + expect(result.functionError).toBeUndefined(); + expect(Date.now() - startedAt).toBeLessThan(8_000); + expect(result.payload).toBe('Failed to forward envelope to Sentry'); + }); +}); From 039d30e362c28a7ad1cedc62866890725845c706 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 6 Nov 2026 13:49:37 -0500 Subject: [PATCH 07/19] fix(node): Resolve the Lambda tunnel shim without `import.meta.url` esbuild leaves `import.meta` empty when it bundles our ESM build to CJS, so `fileURLToPath` threw and `Sentry.init` crashed on Lambda. The shim is now resolved like `@sentry/aws-serverless` resolves its own, and a failure only turns the tunnel off. --- .../suites/esbuild/lambda-tunnel/app.mjs | 36 +++++++++++ .../esbuild/lambda-tunnel/task/index.mjs | 1 + .../suites/esbuild/lambda-tunnel/test.ts | 62 +++++++++++++++++++ packages/node/src/lambda/redirectHandler.ts | 26 +++++--- 4 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/app.mjs create mode 100644 dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/task/index.mjs create mode 100644 dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/test.ts diff --git a/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/app.mjs b/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/app.mjs new file mode 100644 index 000000000000..00d42097f463 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/app.mjs @@ -0,0 +1,36 @@ +import * as Sentry from '@sentry/node'; +import { createServer } from 'node:http'; +import { pathToFileURL } from 'node:url'; + +// Bundled to CJS, where esbuild leaves `import.meta` empty, and run like a Lambda preload: `init`, then the runtime +// loads the module `_HANDLER` points to and calls it with a tunnel request. +(async () => { + const ingest = createServer((req, res) => { + req.resume(); + req.on('end', () => res.end('ingest')); + }).listen(0); + await new Promise(resolve => ingest.once('listening', resolve)); + const dsn = `http://public@localhost:${ingest.address().port}/1`; + + Sentry.init({ dsn, integrations: [Sentry.serveTunnelIntegration()] }); + + const handlerString = process.env._HANDLER; + const moduleName = handlerString.slice(0, handlerString.lastIndexOf('.')); + const modulePath = moduleName.startsWith('/') ? moduleName : `${process.env.LAMBDA_TASK_ROOT}/${moduleName}`; + const handlerModule = await import(pathToFileURL(`${modulePath}.mjs`).href); + + const response = await handlerModule.handler( + { + version: '2.0', + rawPath: Sentry.getTunnelPath(dsn), + requestContext: { http: { method: 'POST' } }, + headers: { host: 'app.example' }, + body: `${JSON.stringify({ dsn })}\n{"type":"event"}\n{}`, + }, + {}, + ); + + // oxlint-disable-next-line no-console -- the test reads the answer from stdout + console.log(`ANSWERED_BY ${response.body}`); + ingest.close(); +})(); diff --git a/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/task/index.mjs b/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/task/index.mjs new file mode 100644 index 000000000000..914944147e85 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/task/index.mjs @@ -0,0 +1 @@ +export const handler = async () => ({ statusCode: 200, body: 'app' }); diff --git a/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/test.ts b/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/test.ts new file mode 100644 index 000000000000..278e4dfae035 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/test.ts @@ -0,0 +1,62 @@ +import { spawnSync } from 'child_process'; +import { copyFileSync, mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { build } from 'esbuild'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; + +const OUT_DIR = join(__dirname, 'tmp_build'); +const BUNDLE = join(OUT_DIR, 'app.cjs'); + +function runOnLambda(bundle: string): { stdout: string; stderr: string; status: number | null } { + // pnpm's `NODE_PATH` would resolve `@sentry/node` from anywhere, which a deployed bundle can't. + const { NODE_PATH: _nodePath, ...env } = process.env; + const result = spawnSync('node', [bundle], { + encoding: 'utf-8', + timeout: 10_000, + env: { ...env, LAMBDA_TASK_ROOT: join(__dirname, 'task'), _HANDLER: 'index.handler' }, + }); + + return { stdout: result.stdout, stderr: result.stderr, status: result.status }; +} + +describe('managed tunnel on Lambda with `@sentry/node` bundled to CJS by esbuild', () => { + beforeAll(async () => { + await build({ + entryPoints: [join(__dirname, 'app.mjs')], + outfile: BUNDLE, + platform: 'node', + format: 'cjs', + bundle: true, + logLevel: 'silent', + }); + }); + + afterAll(() => { + rmSync(OUT_DIR, { recursive: true, force: true }); + }); + + test('answers tunnel requests when the shim is installed next to the bundle', () => { + const { stdout, stderr, status } = runOnLambda(BUNDLE); + + expect(stderr).toBe(''); + expect(status).toBe(0); + expect(stdout).toContain('ANSWERED_BY ingest'); + }); + + test('leaves the handler alone, without crashing `init`, when the shim is not installed', () => { + const deployDir = mkdtempSync(join(tmpdir(), 'sentry-lambda-bundle-')); + const bundle = join(deployDir, 'app.cjs'); + copyFileSync(BUNDLE, bundle); + + try { + const { stdout, stderr, status } = runOnLambda(bundle); + + expect(stderr).toBe(''); + expect(status).toBe(0); + expect(stdout).toContain('ANSWERED_BY app'); + } finally { + rmSync(deployDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/node/src/lambda/redirectHandler.ts b/packages/node/src/lambda/redirectHandler.ts index 4ea158468c71..4dff9501d59c 100644 --- a/packages/node/src/lambda/redirectHandler.ts +++ b/packages/node/src/lambda/redirectHandler.ts @@ -1,10 +1,10 @@ import { debug } from '@sentry/core'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; import { DEBUG_BUILD } from '../debug-build'; import { parseHandlerString, resolveHandlerFile } from './handlerResolution'; +const TUNNEL_SHIM_MODULE_ID = '@sentry/node/run-lambda-handler'; + /** * Redirects the Lambda runtime to a Sentry handler shim by rewriting the `_HANDLER` * environment variable. The runtime reads `_HANDLER` only after all `--import`/`--require` @@ -81,15 +81,21 @@ export function redirectLambdaHandler(resolveShimFile: () => string | undefined, */ export function redirectLambdaHandlerForTunnel(): void { redirectLambdaHandler(() => { - // The shim sits at `build/run-lambda-handler.mjs`, next to the `build/esm` and `build/cjs` this file is built into. - // Rollup converts `import.meta.url` for the CJS build. - const shimFile = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../run-lambda-handler.mjs'); - if (!fs.existsSync(shimFile)) { - DEBUG_BUILD && debug.warn(`Could not find ${shimFile}, not answering tunnel requests.`); + try { + // A CJS bundle has `require`, but leaves `import.meta.url` empty, so it's only read without one. + const resolve = typeof require === 'function' ? require.resolve : createRequire(import.meta.url).resolve; + + return resolve(TUNNEL_SHIM_MODULE_ID); + } catch (error) { + // With `@sentry/node` bundled into the app, there is no shim file to point the runtime at. + DEBUG_BUILD && + debug.warn( + `Could not resolve ${TUNNEL_SHIM_MODULE_ID}, not answering tunnel requests. Wrap the handler with \`@sentry/aws-serverless\`'s \`wrapHandler\` instead.`, + error, + ); + return undefined; } - - return shimFile; }, true); } From 972c49b0b53a5b46febc17528c122c88a80766f5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 6 Nov 2026 13:58:18 -0500 Subject: [PATCH 08/19] ref(tanstackstart-react): Explain why the Lambda handler is redirected without a tunnel --- packages/tanstackstart-react/src/server/sdk.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tanstackstart-react/src/server/sdk.ts b/packages/tanstackstart-react/src/server/sdk.ts index 8d4d8fdf32f9..a2fbee086ce5 100644 --- a/packages/tanstackstart-react/src/server/sdk.ts +++ b/packages/tanstackstart-react/src/server/sdk.ts @@ -27,7 +27,9 @@ export function init(options: NodeOptions): NodeClient | undefined { const client = initNodeSdk(sentryOptions); // `tunnelRoute` enables the tunnel when the server entry loads, after this preload, so the servers and the Lambda - // handler must be ready to answer it already. + // handler must be ready to answer it already. This redirects the Lambda handler even for apps without a tunnel, on + // purpose: the runtime reads `_HANDLER` before the server entry loads, so waiting for the tunnel would be too late. + // Until the tunnel is on, the redirected handler passes every event through. installTunnelHooks(); return client; From 24250cb92eee76086bec54bb6137970500a06b53 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 6 Nov 2026 14:23:25 -0500 Subject: [PATCH 09/19] fix(node): Pass on only the forwarded headers of Sentry's answer on Lambda The Lambda tunnel copied every header of Sentry's answer, like `connection` and `transfer-encoding`, into its result, while the Node tunnel passes on only the ones the browser SDK reads. --- .../aws-serverless/tests/tunnel.test.ts | 8 +++++++ packages/core/src/server.ts | 1 + packages/core/src/utils/tunnelForward.ts | 24 ++++++++++++------- packages/node/src/lambda/tunnel.ts | 5 ++-- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts index a2e7249d0eaa..213c6da8f968 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts @@ -5,6 +5,11 @@ import { getTunnelPath } from '@sentry/aws-serverless'; import { getHostIp } from '../src/stack'; import { expect, test } from './lambda-fixtures'; +/** + * The headers of Sentry's answer the Node tunnel passes on, see `FORWARDED_RESPONSE_HEADERS` in `@sentry/core`. + */ +const FORWARDED_RESPONSE_HEADERS = ['content-type', 'retry-after', 'x-sentry-rate-limits']; + interface InvokeResult { functionError?: string; payload: string; @@ -118,6 +123,9 @@ test.describe('managed tunnel', () => { expect(response.statusCode).toBe(200); // An ALB event with multi-value headers needs multi-value headers back. expect(response).toHaveProperty(shape === 'alb' ? 'multiValueHeaders' : 'headers'); + // Only the headers the browser SDK reads, like the Node answer, not the ones about the connection to Sentry. + const headerNames = Object.keys(response.headers ?? response.multiValueHeaders); + expect(headerNames.filter(name => !FORWARDED_RESPONSE_HEADERS.includes(name))).toEqual([]); await forwarded; }); } diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 1f23709308f7..76e802fdfe67 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -21,6 +21,7 @@ export { export { forwardTunnelRequest as _INTERNAL_forwardTunnelRequest, handleServedNodeTunnelRequest as _INTERNAL_handleServedNodeTunnelRequest, + pickForwardedHeaders as _INTERNAL_pickForwardedHeaders, handleTunnelRequestIfMatched, } from './utils/tunnelForward'; export { serveTunnelIntegration } from './integrations/serveTunnel'; diff --git a/packages/core/src/utils/tunnelForward.ts b/packages/core/src/utils/tunnelForward.ts index 24451dc89f9a..9e3298d7e9f9 100644 --- a/packages/core/src/utils/tunnelForward.ts +++ b/packages/core/src/utils/tunnelForward.ts @@ -193,6 +193,21 @@ function getAllowedDsns(options: TunnelForwardOptions | undefined): string[] { */ const FORWARDED_RESPONSE_HEADERS = ['content-type', 'retry-after', 'x-sentry-rate-limits']; +/** + * The headers of Sentry's answer to pass on to the browser, see `FORWARDED_RESPONSE_HEADERS`. + */ +export function pickForwardedHeaders(response: Response): Record { + const headers: Record = {}; + for (const name of FORWARDED_RESPONSE_HEADERS) { + const value = response.headers.get(name); + if (value) { + headers[name] = value; + } + } + + return headers; +} + function isNodeTunnelResponse(value: unknown): value is NodeTunnelResponse { return typeof (value as NodeTunnelResponse | undefined)?.writeHead === 'function'; } @@ -247,14 +262,7 @@ async function writeNodeResponse(response: NodeTunnelResponse, fetchResponse: Re return; } - const responseHeaders: Record = {}; - for (const name of FORWARDED_RESPONSE_HEADERS) { - const value = fetchResponse.headers.get(name); - if (value) { - responseHeaders[name] = value; - } - } - response.writeHead(fetchResponse.status, responseHeaders); + response.writeHead(fetchResponse.status, pickForwardedHeaders(fetchResponse)); response.end(new Uint8Array(body)); } diff --git a/packages/node/src/lambda/tunnel.ts b/packages/node/src/lambda/tunnel.ts index 1b0efd095618..abab11e11203 100644 --- a/packages/node/src/lambda/tunnel.ts +++ b/packages/node/src/lambda/tunnel.ts @@ -2,6 +2,7 @@ import { debug } from '@sentry/core'; import { _INTERNAL_handleServedTunnelRequest as handleServedTunnelRequest, _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, + _INTERNAL_pickForwardedHeaders as pickForwardedHeaders, } from '@sentry/core/server'; import { DEBUG_BUILD } from '../debug-build'; @@ -93,7 +94,7 @@ export function handleStreamingTunnelEvent(event: unknown, responseStream: unkno const body = new Uint8Array(await response.arrayBuffer()); const stream = awslambda.HttpResponseStream.from(responseStream, { statusCode: response.status, - headers: Object.fromEntries(response.headers), + headers: pickForwardedHeaders(response), }); stream.end(body); }) @@ -152,7 +153,7 @@ function toTunnelRequest(event: unknown): Request | undefined { } async function toHttpResult(response: Response, multiValueHeaders: boolean): Promise { - const headers = Object.fromEntries(response.headers); + const headers = pickForwardedHeaders(response); return { statusCode: response.status, From b57b81b9dbdfde1628bf5a396c434958ec64ab5a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 6 Nov 2026 14:25:17 -0500 Subject: [PATCH 10/19] fix(node): End the Lambda tunnel stream when Sentry's answer breaks off The streaming tunnel only logged when reading Sentry's answer failed, so Lambda held the invocation until the function timeout. It now ends the stream with a 502. --- .../TunnelNodeStreamingBrokenAnswer/index.mjs | 4 +++ .../instrument.mjs | 8 +++++ .../aws-serverless/tests/tunnel.test.ts | 31 +++++++++++++++++++ packages/node/src/lambda/tunnel.ts | 7 +++++ 4 files changed, 50 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingBrokenAnswer/index.mjs create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingBrokenAnswer/instrument.mjs diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingBrokenAnswer/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingBrokenAnswer/index.mjs new file mode 100644 index 000000000000..680bc8969deb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingBrokenAnswer/index.mjs @@ -0,0 +1,4 @@ +export const handler = awslambda.streamifyResponse(async (_event, responseStream) => { + responseStream.write('app'); + responseStream.end(); +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingBrokenAnswer/instrument.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingBrokenAnswer/instrument.mjs new file mode 100644 index 000000000000..054dffd70b7f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingBrokenAnswer/instrument.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + // The test's ingest on this port answers 200 and then breaks off mid-body. + dsn: process.env.SENTRY_DSN.replace(':3031/', ':3033/'), + debug: true, + integrations: [Sentry.serveTunnelIntegration()], +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts index 213c6da8f968..2493510eb974 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts @@ -1,6 +1,7 @@ import { InvokeCommand } from '@aws-sdk/client-lambda'; import type { LambdaClient } from '@aws-sdk/client-lambda'; import { waitForError } from '@sentry-internal/test-utils'; +import { createServer } from 'node:http'; import { getTunnelPath } from '@sentry/aws-serverless'; import { getHostIp } from '../src/stack'; import { expect, test } from './lambda-fixtures'; @@ -164,4 +165,34 @@ test.describe('managed tunnel', () => { expect(Date.now() - startedAt).toBeLessThan(8_000); expect(result.payload).toBe('Failed to forward envelope to Sentry'); }); + + test('ends the stream with an error answer when the answer from Sentry breaks off', async ({ lambdaClient }) => { + // Answers 200, then drops the connection before the body is complete. + const ingest = createServer((req, res) => { + req.resume(); + req.on('end', () => { + res.writeHead(200, { 'content-type': 'application/json', 'content-length': '100' }); + res.write('{"id":'); + setTimeout(() => res.socket?.destroy(), 50); + }); + }).listen(3033, '0.0.0.0'); + + try { + const dsn = await getDsn(3033); + const startedAt = Date.now(); + + const result = await invoke( + lambdaClient, + 'NpmTunnelNodeStreamingBrokenAnswer', + makeHttpEvent('apiGatewayV2', getTunnelPath(dsn)!, makeEnvelope(dsn, 'broken-answer')), + ); + + // An unended stream would hold the invocation until the 10s function timeout. + expect(result.functionError).toBeUndefined(); + expect(Date.now() - startedAt).toBeLessThan(8_000); + expect(result.payload).not.toContain('app'); + } finally { + ingest.close(); + } + }); }); diff --git a/packages/node/src/lambda/tunnel.ts b/packages/node/src/lambda/tunnel.ts index abab11e11203..d05e0217ca39 100644 --- a/packages/node/src/lambda/tunnel.ts +++ b/packages/node/src/lambda/tunnel.ts @@ -100,6 +100,13 @@ export function handleStreamingTunnelEvent(event: unknown, responseStream: unkno }) .catch(error => { DEBUG_BUILD && debug.error('Sentry tunnel: failed to answer a streaming tunnel request', error); + // Lambda holds an unended stream until the function timeout. + try { + awslambda?.HttpResponseStream.from(responseStream, { statusCode: 502, headers: {} }).end(); + } catch { + // `from` already wrote the metadata, so only ending the stream is left. + (responseStream as { end(): void }).end(); + } }); } From 06104ee755d7dfcea0f5766cf3bb2759bb840e16 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 6 Nov 2026 14:35:41 -0500 Subject: [PATCH 11/19] fix(node): Warn when the Lambda tunnel can't redirect the handler With `Sentry.init` in the handler module, the runtime already loaded the handler, so tunnel requests reached the app without a word. The SDK now warns and points to a preload or `wrapHandler`. --- .../TunnelNodeInitInHandler/index.mjs | 20 ++++++++++++++++++ .../TunnelNodeInitInHandler/instrument.mjs | 1 + .../aws-serverless/tests/tunnel.test.ts | 21 +++++++++++++++++++ packages/node/src/lambda/redirectHandler.ts | 10 ++++++++- 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/index.mjs create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/instrument.mjs diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/index.mjs new file mode 100644 index 000000000000..6ba5515c6828 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/index.mjs @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/node'; + +// What the SDK warned about, for the test to read back. +const warnings = []; +const warn = console.warn; +console.warn = (...args) => { + warnings.push(args.join(' ')); + warn(...args); +}; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + debug: true, + integrations: [Sentry.serveTunnelIntegration()], +}); + +export const handler = async event => ({ + statusCode: 200, + body: event?.readWarnings ? JSON.stringify(warnings) : 'app', +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/instrument.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/instrument.mjs new file mode 100644 index 000000000000..6319d3efa287 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/instrument.mjs @@ -0,0 +1 @@ +// No preload: this function initializes the SDK in its handler module. diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts index 2493510eb974..06a6747176b3 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts @@ -195,4 +195,25 @@ test.describe('managed tunnel', () => { ingest.close(); } }); + + test('warns when the SDK is initialized in the handler module, where the tunnel cannot answer', async ({ + lambdaClient, + }) => { + const dsn = await getDsn(); + + const result = await invoke( + lambdaClient, + 'NpmTunnelNodeInitInHandler', + makeHttpEvent('apiGatewayV2', getTunnelPath(dsn)!, makeEnvelope(dsn, 'init-in-handler')), + ); + // The runtime loaded the handler before `init` redirected it, so the app answers. + expect(JSON.parse(result.payload)).toEqual({ statusCode: 200, body: 'app' }); + + const warnings = JSON.parse( + JSON.parse((await invoke(lambdaClient, 'NpmTunnelNodeInitInHandler', { readWarnings: true })).payload).body, + ); + expect(warnings).toContainEqual( + expect.stringContaining('Sentry was initialized after the Lambda runtime loaded the handler'), + ); + }); }); diff --git a/packages/node/src/lambda/redirectHandler.ts b/packages/node/src/lambda/redirectHandler.ts index 4dff9501d59c..119df9fc734e 100644 --- a/packages/node/src/lambda/redirectHandler.ts +++ b/packages/node/src/lambda/redirectHandler.ts @@ -1,4 +1,4 @@ -import { debug } from '@sentry/core'; +import { debug, GLOBAL_OBJ } from '@sentry/core'; import { createRequire } from 'node:module'; import { DEBUG_BUILD } from '../debug-build'; import { parseHandlerString, resolveHandlerFile } from './handlerResolution'; @@ -80,6 +80,14 @@ export function redirectLambdaHandler(resolveShimFile: () => string | undefined, * the handler. */ export function redirectLambdaHandlerForTunnel(): void { + // The runtime defines `awslambda` before it loads the handler, after the preloads ran. So `init` runs in the handler + // module, where the runtime already read `_HANDLER`. + if (DEBUG_BUILD && typeof (GLOBAL_OBJ as { awslambda?: unknown }).awslambda !== 'undefined') { + debug.warn( + "Sentry was initialized after the Lambda runtime loaded the handler, so tunnel requests reach the handler. Initialize Sentry from a preload (`NODE_OPTIONS=--import`), or wrap the handler with `@sentry/aws-serverless`'s `wrapHandler`.", + ); + } + redirectLambdaHandler(() => { try { // A CJS bundle has `require`, but leaves `import.meta.url` empty, so it's only read without one. From 251560744d58a02d9d553250f3a1b4376d0d2f75 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 6 Nov 2026 14:40:15 -0500 Subject: [PATCH 12/19] ref(node): Type the Lambda streaming handler symbols instead of double casting --- .../aws-serverless/tests/tunnel.test.ts | 14 +++++------ packages/node/src/lambda/tunnel.ts | 25 +++++++++++++------ 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts index 06a6747176b3..cdcff9b34420 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts @@ -104,10 +104,11 @@ test.describe('managed tunnel', () => { }); } - for (const [shape, base64] of [ - ['apiGatewayV1', false], - ['alb', false], - ['apiGatewayV2', true], + // An ALB event with multi-value headers needs multi-value headers back. + for (const [shape, base64, headersKey] of [ + ['apiGatewayV1', false, 'headers'], + ['alb', false, 'multiValueHeaders'], + ['apiGatewayV2', true, 'headers'], ] as const) { test(`reads ${base64 ? 'a base64 body from ' : ''}an ${shape} event`, async ({ lambdaClient }) => { const dsn = await getDsn(); @@ -122,10 +123,9 @@ test.describe('managed tunnel', () => { const response = JSON.parse(result.payload); expect(response.statusCode).toBe(200); - // An ALB event with multi-value headers needs multi-value headers back. - expect(response).toHaveProperty(shape === 'alb' ? 'multiValueHeaders' : 'headers'); + expect(response).toHaveProperty(headersKey); // Only the headers the browser SDK reads, like the Node answer, not the ones about the connection to Sentry. - const headerNames = Object.keys(response.headers ?? response.multiValueHeaders); + const headerNames = Object.keys(response[headersKey]); expect(headerNames.filter(name => !FORWARDED_RESPONSE_HEADERS.includes(name))).toEqual([]); await forwarded; }); diff --git a/packages/node/src/lambda/tunnel.ts b/packages/node/src/lambda/tunnel.ts index d05e0217ca39..86466496a340 100644 --- a/packages/node/src/lambda/tunnel.ts +++ b/packages/node/src/lambda/tunnel.ts @@ -173,24 +173,33 @@ async function toHttpResult(response: Response, multiValueHeaders: boolean): Pro } // The symbols the Lambda runtime reads off a streaming handler (`awslambda.streamifyResponse`). -const HANDLER_STREAMING_SYMBOL = Symbol.for('aws.lambda.runtime.handler.streaming'); -const HANDLER_HIGHWATERMARK_SYMBOL = Symbol.for('aws.lambda.runtime.handler.streaming.highWaterMark'); +const HANDLER_STREAMING_SYMBOL: unique symbol = Symbol.for('aws.lambda.runtime.handler.streaming'); +const HANDLER_HIGHWATERMARK_SYMBOL: unique symbol = Symbol.for('aws.lambda.runtime.handler.streaming.highWaterMark'); type LambdaHandler = (...args: unknown[]) => unknown; +type StreamingHandler = LambdaHandler & { + [HANDLER_STREAMING_SYMBOL]?: unknown; + [HANDLER_HIGHWATERMARK_SYMBOL]?: unknown; +}; + /** * Wraps a Lambda handler so it answers managed tunnel requests before the handler runs, and passes every other event * to it unchanged. Streaming handlers keep the symbols the runtime reads off them. */ export function wrapLambdaHandlerForTunnel(handler: LambdaHandler): LambdaHandler { - const handlerSymbols = handler as unknown as Record; - if (handlerSymbols[HANDLER_STREAMING_SYMBOL] === 'response') { - const streamingHandler = function (this: unknown, event: unknown, responseStream: unknown, ...rest: unknown[]) { + const streamingSource: StreamingHandler = handler; + if (streamingSource[HANDLER_STREAMING_SYMBOL] === 'response') { + const streamingHandler: StreamingHandler = function ( + this: unknown, + event: unknown, + responseStream: unknown, + ...rest: unknown[] + ) { return handleStreamingTunnelEvent(event, responseStream) ?? handler.call(this, event, responseStream, ...rest); }; - const streamingSymbols = streamingHandler as unknown as Record; - streamingSymbols[HANDLER_STREAMING_SYMBOL] = handlerSymbols[HANDLER_STREAMING_SYMBOL]; - streamingSymbols[HANDLER_HIGHWATERMARK_SYMBOL] = handlerSymbols[HANDLER_HIGHWATERMARK_SYMBOL]; + streamingHandler[HANDLER_STREAMING_SYMBOL] = streamingSource[HANDLER_STREAMING_SYMBOL]; + streamingHandler[HANDLER_HIGHWATERMARK_SYMBOL] = streamingSource[HANDLER_HIGHWATERMARK_SYMBOL]; return streamingHandler; } From d85d358e44037ccc1a951d79e75e0319b70e9f5f Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 6 Nov 2026 16:04:28 -0500 Subject: [PATCH 13/19] test(node): Don't expect a silent startup from a bundled Lambda SDK A CJS bundle of `@sentry/node` also warns that it can't register its diagnostics-channel hooks, which has nothing to do with the tunnel. --- .../suites/esbuild/lambda-tunnel/test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/test.ts b/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/test.ts index 278e4dfae035..e6162895c3bc 100644 --- a/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/test.ts +++ b/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/test.ts @@ -8,7 +8,7 @@ import { afterAll, beforeAll, describe, expect, test } from 'vitest'; const OUT_DIR = join(__dirname, 'tmp_build'); const BUNDLE = join(OUT_DIR, 'app.cjs'); -function runOnLambda(bundle: string): { stdout: string; stderr: string; status: number | null } { +function runOnLambda(bundle: string): { stdout: string; status: number | null } { // pnpm's `NODE_PATH` would resolve `@sentry/node` from anywhere, which a deployed bundle can't. const { NODE_PATH: _nodePath, ...env } = process.env; const result = spawnSync('node', [bundle], { @@ -17,7 +17,7 @@ function runOnLambda(bundle: string): { stdout: string; stderr: string; status: env: { ...env, LAMBDA_TASK_ROOT: join(__dirname, 'task'), _HANDLER: 'index.handler' }, }); - return { stdout: result.stdout, stderr: result.stderr, status: result.status }; + return { stdout: result.stdout, status: result.status }; } describe('managed tunnel on Lambda with `@sentry/node` bundled to CJS by esbuild', () => { @@ -37,9 +37,8 @@ describe('managed tunnel on Lambda with `@sentry/node` bundled to CJS by esbuild }); test('answers tunnel requests when the shim is installed next to the bundle', () => { - const { stdout, stderr, status } = runOnLambda(BUNDLE); + const { stdout, status } = runOnLambda(BUNDLE); - expect(stderr).toBe(''); expect(status).toBe(0); expect(stdout).toContain('ANSWERED_BY ingest'); }); @@ -50,9 +49,8 @@ describe('managed tunnel on Lambda with `@sentry/node` bundled to CJS by esbuild copyFileSync(BUNDLE, bundle); try { - const { stdout, stderr, status } = runOnLambda(bundle); + const { stdout, status } = runOnLambda(bundle); - expect(stderr).toBe(''); expect(status).toBe(0); expect(stdout).toContain('ANSWERED_BY app'); } finally { From ae14e1b0d09cc475129fb726c034e9c783f99ea5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 6 Nov 2026 16:04:28 -0500 Subject: [PATCH 14/19] fix(node): Match REST API tunnel requests on the path with the stage A REST API's `path` leaves out the stage that an HTTP API's `rawPath` keeps, so no `basePath` worked for both on a named stage. The tunnel now matches the path the browser posted to, `requestContext.path`. --- .../TunnelNodeStagePath/index.mjs | 1 + .../TunnelNodeStagePath/instrument.mjs | 8 ++++ .../aws-serverless/tests/tunnel.test.ts | 43 +++++++++++++++++++ packages/node/src/lambda/tunnel.ts | 6 ++- 4 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStagePath/index.mjs create mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStagePath/instrument.mjs diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStagePath/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStagePath/index.mjs new file mode 100644 index 000000000000..914944147e85 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStagePath/index.mjs @@ -0,0 +1 @@ +export const handler = async () => ({ statusCode: 200, body: 'app' }); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStagePath/instrument.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStagePath/instrument.mjs new file mode 100644 index 000000000000..7dffa69f74c7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStagePath/instrument.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: process.env.SENTRY_DSN, + debug: true, + // Served on an API Gateway stage named `prod`, where the browser posts to `/prod/`. + integrations: [Sentry.serveTunnelIntegration({ basePath: '/prod' })], +}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts index cdcff9b34420..ddd6bb1e03f7 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts @@ -216,4 +216,47 @@ test.describe('managed tunnel', () => { expect.stringContaining('Sentry was initialized after the Lambda runtime loaded the handler'), ); }); + + // The shapes a real API Gateway sends on a stage named `prod`: a REST API leaves the stage out of `path` but keeps it + // in `requestContext.path`, an HTTP API keeps it in `rawPath`. + for (const [api, makeStageEvent] of [ + [ + 'REST API', + (tunnelPath: string, envelope: string) => ({ + httpMethod: 'POST', + path: tunnelPath, + headers: { 'content-type': 'application/x-sentry-envelope' }, + requestContext: { path: `/prod${tunnelPath}`, stage: 'prod' }, + body: envelope, + isBase64Encoded: false, + }), + ], + [ + 'HTTP API', + (tunnelPath: string, envelope: string) => ({ + version: '2.0', + rawPath: `/prod${tunnelPath}`, + rawQueryString: '', + headers: { 'content-type': 'application/x-sentry-envelope' }, + requestContext: { http: { method: 'POST' }, stage: 'prod' }, + body: envelope, + isBase64Encoded: false, + }), + ], + ] as const) { + test(`answers tunnel requests on a named ${api} stage with the stage as \`basePath\``, async ({ lambdaClient }) => { + const dsn = await getDsn(); + const marker = `stage-${api}-${crypto.randomUUID()}`; + const forwarded = waitForError('aws-serverless', event => event.message === marker); + + const result = await invoke( + lambdaClient, + 'NpmTunnelNodeStagePath', + makeStageEvent(getTunnelPath(dsn)!, makeEnvelope(dsn, marker)), + ); + + expect(JSON.parse(result.payload).body).not.toBe('app'); + await forwarded; + }); + } }); diff --git a/packages/node/src/lambda/tunnel.ts b/packages/node/src/lambda/tunnel.ts index 86466496a340..15fc6183da6f 100644 --- a/packages/node/src/lambda/tunnel.ts +++ b/packages/node/src/lambda/tunnel.ts @@ -15,7 +15,7 @@ interface HttpEvent { path?: string; rawPath?: string; rawQueryString?: string; - requestContext?: { http?: { method?: string }; elb?: unknown }; + requestContext?: { http?: { method?: string }; elb?: unknown; path?: string }; headers?: Record | null; multiValueHeaders?: Record | null; body?: string | null; @@ -122,10 +122,12 @@ function toTunnelRequest(event: unknown): Request | undefined { const httpEvent = event as HttpEvent; const method = httpEvent.requestContext?.http?.method ?? httpEvent.httpMethod; + // The path the browser posted to, so one `basePath` fits both sides: a REST API's `path` drops the stage, its + // `requestContext.path` keeps it like an HTTP API's `rawPath` does. An ALB event has neither. const path = typeof httpEvent.rawPath === 'string' ? `${httpEvent.rawPath}${httpEvent.rawQueryString ? `?${httpEvent.rawQueryString}` : ''}` - : httpEvent.path; + : (httpEvent.requestContext?.path ?? httpEvent.path); if (!method || !path || !isServedTunnelRequest({ method, url: path })) { return undefined; } From 62617db834534114f808eca3cdfcf16dfb836bdf Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 6 Nov 2026 16:18:09 -0500 Subject: [PATCH 15/19] fix(node): Always print the warning that the Lambda tunnel can't redirect the handler It's a misconfiguration message, so it goes through `consoleSandbox` instead of the debug logger and shows without `debug: true`. --- .../TunnelNodeInitInHandler/index.mjs | 1 - packages/node/src/lambda/redirectHandler.ts | 13 ++++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/index.mjs index 6ba5515c6828..637d3dc120fd 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/index.mjs +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/index.mjs @@ -10,7 +10,6 @@ console.warn = (...args) => { Sentry.init({ dsn: process.env.SENTRY_DSN, - debug: true, integrations: [Sentry.serveTunnelIntegration()], }); diff --git a/packages/node/src/lambda/redirectHandler.ts b/packages/node/src/lambda/redirectHandler.ts index 119df9fc734e..63a674bb0dcb 100644 --- a/packages/node/src/lambda/redirectHandler.ts +++ b/packages/node/src/lambda/redirectHandler.ts @@ -1,4 +1,4 @@ -import { debug, GLOBAL_OBJ } from '@sentry/core'; +import { consoleSandbox, debug, GLOBAL_OBJ } from '@sentry/core'; import { createRequire } from 'node:module'; import { DEBUG_BUILD } from '../debug-build'; import { parseHandlerString, resolveHandlerFile } from './handlerResolution'; @@ -82,10 +82,13 @@ export function redirectLambdaHandler(resolveShimFile: () => string | undefined, export function redirectLambdaHandlerForTunnel(): void { // The runtime defines `awslambda` before it loads the handler, after the preloads ran. So `init` runs in the handler // module, where the runtime already read `_HANDLER`. - if (DEBUG_BUILD && typeof (GLOBAL_OBJ as { awslambda?: unknown }).awslambda !== 'undefined') { - debug.warn( - "Sentry was initialized after the Lambda runtime loaded the handler, so tunnel requests reach the handler. Initialize Sentry from a preload (`NODE_OPTIONS=--import`), or wrap the handler with `@sentry/aws-serverless`'s `wrapHandler`.", - ); + if (typeof (GLOBAL_OBJ as { awslambda?: unknown }).awslambda !== 'undefined') { + consoleSandbox(() => { + // oxlint-disable-next-line no-console + console.warn( + "[Sentry] Sentry was initialized after the Lambda runtime loaded the handler, so tunnel requests reach the handler. Initialize Sentry from a preload (`NODE_OPTIONS=--import`), or wrap the handler with `@sentry/aws-serverless`'s `wrapHandler`.", + ); + }); } redirectLambdaHandler(() => { From 357b058a998839fc189ee3d9787b80e96048ee54 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 6 Nov 2026 16:22:03 -0500 Subject: [PATCH 16/19] fix(node): Claim a REST API tunnel request if either of its paths matches Which of `path` and `requestContext.path` holds the path the browser posted to depends on the domain, e.g. a custom domain with a base path mapping, so the tunnel no longer picks one. --- packages/node/src/lambda/tunnel.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/node/src/lambda/tunnel.ts b/packages/node/src/lambda/tunnel.ts index 15fc6183da6f..716209ddcbd1 100644 --- a/packages/node/src/lambda/tunnel.ts +++ b/packages/node/src/lambda/tunnel.ts @@ -122,13 +122,15 @@ function toTunnelRequest(event: unknown): Request | undefined { const httpEvent = event as HttpEvent; const method = httpEvent.requestContext?.http?.method ?? httpEvent.httpMethod; - // The path the browser posted to, so one `basePath` fits both sides: a REST API's `path` drops the stage, its - // `requestContext.path` keeps it like an HTTP API's `rawPath` does. An ALB event has neither. - const path = + // A REST API's `path` drops a named stage that its `requestContext.path` keeps, like an HTTP API's `rawPath` does. + // Which one is the path the browser posted to depends on the domain, so a REST request is claimed if either + // matches. An ALB event has only `path`. + const paths = typeof httpEvent.rawPath === 'string' - ? `${httpEvent.rawPath}${httpEvent.rawQueryString ? `?${httpEvent.rawQueryString}` : ''}` - : (httpEvent.requestContext?.path ?? httpEvent.path); - if (!method || !path || !isServedTunnelRequest({ method, url: path })) { + ? [`${httpEvent.rawPath}${httpEvent.rawQueryString ? `?${httpEvent.rawQueryString}` : ''}`] + : [httpEvent.requestContext?.path, httpEvent.path]; + const path = method && paths.find(candidate => candidate && isServedTunnelRequest({ method, url: candidate })); + if (!method || !path) { return undefined; } From 7bf449f0a7f3c4c6f2083d4c59b15bcb09cd4e37 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 6 Nov 2026 16:38:08 -0500 Subject: [PATCH 17/19] test(aws-serverless): Answer a REST API tunnel request when only its stage-stripped path matches --- .../aws-serverless/tests/tunnel.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts index ddd6bb1e03f7..22590d032867 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts @@ -259,4 +259,24 @@ test.describe('managed tunnel', () => { await forwarded; }); } + + // Where the browser posted to the stage-stripped `path`, like a custom domain mapped to the stage. + test('answers a REST API tunnel request when only its stage-stripped path matches', async ({ lambdaClient }) => { + const dsn = await getDsn(); + const marker = `stage-stripped-${crypto.randomUUID()}`; + const forwarded = waitForError('aws-serverless', event => event.message === marker); + const tunnelPath = getTunnelPath(dsn)!; + + const result = await invoke(lambdaClient, 'NpmTunnelNodeAsync', { + httpMethod: 'POST', + path: tunnelPath, + headers: { 'content-type': 'application/x-sentry-envelope' }, + requestContext: { path: `/prod${tunnelPath}`, stage: 'prod' }, + body: makeEnvelope(dsn, marker), + isBase64Encoded: false, + }); + + expect(JSON.parse(result.payload).body).not.toBe('app'); + await forwarded; + }); }); From a6450ee6adc26c380266bf5da3d8d9276b4ea210 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 6 Nov 2026 17:26:32 -0500 Subject: [PATCH 18/19] ref(node,aws-serverless): Answer Lambda tunnel requests in @sentry/aws-serverless only The handler redirect, handler resolution and tunnel-only shim in @sentry/node shipped to every @sentry/node bundle, like Next.js server builds, for a Lambda setup we don't support. They go back to @sentry/aws-serverless, whose `wrapHandler` answers tunnel requests. --- .../tests/runtime-entries.test.ts | 1 - .../index.js | 0 .../instrument.mjs | 2 +- .../TunnelNodeCallback/instrument.mjs | 7 - .../TunnelNodeInitInHandler/index.mjs | 19 --- .../TunnelNodeInitInHandler/instrument.mjs | 1 - .../TunnelNodeStagePath/index.mjs | 1 - .../TunnelNodeStreaming/instrument.mjs | 7 - .../TunnelNodeStreamingUnreachable/index.mjs | 4 - .../index.mjs | 0 .../instrument.mjs | 2 +- .../index.mjs | 0 .../instrument.mjs | 2 +- .../index.mjs | 0 .../instrument.mjs | 2 +- .../aws-serverless/tests/tunnel.test.ts | 33 +---- .../scripts/consistentExports.ts | 6 - .../suites/esbuild/lambda-tunnel/app.mjs | 36 ----- .../esbuild/lambda-tunnel/task/index.mjs | 1 - .../suites/esbuild/lambda-tunnel/test.ts | 60 --------- .../src}/handlerResolution.ts | 0 .../src/integration/awslambda.ts | 62 ++++++++- .../aws-serverless/src/run-lambda-handler.ts | 5 +- packages/aws-serverless/src/sdk.ts | 15 +-- .../lambda => aws-serverless/src}/tunnel.ts | 49 +------ .../test/awslambda-integration.test.ts | 39 ------ packages/node/package.json | 7 +- packages/node/rollup.npm.config.mjs | 22 --- packages/node/src/index.ts | 9 -- packages/node/src/integrations/serveTunnel.ts | 21 +-- packages/node/src/lambda/loadHandler.ts | 48 ------- packages/node/src/lambda/redirectHandler.ts | 127 ------------------ packages/node/src/run-lambda-handler.ts | 11 -- .../tanstackstart-react/src/server/sdk.ts | 6 +- 34 files changed, 83 insertions(+), 522 deletions(-) rename dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/{TunnelNodeCallback => TunnelCallback}/index.js (100%) rename dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/{TunnelNodeAsync => TunnelCallback}/instrument.mjs (69%) delete mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeCallback/instrument.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/index.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/instrument.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStagePath/index.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreaming/instrument.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingUnreachable/index.mjs rename dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/{TunnelNodeAsync => TunnelStagePath}/index.mjs (100%) rename dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/{TunnelNodeStagePath => TunnelStagePath}/instrument.mjs (82%) rename dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/{TunnelNodeStreaming => TunnelStreamingBrokenAnswer}/index.mjs (100%) rename dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/{TunnelNodeStreamingBrokenAnswer => TunnelStreamingBrokenAnswer}/instrument.mjs (81%) rename dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/{TunnelNodeStreamingBrokenAnswer => TunnelStreamingUnreachable}/index.mjs (100%) rename dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/{TunnelNodeStreamingUnreachable => TunnelStreamingUnreachable}/instrument.mjs (83%) delete mode 100644 dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/app.mjs delete mode 100644 dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/task/index.mjs delete mode 100644 dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/test.ts rename packages/{node/src/lambda => aws-serverless/src}/handlerResolution.ts (100%) rename packages/{node/src/lambda => aws-serverless/src}/tunnel.ts (74%) delete mode 100644 packages/node/src/lambda/loadHandler.ts delete mode 100644 packages/node/src/lambda/redirectHandler.ts delete mode 100644 packages/node/src/run-lambda-handler.ts diff --git a/dev-packages/bundler-tests/tests/runtime-entries.test.ts b/dev-packages/bundler-tests/tests/runtime-entries.test.ts index 73a5ccc89360..156102cc6765 100644 --- a/dev-packages/bundler-tests/tests/runtime-entries.test.ts +++ b/dev-packages/bundler-tests/tests/runtime-entries.test.ts @@ -53,7 +53,6 @@ const ESM: Format[] = ['esm']; const CASES: Case[] = [ { specifier: '@sentry/node', conditions: NODE, formats: BOTH }, { specifier: '@sentry/node/import', conditions: NODE, formats: ESM }, - { specifier: '@sentry/node/run-lambda-handler', conditions: NODE, formats: ESM }, { specifier: '@sentry/bun', conditions: ['bun', 'node'], formats: BOTH }, { specifier: '@sentry/deno', conditions: ['deno', 'node'], formats: ESM }, { specifier: '@sentry/vercel-edge', conditions: ['edge-light'], formats: BOTH }, diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeCallback/index.js b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelCallback/index.js similarity index 100% rename from dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeCallback/index.js rename to dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelCallback/index.js diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeAsync/instrument.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelCallback/instrument.mjs similarity index 69% rename from dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeAsync/instrument.mjs rename to dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelCallback/instrument.mjs index 52cd2539a0eb..b513b159600c 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeAsync/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelCallback/instrument.mjs @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/node'; +import * as Sentry from '@sentry/aws-serverless'; Sentry.init({ dsn: process.env.SENTRY_DSN, diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeCallback/instrument.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeCallback/instrument.mjs deleted file mode 100644 index 52cd2539a0eb..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeCallback/instrument.mjs +++ /dev/null @@ -1,7 +0,0 @@ -import * as Sentry from '@sentry/node'; - -Sentry.init({ - dsn: process.env.SENTRY_DSN, - debug: true, - integrations: [Sentry.serveTunnelIntegration()], -}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/index.mjs deleted file mode 100644 index 637d3dc120fd..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/index.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import * as Sentry from '@sentry/node'; - -// What the SDK warned about, for the test to read back. -const warnings = []; -const warn = console.warn; -console.warn = (...args) => { - warnings.push(args.join(' ')); - warn(...args); -}; - -Sentry.init({ - dsn: process.env.SENTRY_DSN, - integrations: [Sentry.serveTunnelIntegration()], -}); - -export const handler = async event => ({ - statusCode: 200, - body: event?.readWarnings ? JSON.stringify(warnings) : 'app', -}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/instrument.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/instrument.mjs deleted file mode 100644 index 6319d3efa287..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeInitInHandler/instrument.mjs +++ /dev/null @@ -1 +0,0 @@ -// No preload: this function initializes the SDK in its handler module. diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStagePath/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStagePath/index.mjs deleted file mode 100644 index 914944147e85..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStagePath/index.mjs +++ /dev/null @@ -1 +0,0 @@ -export const handler = async () => ({ statusCode: 200, body: 'app' }); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreaming/instrument.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreaming/instrument.mjs deleted file mode 100644 index 52cd2539a0eb..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreaming/instrument.mjs +++ /dev/null @@ -1,7 +0,0 @@ -import * as Sentry from '@sentry/node'; - -Sentry.init({ - dsn: process.env.SENTRY_DSN, - debug: true, - integrations: [Sentry.serveTunnelIntegration()], -}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingUnreachable/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingUnreachable/index.mjs deleted file mode 100644 index 680bc8969deb..000000000000 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingUnreachable/index.mjs +++ /dev/null @@ -1,4 +0,0 @@ -export const handler = awslambda.streamifyResponse(async (_event, responseStream) => { - responseStream.write('app'); - responseStream.end(); -}); diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeAsync/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStagePath/index.mjs similarity index 100% rename from dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeAsync/index.mjs rename to dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStagePath/index.mjs diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStagePath/instrument.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStagePath/instrument.mjs similarity index 82% rename from dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStagePath/instrument.mjs rename to dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStagePath/instrument.mjs index 7dffa69f74c7..ce00ff2f3659 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStagePath/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStagePath/instrument.mjs @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/node'; +import * as Sentry from '@sentry/aws-serverless'; Sentry.init({ dsn: process.env.SENTRY_DSN, diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreaming/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreamingBrokenAnswer/index.mjs similarity index 100% rename from dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreaming/index.mjs rename to dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreamingBrokenAnswer/index.mjs diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingBrokenAnswer/instrument.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreamingBrokenAnswer/instrument.mjs similarity index 81% rename from dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingBrokenAnswer/instrument.mjs rename to dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreamingBrokenAnswer/instrument.mjs index 054dffd70b7f..bf77ac2fa2a2 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingBrokenAnswer/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreamingBrokenAnswer/instrument.mjs @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/node'; +import * as Sentry from '@sentry/aws-serverless'; Sentry.init({ // The test's ingest on this port answers 200 and then breaks off mid-body. diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingBrokenAnswer/index.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreamingUnreachable/index.mjs similarity index 100% rename from dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingBrokenAnswer/index.mjs rename to dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreamingUnreachable/index.mjs diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingUnreachable/instrument.mjs b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreamingUnreachable/instrument.mjs similarity index 83% rename from dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingUnreachable/instrument.mjs rename to dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreamingUnreachable/instrument.mjs index dfb6c804a050..64648c0caac6 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelNodeStreamingUnreachable/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/src/lambda-functions-npm/TunnelStreamingUnreachable/instrument.mjs @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/node'; +import * as Sentry from '@sentry/aws-serverless'; Sentry.init({ // Nothing listens on this port, so forwarding a tunnel request fails after the tunnel claimed it. diff --git a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts index 22590d032867..7f96818d70f9 100644 --- a/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts +++ b/dev-packages/e2e-tests/test-applications/aws-serverless/tests/tunnel.test.ts @@ -75,7 +75,7 @@ function makeHttpEvent(shape: EventShape, path: string, envelope: string, base64 } test.describe('managed tunnel', () => { - for (const functionName of ['NpmTunnelAsync', 'NpmTunnelNodeAsync', 'NpmTunnelNodeCallback']) { + for (const functionName of ['NpmTunnelAsync', 'NpmTunnelCallback']) { test(`${functionName} answers tunnel requests before the handler`, async ({ lambdaClient }) => { const dsn = await getDsn(); const marker = `${functionName}-${crypto.randomUUID()}`; @@ -131,7 +131,7 @@ test.describe('managed tunnel', () => { }); } - for (const functionName of ['NpmTunnelStreaming', 'NpmTunnelNodeStreaming']) { + for (const functionName of ['NpmTunnelStreaming']) { test(`${functionName} streams the tunnel answer before the handler`, async ({ lambdaClient }) => { const dsn = await getDsn(); const marker = `${functionName}-${crypto.randomUUID()}`; @@ -156,7 +156,7 @@ test.describe('managed tunnel', () => { const result = await invoke( lambdaClient, - 'NpmTunnelNodeStreamingUnreachable', + 'NpmTunnelStreamingUnreachable', makeHttpEvent('apiGatewayV2', getTunnelPath(dsn)!, makeEnvelope(dsn, 'unreachable')), ); @@ -183,7 +183,7 @@ test.describe('managed tunnel', () => { const result = await invoke( lambdaClient, - 'NpmTunnelNodeStreamingBrokenAnswer', + 'NpmTunnelStreamingBrokenAnswer', makeHttpEvent('apiGatewayV2', getTunnelPath(dsn)!, makeEnvelope(dsn, 'broken-answer')), ); @@ -196,27 +196,6 @@ test.describe('managed tunnel', () => { } }); - test('warns when the SDK is initialized in the handler module, where the tunnel cannot answer', async ({ - lambdaClient, - }) => { - const dsn = await getDsn(); - - const result = await invoke( - lambdaClient, - 'NpmTunnelNodeInitInHandler', - makeHttpEvent('apiGatewayV2', getTunnelPath(dsn)!, makeEnvelope(dsn, 'init-in-handler')), - ); - // The runtime loaded the handler before `init` redirected it, so the app answers. - expect(JSON.parse(result.payload)).toEqual({ statusCode: 200, body: 'app' }); - - const warnings = JSON.parse( - JSON.parse((await invoke(lambdaClient, 'NpmTunnelNodeInitInHandler', { readWarnings: true })).payload).body, - ); - expect(warnings).toContainEqual( - expect.stringContaining('Sentry was initialized after the Lambda runtime loaded the handler'), - ); - }); - // The shapes a real API Gateway sends on a stage named `prod`: a REST API leaves the stage out of `path` but keeps it // in `requestContext.path`, an HTTP API keeps it in `rawPath`. for (const [api, makeStageEvent] of [ @@ -251,7 +230,7 @@ test.describe('managed tunnel', () => { const result = await invoke( lambdaClient, - 'NpmTunnelNodeStagePath', + 'NpmTunnelStagePath', makeStageEvent(getTunnelPath(dsn)!, makeEnvelope(dsn, marker)), ); @@ -267,7 +246,7 @@ test.describe('managed tunnel', () => { const forwarded = waitForError('aws-serverless', event => event.message === marker); const tunnelPath = getTunnelPath(dsn)!; - const result = await invoke(lambdaClient, 'NpmTunnelNodeAsync', { + const result = await invoke(lambdaClient, 'NpmTunnelAsync', { httpMethod: 'POST', path: tunnelPath, headers: { 'content-type': 'application/x-sentry-envelope' }, diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 872712e9b43a..3c880974fe0b 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -22,12 +22,6 @@ const NODE_EXPORTS_IGNORE = [ '_INTERNAL_normalizeCollectionInterval', // Only needed by framework SDKs that enable the managed tunnel after `init` '_INTERNAL_installTunnelHooks', - // Lambda helpers only needed by `@sentry/aws-serverless` - '_INTERNAL_handleLambdaTunnelEvent', - '_INTERNAL_handleStreamingLambdaTunnelEvent', - '_INTERNAL_parseLambdaHandlerString', - '_INTERNAL_redirectLambdaHandler', - '_INTERNAL_resolveLambdaHandlerFile', // not exported by bun 'nativeNodeFetchIntegration', ]; diff --git a/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/app.mjs b/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/app.mjs deleted file mode 100644 index 00d42097f463..000000000000 --- a/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/app.mjs +++ /dev/null @@ -1,36 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { createServer } from 'node:http'; -import { pathToFileURL } from 'node:url'; - -// Bundled to CJS, where esbuild leaves `import.meta` empty, and run like a Lambda preload: `init`, then the runtime -// loads the module `_HANDLER` points to and calls it with a tunnel request. -(async () => { - const ingest = createServer((req, res) => { - req.resume(); - req.on('end', () => res.end('ingest')); - }).listen(0); - await new Promise(resolve => ingest.once('listening', resolve)); - const dsn = `http://public@localhost:${ingest.address().port}/1`; - - Sentry.init({ dsn, integrations: [Sentry.serveTunnelIntegration()] }); - - const handlerString = process.env._HANDLER; - const moduleName = handlerString.slice(0, handlerString.lastIndexOf('.')); - const modulePath = moduleName.startsWith('/') ? moduleName : `${process.env.LAMBDA_TASK_ROOT}/${moduleName}`; - const handlerModule = await import(pathToFileURL(`${modulePath}.mjs`).href); - - const response = await handlerModule.handler( - { - version: '2.0', - rawPath: Sentry.getTunnelPath(dsn), - requestContext: { http: { method: 'POST' } }, - headers: { host: 'app.example' }, - body: `${JSON.stringify({ dsn })}\n{"type":"event"}\n{}`, - }, - {}, - ); - - // oxlint-disable-next-line no-console -- the test reads the answer from stdout - console.log(`ANSWERED_BY ${response.body}`); - ingest.close(); -})(); diff --git a/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/task/index.mjs b/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/task/index.mjs deleted file mode 100644 index 914944147e85..000000000000 --- a/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/task/index.mjs +++ /dev/null @@ -1 +0,0 @@ -export const handler = async () => ({ statusCode: 200, body: 'app' }); diff --git a/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/test.ts b/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/test.ts deleted file mode 100644 index e6162895c3bc..000000000000 --- a/dev-packages/node-integration-tests/suites/esbuild/lambda-tunnel/test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { spawnSync } from 'child_process'; -import { copyFileSync, mkdtempSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; -import { join } from 'path'; -import { build } from 'esbuild'; -import { afterAll, beforeAll, describe, expect, test } from 'vitest'; - -const OUT_DIR = join(__dirname, 'tmp_build'); -const BUNDLE = join(OUT_DIR, 'app.cjs'); - -function runOnLambda(bundle: string): { stdout: string; status: number | null } { - // pnpm's `NODE_PATH` would resolve `@sentry/node` from anywhere, which a deployed bundle can't. - const { NODE_PATH: _nodePath, ...env } = process.env; - const result = spawnSync('node', [bundle], { - encoding: 'utf-8', - timeout: 10_000, - env: { ...env, LAMBDA_TASK_ROOT: join(__dirname, 'task'), _HANDLER: 'index.handler' }, - }); - - return { stdout: result.stdout, status: result.status }; -} - -describe('managed tunnel on Lambda with `@sentry/node` bundled to CJS by esbuild', () => { - beforeAll(async () => { - await build({ - entryPoints: [join(__dirname, 'app.mjs')], - outfile: BUNDLE, - platform: 'node', - format: 'cjs', - bundle: true, - logLevel: 'silent', - }); - }); - - afterAll(() => { - rmSync(OUT_DIR, { recursive: true, force: true }); - }); - - test('answers tunnel requests when the shim is installed next to the bundle', () => { - const { stdout, status } = runOnLambda(BUNDLE); - - expect(status).toBe(0); - expect(stdout).toContain('ANSWERED_BY ingest'); - }); - - test('leaves the handler alone, without crashing `init`, when the shim is not installed', () => { - const deployDir = mkdtempSync(join(tmpdir(), 'sentry-lambda-bundle-')); - const bundle = join(deployDir, 'app.cjs'); - copyFileSync(BUNDLE, bundle); - - try { - const { stdout, status } = runOnLambda(bundle); - - expect(status).toBe(0); - expect(stdout).toContain('ANSWERED_BY app'); - } finally { - rmSync(deployDir, { recursive: true, force: true }); - } - }); -}); diff --git a/packages/node/src/lambda/handlerResolution.ts b/packages/aws-serverless/src/handlerResolution.ts similarity index 100% rename from packages/node/src/lambda/handlerResolution.ts rename to packages/aws-serverless/src/handlerResolution.ts diff --git a/packages/aws-serverless/src/integration/awslambda.ts b/packages/aws-serverless/src/integration/awslambda.ts index 7ddfcc01b796..94ddb92d00f6 100644 --- a/packages/aws-serverless/src/integration/awslambda.ts +++ b/packages/aws-serverless/src/integration/awslambda.ts @@ -1,8 +1,8 @@ import type { IntegrationFn } from '@sentry/core'; import { debug, defineIntegration, getCurrentScope, safeSetSpanJSONAttributes } from '@sentry/core'; -import { _INTERNAL_redirectLambdaHandler } from '@sentry/node'; import { createRequire } from 'node:module'; import { DEBUG_BUILD } from '../debug-build'; +import { parseHandlerString, resolveHandlerFile } from '../handlerResolution'; const SHIM_MODULE_ID = '@sentry/aws-serverless/run-lambda-handler'; @@ -20,11 +20,65 @@ function resolveShimFile(): string | undefined { } /** - * Redirects the Lambda runtime to the Sentry handler shim (`run-lambda-handler.mjs`), which wraps the user's handler - * with the full Lambda instrumentation. See `_INTERNAL_redirectLambdaHandler` in `@sentry/node`. + * 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 { - _INTERNAL_redirectLambdaHandler(resolveShimFile); + 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 = [ diff --git a/packages/aws-serverless/src/run-lambda-handler.ts b/packages/aws-serverless/src/run-lambda-handler.ts index d4b077850e51..a37a3f025443 100644 --- a/packages/aws-serverless/src/run-lambda-handler.ts +++ b/packages/aws-serverless/src/run-lambda-handler.ts @@ -8,10 +8,7 @@ import type { Handler } from 'aws-lambda'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; -import { - _INTERNAL_parseLambdaHandlerString as parseHandlerString, - _INTERNAL_resolveLambdaHandlerFile as resolveHandlerFile, -} from '@sentry/node'; +import { parseHandlerString, resolveHandlerFile } from './handlerResolution'; import { instrumentHandler } from './instrumentHandler'; function fail(message: string): never { diff --git a/packages/aws-serverless/src/sdk.ts b/packages/aws-serverless/src/sdk.ts index 37fcf3266c1c..f39077b4d449 100644 --- a/packages/aws-serverless/src/sdk.ts +++ b/packages/aws-serverless/src/sdk.ts @@ -1,18 +1,11 @@ import type { Scope } from '@sentry/core'; import { debug } from '@sentry/core'; -import { - _INTERNAL_handleLambdaTunnelEvent, - _INTERNAL_handleStreamingLambdaTunnelEvent, - captureException, - captureMessage, - flush, - getCurrentScope, - withScope, -} from '@sentry/node'; +import { captureException, captureMessage, flush, getCurrentScope, withScope } from '@sentry/node'; import type { Context, Handler, StreamifyHandler } from 'aws-lambda'; import { performance } from 'perf_hooks'; import { types } from 'util'; import { DEBUG_BUILD } from './debug-build'; +import { handleStreamingTunnelEvent, handleTunnelEvent } from './tunnel'; import { markEventUnhandled } from './utils'; const { isPromise } = types; @@ -201,7 +194,7 @@ export function wrapHandler( return async (event: TEvent, context: Context) => { // Lambda runs no HTTP server, so tunnel requests are answered here, before the handler. - const tunnelResult = _INTERNAL_handleLambdaTunnelEvent(event); + const tunnelResult = handleTunnelEvent(event); if (tunnelResult) { return tunnelResult as Promise; } @@ -258,7 +251,7 @@ function wrapStreamingHandler( responseStream: Parameters>[1], context: Context, ): Promise => { - const tunnelAnswer = _INTERNAL_handleStreamingLambdaTunnelEvent(event, responseStream); + const tunnelAnswer = handleStreamingTunnelEvent(event, responseStream); if (tunnelAnswer) { return tunnelAnswer as Promise; } diff --git a/packages/node/src/lambda/tunnel.ts b/packages/aws-serverless/src/tunnel.ts similarity index 74% rename from packages/node/src/lambda/tunnel.ts rename to packages/aws-serverless/src/tunnel.ts index 716209ddcbd1..927cc927de82 100644 --- a/packages/node/src/lambda/tunnel.ts +++ b/packages/aws-serverless/src/tunnel.ts @@ -4,7 +4,7 @@ import { _INTERNAL_isServedTunnelRequest as isServedTunnelRequest, _INTERNAL_pickForwardedHeaders as pickForwardedHeaders, } from '@sentry/core/server'; -import { DEBUG_BUILD } from '../debug-build'; +import { DEBUG_BUILD } from './debug-build'; /** * The parts of an API Gateway (REST and HTTP API), function URL or ALB event the tunnel reads. @@ -51,7 +51,7 @@ declare const awslambda: /** * Answers a managed tunnel request that came in as an HTTP event, before the handler runs. Lambda runs no HTTP server, - * so the handler invocation is the only place to do it. Returns `undefined` for every other event. + * so `wrapHandler` answers it from the handler invocation. Returns `undefined` for every other event. * * It never throws. Until a request is claimed, a failure hands the event to the handler. Once claimed, it ends in a * 502 instead. @@ -175,48 +175,3 @@ async function toHttpResult(response: Response, multiValueHeaders: boolean): Pro isBase64Encoded: false, }; } - -// The symbols the Lambda runtime reads off a streaming handler (`awslambda.streamifyResponse`). -const HANDLER_STREAMING_SYMBOL: unique symbol = Symbol.for('aws.lambda.runtime.handler.streaming'); -const HANDLER_HIGHWATERMARK_SYMBOL: unique symbol = Symbol.for('aws.lambda.runtime.handler.streaming.highWaterMark'); - -type LambdaHandler = (...args: unknown[]) => unknown; - -type StreamingHandler = LambdaHandler & { - [HANDLER_STREAMING_SYMBOL]?: unknown; - [HANDLER_HIGHWATERMARK_SYMBOL]?: unknown; -}; - -/** - * Wraps a Lambda handler so it answers managed tunnel requests before the handler runs, and passes every other event - * to it unchanged. Streaming handlers keep the symbols the runtime reads off them. - */ -export function wrapLambdaHandlerForTunnel(handler: LambdaHandler): LambdaHandler { - const streamingSource: StreamingHandler = handler; - if (streamingSource[HANDLER_STREAMING_SYMBOL] === 'response') { - const streamingHandler: StreamingHandler = function ( - this: unknown, - event: unknown, - responseStream: unknown, - ...rest: unknown[] - ) { - return handleStreamingTunnelEvent(event, responseStream) ?? handler.call(this, event, responseStream, ...rest); - }; - streamingHandler[HANDLER_STREAMING_SYMBOL] = streamingSource[HANDLER_STREAMING_SYMBOL]; - streamingHandler[HANDLER_HIGHWATERMARK_SYMBOL] = streamingSource[HANDLER_HIGHWATERMARK_SYMBOL]; - - return streamingHandler; - } - - // Keeps the handler's arity, which the runtime reads to tell a callback handler from an async one (and which the - // Node.js 24 runtime rejects). - if (handler.length > 2) { - return function (this: unknown, event: unknown, context: unknown, callback: unknown) { - return handleTunnelEvent(event) ?? handler.call(this, event, context, callback); - }; - } - - return function (this: unknown, event: unknown, context: unknown) { - return handleTunnelEvent(event) ?? handler.call(this, event, context); - }; -} diff --git a/packages/aws-serverless/test/awslambda-integration.test.ts b/packages/aws-serverless/test/awslambda-integration.test.ts index fe69de1638d1..a1a45fd656b9 100644 --- a/packages/aws-serverless/test/awslambda-integration.test.ts +++ b/packages/aws-serverless/test/awslambda-integration.test.ts @@ -2,7 +2,6 @@ import type { StreamedSpanJSON } from '@sentry/core'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { serveTunnelIntegration } from '@sentry/node'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { awsLambdaIntegration, redirectLambdaHandler } from '../src/integration/awslambda'; @@ -28,7 +27,6 @@ describe('redirectLambdaHandler', () => { delete process.env._HANDLER; delete process.env.LAMBDA_TASK_ROOT; delete process.env.SENTRY_ORIGINAL_HANDLER; - delete process.env.SENTRY_LAMBDA_TUNNEL_ONLY_SHIM; }); afterEach(() => { @@ -98,43 +96,6 @@ describe('redirectLambdaHandler', () => { expect(process.env._HANDLER).toMatch(/\/run-lambda-handler\.handler$/); expect(process.env.SENTRY_ORIGINAL_HANDLER).toBe('index.handler'); }); - - test("takes over from @sentry/node's tunnel-only handler when that redirected first", () => { - process.env.LAMBDA_TASK_ROOT = taskRoot; - process.env._HANDLER = 'index.handler'; - - serveTunnelIntegration().setupOnce!(); - const tunnelHandler = process.env._HANDLER; - awsLambdaIntegration().setupOnce!(); - - expect(tunnelHandler).toMatch(/node\/build\/run-lambda-handler\.handler$/); - expect(process.env._HANDLER).toMatch(/aws-serverless\/build\/npm\/run-lambda-handler\.handler$/); - expect(process.env.SENTRY_ORIGINAL_HANDLER).toBe('index.handler'); - }); - - test('takes over from the tunnel-only handler of another copy of @sentry/node', () => { - process.env.LAMBDA_TASK_ROOT = taskRoot; - process.env.SENTRY_ORIGINAL_HANDLER = 'index.handler'; - process.env._HANDLER = '/opt/other-copy/@sentry/node/build/run-lambda-handler.handler'; - process.env.SENTRY_LAMBDA_TUNNEL_ONLY_SHIM = '1'; - - awsLambdaIntegration().setupOnce!(); - - expect(process.env._HANDLER).toMatch(/aws-serverless\/build\/npm\/run-lambda-handler\.handler$/); - expect(process.env.SENTRY_LAMBDA_TUNNEL_ONLY_SHIM).toBeUndefined(); - }); - - test("leaves the handler alone when @sentry/node's tunnel comes after", () => { - process.env.LAMBDA_TASK_ROOT = taskRoot; - process.env._HANDLER = 'index.handler'; - - awsLambdaIntegration().setupOnce!(); - const awsHandler = process.env._HANDLER; - serveTunnelIntegration().setupOnce!(); - - expect(process.env._HANDLER).toBe(awsHandler); - expect(process.env.SENTRY_ORIGINAL_HANDLER).toBe('index.handler'); - }); }); describe('awsLambdaIntegration processSegmentSpan', () => { diff --git a/packages/node/package.json b/packages/node/package.json index c98fd0d41d75..acf5a4cb0565 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -32,9 +32,6 @@ "default": "./build/import-hook.mjs" } }, - "./run-lambda-handler": { - "default": "./build/run-lambda-handler.mjs" - }, "./vite": { "import": { "types": "./build/types/bundler-plugin/vite.d.ts", @@ -121,9 +118,7 @@ "{projectRoot}/build/cjs", "{projectRoot}/build/npm/esm", "{projectRoot}/build/npm/cjs", - "{projectRoot}/build/import-hook.mjs", - "{projectRoot}/build/run-lambda-handler.mjs", - "{projectRoot}/build/run-lambda-handler.mjs.map" + "{projectRoot}/build/import-hook.mjs" ] } } diff --git a/packages/node/rollup.npm.config.mjs b/packages/node/rollup.npm.config.mjs index 265ddb183a99..45f7f66a6df4 100644 --- a/packages/node/rollup.npm.config.mjs +++ b/packages/node/rollup.npm.config.mjs @@ -12,29 +12,7 @@ const [localVariablesWorkerConfig, getLocalVariablesBase64Code] = createWorkerCo 'build/esm/integrations/local-variables', ); -// The Lambda handler shim `init` redirects `_HANDLER` to for the managed tunnel. Standalone and ESM-only, since it -// uses top-level await to load the user's handler module. Bare imports stay external. -function makeLambdaHandlerShimConfig() { - 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/run-lambda-handler.mjs', - format: 'esm', - preserveModules: false, - }, - }; -} - export default [ - makeLambdaHandlerShimConfig(), // The `@sentry/node/import` entry (`node --import @sentry/node/import app.js`), which registers // the orchestrion diagnostics-channel injection before the app loads. ...makeOrchestrionLoader('./build'), diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 87894cda94f9..0e0250f6c432 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -190,15 +190,6 @@ export { wrapMcpServerWithSentry, } from '@sentry/core/server'; export type { TunnelRequestLike } from '@sentry/core/server'; -export { - handleStreamingTunnelEvent as _INTERNAL_handleStreamingLambdaTunnelEvent, - handleTunnelEvent as _INTERNAL_handleLambdaTunnelEvent, -} from './lambda/tunnel'; -export { - parseHandlerString as _INTERNAL_parseLambdaHandlerString, - resolveHandlerFile as _INTERNAL_resolveLambdaHandlerFile, -} from './lambda/handlerResolution'; -export { redirectLambdaHandler as _INTERNAL_redirectLambdaHandler } from './lambda/redirectHandler'; export { installTunnelHooks as _INTERNAL_installTunnelHooks, serveTunnelIntegration } from './integrations/serveTunnel'; export { getTunnelPath, handleTunnelRequest } from '@sentry/core'; export type { HandleTunnelRequestOptions, ServerTunnelOptions } from '@sentry/core'; diff --git a/packages/node/src/integrations/serveTunnel.ts b/packages/node/src/integrations/serveTunnel.ts index 1f44a38c9873..9a799638563b 100644 --- a/packages/node/src/integrations/serveTunnel.ts +++ b/packages/node/src/integrations/serveTunnel.ts @@ -2,23 +2,13 @@ import { _INTERNAL_instrumentTunnelServers as instrumentTunnelServers, makeServeTunnelIntegration, } from '@sentry/server-utils'; -import { redirectLambdaHandlerForTunnel } from '../lambda/redirectHandler'; /** - * Lets every `node:http` server, and the Lambda handler, answer managed tunnel requests before the app sees them. - * Nothing is answered until the tunnel is on. For framework SDKs that enable the tunnel after `init`, like TanStack - * Start's `tunnelRoute`. + * Lets every `node:http` server answer managed tunnel requests before the app sees them. Nothing is answered until the + * tunnel is on. For framework SDKs that enable the tunnel after `init`, like TanStack Start's `tunnelRoute`. */ export function installTunnelHooks(): void { instrumentTunnelServers(); - redirectLambdaHandlerForTunnelIfOnLambda(); -} - -function redirectLambdaHandlerForTunnelIfOnLambda(): void { - // Lambda runs no HTTP server, so its handler is wrapped to answer tunnel requests. - if (process.env.LAMBDA_TASK_ROOT) { - redirectLambdaHandlerForTunnel(); - } } /** @@ -27,9 +17,8 @@ function redirectLambdaHandlerForTunnelIfOnLambda(): void { * With this integration on the server and `tunnel: true` in the browser `init`, the browser sends its events to your * own server instead of Sentry, where ad-blockers can't drop them, and your server forwards them to Sentry. * - * Every `node:http` server answers the tunnel requests before the app sees them, and so does the Lambda handler when - * `init` runs from a preload. To serve them from your own code instead, set `intercept: false` and call - * `handleTunnelRequestIfMatched`. + * Every `node:http` server answers the tunnel requests before the app sees them. To serve them from your own code + * instead, set `intercept: false` and call `handleTunnelRequestIfMatched`. * * By default only envelopes for the server's own DSN are forwarded. * @@ -48,4 +37,4 @@ function redirectLambdaHandlerForTunnelIfOnLambda(): void { * }); * ``` */ -export const serveTunnelIntegration = makeServeTunnelIntegration(redirectLambdaHandlerForTunnelIfOnLambda); +export const serveTunnelIntegration = makeServeTunnelIntegration(); diff --git a/packages/node/src/lambda/loadHandler.ts b/packages/node/src/lambda/loadHandler.ts deleted file mode 100644 index 6dd8ad6a9bd0..000000000000 --- a/packages/node/src/lambda/loadHandler.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { createRequire } from 'node:module'; -import { pathToFileURL } from 'node:url'; -import { parseHandlerString, resolveHandlerFile } from './handlerResolution'; - -/** - * Loads the handler the Lambda runtime would have loaded, from `SENTRY_ORIGINAL_HANDLER`. For the handler shims - * `_HANDLER` is redirected to, which run it as their own handler. - */ -export async function loadOriginalLambdaHandler(): Promise<(...args: unknown[]) => unknown> { - const taskRoot = process.env.LAMBDA_TASK_ROOT; - const originalHandlerString = process.env.SENTRY_ORIGINAL_HANDLER; - if (!taskRoot || !originalHandlerString) { - failToLoad('LAMBDA_TASK_ROOT or SENTRY_ORIGINAL_HANDLER is not set.'); - } - - const parsedHandler = parseHandlerString(originalHandlerString); - if (!parsedHandler) { - failToLoad(`"${originalHandlerString}" is not a valid handler string.`); - } - - const resolvedFile = resolveHandlerFile(taskRoot, parsedHandler.moduleRoot, parsedHandler.moduleName); - if (!resolvedFile) { - failToLoad(`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') { - failToLoad(`"${parsedHandler.functionPath}" in module "${parsedHandler.moduleName}" is not a function.`); - } - - return originalHandler as (...args: unknown[]) => unknown; -} - -function failToLoad(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`, - ); -} diff --git a/packages/node/src/lambda/redirectHandler.ts b/packages/node/src/lambda/redirectHandler.ts deleted file mode 100644 index 63a674bb0dcb..000000000000 --- a/packages/node/src/lambda/redirectHandler.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { consoleSandbox, debug, GLOBAL_OBJ } from '@sentry/core'; -import { createRequire } from 'node:module'; -import { DEBUG_BUILD } from '../debug-build'; -import { parseHandlerString, resolveHandlerFile } from './handlerResolution'; - -const TUNNEL_SHIM_MODULE_ID = '@sentry/node/run-lambda-handler'; - -/** - * 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(resolveShimFile: () => string | undefined, tunnelOnly = false): 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) { - if (!tunnelOnly) { - replaceTunnelOnlyShim(resolveShimFile); - } - 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`; - if (tunnelOnly) { - // A marker rather than the shim's path, so a full shim recognizes it even from another copy of this package. - process.env.SENTRY_LAMBDA_TUNNEL_ONLY_SHIM = '1'; - } - - DEBUG_BUILD && - debug.log('Redirected lambda handler.', { originalHandler: handlerString, newHandler: process.env._HANDLER }); -} - -/** - * On Lambda, redirects the handler to the shim that answers managed tunnel requests before it, since Lambda runs no - * HTTP server. Only takes effect when `init` runs from a preload (`NODE_OPTIONS=--import`), before the runtime loads - * the handler. - */ -export function redirectLambdaHandlerForTunnel(): void { - // The runtime defines `awslambda` before it loads the handler, after the preloads ran. So `init` runs in the handler - // module, where the runtime already read `_HANDLER`. - if (typeof (GLOBAL_OBJ as { awslambda?: unknown }).awslambda !== 'undefined') { - consoleSandbox(() => { - // oxlint-disable-next-line no-console - console.warn( - "[Sentry] Sentry was initialized after the Lambda runtime loaded the handler, so tunnel requests reach the handler. Initialize Sentry from a preload (`NODE_OPTIONS=--import`), or wrap the handler with `@sentry/aws-serverless`'s `wrapHandler`.", - ); - }); - } - - redirectLambdaHandler(() => { - try { - // A CJS bundle has `require`, but leaves `import.meta.url` empty, so it's only read without one. - const resolve = typeof require === 'function' ? require.resolve : createRequire(import.meta.url).resolve; - - return resolve(TUNNEL_SHIM_MODULE_ID); - } catch (error) { - // With `@sentry/node` bundled into the app, there is no shim file to point the runtime at. - DEBUG_BUILD && - debug.warn( - `Could not resolve ${TUNNEL_SHIM_MODULE_ID}, not answering tunnel requests. Wrap the handler with \`@sentry/aws-serverless\`'s \`wrapHandler\` instead.`, - error, - ); - - return undefined; - } - }, true); -} - -/** - * A shim with the full Lambda instrumentation, like `@sentry/aws-serverless`'s, replaces the tunnel-only one when that - * one redirected first. It answers tunnel requests too, so the order the integrations set up in doesn't matter. - */ -function replaceTunnelOnlyShim(resolveShimFile: () => string | undefined): void { - if (!process.env.SENTRY_LAMBDA_TUNNEL_ONLY_SHIM) { - return; - } - - const shimFile = resolveShimFile(); - if (shimFile) { - process.env._HANDLER = `${shimFile.replace(/\.mjs$/, '')}.handler`; - delete process.env.SENTRY_LAMBDA_TUNNEL_ONLY_SHIM; - } -} diff --git a/packages/node/src/run-lambda-handler.ts b/packages/node/src/run-lambda-handler.ts deleted file mode 100644 index bbc3b0152713..000000000000 --- a/packages/node/src/run-lambda-handler.ts +++ /dev/null @@ -1,11 +0,0 @@ -// The handler shim the AWS Lambda runtime loads in place of the user's handler when `init` redirected `_HANDLER` -// for the managed tunnel (see `lambda/redirectHandler.ts`). Lambda runs no HTTP server, so the handler invocation is -// the only place to answer tunnel requests before the app. -// -// It is built as a standalone, ESM-only `build/run-lambda-handler.mjs`, because it uses top-level await to load the -// user's handler module, which may itself be ESM. `@sentry/aws-serverless` has its own shim with the full Lambda -// instrumentation, and `init` doesn't redirect when that one is in place. -import { loadOriginalLambdaHandler } from './lambda/loadHandler'; -import { wrapLambdaHandlerForTunnel } from './lambda/tunnel'; - -export const handler = wrapLambdaHandlerForTunnel(await loadOriginalLambdaHandler()); diff --git a/packages/tanstackstart-react/src/server/sdk.ts b/packages/tanstackstart-react/src/server/sdk.ts index a2fbee086ce5..987f1105ebc5 100644 --- a/packages/tanstackstart-react/src/server/sdk.ts +++ b/packages/tanstackstart-react/src/server/sdk.ts @@ -26,10 +26,8 @@ export function init(options: NodeOptions): NodeClient | undefined { ]; const client = initNodeSdk(sentryOptions); - // `tunnelRoute` enables the tunnel when the server entry loads, after this preload, so the servers and the Lambda - // handler must be ready to answer it already. This redirects the Lambda handler even for apps without a tunnel, on - // purpose: the runtime reads `_HANDLER` before the server entry loads, so waiting for the tunnel would be too late. - // Until the tunnel is on, the redirected handler passes every event through. + // `tunnelRoute` enables the tunnel when the server entry loads, after this preload, so the servers must be ready to + // answer it already. installTunnelHooks(); return client; From 417ccc725b18554e695953880865b75a2f2adf6b Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Fri, 6 Nov 2026 17:29:05 -0500 Subject: [PATCH 19/19] fix(aws-serverless): Resolve the Lambda handler shim without referencing `require` Webpack can't follow `require.resolve` passed around as a value and warns, so the shim is resolved with `createRequire` from `__filename` or `import.meta.url`. --- packages/aws-serverless/src/integration/awslambda.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/aws-serverless/src/integration/awslambda.ts b/packages/aws-serverless/src/integration/awslambda.ts index 94ddb92d00f6..60642c0e37b5 100644 --- a/packages/aws-serverless/src/integration/awslambda.ts +++ b/packages/aws-serverless/src/integration/awslambda.ts @@ -8,10 +8,10 @@ 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; + // Never `require` itself, which bundlers like webpack can't follow when it's passed around. A CJS module or bundle + // has `__filename`, while a CJS bundle of the ESM build leaves `import.meta.url` empty. + const resolve = createRequire(typeof __filename === 'string' ? __filename : 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);