Skip to content
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
@@ -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,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,
),
});
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 @@ -91,6 +91,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 @@ -76,6 +76,7 @@ export {
type NodeRuntimeMetricsOptions,
contextLinesIntegration,
nodeContextIntegration,
awsLambdaWebIntegration,
localVariablesIntegration,
requestDataIntegration,
functionToStringIntegration,
Expand Down
1 change: 1 addition & 0 deletions packages/bun/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export {
modulesIntegration,
contextLinesIntegration,
nodeContextIntegration,
awsLambdaWebIntegration,
localVariablesIntegration,
requestDataIntegration,
fsIntegration,
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 @@ -76,6 +76,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
94 changes: 94 additions & 0 deletions packages/node/src/integrations/awsLambdaWeb.ts
Original file line number Diff line number Diff line change
@@ -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,
Comment on lines +29 to +30

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.

};

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);
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