diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index 30c08744ef1d..dff3e151b26e 100644 --- a/dev-packages/bun-integration-tests/node-suites/excludes.ts +++ b/dev-packages/bun-integration-tests/node-suites/excludes.ts @@ -7,6 +7,7 @@ // `node:sqlite`, which `flue` needs, and the Vercel keep-alive, which needs `http.server.response.finish`. const NODE_ONLY = [ 'suites/anr/test.ts', + 'suites/aws-lambda-web/**', 'suites/aws-serverless/**', 'suites/breadcrumbs/**', 'suites/child-process/test.ts', diff --git a/dev-packages/deno-integration-tests/node-suites/excludes.ts b/dev-packages/deno-integration-tests/node-suites/excludes.ts index bed8f796a588..e6d5906bc2c8 100644 --- a/dev-packages/deno-integration-tests/node-suites/excludes.ts +++ b/dev-packages/deno-integration-tests/node-suites/excludes.ts @@ -2,8 +2,13 @@ // fails on Deno runs only on the other runtimes, with `test.runIf(supports({ runtimes }))` in the // Node suite, and is not listed here. -// Node-only features: ANR and native thread watchdogs, child process and worker thread breadcrumbs. -const NODE_ONLY = ['suites/anr/test.ts', 'suites/breadcrumbs/**', 'suites/thread-blocked-native/test.ts']; +// Node-only features: ANR and native thread watchdogs, child process and worker thread breadcrumbs, and the AWS Lambda Node runtime. +const NODE_ONLY = [ + 'suites/anr/test.ts', + 'suites/aws-lambda-web/**', + 'suites/breadcrumbs/**', + 'suites/thread-blocked-native/test.ts', +]; // `@sentry/node` instruments `fetch` through undici's diagnostics channels, which Deno's `fetch` // does not publish. `@sentry/deno` has its own `fetchIntegration` for this. These suites check 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 6bee27e997bc..446589a1db0c 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 @@ -51,6 +51,7 @@ const DEPENDENTS: Dependent[] = [ exports: Object.keys(SentryBun), ignoreExports: [ // not supported in bun: + 'awsLambdaWebIntegration', 'NodeClient', 'NODE_VERSION', 'childProcessIntegration', diff --git a/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/instrument.mjs b/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/instrument.mjs new file mode 100644 index 000000000000..d5824acab7ba --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/instrument.mjs @@ -0,0 +1,8 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +Sentry.init({ + dsn: 'https://public@example.com/1', + tracesSampleRate: process.env.NO_TRACING ? undefined : 1, + transport: loggingTransport, +}); diff --git a/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/scenario.mjs b/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/scenario.mjs new file mode 100644 index 000000000000..c3fea461fdd1 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/scenario.mjs @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/node'; +import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests'; +import express from 'express'; + +const app = express(); +let releaseFirst; +const secondRequestStarted = new Promise(resolve => { + releaseFirst = resolve; +}); + +app.get('/:request', async (req, res) => { + if (req.params.request === 'first') { + process.stdout.write('FIRST_STARTED\n'); + await secondRequestStarted; + } else { + releaseFirst(); + } + + Sentry.captureException(new Error(req.params.request)); + Sentry.logger.info('Request handled'); + Sentry.metrics.count('request_count', 1); + res.send({ ok: true }); + setImmediate(() => { + void Sentry.flush(); + }); +}); + +startExpressServerAndSendPortToRunner(app); diff --git a/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/test.ts b/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/test.ts new file mode 100644 index 000000000000..270e9777f8d7 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/test.ts @@ -0,0 +1,111 @@ +import { + AWS_LAMBDA_WEB_ENDPOINT_NAME, + CLOUD_PLATFORM, + CLOUD_PROVIDER, + CLOUD_REGION, + FAAS_COLDSTART, + FAAS_INVOCATION_ID, + FAAS_MAX_MEMORY, + SENTRY_OP, +} from '@sentry/conventions/attributes'; +import { expect, vi } from 'vitest'; +import { createEsmAndCjsTests } from '../../../utils/runner'; + +createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test.each([ + { name: 'default tracing', tracing: true }, + { name: 'tracing disabled', tracing: false }, + ])('$name: isolates concurrent requests and leaves logs and metrics unenriched', async ({ tracing }) => { + const runner = createRunner() + .withEnv({ + AWS_LAMBDA_HTTP_ENDPOINT: '0.0.0.0:3000', + AWS_REGION: 'us-east-1', + AWS_LAMBDA_FUNCTION_MEMORY_SIZE: '2048', + AWS_LAMBDA_INITIALIZATION_TYPE: 'native-http', + AWS_EXECUTION_ENV: 'AWS_Lambda_nodejs24.x-lite', + NO_TRACING: tracing ? '' : '1', + }) + .unordered(); + + const spans = []; + + for (const request of ['first', 'second']) { + runner.expect({ + event: { + exception: { values: [expect.objectContaining({ value: request })] }, + contexts: { + cloud_resource: { + [CLOUD_PROVIDER]: 'aws', + [CLOUD_PLATFORM]: 'aws_lambda', + [CLOUD_REGION]: 'us-east-1', + }, + 'aws.lambda': { + memory_size: 2_147_483_648, + initialization_type: 'native-http', + cold_start: request === 'first', + aws_request_id: request, + endpoint_name: `${request}-endpoint`, + }, + }, + }, + }); + + if (tracing) { + spans.push({ + request, + collected: runner.collectStreamedSpansUntilSegment( + span => span.attributes[FAAS_INVOCATION_ID]?.value === request, + ), + }); + } + } + + const assertUnenriched = (items: { attributes?: Record }[]): void => { + expect(items).toHaveLength(2); + for (const item of items) { + expect(Object.keys(item.attributes || {}).filter(key => /^(aws\.lambda\.|faas\.|cloud\.)/.test(key))).toEqual( + [], + ); + } + }; + + runner.expect({ log: container => assertUnenriched(container.items) }); + runner.expect({ trace_metric: container => assertUnenriched(container.items) }); + + const running = runner.start(); + const first = running.makeRequest('get', '/first', { + headers: { 'lambda-web-request-id': 'first', 'lambda-web-endpoint-name': 'first-endpoint' }, + }); + await vi.waitFor(() => expect(running.getLogs().join('\n')).toContain('FIRST_STARTED'), { timeout: 10_000 }); + + await Promise.all([ + first, + running.makeRequest('get', '/second', { + headers: { 'lambda-web-request-id': 'second', 'lambda-web-endpoint-name': 'second-endpoint' }, + }), + ]); + await running.completed(); + + const segmentSpans = []; + + for (const { request, collected } of spans) { + const items = await collected; + segmentSpans.push(...items.filter(item => item.is_segment)); + + expect(items.find(item => item.is_segment)).toMatchObject({ + name: 'GET /:request', + attributes: { + [SENTRY_OP]: { type: 'string', value: 'http.server' }, + [CLOUD_PLATFORM]: { type: 'string', value: 'aws_lambda' }, + [CLOUD_REGION]: { type: 'string', value: 'us-east-1' }, + [FAAS_MAX_MEMORY]: { type: 'integer', value: 2_147_483_648 }, + [FAAS_COLDSTART]: { type: 'boolean', value: request === 'first' }, + [FAAS_INVOCATION_ID]: { type: 'string', value: request }, + [AWS_LAMBDA_WEB_ENDPOINT_NAME]: { type: 'string', value: `${request}-endpoint` }, + }, + }); + } + + expect(segmentSpans).toHaveLength(tracing ? 2 : 0); + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index 0db5cfe2ef90..a52a7477f6bc 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -95,6 +95,7 @@ export { nativeNodeFetchIntegration, NodeClient, nodeContextIntegration, + awsLambdaWebIntegration, onUncaughtExceptionIntegration, onUnhandledRejectionIntegration, mistralAIIntegration, diff --git a/packages/aws-serverless/src/index.ts b/packages/aws-serverless/src/index.ts index 9df4b06ff240..0803fca702f5 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -78,6 +78,7 @@ export { type NodeRuntimeMetricsOptions, contextLinesIntegration, nodeContextIntegration, + awsLambdaWebIntegration, localVariablesIntegration, requestDataIntegration, functionToStringIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 35eddeb055a7..1f6e6979bee0 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -78,6 +78,7 @@ export { type NodeRuntimeMetricsOptions, contextLinesIntegration, nodeContextIntegration, + awsLambdaWebIntegration, localVariablesIntegration, requestDataIntegration, fsIntegration, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 5ddd71b4b9f4..7fdeb4ab4df2 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -1,4 +1,5 @@ export { httpIntegration } from './integrations/http'; +export { awsLambdaWebIntegration } from './integrations/awsLambdaWeb'; export { nativeNodeFetchIntegration } from './integrations/node-fetch'; export { fsIntegration } from './integrations/fs'; // oxlint-disable-next-line typescript/no-deprecated diff --git a/packages/node/src/integrations/awsLambdaWeb.ts b/packages/node/src/integrations/awsLambdaWeb.ts new file mode 100644 index 000000000000..42b3c5e5d86e --- /dev/null +++ b/packages/node/src/integrations/awsLambdaWeb.ts @@ -0,0 +1,76 @@ +import { + AWS_LAMBDA_INITIALIZATION_TYPE, + AWS_LAMBDA_WEB_ENDPOINT_NAME, + FAAS_COLDSTART, + FAAS_INVOCATION_ID, + FAAS_MAX_MEMORY, +} from '@sentry/conventions/attributes'; +import type { IntegrationFn, SpanAttributes } from '@sentry/core'; +import { defineIntegration, getIsolationScope, safeSetSpanJSONAttributes } from '@sentry/core'; + +const INTEGRATION_NAME = 'AwsLambdaWeb'; + +let requestHandled = false; + +const _awsLambdaWebIntegration = (() => { + // Lambda Web Functions run ordinary HTTP servers without a Lambda handler to wrap. + // We read their request metadata through the `httpServerRequest` hook. Since this hook + // runs for all HTTP servers, we bail out early when we are not in a Lambda Web Function. + // https://docs.aws.amazon.com/lambda/latest/dg/web-functions-revisions-env-vars.html + if (!process.env.AWS_LAMBDA_HTTP_ENDPOINT) { + return { name: INTEGRATION_NAME }; + } + + const memory = Number(process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE) * 1024 * 1024; + const lambdaContext = { + memory_size: Number.isFinite(memory) && memory > 0 ? memory : undefined, + initialization_type: process.env.AWS_LAMBDA_INITIALIZATION_TYPE, + }; + const environmentAttributes = { + [FAAS_MAX_MEMORY]: lambdaContext.memory_size, + [AWS_LAMBDA_INITIALIZATION_TYPE]: lambdaContext.initialization_type, + }; + + return { + name: INTEGRATION_NAME, + setup(client) { + client.on('httpServerRequest', (_request, _response, request) => { + const coldStart = !requestHandled; + requestHandled = true; + const requestId = request.headers?.['lambda-web-request-id']; + const endpointName = request.headers?.['lambda-web-endpoint-name']; + const scope = getIsolationScope(); + scope.setContext('aws.lambda', { + ...scope.getScopeData().contexts['aws.lambda'], + cold_start: coldStart, + aws_request_id: typeof requestId === 'string' ? requestId : undefined, + endpoint_name: typeof endpointName === 'string' ? endpointName : undefined, + }); + }); + }, + processEvent(event) { + event.contexts = { + ...event.contexts, + 'aws.lambda': { ...lambdaContext, ...event.contexts?.['aws.lambda'] }, + }; + return event; + }, + processSegmentSpan(span) { + const requestContext = getIsolationScope().getScopeData().contexts['aws.lambda']; + const attributes: SpanAttributes = { ...environmentAttributes }; + if (typeof requestContext?.cold_start === 'boolean') { + attributes[FAAS_COLDSTART] = requestContext.cold_start; + } + if (typeof requestContext?.aws_request_id === 'string') { + attributes[FAAS_INVOCATION_ID] = requestContext.aws_request_id; + } + if (typeof requestContext?.endpoint_name === 'string') { + attributes[AWS_LAMBDA_WEB_ENDPOINT_NAME] = requestContext.endpoint_name; + } + safeSetSpanJSONAttributes(span, attributes); + }, + }; +}) satisfies IntegrationFn; + +/** Enrich AWS Lambda Web Function spans and error contexts with platform metadata. */ +export const awsLambdaWebIntegration = defineIntegration(_awsLambdaWebIntegration); diff --git a/packages/node/src/integrations/context.ts b/packages/node/src/integrations/context.ts index ef967476b57f..b1fa7d36598c 100644 --- a/packages/node/src/integrations/context.ts +++ b/packages/node/src/integrations/context.ts @@ -548,10 +548,11 @@ function getCloudResourceContext(): CloudResourceContext | undefined { }; } else if (process.env.AWS_REGION) { // https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html + const executionEnv = process.env.AWS_EXECUTION_ENV; return { [CLOUD_PROVIDER]: 'aws', [CLOUD_REGION]: process.env.AWS_REGION, - [CLOUD_PLATFORM]: process.env.AWS_EXECUTION_ENV, + [CLOUD_PLATFORM]: executionEnv?.startsWith('AWS_Lambda') ? 'aws_lambda' : executionEnv, }; } else if (process.env.GCP_PROJECT) { // https://cloud.google.com/composer/docs/how-to/managing/environment-variables#reserved_variables diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 57cf3871256a..ccd4601bfc8b 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -20,6 +20,7 @@ import { import { isMainThread, parentPort } from 'node:worker_threads'; import { detectOrchestrionSetup, getErrorIntegrations, getTracingIntegrations } from '@sentry/server-utils'; import { DEBUG_BUILD } from '../debug-build'; +import { awsLambdaWebIntegration } from '../integrations/awsLambdaWeb'; import { childProcessIntegration } from '../integrations/childProcess'; import { consoleIntegration } from '../integrations/console'; import { nodeContextIntegration } from '../integrations/context'; @@ -66,6 +67,7 @@ function getBaseDefaultIntegrations(): Integration[] { // Event Info contextLinesIntegration(), localVariablesIntegration(), + awsLambdaWebIntegration(), nodeContextIntegration(), childProcessIntegration(), workerThreadsIntegration(), diff --git a/packages/node/test/integrations/awsLambdaWeb.test.ts b/packages/node/test/integrations/awsLambdaWeb.test.ts new file mode 100644 index 000000000000..cd3b30c1b716 --- /dev/null +++ b/packages/node/test/integrations/awsLambdaWeb.test.ts @@ -0,0 +1,181 @@ +import { + AWS_LAMBDA_INITIALIZATION_TYPE, + AWS_LAMBDA_WEB_ENDPOINT_NAME, + CLOUD_REGION, + FAAS_COLDSTART, + FAAS_INVOCATION_ID, + FAAS_MAX_MEMORY, + SENTRY_ORIGIN, +} from '@sentry/conventions/attributes'; +import type { Integration } from '@sentry/core'; +import { getCurrentScope, getIsolationScope, SentrySpan, spanToJSON, withIsolationScope } from '@sentry/core'; +import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NodeClient } from '../../src/sdk/client'; + +describe('awsLambdaWebIntegration', () => { + let integration: Integration; + let client: NodeClient; + + beforeEach(async () => { + vi.resetModules(); + setAsyncLocalStorageAsyncContextStrategy(); + vi.stubEnv('AWS_LAMBDA_HTTP_ENDPOINT', '0.0.0.0:3000'); + vi.stubEnv('AWS_REGION', 'us-east-1'); + vi.stubEnv('AWS_LAMBDA_FUNCTION_MEMORY_SIZE', '2048'); + vi.stubEnv('AWS_LAMBDA_INITIALIZATION_TYPE', 'native-http'); + const { awsLambdaWebIntegration } = await import('../../src/integrations/awsLambdaWeb'); + integration = awsLambdaWebIntegration(); + client = new NodeClient({ + dsn: 'https://public@example.com/1', + integrations: [], + stackParser: () => [], + transport: () => ({ send: () => Promise.resolve({}), flush: () => Promise.resolve(true) }), + }); + getCurrentScope().setClient(client); + }); + + afterEach(async () => { + client.dispose(); + vi.restoreAllMocks(); + await client.close(); + getCurrentScope().setClient(undefined); + getIsolationScope().setContext('aws.lambda', null); + vi.unstubAllEnvs(); + }); + + it('is inert outside Web Functions', async () => { + vi.stubEnv('AWS_LAMBDA_HTTP_ENDPOINT', undefined); + const { awsLambdaWebIntegration } = await import('../../src/integrations/awsLambdaWeb'); + const disabled = awsLambdaWebIntegration(); + + expect(disabled).toEqual({ name: 'AwsLambdaWeb' }); + }); + + it('preserves request and user context while adding environment metadata', () => { + const event = { + contexts: { 'aws.lambda': { aws_request_id: 'request' }, cloud_resource: { [CLOUD_REGION]: 'custom' } }, + }; + expect(integration.processEvent!(event, {}, client)).toEqual({ + contexts: { + 'aws.lambda': { aws_request_id: 'request', memory_size: 2_147_483_648, initialization_type: 'native-http' }, + cloud_resource: { [CLOUD_REGION]: 'custom' }, + }, + }); + }); + + it('preserves existing Lambda context while replacing request-specific values', () => { + integration.setup!(client); + + withIsolationScope(scope => { + scope.setContext('aws.lambda', { + function_name: 'checkout', + revision_id: 'rev-current', + aws_request_id: 'previous-request', + endpoint_name: 'previous-endpoint', + cold_start: false, + }); + client.emit( + 'httpServerRequest', + {}, + {}, + { headers: { 'lambda-web-request-id': 'current-request', 'lambda-web-endpoint-name': 'prod' } }, + ); + + expect(scope.getScopeData().contexts['aws.lambda']).toEqual({ + function_name: 'checkout', + revision_id: 'rev-current', + aws_request_id: 'current-request', + endpoint_name: 'prod', + cold_start: true, + }); + }); + }); + + it('preserves explicit root span attributes while filling missing metadata', () => { + integration.setup!(client); + + withIsolationScope(() => { + client.emit('httpServerRequest', {}, {}, { headers: { 'lambda-web-request-id': 'request' } }); + const span = new SentrySpan({ + name: 'request', + attributes: { [CLOUD_REGION]: 'custom-region', [FAAS_COLDSTART]: false, [FAAS_MAX_MEMORY]: 0 }, + }); + const spanJSON = spanToJSON(span); + integration.processSegmentSpan!(spanJSON, client); + + expect(spanJSON.attributes).toEqual({ + [SENTRY_ORIGIN]: 'manual', + [CLOUD_REGION]: 'custom-region', + [FAAS_COLDSTART]: false, + [FAAS_INVOCATION_ID]: 'request', + [FAAS_MAX_MEMORY]: 0, + [AWS_LAMBDA_INITIALIZATION_TYPE]: 'native-http', + }); + }); + }); + + it('clears inherited request identifiers when headers are absent', () => { + integration.setup!(client); + + withIsolationScope(scope => { + scope.setContext('aws.lambda', { revision_id: 'rev-current', aws_request_id: 'old', endpoint_name: 'old' }); + client.emit('httpServerRequest', {}, {}, { headers: {} }); + const span = new SentrySpan({ name: 'request' }); + const spanJSON = spanToJSON(span); + integration.processSegmentSpan!(spanJSON, client); + + expect(scope.getScopeData().contexts['aws.lambda']).toEqual({ revision_id: 'rev-current', cold_start: true }); + expect(spanJSON.attributes).not.toHaveProperty([FAAS_INVOCATION_ID]); + expect(spanJSON.attributes).not.toHaveProperty([AWS_LAMBDA_WEB_ENDPOINT_NAME]); + }); + }); + + it('adds static metadata to segment spans outside requests without overwriting attributes', () => { + const span = spanToJSON(new SentrySpan({ name: 'startup', attributes: { [CLOUD_REGION]: 'custom-region' } })); + + integration.processSegmentSpan!(span, client); + + expect(span.attributes).toEqual({ + [SENTRY_ORIGIN]: 'manual', + [CLOUD_REGION]: 'custom-region', + [FAAS_MAX_MEMORY]: 2_147_483_648, + [AWS_LAMBDA_INITIALIZATION_TYPE]: 'native-http', + }); + }); + + it.each([undefined, '', 'invalid', '0', '-1', 'Infinity', '1e308'])( + 'omits invalid memory size %s from spans and error contexts', + async memory => { + vi.stubEnv('AWS_LAMBDA_FUNCTION_MEMORY_SIZE', memory); + const { awsLambdaWebIntegration } = await import('../../src/integrations/awsLambdaWeb'); + const integration = awsLambdaWebIntegration(); + const span = spanToJSON(new SentrySpan({ name: 'startup' })); + const event = { contexts: { 'aws.lambda': {} } }; + + integration.processSegmentSpan!(span, client); + integration.processEvent!(event, {}, client); + + expect(span.attributes).not.toHaveProperty([FAAS_MAX_MEMORY]); + expect(event.contexts['aws.lambda']).toHaveProperty('memory_size', undefined); + }, + ); + + it('omits unavailable metadata and does not infer function identity', async () => { + vi.stubEnv('AWS_REGION', undefined); + vi.stubEnv('AWS_LAMBDA_FUNCTION_MEMORY_SIZE', 'invalid'); + vi.stubEnv('AWS_LAMBDA_INITIALIZATION_TYPE', undefined); + const { awsLambdaWebIntegration } = await import('../../src/integrations/awsLambdaWeb'); + const minimal = awsLambdaWebIntegration(); + minimal.setup!(client); + withIsolationScope(scope => { + client.emit('httpServerRequest', {}, {}, { headers: {} }); + expect(scope.getScopeData().contexts['aws.lambda']).toEqual({ cold_start: true }); + }); + expect(minimal.processEvent!({}, {}, client)).toEqual({ + contexts: { + 'aws.lambda': {}, + }, + }); + }); +});