From 32b5d104fc9e3adca03d49adf257d342375e16d0 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Wed, 14 Oct 2026 10:26:59 +0200 Subject: [PATCH 1/8] feat(node): Add `awsLambdaWebIntegration` for Lambda Web Functions --- .../node-suites/excludes.ts | 1 + .../node-suites/excludes.ts | 9 +- .../aws-lambda-web/requests/instrument.mjs | 15 ++ .../aws-lambda-web/requests/scenario.mjs | 40 +++ .../suites/aws-lambda-web/requests/test.ts | 94 +++++++ packages/node/src/index.ts | 1 + .../node/src/integrations/awsLambdaWeb.ts | 94 +++++++ packages/node/src/sdk/index.ts | 2 + .../test/integrations/awsLambdaWeb.test.ts | 248 ++++++++++++++++++ 9 files changed, 502 insertions(+), 2 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/aws-lambda-web/requests/instrument.mjs create mode 100644 dev-packages/node-integration-tests/suites/aws-lambda-web/requests/scenario.mjs create mode 100644 dev-packages/node-integration-tests/suites/aws-lambda-web/requests/test.ts create mode 100644 packages/node/src/integrations/awsLambdaWeb.ts create mode 100644 packages/node/test/integrations/awsLambdaWeb.test.ts 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..ec519e97d7c5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/instrument.mjs @@ -0,0 +1,15 @@ +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +const client = Sentry.init({ + dsn: 'https://public@example.com/1', + tracesSampleRate: process.env.NO_TRACING ? undefined : 1, + transport: loggingTransport, + integrations: process.env.FRAMEWORK_ROOT ? [Sentry.httpIntegration({ disableIncomingRequestSpans: true })] : [], +}); + +if (process.env.FRAMEWORK_ROOT === 'late-op') { + client.on('spanEnd', span => { + if (Sentry.getRootSpan(span) === span) span.setAttribute('sentry.op', 'http.server'); + }); +} 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..1016833e8a9f --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/scenario.mjs @@ -0,0 +1,40 @@ +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; +}); + +async function handleRequest(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(req.params.request); + Sentry.metrics.count('request_count', 1); + res.send({ ok: true }); + setImmediate(() => { + void Sentry.flush(); + }); +} + +app.get('/:request', (req, res) => { + if (process.env.FRAMEWORK_ROOT === 'forked-scope') { + return Sentry.withIsolationScope(() => + Sentry.startSpan({ name: 'GET /:request', op: 'http.server' }, () => handleRequest(req, res)), + ); + } + if (process.env.FRAMEWORK_ROOT === 'late-op') { + return Sentry.startSpan({ name: 'GET /:request' }, () => handleRequest(req, res)); + } + return handleRequest(req, res); +}); + +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..ea6b33eb07b5 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/aws-lambda-web/requests/test.ts @@ -0,0 +1,94 @@ +import { expect, vi } from 'vitest'; +import { createEsmAndCjsTests } from '../../../utils/runner'; + +createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { + test('isolates concurrent requests and leaves logs and metrics unenriched, with and without tracing', async () => { + for (const { tracing, frameworkRoot } of [ + { tracing: true, frameworkRoot: '' }, + { tracing: false, frameworkRoot: '' }, + { tracing: true, frameworkRoot: 'forked-scope' }, + { tracing: true, frameworkRoot: 'late-op' }, + ]) { + 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', + FRAMEWORK_ROOT: frameworkRoot, + }) + .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: 2048, + 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' }, + 'aws.lambda.memory_size': { type: 'integer', value: 2048 }, + '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/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..4ad64b0a90bf --- /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); + const lambdaContext = { + memory_size: Number.isFinite(memory) && memory > 0 ? memory : undefined, + initialization_type: process.env.AWS_LAMBDA_INITIALIZATION_TYPE, + }; + const environmentAttributes = { + ...cloudContext, + 'aws.lambda.memory_size': 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..c7ad527ec743 --- /dev/null +++ b/packages/node/test/integrations/awsLambdaWeb.test.ts @@ -0,0 +1,248 @@ +import type { Integration } from '@sentry/core'; +import { + getCurrentScope, + getIsolationScope, + SentrySpan, + spanStreamingIntegration, + spanToJSON, + startInactiveSpan, + startSpan, + 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('keeps request context isolated and assigns cold start before tracing or sampling', async () => { + integration.setup!(client); + let releaseFirst!: () => void; + const barrier = new Promise(resolve => { + releaseFirst = resolve; + }); + const first = withIsolationScope(async scope => { + client.emit( + 'httpServerRequest', + {}, + {}, + { headers: { 'lambda-web-request-id': 'first', 'lambda-web-endpoint-name': 'prod' } }, + ); + await barrier; + return scope.getScopeData(); + }); + const second = withIsolationScope(scope => { + client.emit( + 'httpServerRequest', + {}, + {}, + { headers: { 'lambda-web-request-id': 'second', 'lambda-web-endpoint-name': 'canary' } }, + ); + return scope.getScopeData(); + }); + releaseFirst(); + const firstData = await first; + + expect(firstData.contexts['aws.lambda']).toEqual({ + cold_start: true, + aws_request_id: 'first', + endpoint_name: 'prod', + }); + expect(second.contexts['aws.lambda']).toEqual({ + cold_start: false, + aws_request_id: 'second', + endpoint_name: 'canary', + }); + expect(firstData.attributes).toEqual({}); + expect(second.attributes).toEqual({}); + expect(getIsolationScope().getScopeData().contexts['aws.lambda']).toBeUndefined(); + }); + + 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: 2048, 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, 'aws.lambda.memory_size': 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', + 'aws.lambda.memory_size': 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('enriches segment spans without enriching child spans', () => { + client.getOptions().tracesSampleRate = 1; + client.addIntegration(integration); + client.addIntegration(spanStreamingIntegration()); + const captured = vi.fn(); + client.on('processSpan', span => { + captured(span.is_segment, span.attributes['faas.invocation_id'], span.attributes['cloud.platform']); + }); + + withIsolationScope(() => { + client.emit('httpServerRequest', {}, {}, { headers: { 'lambda-web-request-id': 'request' } }); + startSpan({ name: 'request' }, () => { + startInactiveSpan({ name: 'child' }).end(); + }); + }); + + expect(captured).toHaveBeenCalledTimes(2); + expect(captured).toHaveBeenNthCalledWith(1, false, undefined, undefined); + expect(captured).toHaveBeenNthCalledWith(2, true, 'request', 'aws_lambda'); + expect(getIsolationScope().getScopeData().contexts['aws.lambda']).toBeUndefined(); + }); + + 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', + 'aws.lambda.memory_size': 2048, + 'aws.lambda.initialization_type': 'native-http', + }); + }); + + 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': {}, + }, + }); + }); +}); From 9f349a33d2b05c54ce922f41f01ed5821b4994d6 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Wed, 14 Oct 2026 10:45:18 +0200 Subject: [PATCH 2/8] fix(node): Use faas.max_memory for Lambda Web Function spans Co-Authored-By: Codex --- .../suites/aws-lambda-web/requests/test.ts | 2 +- .../node/src/integrations/awsLambdaWeb.ts | 2 +- .../test/integrations/awsLambdaWeb.test.ts | 20 ++++++++++++++++--- 3 files changed, 19 insertions(+), 5 deletions(-) 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 index ea6b33eb07b5..a87a81987f53 100644 --- 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 @@ -82,7 +82,7 @@ createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, 'sentry.op': { type: 'string', value: 'http.server' }, 'cloud.platform': { type: 'string', value: 'aws_lambda' }, 'cloud.region': { type: 'string', value: 'us-east-1' }, - 'aws.lambda.memory_size': { type: 'integer', value: 2048 }, + '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/node/src/integrations/awsLambdaWeb.ts b/packages/node/src/integrations/awsLambdaWeb.ts index 4ad64b0a90bf..059879190b4c 100644 --- a/packages/node/src/integrations/awsLambdaWeb.ts +++ b/packages/node/src/integrations/awsLambdaWeb.ts @@ -26,7 +26,7 @@ const _awsLambdaWebIntegration = (() => { }; const environmentAttributes = { ...cloudContext, - 'aws.lambda.memory_size': lambdaContext.memory_size, + 'faas.max_memory': lambdaContext.memory_size ? lambdaContext.memory_size * 1024 * 1024 : undefined, 'aws.lambda.initialization_type': lambdaContext.initialization_type, }; diff --git a/packages/node/test/integrations/awsLambdaWeb.test.ts b/packages/node/test/integrations/awsLambdaWeb.test.ts index c7ad527ec743..d7c6d158d1b0 100644 --- a/packages/node/test/integrations/awsLambdaWeb.test.ts +++ b/packages/node/test/integrations/awsLambdaWeb.test.ts @@ -153,7 +153,7 @@ describe('awsLambdaWebIntegration', () => { client.emit('httpServerRequest', {}, {}, { headers: { 'lambda-web-request-id': 'request' } }); const span = new SentrySpan({ name: 'request', - attributes: { 'cloud.region': 'custom-region', 'faas.coldstart': false, 'aws.lambda.memory_size': 0 }, + attributes: { 'cloud.region': 'custom-region', 'faas.coldstart': false, 'faas.max_memory': 0 }, }); const spanJSON = spanToJSON(span); integration.processSegmentSpan!(spanJSON, client); @@ -166,7 +166,7 @@ describe('awsLambdaWebIntegration', () => { 'cloud.account.id': '123456789012', 'faas.coldstart': false, 'faas.invocation_id': 'request', - 'aws.lambda.memory_size': 0, + 'faas.max_memory': 0, 'aws.lambda.initialization_type': 'native-http', }); }); @@ -221,11 +221,25 @@ describe('awsLambdaWebIntegration', () => { 'cloud.provider': 'aws', 'cloud.platform': 'aws_lambda', 'cloud.account.id': '123456789012', - 'aws.lambda.memory_size': 2048, + 'faas.max_memory': 2_147_483_648, 'aws.lambda.initialization_type': 'native-http', }); }); + it.each([undefined, '', 'invalid', '0', '-1', 'Infinity'])( + 'omits invalid memory size %s from spans', + 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' })); + + integration.processSegmentSpan!(span, client); + + expect(span.attributes).not.toHaveProperty('faas.max_memory'); + }, + ); + it('omits unavailable metadata and does not infer function identity', async () => { vi.stubEnv('AWS_REGION', undefined); vi.stubEnv('AWS_ACCOUNT_ID', undefined); From 4eb78d8b1e80e07d9e4e44e96afdf479317251c2 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Wed, 14 Oct 2026 10:48:37 +0200 Subject: [PATCH 3/8] fix(node): Use bytes for Lambda Web Function memory context Co-Authored-By: Codex --- .../suites/aws-lambda-web/requests/test.ts | 2 +- packages/node/src/integrations/awsLambdaWeb.ts | 4 ++-- packages/node/test/integrations/awsLambdaWeb.test.ts | 9 ++++++--- 3 files changed, 9 insertions(+), 6 deletions(-) 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 index a87a81987f53..4d6856a26df7 100644 --- 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 @@ -34,7 +34,7 @@ createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, 'cloud.account.id': '123456789012', }, 'aws.lambda': { - memory_size: 2048, + memory_size: 2_147_483_648, initialization_type: 'native-http', cold_start: request === 'first', aws_request_id: request, diff --git a/packages/node/src/integrations/awsLambdaWeb.ts b/packages/node/src/integrations/awsLambdaWeb.ts index 059879190b4c..537318de30ba 100644 --- a/packages/node/src/integrations/awsLambdaWeb.ts +++ b/packages/node/src/integrations/awsLambdaWeb.ts @@ -19,14 +19,14 @@ const _awsLambdaWebIntegration = (() => { [CLOUD_REGION]: process.env.AWS_REGION, [CLOUD_ACCOUNT_ID]: process.env.AWS_ACCOUNT_ID, }; - const memory = Number(process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE); + 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 ? lambdaContext.memory_size * 1024 * 1024 : undefined, + 'faas.max_memory': lambdaContext.memory_size, 'aws.lambda.initialization_type': lambdaContext.initialization_type, }; diff --git a/packages/node/test/integrations/awsLambdaWeb.test.ts b/packages/node/test/integrations/awsLambdaWeb.test.ts index d7c6d158d1b0..6375c9e92169 100644 --- a/packages/node/test/integrations/awsLambdaWeb.test.ts +++ b/packages/node/test/integrations/awsLambdaWeb.test.ts @@ -107,7 +107,7 @@ describe('awsLambdaWebIntegration', () => { }; expect(integration.processEvent!(event, {}, client)).toEqual({ contexts: { - 'aws.lambda': { aws_request_id: 'request', memory_size: 2048, initialization_type: 'native-http' }, + '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', @@ -226,17 +226,20 @@ describe('awsLambdaWebIntegration', () => { }); }); - it.each([undefined, '', 'invalid', '0', '-1', 'Infinity'])( - 'omits invalid memory size %s from spans', + 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); }, ); From 863988134b51519c6c34b97e43297f777279964f Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Wed, 14 Oct 2026 11:02:30 +0200 Subject: [PATCH 4/8] test(node): Use a fixed log message in Lambda Web Function fixture Co-Authored-By: Codex --- .../suites/aws-lambda-web/requests/scenario.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 1016833e8a9f..3c40c98ee9f1 100644 --- 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 @@ -17,7 +17,7 @@ async function handleRequest(req, res) { } Sentry.captureException(new Error(req.params.request)); - Sentry.logger.info(req.params.request); + Sentry.logger.info('Request handled'); Sentry.metrics.count('request_count', 1); res.send({ ok: true }); setImmediate(() => { From 8106676b000d445c208f5b2241e0cef75e6052fc Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Wed, 14 Oct 2026 12:06:27 +0200 Subject: [PATCH 5/8] fix(node): Re-export Lambda Web integration from dependent SDKs Co-Authored-By: Codex --- packages/astro/src/index.server.ts | 1 + packages/aws-serverless/src/index.ts | 1 + packages/bun/src/index.ts | 1 + packages/google-cloud-serverless/src/index.ts | 1 + 4 files changed, 4 insertions(+) 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, From 9e67f460afd30860584669a1caf997f2643551b0 Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Wed, 14 Oct 2026 12:55:13 +0200 Subject: [PATCH 6/8] test(node): Clarify Lambda Web scenarios and use attribute constants Co-Authored-By: Codex --- .../suites/aws-lambda-web/requests/test.ts | 48 +++++++++------ .../test/integrations/awsLambdaWeb.test.ts | 59 +++++++++++-------- 2 files changed, 63 insertions(+), 44 deletions(-) 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 index 4d6856a26df7..dc23144ef261 100644 --- 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 @@ -1,14 +1,24 @@ +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('isolates concurrent requests and leaves logs and metrics unenriched, with and without tracing', async () => { - for (const { tracing, frameworkRoot } of [ - { tracing: true, frameworkRoot: '' }, - { tracing: false, frameworkRoot: '' }, - { tracing: true, frameworkRoot: 'forked-scope' }, - { tracing: true, frameworkRoot: 'late-op' }, - ]) { + test.each([ + { name: 'default tracing', tracing: true, frameworkRoot: '' }, + { name: 'tracing disabled', tracing: false, frameworkRoot: '' }, + { name: 'forked isolation scope', tracing: true, frameworkRoot: 'forked-scope' }, + { name: 'span operation assigned at span end', tracing: true, frameworkRoot: 'late-op' }, + ])( + '$name: isolates concurrent requests and leaves logs and metrics unenriched', + async ({ tracing, frameworkRoot }) => { const runner = createRunner() .withEnv({ AWS_LAMBDA_HTTP_ENDPOINT: '0.0.0.0:3000', @@ -28,10 +38,10 @@ createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, 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', + [CLOUD_PROVIDER]: 'aws', + [CLOUD_PLATFORM]: 'aws_lambda', + [CLOUD_REGION]: 'us-east-1', + [CLOUD_ACCOUNT_ID]: '123456789012', }, 'aws.lambda': { memory_size: 2_147_483_648, @@ -47,7 +57,7 @@ createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, spans.push({ request, collected: runner.collectStreamedSpansUntilSegment( - span => span.attributes['faas.invocation_id']?.value === request, + span => span.attributes[FAAS_INVOCATION_ID]?.value === request, ), }); } @@ -79,16 +89,16 @@ createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, 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' }, + [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 }, + [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/node/test/integrations/awsLambdaWeb.test.ts b/packages/node/test/integrations/awsLambdaWeb.test.ts index 6375c9e92169..e71b546a8bf9 100644 --- a/packages/node/test/integrations/awsLambdaWeb.test.ts +++ b/packages/node/test/integrations/awsLambdaWeb.test.ts @@ -1,3 +1,12 @@ +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, @@ -55,7 +64,7 @@ describe('awsLambdaWebIntegration', () => { disabled.processSegmentSpan!(span, client); expect(disabled.processEvent!({}, {}, client)).toEqual({}); - expect(span.attributes).toEqual({ 'sentry.origin': 'manual' }); + expect(span.attributes).toEqual({ [SENTRY_ORIGIN]: 'manual' }); }); it('keeps request context isolated and assigns cold start before tracing or sampling', async () => { @@ -103,16 +112,16 @@ describe('awsLambdaWebIntegration', () => { it('preserves request and user context while adding environment metadata', () => { const event = { - contexts: { 'aws.lambda': { aws_request_id: 'request' }, cloud_resource: { 'cloud.region': 'custom' } }, + 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', + [CLOUD_PROVIDER]: 'aws', + [CLOUD_PLATFORM]: 'aws_lambda', + [CLOUD_REGION]: 'custom', + [CLOUD_ACCOUNT_ID]: '123456789012', }, }, }); @@ -153,19 +162,19 @@ describe('awsLambdaWebIntegration', () => { 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 }, + 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', + [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', }); @@ -183,8 +192,8 @@ describe('awsLambdaWebIntegration', () => { 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'); + expect(spanJSON.attributes).not.toHaveProperty([FAAS_INVOCATION_ID]); + expect(spanJSON.attributes).not.toHaveProperty(['aws.lambda.web.endpoint_name']); }); }); @@ -194,7 +203,7 @@ describe('awsLambdaWebIntegration', () => { client.addIntegration(spanStreamingIntegration()); const captured = vi.fn(); client.on('processSpan', span => { - captured(span.is_segment, span.attributes['faas.invocation_id'], span.attributes['cloud.platform']); + captured(span.is_segment, span.attributes[FAAS_INVOCATION_ID], span.attributes[CLOUD_PLATFORM]); }); withIsolationScope(() => { @@ -211,16 +220,16 @@ describe('awsLambdaWebIntegration', () => { }); it('adds static metadata to segment spans outside requests without overwriting attributes', () => { - const span = spanToJSON(new SentrySpan({ name: 'startup', attributes: { 'cloud.region': 'custom-region' } })); + 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', + [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', }); @@ -238,7 +247,7 @@ describe('awsLambdaWebIntegration', () => { integration.processSegmentSpan!(span, client); integration.processEvent!(event, {}, client); - expect(span.attributes).not.toHaveProperty('faas.max_memory'); + expect(span.attributes).not.toHaveProperty(['faas.max_memory']); expect(event.contexts['aws.lambda']).toHaveProperty('memory_size', undefined); }, ); @@ -257,7 +266,7 @@ describe('awsLambdaWebIntegration', () => { }); expect(minimal.processEvent!({}, {}, client)).toEqual({ contexts: { - cloud_resource: { 'cloud.provider': 'aws', 'cloud.platform': 'aws_lambda' }, + cloud_resource: { [CLOUD_PROVIDER]: 'aws', [CLOUD_PLATFORM]: 'aws_lambda' }, 'aws.lambda': {}, }, }); From 94fd6f788baeeb3a1afd64d2e043a7c4aea3b0ca Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Wed, 14 Oct 2026 13:16:50 +0200 Subject: [PATCH 7/8] test(node): Simplify Lambda Web Function fixture Co-Authored-By: Codex --- .../aws-lambda-web/requests/instrument.mjs | 9 +- .../aws-lambda-web/requests/scenario.mjs | 14 +- .../suites/aws-lambda-web/requests/test.ts | 162 +++++++++--------- 3 files changed, 80 insertions(+), 105 deletions(-) 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 index ec519e97d7c5..d5824acab7ba 100644 --- 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 @@ -1,15 +1,8 @@ import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; -const client = Sentry.init({ +Sentry.init({ dsn: 'https://public@example.com/1', tracesSampleRate: process.env.NO_TRACING ? undefined : 1, transport: loggingTransport, - integrations: process.env.FRAMEWORK_ROOT ? [Sentry.httpIntegration({ disableIncomingRequestSpans: true })] : [], }); - -if (process.env.FRAMEWORK_ROOT === 'late-op') { - client.on('spanEnd', span => { - if (Sentry.getRootSpan(span) === span) span.setAttribute('sentry.op', 'http.server'); - }); -} 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 index 3c40c98ee9f1..c3fea461fdd1 100644 --- 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 @@ -8,7 +8,7 @@ const secondRequestStarted = new Promise(resolve => { releaseFirst = resolve; }); -async function handleRequest(req, res) { +app.get('/:request', async (req, res) => { if (req.params.request === 'first') { process.stdout.write('FIRST_STARTED\n'); await secondRequestStarted; @@ -23,18 +23,6 @@ async function handleRequest(req, res) { setImmediate(() => { void Sentry.flush(); }); -} - -app.get('/:request', (req, res) => { - if (process.env.FRAMEWORK_ROOT === 'forked-scope') { - return Sentry.withIsolationScope(() => - Sentry.startSpan({ name: 'GET /:request', op: 'http.server' }, () => handleRequest(req, res)), - ); - } - if (process.env.FRAMEWORK_ROOT === 'late-op') { - return Sentry.startSpan({ name: 'GET /:request' }, () => handleRequest(req, res)); - } - return handleRequest(req, res); }); 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 index dc23144ef261..a83c0145b668 100644 --- 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 @@ -12,93 +12,87 @@ import { createEsmAndCjsTests } from '../../../utils/runner'; createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createRunner, test) => { test.each([ - { name: 'default tracing', tracing: true, frameworkRoot: '' }, - { name: 'tracing disabled', tracing: false, frameworkRoot: '' }, - { name: 'forked isolation scope', tracing: true, frameworkRoot: 'forked-scope' }, - { name: 'span operation assigned at span end', tracing: true, frameworkRoot: 'late-op' }, - ])( - '$name: isolates concurrent requests and leaves logs and metrics unenriched', - async ({ tracing, frameworkRoot }) => { - 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', - FRAMEWORK_ROOT: frameworkRoot, - }) - .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`, - }, + { 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` }, - }, + 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` }, + }, + }); + } + }); }); From c5e823d2e9e6b61443c37fbe8463ad384509039e Mon Sep 17 00:00:00 2001 From: Martin Sonnberger Date: Wed, 14 Oct 2026 13:34:00 +0200 Subject: [PATCH 8/8] test(node): Remove redundant Lambda Web unit tests Co-Authored-By: Codex --- .../test/integrations/awsLambdaWeb.test.ts | 76 +------------------ 1 file changed, 1 insertion(+), 75 deletions(-) diff --git a/packages/node/test/integrations/awsLambdaWeb.test.ts b/packages/node/test/integrations/awsLambdaWeb.test.ts index e71b546a8bf9..1fa875fb05c9 100644 --- a/packages/node/test/integrations/awsLambdaWeb.test.ts +++ b/packages/node/test/integrations/awsLambdaWeb.test.ts @@ -8,16 +8,7 @@ import { SENTRY_ORIGIN, } from '@sentry/conventions/attributes'; import type { Integration } from '@sentry/core'; -import { - getCurrentScope, - getIsolationScope, - SentrySpan, - spanStreamingIntegration, - spanToJSON, - startInactiveSpan, - startSpan, - withIsolationScope, -} 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'; @@ -67,49 +58,6 @@ describe('awsLambdaWebIntegration', () => { expect(span.attributes).toEqual({ [SENTRY_ORIGIN]: 'manual' }); }); - it('keeps request context isolated and assigns cold start before tracing or sampling', async () => { - integration.setup!(client); - let releaseFirst!: () => void; - const barrier = new Promise(resolve => { - releaseFirst = resolve; - }); - const first = withIsolationScope(async scope => { - client.emit( - 'httpServerRequest', - {}, - {}, - { headers: { 'lambda-web-request-id': 'first', 'lambda-web-endpoint-name': 'prod' } }, - ); - await barrier; - return scope.getScopeData(); - }); - const second = withIsolationScope(scope => { - client.emit( - 'httpServerRequest', - {}, - {}, - { headers: { 'lambda-web-request-id': 'second', 'lambda-web-endpoint-name': 'canary' } }, - ); - return scope.getScopeData(); - }); - releaseFirst(); - const firstData = await first; - - expect(firstData.contexts['aws.lambda']).toEqual({ - cold_start: true, - aws_request_id: 'first', - endpoint_name: 'prod', - }); - expect(second.contexts['aws.lambda']).toEqual({ - cold_start: false, - aws_request_id: 'second', - endpoint_name: 'canary', - }); - expect(firstData.attributes).toEqual({}); - expect(second.attributes).toEqual({}); - expect(getIsolationScope().getScopeData().contexts['aws.lambda']).toBeUndefined(); - }); - it('preserves request and user context while adding environment metadata', () => { const event = { contexts: { 'aws.lambda': { aws_request_id: 'request' }, cloud_resource: { [CLOUD_REGION]: 'custom' } }, @@ -197,28 +145,6 @@ describe('awsLambdaWebIntegration', () => { }); }); - it('enriches segment spans without enriching child spans', () => { - client.getOptions().tracesSampleRate = 1; - client.addIntegration(integration); - client.addIntegration(spanStreamingIntegration()); - const captured = vi.fn(); - client.on('processSpan', span => { - captured(span.is_segment, span.attributes[FAAS_INVOCATION_ID], span.attributes[CLOUD_PLATFORM]); - }); - - withIsolationScope(() => { - client.emit('httpServerRequest', {}, {}, { headers: { 'lambda-web-request-id': 'request' } }); - startSpan({ name: 'request' }, () => { - startInactiveSpan({ name: 'child' }).end(); - }); - }); - - expect(captured).toHaveBeenCalledTimes(2); - expect(captured).toHaveBeenNthCalledWith(1, false, undefined, undefined); - expect(captured).toHaveBeenNthCalledWith(2, true, 'request', 'aws_lambda'); - expect(getIsolationScope().getScopeData().contexts['aws.lambda']).toBeUndefined(); - }); - it('adds static metadata to segment spans outside requests without overwriting attributes', () => { const span = spanToJSON(new SentrySpan({ name: 'startup', attributes: { [CLOUD_REGION]: 'custom-region' } }));