Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6390ae7
feat(node,aws-serverless): Answer managed tunnel requests on Lambda
logaretm Nov 2, 2026
e74ae1c
test(bundler-tests): Classify the `@sentry/node/run-lambda-handler` e…
logaretm Nov 2, 2026
ccb7436
ref(node): Name the Lambda tunnel event handlers handle*TunnelEvent
logaretm Nov 3, 2026
47a5dd9
ref(node): Build the Lambda tunnel integration from the server-utils …
logaretm Nov 3, 2026
f77208b
fix(node): Recognize the tunnel-only Lambda shim by a marker instead …
logaretm Nov 3, 2026
6d18ceb
test(aws-serverless): Answer managed tunnel requests on a real Lambda…
logaretm Nov 5, 2026
039d30e
fix(node): Resolve the Lambda tunnel shim without `import.meta.url`
logaretm Nov 6, 2026
972c49b
ref(tanstackstart-react): Explain why the Lambda handler is redirecte…
logaretm Nov 6, 2026
24250cb
fix(node): Pass on only the forwarded headers of Sentry's answer on L…
logaretm Nov 6, 2026
b57b81b
fix(node): End the Lambda tunnel stream when Sentry's answer breaks off
logaretm Nov 6, 2026
06104ee
fix(node): Warn when the Lambda tunnel can't redirect the handler
logaretm Nov 6, 2026
2515607
ref(node): Type the Lambda streaming handler symbols instead of doubl…
logaretm Nov 6, 2026
d85d358
test(node): Don't expect a silent startup from a bundled Lambda SDK
logaretm Nov 6, 2026
ae14e1b
fix(node): Match REST API tunnel requests on the path with the stage
logaretm Nov 6, 2026
62617db
fix(node): Always print the warning that the Lambda tunnel can't redi…
logaretm Nov 6, 2026
357b058
fix(node): Claim a REST API tunnel request if either of its paths mat…
logaretm Nov 6, 2026
7bf449f
test(aws-serverless): Answer a REST API tunnel request when only its …
logaretm Nov 6, 2026
a6450ee
ref(node,aws-serverless): Answer Lambda tunnel requests in @sentry/aw…
logaretm Nov 6, 2026
417ccc7
fix(aws-serverless): Resolve the Lambda handler shim without referenc…
logaretm Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const handler = async () => ({ statusCode: 200, body: 'app' });
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as Sentry from '@sentry/aws-serverless';

Sentry.init({
dsn: process.env.SENTRY_DSN,
debug: true,
integrations: [Sentry.serveTunnelIntegration()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
exports.handler = (_event, _context, callback) => {
callback(null, { statusCode: 200, body: 'app' });
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as Sentry from '@sentry/aws-serverless';

Sentry.init({
dsn: process.env.SENTRY_DSN,
debug: true,
integrations: [Sentry.serveTunnelIntegration()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const handler = async () => ({ statusCode: 200, body: 'app' });
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as Sentry from '@sentry/aws-serverless';

Sentry.init({
dsn: process.env.SENTRY_DSN,
debug: true,
// Served on an API Gateway stage named `prod`, where the browser posts to `/prod/<path>`.
integrations: [Sentry.serveTunnelIntegration({ basePath: '/prod' })],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const handler = awslambda.streamifyResponse(async (_event, responseStream) => {
responseStream.write('app');
responseStream.end();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as Sentry from '@sentry/aws-serverless';

Sentry.init({
dsn: process.env.SENTRY_DSN,
debug: true,
integrations: [Sentry.serveTunnelIntegration()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const handler = awslambda.streamifyResponse(async (_event, responseStream) => {
responseStream.write('app');
responseStream.end();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as Sentry from '@sentry/aws-serverless';

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()],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const handler = awslambda.streamifyResponse(async (_event, responseStream) => {
responseStream.write('app');
responseStream.end();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
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.
dsn: process.env.SENTRY_DSN.replace(':3031/', ':3032/'),
debug: true,
integrations: [Sentry.serveTunnelIntegration()],
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
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';

/**
* 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;
}

async function invoke(lambdaClient: LambdaClient, functionName: string, event: unknown): Promise<InvokeResult> {
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<string> {
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<string, unknown> {
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', 'NpmTunnelCallback']) {
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' });
});
}

// 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();
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);
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[headersKey]);
expect(headerNames.filter(name => !FORWARDED_RESPONSE_HEADERS.includes(name))).toEqual([]);
await forwarded;
});
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}

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()}`;
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,
'NpmTunnelStreamingUnreachable',
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');
});

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,
'NpmTunnelStreamingBrokenAnswer',
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();
}
});

// 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,
'NpmTunnelStagePath',
makeStageEvent(getTunnelPath(dsn)!, makeEnvelope(dsn, marker)),
);

expect(JSON.parse(result.payload).body).not.toBe('app');
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, 'NpmTunnelAsync', {
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;
});
});
8 changes: 4 additions & 4 deletions packages/aws-serverless/src/integration/awslambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions packages/aws-serverless/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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;
Expand Down Expand Up @@ -192,6 +193,12 @@ export function wrapHandler<TEvent, TResult>(
: (handler as AsyncHandler<Handler<TEvent, TResult>>);

return async (event: TEvent, context: Context) => {
// Lambda runs no HTTP server, so tunnel requests are answered here, before the handler.
const tunnelResult = handleTunnelEvent(event);
if (tunnelResult) {
return tunnelResult as Promise<TResult>;
}
Comment thread
logaretm marked this conversation as resolved.
Resolved

context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop;

timeoutWarningTimer = setupTimeoutWarning(context, options);
Expand Down Expand Up @@ -244,6 +251,11 @@ function wrapStreamingHandler<TEvent, TResult>(
responseStream: Parameters<StreamifyHandler<TEvent, TResult>>[1],
context: Context,
): Promise<TResult> => {
const tunnelAnswer = handleStreamingTunnelEvent(event, responseStream);
if (tunnelAnswer) {
return tunnelAnswer as Promise<TResult>;
}

context.callbackWaitsForEmptyEventLoop = options.callbackWaitsForEmptyEventLoop;

timeoutWarningTimer = setupTimeoutWarning(context, options);
Expand Down
Loading
Loading