Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit e7beae6

Browse files
authored
feat(aws): Add experimental AWS Lambda extension for tunnelling events (#17525)
This introduces a new experimental Sentry Lambda extension within the existing Sentry Lambda layer. Sentry events are being tunnelled through the extension, where they are then forwarded to Sentry. Initial benchmarks using ApacheBench show a reduction in request processing time of around 26% (from 243ms to 180ms on average over 100 requests; function pre-warmed). To enable it, set `_experiments.enableLambdaExtension` in your Sentry config like this: ```js Sentry.init({ // ...other config dsn: "<YOUR_DSN>", _experiments: { enableLambdaExtension: true } }) ``` closes #12856 relates to #3051
1 parent ef651c5 commit e7beae6

File tree

12 files changed

+416
-9
lines changed

12 files changed

+416
-9
lines changed
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

‎packages/aws-serverless/src/init.ts

Lines changed: 31 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,45 @@ 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) {
40+
if (sdkSource === 'aws-lambda-layer') {
41+
if (!opts.tunnel) {
42+
DEBUG_BUILD && debug.log('Proxying Sentry events through the Sentry Lambda extension');
43+
opts.tunnel = 'http://localhost:9000/envelope';
44+
} else {
45+
DEBUG_BUILD &&
46+
debug.warn(
47+
`Using a custom tunnel with the Sentry Lambda extension is not supported. Events will be tunnelled to ${opts.tunnel} and not through the extension.`,
48+
);
49+
}
50+
} else {
51+
DEBUG_BUILD && debug.warn('The Sentry Lambda extension is only supported when using the AWS Lambda layer.');
52+
}
53+
}
54+
55+
applySdkMetadata(opts, 'aws-serverless', ['aws-serverless'], sdkSource);
2956

3057
return initWithoutDefaultIntegrations(opts);
3158
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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+
60+
/**
61+
* Reports an error to the extension API.
62+
* @param phase The phase of the extension.
63+
* @param err The error to report.
64+
*/
65+
public async error(phase: 'init' | 'exit', err: Error): Promise<never> {
66+
if (!this._extensionId) {
67+
throw new Error('Extension ID is not set');
68+
}
69+
70+
const errorType = `Extension.${err.name || 'UnknownError'}`;
71+
72+
const res = await fetch(`${this._baseUrl}/${phase}/error`, {
73+
method: 'POST',
74+
body: JSON.stringify({
75+
errorMessage: err.message || err.toString(),
76+
errorType,
77+
stackTrace: [err.stack],
78+
}),
79+
headers: {
80+
'Content-Type': 'application/json',
81+
'Lambda-Extension-Identifier': this._extensionId,
82+
'Lambda-Extension-Function-Error': errorType,
83+
},
84+
});
85+
86+
if (!res.ok) {
87+
DEBUG_BUILD && debug.error(`Failed to report error: ${await res.text()}`);
88+
}
89+
90+
throw err;
91+
}
92+
93+
/**
94+
* Starts the Sentry tunnel.
95+
*/
96+
public startSentryTunnel(): void {
97+
const server = http.createServer(async (req, res) => {
98+
if (req.method === 'POST' && req.url?.startsWith('/envelope')) {
99+
try {
100+
const buf = await buffer(req);
101+
// Extract the actual bytes from the Buffer by slicing its underlying ArrayBuffer
102+
// This ensures we get only the data portion without any padding or offset
103+
const envelopeBytes = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
104+
const envelope = new TextDecoder().decode(envelopeBytes);
105+
const piece = envelope.split('\n')[0];
106+
const header = JSON.parse(piece || '{}') as { dsn?: string };
107+
if (!header.dsn) {
108+
throw new Error('DSN is not set');
109+
}
110+
const dsn = dsnFromString(header.dsn);
111+
if (!dsn) {
112+
throw new Error('Invalid DSN');
113+
}
114+
const upstreamSentryUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsn);
115+
116+
fetch(upstreamSentryUrl, {
117+
method: 'POST',
118+
body: envelopeBytes,
119+
}).catch(err => {
120+
DEBUG_BUILD && debug.error('Error sending envelope to Sentry', err);
121+
});
122+
123+
res.writeHead(200, { 'Content-Type': 'application/json' });
124+
res.end(JSON.stringify({}));
125+
} catch (e) {
126+
DEBUG_BUILD && debug.error('Error tunneling to Sentry', e);
127+
res.writeHead(500, { 'Content-Type': 'application/json' });
128+
res.end(JSON.stringify({ error: 'Error tunneling to Sentry' }));
129+
}
130+
} else {
131+
res.writeHead(404, { 'Content-Type': 'application/json' });
132+
res.end(JSON.stringify({ error: 'Not found' }));
133+
}
134+
});
135+
136+
server.listen(9000, () => {
137+
DEBUG_BUILD && debug.log('Sentry proxy listening on port 9000');
138+
});
139+
140+
server.on('error', err => {
141+
DEBUG_BUILD && debug.error('Error starting Sentry proxy', err);
142+
process.exit(1);
143+
});
144+
}
145+
}
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)

AltStyle によって変換されたページ (->オリジナル) /