Skip to content

Commit bddcd78

Browse files
authored
feat(instrumentation-aws-lambda): add runtime-aware handler support for Node.js 24 compatibility (#3255)
Fixes: #3251
1 parent d5074e1 commit bddcd78

File tree

6 files changed

+280
-242
lines changed

6 files changed

+280
-242
lines changed

packages/instrumentation-aws-lambda/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
<!-- markdownlint-disable MD007 MD034 -->
22
# Changelog
33

4+
## [Unreleased]
5+
6+
### Features
7+
8+
* **instrumentation-aws-lambda:** Added runtime-aware handler support. The instrumentation automatically detects the Node.js runtime version from `AWS_EXECUTION_ENV` and adapts handler signatures accordingly:
9+
* **Node.js 24+**: Only Promise-based handlers are supported (callbacks deprecated by AWS Lambda)
10+
* **Node.js 22 and lower**: Both callback-based and Promise-based handlers are supported for backward compatibility
11+
This ensures seamless operation across different Node.js runtime versions while respecting AWS Lambda's removal of callbacks in Node.js 24+.
12+
413
## [0.60.1](https://github.com/open-telemetry/opentelemetry-js-contrib/compare/instrumentation-aws-lambda-v0.60.0...instrumentation-aws-lambda-v0.60.1) (2025-11-24)
514

615

packages/instrumentation-aws-lambda/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,33 @@ npm install --save @opentelemetry/instrumentation-aws-lambda
2323

2424
- This package will instrument the lambda execution regardless of versions.
2525

26+
## Important Notes
27+
28+
### Handler Types Supported
29+
30+
This instrumentation automatically detects the Node.js runtime version and supports handlers accordingly:
31+
32+
- **Node.js 24+**: Only Promise-based handlers are supported (callbacks are deprecated by AWS Lambda)
33+
- **Node.js 22 and lower**: Both callback-based and Promise-based handlers are supported for backward compatibility
34+
35+
The instrumentation detects the runtime version from the `AWS_EXECUTION_ENV` environment variable and adapts the handler signature accordingly. For Node.js 24+, the handler signature is `(event, context)`, while for Node.js 22 and lower, it supports both `(event, context, callback)` and `(event, context)`.
36+
37+
Example handlers:
38+
39+
```js
40+
// Callback-based handler (Node.js 22 and lower only)
41+
exports.handler = function(event, context, callback) {
42+
callback(null, 'ok');
43+
};
44+
45+
// Promise-based handler (all Node.js versions)
46+
export const handler = async (event, context) => {
47+
return 'ok';
48+
};
49+
```
50+
51+
**Note**: AWS Lambda has removed callback-based handlers in Node.js 24 runtime. It's required to migrate to Promise-based handlers when upgrading to Node.js 24+.
52+
2653
## Usage
2754

2855
Create a file to initialize the instrumentation, such as `lambda-wrapper.js`.

packages/instrumentation-aws-lambda/src/instrumentation.ts

Lines changed: 119 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,26 @@ export const AWS_HANDLER_STREAMING_SYMBOL = Symbol.for(
7171
);
7272
export const AWS_HANDLER_STREAMING_RESPONSE = 'response';
7373

74+
/**
75+
* Determines if callback-based handlers are supported based on the Node.js runtime version.
76+
* Returns true if callbacks are supported (Node.js < 24).
77+
* Returns false if AWS_EXECUTION_ENV is not set or doesn't match the expected format.
78+
*/
79+
function isSupportingCallbacks(): boolean {
80+
const executionEnv = process.env.AWS_EXECUTION_ENV;
81+
if (!executionEnv) {
82+
return false;
83+
}
84+
85+
// AWS_EXECUTION_ENV format: AWS_Lambda_nodejs24.x, AWS_Lambda_nodejs22.x, etc.
86+
const match = executionEnv.match(/AWS_Lambda_nodejs(\d+)\./);
87+
if (match && match[1]) {
88+
return parseInt(match[1], 10) < 24;
89+
}
90+
91+
return false;
92+
}
93+
7494
export class AwsLambdaInstrumentation extends InstrumentationBase<AwsLambdaInstrumentationConfig> {
7595
private declare _traceForceFlusher?: () => Promise<void>;
7696
private declare _metricForceFlusher?: () => Promise<void>;
@@ -272,47 +292,104 @@ export class AwsLambdaInstrumentation extends InstrumentationBase<AwsLambdaInstr
272292
};
273293
}
274294

275-
return function patchedHandler(
276-
this: never,
277-
// The event can be a user type, it truly is any.
278-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
279-
event: any,
280-
context: Context,
281-
callback: Callback
282-
) {
283-
_onRequest();
284-
285-
const parent = plugin._determineParent(event, context);
286-
287-
const span = plugin._createSpanForRequest(
288-
event,
289-
context,
290-
requestIsColdStart,
291-
parent
292-
);
295+
// Determine whether to support callbacks based on runtime version
296+
const supportsCallbacks = isSupportingCallbacks();
293297

294-
plugin._applyRequestHook(span, event, context);
295-
296-
return otelContext.with(trace.setSpan(parent, span), () => {
297-
// Lambda seems to pass a callback even if handler is of Promise form, so we wrap all the time before calling
298-
// the handler and see if the result is a Promise or not. In such a case, the callback is usually ignored. If
299-
// the handler happened to both call the callback and complete a returned Promise, whichever happens first will
300-
// win and the latter will be ignored.
301-
const wrappedCallback = plugin._wrapCallback(callback, span);
302-
const maybePromise = safeExecuteInTheMiddle(
303-
() => original.apply(this, [event, context, wrappedCallback]),
304-
error => {
305-
if (error != null) {
306-
// Exception thrown synchronously before resolving callback / promise.
307-
plugin._applyResponseHook(span, error);
308-
plugin._endSpan(span, error, () => {});
309-
}
298+
if (supportsCallbacks) {
299+
// Node.js 22 and lower: Support callback-based handlers for backward compatibility
300+
return function patchedHandlerWithCallback(
301+
this: never,
302+
// The event can be a user type, it truly is any.
303+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
304+
event: any,
305+
context: Context,
306+
callback?: Callback
307+
) {
308+
_onRequest();
309+
310+
const parent = plugin._determineParent(event, context);
311+
312+
const span = plugin._createSpanForRequest(
313+
event,
314+
context,
315+
requestIsColdStart,
316+
parent
317+
);
318+
319+
plugin._applyRequestHook(span, event, context);
320+
321+
return otelContext.with(trace.setSpan(parent, span), () => {
322+
// Support both callback-based and Promise-based handlers for backward compatibility
323+
if (callback) {
324+
const wrappedCallback = plugin._wrapCallback(callback, span);
325+
const maybePromise = safeExecuteInTheMiddle(
326+
() => original.apply(this, [event, context, wrappedCallback]),
327+
error => {
328+
if (error != null) {
329+
// Exception thrown synchronously before resolving callback / promise.
330+
plugin._applyResponseHook(span, error);
331+
plugin._endSpan(span, error, () => {});
332+
}
333+
}
334+
) as Promise<{}> | undefined;
335+
336+
return plugin._handlePromiseResult(span, maybePromise);
337+
} else {
338+
// Promise-based handler
339+
const maybePromise = safeExecuteInTheMiddle(
340+
() => (original as (event: any, context: Context) => Promise<any> | any).apply(this, [event, context]),
341+
error => {
342+
if (error != null) {
343+
// Exception thrown synchronously before resolving promise.
344+
plugin._applyResponseHook(span, error);
345+
plugin._endSpan(span, error, () => {});
346+
}
347+
}
348+
) as Promise<{}> | undefined;
349+
350+
return plugin._handlePromiseResult(span, maybePromise);
310351
}
311-
) as Promise<{}> | undefined;
352+
});
353+
};
354+
} else {
355+
// Node.js 24+: Only Promise-based handlers (callbacks deprecated)
356+
return function patchedHandler(
357+
this: never,
358+
// The event can be a user type, it truly is any.
359+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
360+
event: any,
361+
context: Context
362+
) {
363+
_onRequest();
312364

313-
return plugin._handlePromiseResult(span, maybePromise);
314-
});
315-
};
365+
const parent = plugin._determineParent(event, context);
366+
367+
const span = plugin._createSpanForRequest(
368+
event,
369+
context,
370+
requestIsColdStart,
371+
parent
372+
);
373+
374+
plugin._applyRequestHook(span, event, context);
375+
376+
return otelContext.with(trace.setSpan(parent, span), () => {
377+
// Promise-based handler only (Node.js 24+)
378+
const maybePromise = safeExecuteInTheMiddle(
379+
() => (original as (event: any, context: Context) => Promise<any> | any).apply(this, [event, context]),
380+
error => {
381+
if (error != null) {
382+
// Exception thrown synchronously before resolving promise.
383+
plugin._applyResponseHook(span, error);
384+
plugin._endSpan(span, error, () => {});
385+
}
386+
}
387+
) as Promise<{}> | undefined;
388+
389+
return plugin._handlePromiseResult(span, maybePromise);
390+
});
391+
};
392+
}
316393
}
317394

318395
private _createSpanForRequest(
@@ -443,11 +520,11 @@ export class AwsLambdaInstrumentation extends InstrumentationBase<AwsLambdaInstr
443520
private _wrapCallback(original: Callback, span: Span): Callback {
444521
const plugin = this;
445522
return function wrappedCallback(this: never, err, res) {
446-
diag.debug('executing wrapped lookup callback function');
523+
plugin._diag.debug('executing wrapped callback function');
447524
plugin._applyResponseHook(span, err, res);
448525

449526
plugin._endSpan(span, err, () => {
450-
diag.debug('executing original lookup callback function');
527+
plugin._diag.debug('executing original callback function');
451528
return original.apply(this, [err, res]);
452529
});
453530
};
@@ -482,14 +559,14 @@ export class AwsLambdaInstrumentation extends InstrumentationBase<AwsLambdaInstr
482559
flushers.push(this._traceForceFlusher());
483560
} else {
484561
diag.debug(
485-
'Spans may not be exported for the lambda function because we are not force flushing before callback.'
562+
'Spans may not be exported for the lambda function because we are not force flushing before handler completion.'
486563
);
487564
}
488565
if (this._metricForceFlusher) {
489566
flushers.push(this._metricForceFlusher());
490567
} else {
491568
diag.debug(
492-
'Metrics may not be exported for the lambda function because we are not force flushing before callback.'
569+
'Metrics may not be exported for the lambda function because we are not force flushing before handler completion.'
493570
);
494571
}
495572

packages/instrumentation-aws-lambda/test/integrations/lambda-handler.force-flush.test.ts

Lines changed: 5 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -100,19 +100,7 @@ describe('force flush', () => {
100100
provider.forceFlush = forceFlush;
101101
initializeHandlerTracing('lambda-test/sync.handler', provider);
102102

103-
await new Promise((resolve, reject) => {
104-
lambdaRequire('lambda-test/sync').handler(
105-
'arg',
106-
ctx,
107-
(err: Error, res: any) => {
108-
if (err) {
109-
reject(err);
110-
} else {
111-
resolve(res);
112-
}
113-
}
114-
);
115-
});
103+
await lambdaRequire('lambda-test/sync').handler('arg', ctx);
116104

117105
assert.strictEqual(forceFlushed, true);
118106
});
@@ -133,19 +121,7 @@ describe('force flush', () => {
133121
nodeTracerProvider.forceFlush = forceFlush;
134122
initializeHandlerTracing('lambda-test/sync.handler', provider);
135123

136-
await new Promise((resolve, reject) => {
137-
lambdaRequire('lambda-test/sync').handler(
138-
'arg',
139-
ctx,
140-
(err: Error, res: any) => {
141-
if (err) {
142-
reject(err);
143-
} else {
144-
resolve(res);
145-
}
146-
}
147-
);
148-
});
124+
await lambdaRequire('lambda-test/sync').handler('arg', ctx);
149125

150126
assert.strictEqual(forceFlushed, true);
151127
});
@@ -165,24 +141,12 @@ describe('force flush', () => {
165141
provider.forceFlush = forceFlush;
166142
initializeHandlerMetrics('lambda-test/sync.handler', provider);
167143

168-
await new Promise((resolve, reject) => {
169-
lambdaRequire('lambda-test/sync').handler(
170-
'arg',
171-
ctx,
172-
(err: Error, res: any) => {
173-
if (err) {
174-
reject(err);
175-
} else {
176-
resolve(res);
177-
}
178-
}
179-
);
180-
});
144+
await lambdaRequire('lambda-test/sync').handler('arg', ctx);
181145

182146
assert.strictEqual(forceFlushed, true);
183147
});
184148

185-
it('should callback once after force flush providers', async () => {
149+
it('should complete handler after force flush providers', async () => {
186150
const nodeTracerProvider = new NodeTracerProvider({
187151
spanProcessors: [new BatchSpanProcessor(traceMemoryExporter)],
188152
});
@@ -216,24 +180,9 @@ describe('force flush', () => {
216180
instrumentation.setTracerProvider(tracerProvider);
217181
instrumentation.setMeterProvider(meterProvider);
218182

219-
let callbackCount = 0;
220-
await new Promise((resolve, reject) => {
221-
lambdaRequire('lambda-test/sync').handler(
222-
'arg',
223-
ctx,
224-
(err: Error, res: any) => {
225-
callbackCount++;
226-
if (err) {
227-
reject(err);
228-
} else {
229-
resolve(res);
230-
}
231-
}
232-
);
233-
});
183+
await lambdaRequire('lambda-test/sync').handler('arg', ctx);
234184

235185
assert.strictEqual(tracerForceFlushed, true);
236186
assert.strictEqual(meterForceFlushed, true);
237-
assert.strictEqual(callbackCount, 1);
238187
});
239188
});

0 commit comments

Comments
 (0)