diff --git a/dev-packages/bun-integration-tests/node-suites/excludes.ts b/dev-packages/bun-integration-tests/node-suites/excludes.ts index 65edcd311712..7fc29d96f476 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 a6ad2a290f16..57db9f954e21 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/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..a83c0145b668 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/test.ts @@ -0,0 +1,98 @@ +import { + CLOUD_ACCOUNT_ID, + CLOUD_PLATFORM, + CLOUD_PROVIDER, + CLOUD_REGION, + FAAS_COLDSTART, + FAAS_INVOCATION_ID, + 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_ACCOUNT_ID: '123456789012', + 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', + [CLOUD_ACCOUNT_ID]: '123456789012', + }, + '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(); + for (const { request, collected } of spans) { + const items = await collected; + 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` }, + }, + }); + } + }); +}); diff --git a/packages/astro/src/index.server.ts b/packages/astro/src/index.server.ts index dc77f7d5f6b9..ee75b42dca74 100644 --- a/packages/astro/src/index.server.ts +++ b/packages/astro/src/index.server.ts @@ -91,6 +91,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 cd302d5523f0..e1312b43bea2 100644 --- a/packages/aws-serverless/src/index.ts +++ b/packages/aws-serverless/src/index.ts @@ -76,6 +76,7 @@ export { type NodeRuntimeMetricsOptions, contextLinesIntegration, nodeContextIntegration, + awsLambdaWebIntegration, localVariablesIntegration, requestDataIntegration, functionToStringIntegration, diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index c0cc191e2850..64f4d0bc9000 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -96,6 +96,7 @@ export { modulesIntegration, contextLinesIntegration, nodeContextIntegration, + awsLambdaWebIntegration, localVariablesIntegration, requestDataIntegration, fsIntegration, diff --git a/packages/google-cloud-serverless/src/index.ts b/packages/google-cloud-serverless/src/index.ts index 5bbf486e2342..334281ad61eb 100644 --- a/packages/google-cloud-serverless/src/index.ts +++ b/packages/google-cloud-serverless/src/index.ts @@ -76,6 +76,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 df5d309ee9e6..98570782beaf 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..537318de30ba --- /dev/null +++ b/packages/node/src/integrations/awsLambdaWeb.ts @@ -0,0 +1,94 @@ +import { + CLOUD_ACCOUNT_ID, + CLOUD_PLATFORM, + CLOUD_PROVIDER, + CLOUD_REGION, + FAAS_COLDSTART, + FAAS_INVOCATION_ID, +} from '@sentry/conventions/attributes'; +import type { IntegrationFn, SpanAttributes } from '@sentry/core'; +import { defineIntegration, getClient, getIsolationScope, safeSetSpanJSONAttributes } from '@sentry/core'; + +let requestHandled = false; + +const _awsLambdaWebIntegration = (() => { + const enabled = !!process.env.AWS_LAMBDA_HTTP_ENDPOINT; + const cloudContext: SpanAttributes = { + [CLOUD_PROVIDER]: 'aws', + [CLOUD_PLATFORM]: 'aws_lambda', + [CLOUD_REGION]: process.env.AWS_REGION, + [CLOUD_ACCOUNT_ID]: process.env.AWS_ACCOUNT_ID, + }; + 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 = { + ...cloudContext, + 'faas.max_memory': lambdaContext.memory_size, + 'aws.lambda.initialization_type': lambdaContext.initialization_type, + }; + + return { + name: 'AwsLambdaWeb', + setup(client) { + if (!enabled) return; + + client.on('httpServerRequest', (_request, _response, request) => { + if (getClient() !== client) { + return; + } + + const coldStart = !requestHandled; + requestHandled = true; + const requestId = request.headers?.['lambda-web-request-id']; + const endpointName = request.headers?.['lambda-web-endpoint-name']; + const scope = getIsolationScope(); + const requestContext: { cold_start: boolean; aws_request_id?: string; endpoint_name?: string } = { + ...scope.getScopeData().contexts['aws.lambda'], + cold_start: coldStart, + }; + if (typeof requestId === 'string') { + requestContext.aws_request_id = requestId; + } else { + delete requestContext.aws_request_id; + } + if (typeof endpointName === 'string') { + requestContext.endpoint_name = endpointName; + } else { + delete requestContext.endpoint_name; + } + scope.setContext('aws.lambda', requestContext); + }); + }, + processEvent(event) { + if (!enabled) return event; + event.contexts = { + ...event.contexts, + cloud_resource: { ...cloudContext, ...event.contexts?.cloud_resource }, + 'aws.lambda': { ...lambdaContext, ...event.contexts?.['aws.lambda'] }, + }; + return event; + }, + processSegmentSpan(span) { + if (!enabled) return; + + 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/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..1fa875fb05c9 --- /dev/null +++ b/packages/node/test/integrations/awsLambdaWeb.test.ts @@ -0,0 +1,200 @@ +import { + CLOUD_ACCOUNT_ID, + CLOUD_PLATFORM, + CLOUD_PROVIDER, + CLOUD_REGION, + FAAS_COLDSTART, + FAAS_INVOCATION_ID, + 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_ACCOUNT_ID', '123456789012'); + 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(); + disabled.setup!(client); + const span = spanToJSON(new SentrySpan({ name: 'outside Web Functions' })); + + disabled.processSegmentSpan!(span, client); + + expect(disabled.processEvent!({}, {}, client)).toEqual({}); + expect(span.attributes).toEqual({ [SENTRY_ORIGIN]: 'manual' }); + }); + + 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_PROVIDER]: 'aws', + [CLOUD_PLATFORM]: 'aws_lambda', + [CLOUD_REGION]: 'custom', + [CLOUD_ACCOUNT_ID]: '123456789012', + }, + }, + }); + }); + + 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', + [CLOUD_PROVIDER]: 'aws', + [CLOUD_PLATFORM]: 'aws_lambda', + [CLOUD_ACCOUNT_ID]: '123456789012', + [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', + [CLOUD_PROVIDER]: 'aws', + [CLOUD_PLATFORM]: 'aws_lambda', + [CLOUD_ACCOUNT_ID]: '123456789012', + '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_ACCOUNT_ID', 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: { + cloud_resource: { [CLOUD_PROVIDER]: 'aws', [CLOUD_PLATFORM]: 'aws_lambda' }, + 'aws.lambda': {}, + }, + }); + }); +});