Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 7 additions & 2 deletions dev-packages/deno-integration-tests/node-suites/excludes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const DEPENDENTS: Dependent[] = [
exports: Object.keys(SentryBun),
ignoreExports: [
// not supported in bun:
'awsLambdaWebIntegration',
'NodeClient',
'NODE_VERSION',
'childProcessIntegration',
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
});
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
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_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,
),
});
Comment thread
cursor[bot] marked this conversation as resolved.
}
}
const assertUnenriched = (items: { attributes?: Record<string, unknown> }[]): 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` },
},
});
}
});
});
1 change: 1 addition & 0 deletions packages/astro/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export {
nativeNodeFetchIntegration,
NodeClient,
nodeContextIntegration,
awsLambdaWebIntegration,
onUncaughtExceptionIntegration,
onUnhandledRejectionIntegration,
mistralAIIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/aws-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export {
type NodeRuntimeMetricsOptions,
contextLinesIntegration,
nodeContextIntegration,
awsLambdaWebIntegration,
localVariablesIntegration,
requestDataIntegration,
functionToStringIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/google-cloud-serverless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export {
type NodeRuntimeMetricsOptions,
contextLinesIntegration,
nodeContextIntegration,
awsLambdaWebIntegration,
localVariablesIntegration,
requestDataIntegration,
fsIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/node/src/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
66 changes: 66 additions & 0 deletions packages/node/src/integrations/awsLambdaWeb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { FAAS_COLDSTART, FAAS_INVOCATION_ID } from '@sentry/conventions/attributes';
import type { IntegrationFn, SpanAttributes } from '@sentry/core';
import { defineIntegration, getIsolationScope, safeSetSpanJSONAttributes } from '@sentry/core';

let requestHandled = false;

const _awsLambdaWebIntegration = (() => {
const enabled = !!process.env.AWS_LAMBDA_HTTP_ENDPOINT;
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,
Comment on lines +15 to +16

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we already have those attributes in conventions?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Def wanna make these not hard-coded once that lands and probably, wait on this until then. If we need to land this first for some reason, then we should open a tracking issue to make sure it's not forgotten.

};

return {
name: 'AwsLambdaWeb',
setup(client) {
if (!enabled) return;

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) {
if (!enabled) return event;
event.contexts = {
...event.contexts,
'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);
8 changes: 5 additions & 3 deletions packages/node/src/integrations/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { readdir, readFile } from 'node:fs';
import * as os from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { CLOUD_PLATFORM, CLOUD_PROVIDER, CLOUD_REGION } from '@sentry/conventions/attributes';
import type {
AppContext,
CloudResourceContext,
Expand Down Expand Up @@ -524,10 +525,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_PROVIDER]: 'aws',
[CLOUD_REGION]: process.env.AWS_REGION,
[CLOUD_PLATFORM]: executionEnv?.startsWith('AWS_Lambda') ? 'aws_lambda' : executionEnv,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent cloud.platform value change

Medium Severity

nodeContextIntegration now writes cloud.platform as aws_lambda whenever AWS_EXECUTION_ENV starts with AWS_Lambda, instead of the previous raw value such as AWS_Lambda_nodejs24.x. This applies to every Node Lambda app, not only Web Functions, so existing queries and alerts keyed on the old value can stop matching, and the diff has no migration note. Flagged because the review rules call out silent payload changes without a migration callout.

Fix in Cursor Fix in Web

Triggered by project rule: PR Review Guidelines for Cursor Bot

Reviewed by Cursor Bugbot for commit bf3b43e. Configure here.

};
} else if (process.env.GCP_PROJECT) {
// https://cloud.google.com/composer/docs/how-to/managing/environment-variables#reserved_variables
Expand Down
2 changes: 2 additions & 0 deletions packages/node/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -66,6 +67,7 @@ function getBaseDefaultIntegrations(): Integration[] {
// Event Info
contextLinesIntegration(),
localVariablesIntegration(),
awsLambdaWebIntegration(),
nodeContextIntegration(),
childProcessIntegration(),
workerThreadsIntegration(),
Expand Down
Loading
Loading