diff --git a/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/aws_opentelemetry_configurator.py b/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/aws_opentelemetry_configurator.py index 0ac1f69de..99829e5a0 100644 --- a/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/aws_opentelemetry_configurator.py +++ b/aws-opentelemetry-distro/src/amazon/opentelemetry/distro/aws_opentelemetry_configurator.py @@ -250,7 +250,8 @@ def _customize_exporter(span_exporter: SpanExporter, resource: Resource) -> Span if not _is_application_signals_enabled(): return span_exporter if _is_lambda_environment(): - return AwsMetricAttributesSpanExporterBuilder(OTLPUdpSpanExporter(), resource).build() + traces_endpoint = os.environ.get(AWS_XRAY_DAEMON_ADDRESS_CONFIG, "127.0.0.1:2000") + return AwsMetricAttributesSpanExporterBuilder(OTLPUdpSpanExporter(endpoint=traces_endpoint), resource).build() return AwsMetricAttributesSpanExporterBuilder(span_exporter, resource).build() diff --git a/lambda-layer/build.sh b/lambda-layer/build.sh new file mode 100755 index 000000000..c67fedc03 --- /dev/null +++ b/lambda-layer/build.sh @@ -0,0 +1,17 @@ +#!/bin/bash +set -e + +pushd src || exit +rm -rf build +./build-lambda-layer.sh +popd || exit + +pushd sample-apps || exit +rm -rf build +./package-lambda-function.sh +popd || exit + +pushd terraform/lambda || exit +terraform init +terraform apply -auto-approve +popd || exit \ No newline at end of file diff --git a/lambda-layer/sample-apps/function/lambda_function.py b/lambda-layer/sample-apps/function/lambda_function.py new file mode 100644 index 000000000..c631abb86 --- /dev/null +++ b/lambda-layer/sample-apps/function/lambda_function.py @@ -0,0 +1,17 @@ +import json +import os + +import boto3 +import requests + +client = boto3.client("s3") + + +# lambda function +def lambda_handler(event, context): + + requests.get("https://aws.amazon.com/") + + client.list_buckets() + + return {"statusCode": 200, "body": json.dumps(os.environ.get("_X_AMZN_TRACE_ID"))} diff --git a/lambda-layer/sample-apps/function/requirements.txt b/lambda-layer/sample-apps/function/requirements.txt new file mode 100644 index 000000000..663bd1f6a --- /dev/null +++ b/lambda-layer/sample-apps/function/requirements.txt @@ -0,0 +1 @@ +requests \ No newline at end of file diff --git a/lambda-layer/sample-apps/package-lambda-function.sh b/lambda-layer/sample-apps/package-lambda-function.sh new file mode 100755 index 000000000..e2be3fe07 --- /dev/null +++ b/lambda-layer/sample-apps/package-lambda-function.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +mkdir -p build/python +python3 -m pip install -r function/requirements.txt -t build/python +cp function/lambda_function.py build/python +cd build/python +zip -r ../function.zip ./* diff --git a/lambda-layer/src/Dockerfile b/lambda-layer/src/Dockerfile new file mode 100644 index 000000000..49f1a957b --- /dev/null +++ b/lambda-layer/src/Dockerfile @@ -0,0 +1,19 @@ +ARG runtime=python3.12 + +FROM public.ecr.aws/sam/build-${runtime} + +ADD . /workspace + +WORKDIR /workspace + +RUN mkdir -p /build && \ + python3 -m pip install aws-opentelemetry-distro/ -t /build/python && \ + mv otel_wrapper.py /build/python && \ + mv otel-instrument /build && \ + chmod 755 /build/otel-instrument && \ + rm -rf /build/python/boto* && \ + rm -rf /build/python/urllib3* && \ + cd /build && \ + zip -r aws-opentelemetry-python-layer.zip otel-instrument python + +CMD ["cp", "/build/aws-opentelemetry-python-layer.zip", "/out/aws-opentelemetry-python-layer.zip"] diff --git a/lambda-layer/src/build-lambda-layer.sh b/lambda-layer/src/build-lambda-layer.sh new file mode 100755 index 000000000..647ebb913 --- /dev/null +++ b/lambda-layer/src/build-lambda-layer.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -e + +rm -rf build +rm -rf ./aws-opentelemetry-distro +cp -r ../../aws-opentelemetry-distro ./ +mkdir -p build +docker build --progress plain -t aws-opentelemetry-python-layer . +docker run --rm -v "$(pwd)/build:/out" aws-opentelemetry-python-layer +rm -rf ./aws-opentelemetry-distro \ No newline at end of file diff --git a/lambda-layer/src/otel-instrument b/lambda-layer/src/otel-instrument new file mode 100755 index 000000000..3e51ff7b1 --- /dev/null +++ b/lambda-layer/src/otel-instrument @@ -0,0 +1,146 @@ +#!/bin/bash + +set -ef -o pipefail + +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +: <<'END_DOCUMENTATION' +`otel-instrument` + +This script configures and sets up OpenTelemetry Python with the values we +expect will be used by the common user. It does this by setting the environment +variables OpenTelemetry uses, and then initializing OpenTelemetry using the +`opentelemetry-instrument` auto instrumentation script from the +`opentelemetry-instrumentation` package. + +Additionally, this configuration assumes the user is using packages conforming +to the `opentelemetry-instrumentation` and `opentelemetry-sdk` specifications. + +DO NOT use this script for anything else besides SETTING ENVIRONMENT VARIABLES. + +See more: +https://docs.aws.amazon.com/lambda/latest/dg/runtimes-modify.html#runtime-wrapper + +Usage +----- +We expect this file to be at the root of a Lambda Layer. Having it anywhere else +seems to mean AWS Lambda cannot find it. + +In the configuration of an AWS Lambda function with this file at the +root level of a Lambda Layer: + +.. code:: + + AWS_LAMBDA_EXEC_WRAPPER = /opt/otel-instrument + +END_DOCUMENTATION + +# Use constants to access the environment variables we want to use in this +# script. + +# See more: +# https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html#configuration-envvars-runtime + +# - Reserved environment variables + +# - - $AWS_LAMBDA_FUNCTION_NAME +# - - $LAMBDA_RUNTIME_DIR + +# - Unreserved environment variables + +# - - $PYTHONPATH + +# Update the python paths for packages with `sys.path` and `PYTHONPATH` + +# - We know that the path to the Lambda Layer OpenTelemetry Python packages are +# well defined, so we can add them to the PYTHONPATH. +# +# See more: +# https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html#configuration-layers-path + +export LAMBDA_LAYER_PKGS_DIR="/opt/python"; + +# - Set Lambda Layer python packages in PYTHONPATH so `opentelemetry-instrument` +# script can find them (it needs to find `opentelemetry` to find the auto +# instrumentation `run()` method later) + +export PYTHONPATH="$LAMBDA_LAYER_PKGS_DIR:$PYTHONPATH"; + +# - Set Lambda runtime python packages in PYTHONPATH so +# `opentelemetry-instrument` script can find them during auto instrumentation +# and instrument them. + +export PYTHONPATH="$LAMBDA_RUNTIME_DIR:$PYTHONPATH"; + +# Configure OpenTelemetry Python with environment variables + +# - We leave `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` to its default. This is +# `http://localhost:4318/v1/traces` because we are using the HTTP exporter + +# - If OTEL_EXPORTER_OTLP_PROTOCOL is not set by user, the default exporting protocol is http/protobuf. +if [ -z "${OTEL_EXPORTER_OTLP_PROTOCOL}" ]; then + export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf +fi + +# - Set the service name + +if [ -z "${OTEL_SERVICE_NAME}" ]; then + export OTEL_SERVICE_NAME=$AWS_LAMBDA_FUNCTION_NAME; +fi + +# - Set the propagators + +if [[ -z "$OTEL_PROPAGATORS" ]]; then + export OTEL_PROPAGATORS="tracecontext,baggage,xray" +fi + +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"; + +# - If Application Signals is enabled + +if [ "${OTEL_AWS_APPLICATION_SIGNALS_ENABLED}" = "true" ]; then + export OTEL_PYTHON_DISTRO="aws_distro"; + export OTEL_PYTHON_CONFIGURATOR="aws_configurator"; + export OTEL_METRICS_EXPORTER="none"; + export OTEL_LOGS_EXPORTER="none"; +fi + +if [ -z "${OTEL_RESOURCE_ATTRIBUTES}" ]; then + export OTEL_RESOURCE_ATTRIBUTES=$LAMBDA_RESOURCE_ATTRIBUTES; +else + export OTEL_RESOURCE_ATTRIBUTES="$LAMBDA_RESOURCE_ATTRIBUTES,$OTEL_RESOURCE_ATTRIBUTES"; +fi + +# - Enable botocore instrumentation by default + +if [ -z ${OTEL_PYTHON_DISABLED_INSTRUMENTATIONS} ]; then + export OTEL_PYTHON_DISABLED_INSTRUMENTATIONS="aio-pika,aiohttp-client,aiopg,asgi,asyncpg,boto3sqs,boto,cassandra,celery,confluent-kafka,dbapi,django,elasticsearch,fastapi,falcon,flask,grpc,httpx,jinja2,kafka-python,logging,mysql,mysqlclient,pika,psycopg2,pymemcache,pymongo,pymysql,pyramid,redis,remoulade,requests,sklearn,sqlalchemy,sqlite3,starlette,system-metrics,tornado,tortoiseorm,urllib,urllib3,wsgi" +fi + +# - Use a wrapper because AWS Lambda's `python3 /var/runtime/bootstrap.py` will +# use `imp.load_module` to load the function from the `_HANDLER` environment +# variable. This RELOADS the module and REMOVES any instrumentation patching +# done earlier. So we delay instrumentation until `bootstrap.py` imports +# `otel_wrapper.py` at which we know the patching will be picked up. +# +# See more: +# https://docs.python.org/3/library/imp.html#imp.load_module + +export ORIG_HANDLER=$_HANDLER; +export _HANDLER="otel_wrapper.lambda_handler"; + +# - Call the upstream auto instrumentation script + +exec python3 $LAMBDA_LAYER_PKGS_DIR/bin/opentelemetry-instrument "$@" diff --git a/lambda-layer/src/otel_wrapper.py b/lambda-layer/src/otel_wrapper.py new file mode 100644 index 000000000..295410406 --- /dev/null +++ b/lambda-layer/src/otel_wrapper.py @@ -0,0 +1,66 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +`otel_wrapper.py` + +This file serves as a wrapper over the user's Lambda function. + +Usage +----- +Patch the reserved `_HANDLER` Lambda environment variable to point to this +file's `otel_wrapper.lambda_handler` property. Do this having saved the original +`_HANDLER` in the `ORIG_HANDLER` environment variable. Doing this makes it so +that **on import of this file, the handler is instrumented**. + +Instrumenting any earlier will cause the instrumentation to be lost because the +AWS Service uses `imp.load_module` to import the handler which RELOADS the +module. This is why AwsLambdaInstrumentor cannot be instrumented with the +`opentelemetry-instrument` script. + +See more: +https://docs.python.org/3/library/imp.html#imp.load_module + +""" + +import os +from importlib import import_module + +from opentelemetry.instrumentation.aws_lambda import AwsLambdaInstrumentor + + +def modify_module_name(module_name): + """Returns a valid modified module to get imported""" + return ".".join(module_name.split("/")) + + +class HandlerError(Exception): + pass + + +AwsLambdaInstrumentor().instrument() + +path = os.environ.get("ORIG_HANDLER") + +if path is None: + raise HandlerError("ORIG_HANDLER is not defined.") + +try: + (mod_name, handler_name) = path.rsplit(".", 1) +except ValueError as e: + raise HandlerError("Bad path '{}' for ORIG_HANDLER: {}".format(path, str(e))) + +modified_mod_name = modify_module_name(mod_name) +handler_module = import_module(modified_mod_name) +lambda_handler = getattr(handler_module, handler_name) diff --git a/lambda-layer/src/tests/mocks/lambda_function.py b/lambda-layer/src/tests/mocks/lambda_function.py new file mode 100644 index 000000000..c29257565 --- /dev/null +++ b/lambda-layer/src/tests/mocks/lambda_function.py @@ -0,0 +1,17 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +def handler(event, context): + return "200 ok" diff --git a/lambda-layer/src/tests/requirements.txt b/lambda-layer/src/tests/requirements.txt new file mode 100644 index 000000000..3bca3e0e4 --- /dev/null +++ b/lambda-layer/src/tests/requirements.txt @@ -0,0 +1,3 @@ +# Dependencies used in tests only +opentelemetry-test-utils==0.46b0 +opentelemetry-instrumentation-aws-lambda==0.46b0 diff --git a/lambda-layer/src/tests/test_lambda_instrumentation.py b/lambda-layer/src/tests/test_lambda_instrumentation.py new file mode 100644 index 000000000..0b44d1deb --- /dev/null +++ b/lambda-layer/src/tests/test_lambda_instrumentation.py @@ -0,0 +1,270 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +This file tests that the `otel-instrument` script included in this repository +successfully instruments OTel Python in a mock Lambda environment. +""" + +import fileinput +import os +import subprocess +import sys +from importlib import import_module +from shutil import which +from unittest import mock + +from opentelemetry.environment_variables import OTEL_PROPAGATORS +from opentelemetry.instrumentation.aws_lambda import _HANDLER, _X_AMZN_TRACE_ID, ORIG_HANDLER, AwsLambdaInstrumentor +from opentelemetry.propagators.aws.aws_xray_propagator import TRACE_ID_FIRST_PART_LENGTH, TRACE_ID_VERSION +from opentelemetry.semconv.resource import ResourceAttributes +from opentelemetry.semconv.trace import SpanAttributes +from opentelemetry.test.test_base import TestBase +from opentelemetry.trace import SpanKind +from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator + +AWS_LAMBDA_EXEC_WRAPPER = "AWS_LAMBDA_EXEC_WRAPPER" +INIT_OTEL_SCRIPTS_DIR = os.path.join(*(os.path.dirname(__file__), "..")) +TOX_PYTHON_DIRECTORY = os.path.dirname(os.path.dirname(which("python3"))) + + +class MockLambdaContext: + def __init__(self, aws_request_id, invoked_function_arn): + self.invoked_function_arn = invoked_function_arn + self.aws_request_id = aws_request_id + + +MOCK_LAMBDA_CONTEXT = MockLambdaContext( + aws_request_id="mock_aws_request_id", + invoked_function_arn="arn:aws:lambda:us-west-2:123456789012:function:my-function", +) + +MOCK_XRAY_TRACE_ID = 0x5FB7331105E8BB83207FA31D4D9CDB4C +MOCK_XRAY_TRACE_ID_STR = f"{MOCK_XRAY_TRACE_ID:x}" +MOCK_XRAY_PARENT_SPAN_ID = 0x3328B8445A6DBAD2 +MOCK_XRAY_TRACE_CONTEXT_COMMON = ( + f"Root={TRACE_ID_VERSION}-{MOCK_XRAY_TRACE_ID_STR[:TRACE_ID_FIRST_PART_LENGTH]}" + f"-{MOCK_XRAY_TRACE_ID_STR[TRACE_ID_FIRST_PART_LENGTH:]};Parent={MOCK_XRAY_PARENT_SPAN_ID:x}" +) +MOCK_XRAY_TRACE_CONTEXT_SAMPLED = f"{MOCK_XRAY_TRACE_CONTEXT_COMMON};Sampled=1" +MOCK_XRAY_TRACE_CONTEXT_NOT_SAMPLED = f"{MOCK_XRAY_TRACE_CONTEXT_COMMON};Sampled=0" + +# See more: +# https://www.w3.org/TR/trace-context/#examples-of-http-traceparent-headers + +MOCK_W3C_TRACE_ID = 0x5CE0E9A56015FEC5AADFA328AE398115 +MOCK_W3C_PARENT_SPAN_ID = 0xAB54A98CEB1F0AD2 +MOCK_W3C_TRACE_CONTEXT_SAMPLED = f"00-{MOCK_W3C_TRACE_ID:x}-{MOCK_W3C_PARENT_SPAN_ID:x}-01" + +MOCK_W3C_TRACE_STATE_KEY = "vendor_specific_key" +MOCK_W3C_TRACE_STATE_VALUE = "test_value" + + +def replace_in_file(filename, old_text, new_text): + with fileinput.FileInput(filename, inplace=True) as file_object: + for line in file_object: + # This directs the output to the file, not the console + print(line.replace(old_text, new_text), end="") + + +def mock_aws_lambda_exec_wrapper(): + """Mocks automatically instrumenting user Lambda function by pointing + `AWS_LAMBDA_EXEC_WRAPPER` to the `otel-instrument` script. + + TODO: It would be better if `moto`'s `mock_lambda` supported setting + AWS_LAMBDA_EXEC_WRAPPER so we could make the call to Lambda instead. + + See more: + https://aws-otel.github.io/docs/getting-started/lambda/lambda-python + """ + + # NOTE: Because we run as a subprocess, the python packages are NOT patched + # with instrumentation. In this test we just make sure we can complete auto + # instrumentation without error and the correct environment variables are + # set. A future improvement might have us run `opentelemetry-instrument` in + # this process to imitate `otel-instrument`, but our lambda handler does not + # call other instrumented libraries so we have no use for it for now. + + print_environ_program = ( + "import os;" + f"print(f\"{ORIG_HANDLER}={{os.environ['{ORIG_HANDLER}']}}\");" + f"print(f\"{_HANDLER}={{os.environ['{_HANDLER}']}}\");" + ) + + completed_subprocess = subprocess.run( + [ + os.path.join(INIT_OTEL_SCRIPTS_DIR, "otel-instrument"), + "python3", + "-c", + print_environ_program, + ], + check=True, + stdout=subprocess.PIPE, + text=True, + ) + + # NOTE: Because `otel-instrument` cannot affect this python environment, we + # parse the stdout produced by our test python program to update the + # environment in this parent python process. + + for env_var_line in completed_subprocess.stdout.split("\n"): + if env_var_line: + env_key, env_value = env_var_line.split("=") + os.environ[env_key] = env_value + + +def mock_execute_lambda(event=None): + """Mocks the AWS Lambda execution. Mocks importing and then calling the + method at the current `_HANDLER` environment variable. Like the real Lambda, + if `AWS_LAMBDA_EXEC_WRAPPER` is defined, it executes that before `_HANDLER`. + + NOTE: We don't use `moto`'s `mock_lambda` because we are not instrumenting + calls to AWS Lambda using the AWS SDK. Instead, we are instrumenting AWS + Lambda itself. + + See more: + https://docs.aws.amazon.com/lambda/latest/dg/runtimes-modify.html#runtime-wrapper + + Args: + event: The Lambda event which may or may not be used by instrumentation. + """ + + # The point of the repo is to test using the script, so we can count on it + # being here for every test and do not check for its existence. + # if os.environ[AWS_LAMBDA_EXEC_WRAPPER]: + globals()[os.environ[AWS_LAMBDA_EXEC_WRAPPER]]() + + module_name, handler_name = os.environ[_HANDLER].rsplit(".", 1) + handler_module = import_module(module_name.replace("/", ".")) + getattr(handler_module, handler_name)(event, MOCK_LAMBDA_CONTEXT) + + +class TestAwsLambdaInstrumentor(TestBase): + """AWS Lambda Instrumentation Testsuite""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + sys.path.append(INIT_OTEL_SCRIPTS_DIR) + replace_in_file( + os.path.join(INIT_OTEL_SCRIPTS_DIR, "otel-instrument"), + 'export LAMBDA_LAYER_PKGS_DIR="/opt/python"', + f'export LAMBDA_LAYER_PKGS_DIR="{TOX_PYTHON_DIRECTORY}"', + ) + + def setUp(self): + super().setUp() + self.common_env_patch = mock.patch.dict( + "os.environ", + { + AWS_LAMBDA_EXEC_WRAPPER: "mock_aws_lambda_exec_wrapper", + _HANDLER: "mocks.lambda_function.handler", + }, + ) + self.common_env_patch.start() + + def tearDown(self): + super().tearDown() + self.common_env_patch.stop() + AwsLambdaInstrumentor().uninstrument() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + sys.path.remove(INIT_OTEL_SCRIPTS_DIR) + replace_in_file( + os.path.join(INIT_OTEL_SCRIPTS_DIR, "otel-instrument"), + f'export LAMBDA_LAYER_PKGS_DIR="{TOX_PYTHON_DIRECTORY}"', + 'export LAMBDA_LAYER_PKGS_DIR="/opt/python"', + ) + + def test_active_tracing(self): + test_env_patch = mock.patch.dict( + "os.environ", + { + **os.environ, + # Using Active tracing + _X_AMZN_TRACE_ID: MOCK_XRAY_TRACE_CONTEXT_SAMPLED, + }, + ) + test_env_patch.start() + + mock_execute_lambda() + + spans = self.memory_exporter.get_finished_spans() + + assert spans + + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertEqual(span.name, os.environ[ORIG_HANDLER]) + self.assertEqual(span.get_span_context().trace_id, MOCK_XRAY_TRACE_ID) + self.assertEqual(span.kind, SpanKind.SERVER) + self.assertSpanHasAttributes( + span, + { + ResourceAttributes.CLOUD_RESOURCE_ID: MOCK_LAMBDA_CONTEXT.invoked_function_arn, + SpanAttributes.FAAS_INVOCATION_ID: MOCK_LAMBDA_CONTEXT.aws_request_id, + }, + ) + + parent_context = span.parent + self.assertEqual(parent_context.trace_id, span.get_span_context().trace_id) + self.assertEqual(parent_context.span_id, MOCK_XRAY_PARENT_SPAN_ID) + self.assertTrue(parent_context.is_remote) + + test_env_patch.stop() + + def test_parent_context_from_lambda_event(self): + test_env_patch = mock.patch.dict( + "os.environ", + { + **os.environ, + # NOT Active Tracing + _X_AMZN_TRACE_ID: MOCK_XRAY_TRACE_CONTEXT_NOT_SAMPLED, + # NOT using the X-Ray Propagator + OTEL_PROPAGATORS: "tracecontext", + }, + ) + test_env_patch.start() + + trace_state_header = f"{MOCK_W3C_TRACE_STATE_KEY}={MOCK_W3C_TRACE_STATE_VALUE},foo=1,bar=2" + mock_execute_lambda( + { + "headers": { + TraceContextTextMapPropagator._TRACEPARENT_HEADER_NAME: MOCK_W3C_TRACE_CONTEXT_SAMPLED, + TraceContextTextMapPropagator._TRACESTATE_HEADER_NAME: trace_state_header, + } + } + ) + + spans = self.memory_exporter.get_finished_spans() + + assert spans + + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertEqual(span.get_span_context().trace_id, MOCK_W3C_TRACE_ID) + + parent_context = span.parent + self.assertEqual(parent_context.trace_id, span.get_span_context().trace_id) + self.assertEqual(parent_context.span_id, MOCK_W3C_PARENT_SPAN_ID) + self.assertEqual(len(parent_context.trace_state), 3) + self.assertEqual( + parent_context.trace_state.get(MOCK_W3C_TRACE_STATE_KEY), + MOCK_W3C_TRACE_STATE_VALUE, + ) + self.assertTrue(parent_context.is_remote) + + test_env_patch.stop() diff --git a/lambda-layer/src/tox.ini b/lambda-layer/src/tox.ini new file mode 100644 index 000000000..4b7f733e0 --- /dev/null +++ b/lambda-layer/src/tox.ini @@ -0,0 +1,28 @@ +[tox] +envlist = + ; opentelemetry-instrumentation-aws-lambda + py3{10,11,12}-test-instrumentation-aws-lambda + +minversion = 3.10 + +skip_missing_interpreters = True + +skipsdist = True + +[testenv] +passenv = TOXENV + +setenv = + OTEL_PYTHON_TRACER_PROVIDER=sdk_tracer_provider + +changedir = + test-instrumentation-aws-lambda: {toxinidir}/tests + +commands_pre = + test: pip install -r requirements.txt + +deps = + test: pytest + +commands = + test: pytest {posargs} diff --git a/lambda-layer/terraform/api-gateway-proxy/main.tf b/lambda-layer/terraform/api-gateway-proxy/main.tf new file mode 100644 index 000000000..4bd8afbe1 --- /dev/null +++ b/lambda-layer/terraform/api-gateway-proxy/main.tf @@ -0,0 +1,67 @@ +resource "aws_api_gateway_rest_api" "lambda_api_proxy" { + name = var.name +} + +resource "aws_api_gateway_resource" "lambda_api_proxy" { + rest_api_id = aws_api_gateway_rest_api.lambda_api_proxy.id + parent_id = aws_api_gateway_rest_api.lambda_api_proxy.root_resource_id + path_part = "{proxy+}" +} + +resource "aws_api_gateway_method" "lambda_api_proxy" { + rest_api_id = aws_api_gateway_rest_api.lambda_api_proxy.id + resource_id = aws_api_gateway_resource.lambda_api_proxy.id + http_method = "ANY" + authorization = "NONE" +} + +resource "aws_api_gateway_integration" "lambda_api" { + rest_api_id = aws_api_gateway_rest_api.lambda_api_proxy.id + resource_id = aws_api_gateway_method.lambda_api_proxy.resource_id + http_method = aws_api_gateway_method.lambda_api_proxy.http_method + + integration_http_method = "POST" + type = "AWS_PROXY" + uri = var.function_invoke_arn +} + +resource "aws_api_gateway_method" "lambda_api_proxy_root_nodejs" { + rest_api_id = aws_api_gateway_rest_api.lambda_api_proxy.id + resource_id = aws_api_gateway_rest_api.lambda_api_proxy.root_resource_id + http_method = "ANY" + authorization = "NONE" +} + +resource "aws_api_gateway_integration" "lambda_api_root_nodejs" { + rest_api_id = aws_api_gateway_rest_api.lambda_api_proxy.id + resource_id = aws_api_gateway_method.lambda_api_proxy_root_nodejs.resource_id + http_method = aws_api_gateway_method.lambda_api_proxy_root_nodejs.http_method + + integration_http_method = "POST" + type = "AWS_PROXY" + uri = var.function_invoke_arn +} + +resource "aws_api_gateway_deployment" "lambda_api_proxy" { + depends_on = [ + aws_api_gateway_integration.lambda_api, + aws_api_gateway_integration.lambda_api_root_nodejs, + ] + + rest_api_id = aws_api_gateway_rest_api.lambda_api_proxy.id +} + +resource "aws_api_gateway_stage" "test" { + stage_name = "default" + rest_api_id = aws_api_gateway_rest_api.lambda_api_proxy.id + deployment_id = aws_api_gateway_deployment.lambda_api_proxy.id + xray_tracing_enabled = var.enable_xray_tracing +} + +resource "aws_lambda_permission" "lambda_api_allow_gateway_nodejs" { + action = "lambda:InvokeFunction" + function_name = var.function_name + qualifier = var.function_qualifier + principal = "apigateway.amazonaws.com" + source_arn = "${aws_api_gateway_rest_api.lambda_api_proxy.execution_arn}/*/*" +} diff --git a/lambda-layer/terraform/api-gateway-proxy/outputs.tf b/lambda-layer/terraform/api-gateway-proxy/outputs.tf new file mode 100644 index 000000000..646329f4d --- /dev/null +++ b/lambda-layer/terraform/api-gateway-proxy/outputs.tf @@ -0,0 +1,3 @@ +output "api_gateway_url" { + value = aws_api_gateway_stage.test.invoke_url +} diff --git a/lambda-layer/terraform/api-gateway-proxy/variables.tf b/lambda-layer/terraform/api-gateway-proxy/variables.tf new file mode 100644 index 000000000..125cd1f20 --- /dev/null +++ b/lambda-layer/terraform/api-gateway-proxy/variables.tf @@ -0,0 +1,26 @@ +variable "name" { + type = string + description = "Name of API gateway to create" +} + +variable "function_name" { + type = string + description = "Name of function to proxy to" +} + +variable "function_qualifier" { + type = string + default = null + description = "Qualifier of function to proxy to" +} + +variable "function_invoke_arn" { + type = string + description = "Invoke ARN of function to proxy to" +} + +variable "enable_xray_tracing" { + type = bool + description = "Whether to enable xray tracing of the API gateway" + default = true +} diff --git a/lambda-layer/terraform/lambda/main.tf b/lambda-layer/terraform/lambda/main.tf new file mode 100644 index 000000000..813544fff --- /dev/null +++ b/lambda-layer/terraform/lambda/main.tf @@ -0,0 +1,67 @@ +locals { + architecture = var.architecture == "x86_64" ? "amd64" : "arm64" +} + +resource "aws_lambda_layer_version" "sdk_layer" { + layer_name = var.sdk_layer_name + filename = "${path.module}/../../src/build/aws-opentelemetry-python-layer.zip" + compatible_runtimes = ["python3.10", "python3.11", "python3.12"] + license_info = "Apache-2.0" + source_code_hash = filebase64sha256("${path.module}/../../src/build/aws-opentelemetry-python-layer.zip") +} + +module "test-function" { + source = "terraform-aws-modules/lambda/aws" + + architectures = compact([var.architecture]) + function_name = var.function_name + handler = "lambda_function.lambda_handler" + runtime = var.runtime + + create_package = false + local_existing_package = "${path.module}/../../sample-apps/build/function.zip" + + memory_size = 512 + timeout = 30 + + layers = compact([aws_lambda_layer_version.sdk_layer.arn]) + + environment_variables = { + AWS_LAMBDA_EXEC_WRAPPER = "/opt/otel-instrument" + OTEL_AWS_APPLICATION_SIGNALS_ENABLED = "true" + } + + tracing_mode = var.tracing_mode + + attach_policy_statements = true + policy_statements = { + s3 = { + effect = "Allow" + actions = [ + "s3:ListAllMyBuckets" + ] + resources = [ + "*" + ] + } + } +} + +module "api-gateway" { + source = "../api-gateway-proxy" + + name = var.function_name + function_name = module.test-function.lambda_function_name + function_invoke_arn = module.test-function.lambda_function_invoke_arn + enable_xray_tracing = true +} + +resource "aws_iam_role_policy_attachment" "hello-lambda-cloudwatch" { + role = module.test-function.lambda_function_name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy_attachment" "test_xray" { + role = module.test-function.lambda_function_name + policy_arn = "arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess" +} diff --git a/lambda-layer/terraform/lambda/outputs.tf b/lambda-layer/terraform/lambda/outputs.tf new file mode 100644 index 000000000..0a4c0ea66 --- /dev/null +++ b/lambda-layer/terraform/lambda/outputs.tf @@ -0,0 +1,11 @@ +output "api-gateway-url" { + value = module.api-gateway.api_gateway_url +} + +output "function_role_name" { + value = module.test-function.lambda_role_name +} + +output "sdk_layer_arn" { + value = aws_lambda_layer_version.sdk_layer.arn +} diff --git a/lambda-layer/terraform/lambda/variables.tf b/lambda-layer/terraform/lambda/variables.tf new file mode 100644 index 000000000..7f1c5386e --- /dev/null +++ b/lambda-layer/terraform/lambda/variables.tf @@ -0,0 +1,35 @@ +variable "sdk_layer_name" { + type = string + description = "Name of published SDK layer" + default = "aws-opentelemetry-distro-python" +} + +variable "function_name" { + type = string + description = "Name of sample app function / API gateway" + default = "aws-opentelemetry-distro-python" +} + +variable "architecture" { + type = string + description = "Lambda function architecture, either arm64 or x86_64" + default = "x86_64" +} + +variable "runtime" { + type = string + description = "Python runtime version used for sample Lambda Function" + default = "python3.12" +} + +variable "tracing_mode" { + type = string + description = "Lambda function tracing mode" + default = "Active" +} + +variable "enable_collector_layer" { + type = bool + description = "Enables building and usage of a layer for the collector. If false, it means either the SDK layer includes the collector or it is not used." + default = false +}