Skip to content

Commit d98074a

Browse files
committed
feat(aws): Add AWS Lambda extension
1 parent 9e70a5a commit d98074a

File tree

12 files changed

+403
-9
lines changed

12 files changed

+403
-9
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as Sentry from '@sentry/aws-serverless';
2+
3+
Sentry.init({
4+
dsn: process.env.SENTRY_DSN,
5+
tracesSampleRate: 1,
6+
debug: true,
7+
_experiments: {
8+
enableLambdaExtension: true,
9+
},
10+
});
11+
12+
export const handler = async (event, context) => {
13+
Sentry.startSpan({ name: 'manual-span', op: 'test' }, async () => {
14+
return 'Hello, world!';
15+
});
16+
};

dev-packages/e2e-tests/test-applications/aws-serverless/tests/layer.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,4 +242,52 @@ test.describe('Lambda layer', () => {
242242
}),
243243
);
244244
});
245+
246+
test('experimental extension works', async ({ lambdaClient }) => {
247+
const transactionEventPromise = waitForTransaction('aws-serverless-lambda-sam', transactionEvent => {
248+
return transactionEvent?.transaction === 'LayerExperimentalExtension';
249+
});
250+
251+
await lambdaClient.send(
252+
new InvokeCommand({
253+
FunctionName: 'LayerExperimentalExtension',
254+
Payload: JSON.stringify({}),
255+
}),
256+
);
257+
258+
const transactionEvent = await transactionEventPromise;
259+
260+
expect(transactionEvent.transaction).toEqual('LayerExperimentalExtension');
261+
expect(transactionEvent.contexts?.trace).toEqual({
262+
data: {
263+
'sentry.sample_rate': 1,
264+
'sentry.source': 'custom',
265+
'sentry.origin': 'auto.otel.aws-lambda',
266+
'sentry.op': 'function.aws.lambda',
267+
'cloud.account.id': '012345678912',
268+
'faas.execution': expect.any(String),
269+
'faas.id': 'arn:aws:lambda:us-east-1:012345678912:function:LayerExperimentalExtension',
270+
'faas.coldstart': true,
271+
'otel.kind': 'SERVER',
272+
},
273+
op: 'function.aws.lambda',
274+
origin: 'auto.otel.aws-lambda',
275+
span_id: expect.stringMatching(/[a-f0-9]{16}/),
276+
status: 'ok',
277+
trace_id: expect.stringMatching(/[a-f0-9]{32}/),
278+
});
279+
280+
expect(transactionEvent.spans).toHaveLength(1);
281+
282+
expect(transactionEvent.spans).toContainEqual(
283+
expect.objectContaining({
284+
data: expect.objectContaining({
285+
'sentry.op': 'test',
286+
'sentry.origin': 'manual',
287+
}),
288+
description: 'manual-span',
289+
op: 'test',
290+
}),
291+
);
292+
});
245293
});

dev-packages/rollup-utils/bundleHelpers.mjs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export function makeBaseBundleConfig(options) {
5353
},
5454
},
5555
context: 'window',
56-
plugins: [rrwebBuildPlugin, markAsBrowserBuildPlugin],
56+
plugins: [rrwebBuildPlugin, markAsBrowserBuildPlugin, licensePlugin],
5757
};
5858

5959
// used by `@sentry/wasm` & pluggable integrations from core/browser (bundles which need to be combined with a stand-alone SDK bundle)
@@ -87,14 +87,23 @@ export function makeBaseBundleConfig(options) {
8787
// code to add after the CJS wrapper
8888
footer: '}(window));',
8989
},
90-
plugins: [rrwebBuildPlugin, markAsBrowserBuildPlugin],
90+
plugins: [rrwebBuildPlugin, markAsBrowserBuildPlugin, licensePlugin],
9191
};
9292

9393
const workerBundleConfig = {
9494
output: {
9595
format: 'esm',
9696
},
97-
plugins: [commonJSPlugin, makeTerserPlugin()],
97+
plugins: [commonJSPlugin, makeTerserPlugin(), licensePlugin],
98+
// Don't bundle any of Node's core modules
99+
external: builtinModules,
100+
};
101+
102+
const awsLambdaExtensionBundleConfig = {
103+
output: {
104+
format: 'esm',
105+
},
106+
plugins: [commonJSPlugin, makeIsDebugBuildPlugin(true), makeTerserPlugin()],
98107
// Don't bundle any of Node's core modules
99108
external: builtinModules,
100109
};
@@ -110,14 +119,15 @@ export function makeBaseBundleConfig(options) {
110119
strict: false,
111120
esModule: false,
112121
},
113-
plugins: [sucrasePlugin, nodeResolvePlugin, cleanupPlugin, licensePlugin],
122+
plugins: [sucrasePlugin, nodeResolvePlugin, cleanupPlugin],
114123
treeshake: 'smallest',
115124
};
116125

117126
const bundleTypeConfigMap = {
118127
standalone: standAloneBundleConfig,
119128
addon: addOnBundleConfig,
120129
'node-worker': workerBundleConfig,
130+
'lambda-extension': awsLambdaExtensionBundleConfig,
121131
};
122132

123133
return deepMerge.all([sharedBundleConfig, bundleTypeConfigMap[bundleType], packageSpecificConfig || {}], {

packages/aws-serverless/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979
},
8080
"scripts": {
8181
"build": "run-p build:transpile build:types",
82-
"build:layer": "yarn ts-node scripts/buildLambdaLayer.ts",
82+
"build:layer": "rollup -c rollup.lambda-extension.config.mjs && yarn ts-node scripts/buildLambdaLayer.ts",
8383
"build:dev": "run-p build:transpile build:types",
8484
"build:transpile": "rollup -c rollup.npm.config.mjs && yarn build:layer",
8585
"build:types": "run-s build:types:core build:types:downlevel",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { makeBaseBundleConfig } from '@sentry-internal/rollup-utils';
2+
3+
export default [
4+
makeBaseBundleConfig({
5+
bundleType: 'lambda-extension',
6+
entrypoints: ['src/lambda-extension/index.ts'],
7+
outputFileBase: 'index.mjs',
8+
packageSpecificConfig: {
9+
output: {
10+
dir: 'build/aws/dist-serverless/sentry-extension',
11+
sourcemap: false,
12+
},
13+
},
14+
}),
15+
];

packages/aws-serverless/scripts/buildLambdaLayer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ async function buildLambdaLayer(): Promise<void> {
4545

4646
replaceSDKSource();
4747

48+
fsForceMkdirSync('./build/aws/dist-serverless/extensions');
49+
fs.copyFileSync('./src/lambda-extension/sentry-extension', './build/aws/dist-serverless/extensions/sentry-extension');
50+
fs.chmodSync('./build/aws/dist-serverless/extensions/sentry-extension', 0o755);
51+
fs.chmodSync('./build/aws/dist-serverless/sentry-extension/index.mjs', 0o755);
52+
4853
const zipFilename = `sentry-node-serverless-${version}.zip`;
4954
console.log(`Creating final layer zip file ${zipFilename}.`);
5055
// need to preserve the symlink above with -y
Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { Integration, Options } from '@sentry/core';
2-
import { applySdkMetadata, getSDKSource } from '@sentry/core';
2+
import { applySdkMetadata, debug, getSDKSource } from '@sentry/core';
33
import type { NodeClient, NodeOptions } from '@sentry/node';
44
import { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations } from '@sentry/node';
5+
import { DEBUG_BUILD } from './debug-build';
56
import { awsIntegration } from './integration/aws';
67
import { awsLambdaIntegration } from './integration/awslambda';
7-
88
/**
99
* Get the default integrations for the AWSLambda SDK.
1010
*/
@@ -14,18 +14,34 @@ export function getDefaultIntegrations(_options: Options): Integration[] {
1414
return [...getDefaultIntegrationsWithoutPerformance(), awsIntegration(), awsLambdaIntegration()];
1515
}
1616

17+
export interface AwsServerlessOptions extends NodeOptions {
18+
_experiments?: NodeOptions['_experiments'] & {
19+
/**
20+
* If proxying Sentry events through the Sentry Lambda extension should be enabled.
21+
*/
22+
enableLambdaExtension?: boolean;
23+
};
24+
}
25+
1726
/**
1827
* Initializes the Sentry AWS Lambda SDK.
1928
*
2029
* @param options Configuration options for the SDK, @see {@link AWSLambdaOptions}.
2130
*/
22-
export function init(options: NodeOptions = {}): NodeClient | undefined {
31+
export function init(options: AwsServerlessOptions = {}): NodeClient | undefined {
2332
const opts = {
2433
defaultIntegrations: getDefaultIntegrations(options),
2534
...options,
2635
};
2736

28-
applySdkMetadata(opts, 'aws-serverless', ['aws-serverless'], getSDKSource());
37+
const sdkSource = getSDKSource();
38+
39+
if (opts._experiments?.enableLambdaExtension && sdkSource === 'aws-lambda-layer' && !opts.tunnel) {
40+
DEBUG_BUILD && debug.log('Proxying Sentry events through the Sentry Lambda extension');
41+
opts.tunnel = 'http://localhost:9000/envelope';
42+
}
43+
44+
applySdkMetadata(opts, 'aws-serverless', ['aws-serverless'], sdkSource);
2945

3046
return initWithoutDefaultIntegrations(opts);
3147
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import * as http from 'node:http';
2+
import { buffer } from 'node:stream/consumers';
3+
import { debug, dsnFromString, getEnvelopeEndpointWithUrlEncodedAuth } from '@sentry/core';
4+
import { DEBUG_BUILD } from './debug-build';
5+
6+
/**
7+
* The Extension API Client.
8+
*/
9+
export class AwsLambdaExtension {
10+
private readonly _baseUrl: string;
11+
private _extensionId: string | null;
12+
13+
public constructor() {
14+
this._baseUrl = `http://${process.env.AWS_LAMBDA_RUNTIME_API}/2020-01-01/extension`;
15+
this._extensionId = null;
16+
}
17+
18+
/**
19+
* Register this extension as an external extension with AWS.
20+
*/
21+
public async register(): Promise<void> {
22+
const res = await fetch(`${this._baseUrl}/register`, {
23+
method: 'POST',
24+
body: JSON.stringify({
25+
events: ['INVOKE', 'SHUTDOWN'],
26+
}),
27+
headers: {
28+
'Content-Type': 'application/json',
29+
'Lambda-Extension-Name': 'sentry-extension',
30+
},
31+
});
32+
33+
if (!res.ok) {
34+
throw new Error(`Failed to register with the extension API: ${await res.text()}`);
35+
}
36+
37+
this._extensionId = res.headers.get('lambda-extension-identifier');
38+
}
39+
40+
/**
41+
* Advances the extension to the next event.
42+
*/
43+
public async next(): Promise<void> {
44+
if (!this._extensionId) {
45+
throw new Error('Extension ID is not set');
46+
}
47+
48+
const res = await fetch(`${this._baseUrl}/event/next`, {
49+
headers: {
50+
'Lambda-Extension-Identifier': this._extensionId,
51+
'Content-Type': 'application/json',
52+
},
53+
});
54+
55+
if (!res.ok) {
56+
throw new Error(`Failed to advance to next event: ${await res.text()}`);
57+
}
58+
59+
const event = (await res.json()) as { eventType: string };
60+
61+
if (event.eventType === 'SHUTDOWN') {
62+
await new Promise(resolve => setTimeout(resolve, 1000));
63+
}
64+
}
65+
66+
/**
67+
* Reports an error to the extension API.
68+
* @param phase The phase of the extension.
69+
* @param err The error to report.
70+
*/
71+
public async error(phase: 'init' | 'exit', err: Error): Promise<never> {
72+
if (!this._extensionId) {
73+
throw new Error('Extension ID is not set');
74+
}
75+
76+
const errorType = `Extension.${err.name || 'UnknownError'}`;
77+
78+
const res = await fetch(`${this._baseUrl}/${phase}/error`, {
79+
method: 'POST',
80+
body: JSON.stringify({
81+
errorMessage: err.message || err.toString(),
82+
errorType,
83+
stackTrace: [err.stack],
84+
}),
85+
headers: {
86+
'Content-Type': 'application/json',
87+
'Lambda-Extension-Identifier': this._extensionId,
88+
'Lambda-Extension-Function-Error': errorType,
89+
},
90+
});
91+
92+
if (!res.ok) {
93+
DEBUG_BUILD && debug.error(`Failed to report error: ${await res.text()}`);
94+
}
95+
96+
throw err;
97+
}
98+
99+
/**
100+
* Starts the Sentry tunnel.
101+
*/
102+
public startSentryTunnel(): void {
103+
const server = http.createServer(async (req, res) => {
104+
if (req.url?.startsWith('/envelope')) {
105+
try {
106+
const buf = await buffer(req);
107+
// Extract the actual bytes from the Buffer by slicing its underlying ArrayBuffer
108+
// This ensures we get only the data portion without any padding or offset
109+
const envelopeBytes = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
110+
const envelope = new TextDecoder().decode(envelopeBytes);
111+
const piece = envelope.split('\n')[0];
112+
const header = JSON.parse(piece ?? '{}') as { dsn?: string };
113+
if (!header.dsn) {
114+
throw new Error('DSN is not set');
115+
}
116+
const dsn = dsnFromString(header.dsn);
117+
if (!dsn) {
118+
throw new Error('Invalid DSN');
119+
}
120+
const upstreamSentryUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsn);
121+
122+
fetch(upstreamSentryUrl, {
123+
method: 'POST',
124+
body: envelopeBytes,
125+
}).catch(err => {
126+
DEBUG_BUILD && debug.error('Error sending envelope to Sentry', err);
127+
});
128+
129+
res.writeHead(200, { 'Content-Type': 'application/json' });
130+
res.end(JSON.stringify({}));
131+
} catch (e) {
132+
DEBUG_BUILD && debug.error('Error tunneling to Sentry', e);
133+
res.writeHead(500, { 'Content-Type': 'application/json' });
134+
res.end(JSON.stringify({ error: 'Error tunneling to Sentry' }));
135+
}
136+
}
137+
});
138+
139+
server.listen(9000, () => {
140+
DEBUG_BUILD && debug.log('Sentry proxy listening on port 9000');
141+
});
142+
}
143+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
declare const __DEBUG_BUILD__: boolean;
2+
3+
/**
4+
* This serves as a build time flag that will be true by default, but false in non-debug builds or if users replace `__SENTRY_DEBUG__` in their generated code.
5+
*
6+
* ATTENTION: This constant must never cross package boundaries (i.e. be exported) to guarantee that it can be used for tree shaking.
7+
*/
8+
export const DEBUG_BUILD = __DEBUG_BUILD__;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env node
2+
import { debug } from '@sentry/core';
3+
import { AwsLambdaExtension } from './aws-lambda-extension';
4+
import { DEBUG_BUILD } from './debug-build';
5+
6+
async function main(): Promise<void> {
7+
const extension = new AwsLambdaExtension();
8+
9+
await extension.register();
10+
11+
extension.startSentryTunnel();
12+
13+
// eslint-disable-next-line no-constant-condition
14+
while (true) {
15+
await extension.next();
16+
}
17+
}
18+
19+
main().catch(err => {
20+
DEBUG_BUILD && debug.error('Error in Lambda Extension', err);
21+
});

0 commit comments

Comments
 (0)