Skip to content

Commit 2df6d5d

Browse files
authored
Patch aws-lambda instrumentation to support ESM (#101)
*Description of changes:* 1. Add `IS_ESM` check in `otel_instrument` wrapper before calling lambda handler. If it is EMS format, add ESM node-options [supported](https://github.com/open-telemetry/opentelemetry-js/blob/966ac176af249d86de6cb10feac2306062846768/doc/esm-support.md#esm-options-for-different-versions-of-nodejs) by OTel community. Added a new wrapper script `otel_instrument_esm` if the esm auto detection logic is failed, customer can opt-in to tell tell layer go with ESM instrumentation path. 2. Set a new env var `HANDLER_IS_ESM` for lambda function when ESM is detected 3. Patch aws-lambda instrumentation, when `IS_ESM` env var is set, apply ESM supported `InstrumentationNodeModuleDefinition` to patch function handler, otherwise keep using the existing handler patcher. Note: this change add a new branch for supporting ESM, the existing CommonJS path should not be impacted. 1. open-telemetry/opentelemetry-js#4842 2. open-telemetry/opentelemetry-js-contrib#1942 *Test* ``` 2024-10-09T20:38:13.411-07:00 | Instrumenting lambda handler { -- | --   | 2024-10-09T20:38:13.411-07:00 | taskRoot: '/var/task',   | 2024-10-09T20:38:13.411-07:00 | handlerDef: 'index.handler',   | 2024-10-09T20:38:13.411-07:00 | handler: 'index.handler',   | 2024-10-09T20:38:13.411-07:00 | moduleRoot: '',   | 2024-10-09T20:38:13.411-07:00 | module: 'index',   | 2024-10-09T20:38:13.411-07:00 | filename: '/var/task/index.mjs',   | 2024-10-09T20:38:13.411-07:00 | functionName: 'handler'   | 2024-10-09T20:38:13.411-07:00 | }   | 2024-10-09T20:38:15.386-07:00 | 'cloud.account.id': '252610625673', -- | -- | --   | 2024-10-09T20:38:15.386-07:00 | 'aws.is.local.root': true,   | 2024-10-09T20:38:15.386-07:00 | 'aws.local.service': 'TestESM',   | 2024-10-09T20:38:15.386-07:00 | 'aws.local.operation': 'TestESM/Handler',   | 2024-10-09T20:38:15.386-07:00 | 'aws.span.kind': 'LOCAL_ROOT' ``` By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
1 parent 032677f commit 2df6d5d

File tree

5 files changed

+364
-3
lines changed

5 files changed

+364
-3
lines changed
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
// Modifications Copyright The OpenTelemetry Authors. Licensed under the Apache License 2.0 License.
4+
5+
import { AwsLambdaInstrumentation } from '@opentelemetry/instrumentation-aws-lambda';
6+
import * as path from 'path';
7+
import * as fs from 'fs';
8+
import {
9+
InstrumentationNodeModuleDefinition,
10+
InstrumentationNodeModuleFile,
11+
isWrapped,
12+
} from '@opentelemetry/instrumentation';
13+
import { diag } from '@opentelemetry/api';
14+
15+
export class AwsLambdaInstrumentationPatch extends AwsLambdaInstrumentation {
16+
override init() {
17+
// Custom logic before calling the original implementation
18+
diag.debug('Initializing AwsLambdaInstrumentationPatch');
19+
const taskRoot = process.env.LAMBDA_TASK_ROOT;
20+
const handlerDef = this._config.lambdaHandler ?? process.env._HANDLER;
21+
22+
// _HANDLER and LAMBDA_TASK_ROOT are always defined in Lambda but guard bail out if in the future this changes.
23+
if (!taskRoot || !handlerDef) {
24+
this._diag.debug('Skipping lambda instrumentation: no _HANDLER/lambdaHandler or LAMBDA_TASK_ROOT.', {
25+
taskRoot,
26+
handlerDef,
27+
});
28+
return [];
29+
}
30+
31+
const handler = path.basename(handlerDef);
32+
const moduleRoot = handlerDef.substr(0, handlerDef.length - handler.length);
33+
34+
const [module, functionName] = handler.split('.', 2);
35+
36+
// Lambda loads user function using an absolute path.
37+
let filename = path.resolve(taskRoot, moduleRoot, module);
38+
if (!filename.endsWith('.js')) {
39+
// its impossible to know in advance if the user has a cjs or js or mjs file.
40+
// check that the .js file exists otherwise fallback to next known possibility
41+
try {
42+
fs.statSync(`${filename}.js`);
43+
filename += '.js';
44+
} catch (e) {
45+
// fallback to .cjs
46+
try {
47+
fs.statSync(`${filename}.cjs`);
48+
filename += '.cjs';
49+
} catch (e) {
50+
// fall back to .mjs
51+
filename += '.mjs';
52+
}
53+
}
54+
}
55+
56+
diag.debug('Instrumenting lambda handler', {
57+
taskRoot,
58+
handlerDef,
59+
handler,
60+
moduleRoot,
61+
module,
62+
filename,
63+
functionName,
64+
});
65+
66+
if (filename.endsWith('.mjs') || process.env.HANDLER_IS_ESM) {
67+
return [
68+
new InstrumentationNodeModuleDefinition(
69+
// NB: The patching infrastructure seems to match names backwards, this must be the filename, while
70+
// InstrumentationNodeModuleFile must be the module name.
71+
filename,
72+
['*'],
73+
(moduleExports: any) => {
74+
diag.debug('Applying patch for lambda esm handler');
75+
if (isWrapped(moduleExports[functionName])) {
76+
this._unwrap(moduleExports, functionName);
77+
}
78+
this._wrap(moduleExports, functionName, (this as any)._getHandler());
79+
return moduleExports;
80+
},
81+
(moduleExports?: any) => {
82+
if (moduleExports == null) return;
83+
diag.debug('Removing patch for lambda esm handler');
84+
this._unwrap(moduleExports, functionName);
85+
}
86+
),
87+
];
88+
} else {
89+
return [
90+
new InstrumentationNodeModuleDefinition(
91+
// NB: The patching infrastructure seems to match names backwards, this must be the filename, while
92+
// InstrumentationNodeModuleFile must be the module name.
93+
filename,
94+
['*'],
95+
undefined,
96+
undefined,
97+
[
98+
new InstrumentationNodeModuleFile(
99+
module,
100+
['*'],
101+
(moduleExports: any) => {
102+
diag.debug('Applying patch for lambda handler');
103+
if (isWrapped(moduleExports[functionName])) {
104+
this._unwrap(moduleExports, functionName);
105+
}
106+
this._wrap(moduleExports, functionName, (this as any)._getHandler());
107+
return moduleExports;
108+
},
109+
(moduleExports?: any) => {
110+
if (moduleExports == null) return;
111+
diag.debug('Removing patch for lambda handler');
112+
this._unwrap(moduleExports, functionName);
113+
}
114+
),
115+
]
116+
),
117+
];
118+
}
119+
}
120+
}

aws-distro-opentelemetry-node-autoinstrumentation/src/patches/instrumentation-patch.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
trace,
1313
} from '@opentelemetry/api';
1414
import { Instrumentation } from '@opentelemetry/instrumentation';
15-
import { AwsLambdaInstrumentation } from '@opentelemetry/instrumentation-aws-lambda';
1615
import { AwsSdkInstrumentationConfig, NormalizedRequest } from '@opentelemetry/instrumentation-aws-sdk';
1716
import { AWSXRAY_TRACE_ID_HEADER, AWSXRayPropagator } from '@opentelemetry/propagator-aws-xray';
1817
import { APIGatewayProxyEventHeaders, Context } from 'aws-lambda';
@@ -26,6 +25,7 @@ import {
2625
} from './aws/services/bedrock';
2726
import { KinesisServiceExtension } from './aws/services/kinesis';
2827
import { S3ServiceExtension } from './aws/services/s3';
28+
import { AwsLambdaInstrumentationPatch } from './aws/services/aws-lambda';
2929

3030
export const traceContextEnvironmentKey = '_X_AMZN_TRACE_ID';
3131
const awsPropagator = new AWSXRayPropagator();
@@ -65,7 +65,7 @@ export function applyInstrumentationPatches(instrumentations: Instrumentation[])
6565
}
6666
} else if (instrumentation.instrumentationName === '@opentelemetry/instrumentation-aws-lambda') {
6767
diag.debug('Overriding aws lambda instrumentation');
68-
const lambdaInstrumentation = new AwsLambdaInstrumentation({
68+
const lambdaInstrumentation = new AwsLambdaInstrumentationPatch({
6969
eventContextExtractor: customExtractor,
7070
disableAwsContextPropagation: true,
7171
});
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import * as assert from 'assert';
4+
import * as sinon from 'sinon';
5+
import * as path from 'path';
6+
import * as fs from 'fs';
7+
import { diag } from '@opentelemetry/api';
8+
import { InstrumentationNodeModuleDefinition } from '@opentelemetry/instrumentation';
9+
import { AwsLambdaInstrumentationPatch } from '../../../../src/patches/aws/services/aws-lambda';
10+
11+
describe('AwsLambdaInstrumentationPatch', () => {
12+
let instrumentation: AwsLambdaInstrumentationPatch;
13+
14+
beforeEach(() => {
15+
instrumentation = new AwsLambdaInstrumentationPatch({});
16+
});
17+
18+
afterEach(() => {
19+
sinon.restore();
20+
});
21+
22+
describe('init', () => {
23+
it('should skip instrumentation when LAMBDA_TASK_ROOT and _HANDLER are not set', () => {
24+
process.env.LAMBDA_TASK_ROOT = '';
25+
process.env._HANDLER = '';
26+
27+
const result = instrumentation.init();
28+
29+
assert.strictEqual(result.length, 0);
30+
});
31+
32+
it('should fallback to .cjs if .js does not exist', () => {
33+
process.env.LAMBDA_TASK_ROOT = '/var/task';
34+
process.env._HANDLER = 'src/index.handler';
35+
36+
sinon.stub(path, 'basename').returns('index.handler');
37+
sinon
38+
.stub(fs, 'statSync')
39+
.onFirstCall()
40+
.throws(new Error('File not found')) // .js file does not exist
41+
.onSecondCall()
42+
.returns({} as any); // .cjs file exists
43+
44+
const result = instrumentation.init();
45+
46+
assert.strictEqual(result[0].name, '/var/task/src/index.cjs');
47+
});
48+
49+
it('should fallback to .mjs when .js and .cjs do not exist', () => {
50+
process.env.LAMBDA_TASK_ROOT = '/var/task';
51+
process.env._HANDLER = 'src/index.handler';
52+
53+
sinon.stub(path, 'basename').returns('index.handler');
54+
sinon
55+
.stub(fs, 'statSync')
56+
.onFirstCall()
57+
.throws(new Error('File not found')) // .js not found
58+
.onSecondCall()
59+
.throws(new Error('File not found')); // .cjs not found
60+
61+
const result = instrumentation.init();
62+
63+
assert.strictEqual(result[0].name, '/var/task/src/index.mjs');
64+
});
65+
66+
it('should instrument CommonJS handler correctly', () => {
67+
process.env.LAMBDA_TASK_ROOT = '/var/task';
68+
process.env._HANDLER = 'src/index.handler';
69+
70+
sinon.stub(path, 'basename').returns('index.handler');
71+
sinon.stub(fs, 'statSync').returns({} as any); // Mock that the .js file exists
72+
const debugStub = sinon.stub(diag, 'debug');
73+
74+
const result = instrumentation.init();
75+
76+
assert.strictEqual(result.length, 1);
77+
assert.strictEqual(result[0].name, '/var/task/src/index.js');
78+
assert(result[0] instanceof InstrumentationNodeModuleDefinition);
79+
assert.strictEqual(result[0].files.length, 1);
80+
assert(debugStub.calledWithMatch('Instrumenting lambda handler', sinon.match.object));
81+
});
82+
83+
it('should return ESM instrumentation for .mjs files or when HANDLER_IS_ESM is set', () => {
84+
process.env.LAMBDA_TASK_ROOT = '/var/task';
85+
process.env._HANDLER = 'src/index.handler';
86+
process.env.HANDLER_IS_ESM = 'true'; // ESM environment variable set
87+
88+
sinon.stub(path, 'basename').returns('index.handler');
89+
sinon.stub(fs, 'statSync').throws(new Error('File not found')); // No .js or .cjs file exists
90+
91+
const result = instrumentation.init();
92+
93+
assert.strictEqual(result.length, 1);
94+
assert.strictEqual(result[0].name, '/var/task/src/index.mjs');
95+
assert(result[0] instanceof InstrumentationNodeModuleDefinition);
96+
assert.strictEqual(result[0].files.length, 0); //
97+
delete process.env.HANDLER_IS_ESM;
98+
});
99+
});
100+
101+
it('should apply and remove patches correctly for a MJS handler', () => {
102+
process.env.LAMBDA_TASK_ROOT = '/var/task';
103+
process.env._HANDLER = 'src/index.handler';
104+
process.env.HANDLER_IS_ESM = 'true'; // ESM environment variable set
105+
106+
// Mock the module exports object with a sample function
107+
const fakeModuleExports = { handler: sinon.stub() };
108+
109+
const wrapSpy = sinon.spy(instrumentation, '_wrap' as any);
110+
const unwrapSpy = sinon.spy(instrumentation, '_unwrap' as any);
111+
112+
const result = instrumentation.init()[0];
113+
// Ensure result contains patch and unpatch functions
114+
assert(result.patch, 'patch function should be defined');
115+
assert(result.unpatch, 'unpatch function should be defined');
116+
117+
// Call the patch function with the mocked module exports
118+
result.patch(fakeModuleExports);
119+
120+
// Assert that wrap is called after patching
121+
assert(wrapSpy.calledOnce, '_wrap should be called once when patch is applied');
122+
123+
// Call the unpatch function with the mocked module exports
124+
result.unpatch(fakeModuleExports);
125+
126+
// Assert that unwrap is called after unpatching
127+
assert(unwrapSpy.calledOnce, '_unwrap should be called once when unpatch is called');
128+
129+
delete process.env.HANDLER_IS_ESM;
130+
});
131+
132+
it('should apply and remove patches correctly for a CJS handler', () => {
133+
process.env.LAMBDA_TASK_ROOT = '/var/task';
134+
process.env._HANDLER = 'src/index.handler';
135+
136+
// Mock the module exports object with a sample function
137+
const fakeModuleExports = { handler: sinon.stub() };
138+
sinon.stub(fs, 'statSync').returns({} as any); // Mock that the .js file exists
139+
140+
const wrapSpy = sinon.spy(instrumentation, '_wrap' as any);
141+
const unwrapSpy = sinon.spy(instrumentation, '_unwrap' as any);
142+
143+
const result = instrumentation.init()[0];
144+
// Ensure result contains patch and unpatch functions
145+
assert(result.files[0].patch, 'patch function should be defined');
146+
assert(result.files[0].unpatch, 'unpatch function should be defined');
147+
148+
// Call the patch function with the mocked module exports
149+
result.files[0].patch(fakeModuleExports);
150+
151+
// Assert that wrap is called after patching
152+
assert(wrapSpy.calledOnce, '_wrap should be called once when patch is applied');
153+
154+
// Call the unpatch function with the mocked module exports
155+
result.files[0].unpatch(fakeModuleExports);
156+
157+
// Assert that unwrap is called after unpatching
158+
assert(unwrapSpy.calledOnce, '_unwrap should be called once when unpatch is called');
159+
});
160+
});

lambda-layer/packages/layer/scripts/otel-instrument

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,40 @@
11
#!/bin/bash
2-
export NODE_OPTIONS="${NODE_OPTIONS} --require /opt/wrapper.js"
2+
isESMScript() {
3+
# Lambda function root directory
4+
TASK_DIR="/var/task"
5+
6+
# Flag variables to track conditions
7+
local found_mjs=false
8+
local is_module=false
9+
10+
# Check for any files ending with `.mjs`
11+
if ls "$TASK_DIR"/*.mjs &>/dev/null; then
12+
found_mjs=true
13+
fi
14+
15+
# Check if `package.json` exists and if it contains `"type": "module"`
16+
if [ -f "$TASK_DIR/package.json" ]; then
17+
# Check for the `"type": "module"` attribute in `package.json`
18+
if grep -q '"type": *"module"' "$TASK_DIR/package.json"; then
19+
is_module=true
20+
fi
21+
fi
22+
23+
# Return true if both conditions are met
24+
if $found_mjs || $is_module; then
25+
return 0 # 0 in bash means true
26+
else
27+
return 1 # 1 in bash means false
28+
fi
29+
}
30+
31+
if isESMScript; then
32+
export NODE_OPTIONS="${NODE_OPTIONS} --import @aws/aws-distro-opentelemetry-node-autoinstrumentation/register --experimental-loader=@opentelemetry/instrumentation/hook.mjs"
33+
export HANDLER_IS_ESM=true
34+
else
35+
export NODE_OPTIONS="${NODE_OPTIONS} --require /opt/wrapper.js"
36+
fi
37+
338
export LAMBDA_RESOURCE_ATTRIBUTES="cloud.region=$AWS_REGION,cloud.provider=aws,faas.name=$AWS_LAMBDA_FUNCTION_NAME,faas.version=$AWS_LAMBDA_FUNCTION_VERSION,faas.instance=$AWS_LAMBDA_LOG_STREAM_NAME,aws.log.group.names=$AWS_LAMBDA_LOG_GROUP_NAME";
439

540

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/bin/bash
2+
export NODE_OPTIONS="${NODE_OPTIONS} --import @aws/aws-distro-opentelemetry-node-autoinstrumentation/register --experimental-loader=@opentelemetry/instrumentation/hook.mjs"
3+
export LAMBDA_RESOURCE_ATTRIBUTES="cloud.region=$AWS_REGION,cloud.provider=aws,faas.name=$AWS_LAMBDA_FUNCTION_NAME,faas.version=$AWS_LAMBDA_FUNCTION_VERSION,faas.instance=$AWS_LAMBDA_LOG_STREAM_NAME,aws.log.group.names=$AWS_LAMBDA_LOG_GROUP_NAME";
4+
export HANDLER_IS_ESM=true
5+
6+
# - If OTEL_EXPORTER_OTLP_PROTOCOL is not set by user, the default exporting protocol is http/protobuf.
7+
if [ -z "${OTEL_EXPORTER_OTLP_PROTOCOL}" ]; then
8+
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
9+
fi
10+
11+
# - If OTEL_NODE_ENABLED_INSTRUMENTATIONS is not set by user, use default instrumentation
12+
if [ -z "${OTEL_NODE_ENABLED_INSTRUMENTATIONS}" ]; then
13+
export OTEL_NODE_ENABLED_INSTRUMENTATIONS="aws-lambda,aws-sdk"
14+
fi
15+
16+
# - Set the service name
17+
if [ -z "${OTEL_SERVICE_NAME}" ]; then
18+
export OTEL_SERVICE_NAME=$AWS_LAMBDA_FUNCTION_NAME;
19+
fi
20+
21+
# - Set the propagators
22+
if [[ -z "$OTEL_PROPAGATORS" ]]; then
23+
export OTEL_PROPAGATORS="tracecontext,baggage,xray"
24+
fi
25+
26+
# - Set Application Signals configuration
27+
if [ -z "${OTEL_AWS_APPLICATION_SIGNALS_ENABLED}" ]; then
28+
export OTEL_AWS_APPLICATION_SIGNALS_ENABLED="true";
29+
fi
30+
31+
if [ -z "${OTEL_METRICS_EXPORTER}" ]; then
32+
export OTEL_METRICS_EXPORTER="none";
33+
fi
34+
35+
# - Append Lambda Resource Attributes to OTel Resource Attribute List
36+
if [ -z "${OTEL_RESOURCE_ATTRIBUTES}" ]; then
37+
export OTEL_RESOURCE_ATTRIBUTES=$LAMBDA_RESOURCE_ATTRIBUTES;
38+
else
39+
export OTEL_RESOURCE_ATTRIBUTES="$LAMBDA_RESOURCE_ATTRIBUTES,$OTEL_RESOURCE_ATTRIBUTES";
40+
fi
41+
42+
if [[ $OTEL_RESOURCE_ATTRIBUTES != *"service.name="* ]]; then
43+
export OTEL_RESOURCE_ATTRIBUTES="service.name=${AWS_LAMBDA_FUNCTION_NAME},${OTEL_RESOURCE_ATTRIBUTES}"
44+
fi
45+
46+
exec "$@"

0 commit comments

Comments
 (0)