diff --git a/.github/scripts/check_otel_wheel_dependencies.py b/.github/scripts/check_otel_wheel_dependencies.py new file mode 100644 index 00000000..aaf43f2b --- /dev/null +++ b/.github/scripts/check_otel_wheel_dependencies.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import email +import zipfile +from pathlib import Path + +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name + + +EXPECTED_STANDALONE_DEPENDENCIES = { + "opentelemetry-api", + "opentelemetry-propagator-aws-xray", + "opentelemetry-sdk", +} + + +def check_otel_wheel_dependencies(wheel: Path) -> None: + """Validate that OpenTelemetry dependencies are layer-provided by default.""" + requirements = _read_requirements(wheel) + base_otel_dependencies = { + canonicalize_name(requirement.name) + for requirement in requirements + if canonicalize_name(requirement.name).startswith("opentelemetry-") + and (requirement.marker is None or requirement.marker.evaluate({"extra": ""})) + } + if base_otel_dependencies: + names = ", ".join(sorted(base_otel_dependencies)) + raise ValueError( + f"{wheel.name} installs OpenTelemetry dependencies by default: {names}" + ) + + standalone_dependencies = { + canonicalize_name(requirement.name) + for requirement in requirements + if canonicalize_name(requirement.name).startswith("opentelemetry-") + and requirement.marker is not None + and requirement.marker.evaluate({"extra": "standalone"}) + } + if standalone_dependencies != EXPECTED_STANDALONE_DEPENDENCIES: + expected = ", ".join(sorted(EXPECTED_STANDALONE_DEPENDENCIES)) + actual = ", ".join(sorted(standalone_dependencies)) or "none" + raise ValueError( + f"{wheel.name} standalone OpenTelemetry dependencies must be " + f"{expected}; found {actual}" + ) + + +def _read_requirements(wheel: Path) -> list[Requirement]: + if not wheel.is_file(): + raise FileNotFoundError(wheel) + + with zipfile.ZipFile(wheel) as archive: + metadata_files = [ + name for name in archive.namelist() if name.endswith(".dist-info/METADATA") + ] + if len(metadata_files) != 1: + raise ValueError( + f"{wheel.name} must contain exactly one dist-info/METADATA file" + ) + metadata = email.message_from_bytes(archive.read(metadata_files[0])) + + return [ + Requirement(value) for value in metadata.get_all("Requires-Dist", failobj=[]) + ] + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Validate the OTel plugin wheel dependency contract." + ) + parser.add_argument("wheel", type=Path) + args = parser.parse_args(argv) + + check_otel_wheel_dependencies(args.wheel) + print(args.wheel) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/tests/test_check_otel_wheel_dependencies.py b/.github/scripts/tests/test_check_otel_wheel_dependencies.py new file mode 100644 index 00000000..0e45f803 --- /dev/null +++ b/.github/scripts/tests/test_check_otel_wheel_dependencies.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import os +import sys +import zipfile +from pathlib import Path + +import pytest + + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from check_otel_wheel_dependencies import check_otel_wheel_dependencies + + +def _write_wheel(tmp_path: Path, requirements: tuple[str, ...]) -> Path: + wheel = tmp_path / "aws_durable_execution_sdk_python_otel-1.0.0-py3-none-any.whl" + metadata = [ + "Metadata-Version: 2.4", + "Name: aws-durable-execution-sdk-python-otel", + "Version: 1.0.0", + *(f"Requires-Dist: {requirement}" for requirement in requirements), + "", + ] + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr( + "aws_durable_execution_sdk_python_otel-1.0.0.dist-info/METADATA", + "\n".join(metadata), + ) + return wheel + + +def test_accepts_layer_provided_dependencies_with_standalone_extra( + tmp_path: Path, +) -> None: + wheel = _write_wheel( + tmp_path, + ( + "aws-durable-execution-sdk-python>=1.8.0", + "OpenTelemetry_API>=1.20.0; extra == 'standalone'", + "opentelemetry.sdk>=1.20.0; extra == 'standalone'", + "OpenTelemetry-Propagator_AWS-XRay; extra == 'standalone'", + ), + ) + + check_otel_wheel_dependencies(wheel) + + +def test_rejects_default_opentelemetry_dependency(tmp_path: Path) -> None: + wheel = _write_wheel( + tmp_path, + ( + "aws-durable-execution-sdk-python>=1.8.0", + "opentelemetry-sdk>=1.20.0", + "opentelemetry-api>=1.20.0; extra == 'standalone'", + "opentelemetry-sdk>=1.20.0; extra == 'standalone'", + "opentelemetry-propagator-aws-xray; extra == 'standalone'", + ), + ) + + with pytest.raises(ValueError, match="installs OpenTelemetry dependencies"): + check_otel_wheel_dependencies(wheel) + + +def test_rejects_noncanonical_default_opentelemetry_dependency( + tmp_path: Path, +) -> None: + wheel = _write_wheel( + tmp_path, + ( + "aws-durable-execution-sdk-python>=1.8.0", + "OpenTelemetry_SDK>=1.20.0", + "opentelemetry-api>=1.20.0; extra == 'standalone'", + "opentelemetry-sdk>=1.20.0; extra == 'standalone'", + "opentelemetry-propagator-aws-xray; extra == 'standalone'", + ), + ) + + with pytest.raises(ValueError, match="installs OpenTelemetry dependencies"): + check_otel_wheel_dependencies(wheel) + + +def test_rejects_incomplete_standalone_extra(tmp_path: Path) -> None: + wheel = _write_wheel( + tmp_path, + ( + "aws-durable-execution-sdk-python>=1.8.0", + "opentelemetry-api>=1.20.0; extra == 'standalone'", + "opentelemetry-sdk>=1.20.0; extra == 'standalone'", + ), + ) + + with pytest.raises( + ValueError, match="standalone OpenTelemetry dependencies must be" + ): + check_otel_wheel_dependencies(wheel) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42b6a50a..63c4b44c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,11 @@ jobs: cd "$GITHUB_WORKSPACE" fi done + - name: Verify OTel wheel dependency contract + run: | + OTEL_WHEEL=$(find packages/aws-durable-execution-sdk-python-otel/dist \ + -name 'aws_durable_execution_sdk_python_otel-*.whl' -print -quit) + python .github/scripts/check_otel_wheel_dependencies.py "$OTEL_WHEEL" - name: Verify legal files in published distributions run: | python .github/scripts/check_dist_legal_files.py \ diff --git a/.github/workflows/lambda-layer-publish.yml b/.github/workflows/lambda-layer-publish.yml index b28c9792..593ff96a 100644 --- a/.github/workflows/lambda-layer-publish.yml +++ b/.github/workflows/lambda-layer-publish.yml @@ -120,6 +120,12 @@ jobs: working-directory: packages/aws-durable-execution-sdk-python-otel run: hatch build + - name: Verify OTel wheel dependency contract + run: | + OTEL_WHEEL=$(find packages/aws-durable-execution-sdk-python-otel/dist \ + -name 'aws_durable_execution_sdk_python_otel-*.whl' -print -quit) + python .github/scripts/check_otel_wheel_dependencies.py "$OTEL_WHEEL" + - name: Verify legal files run: | python .github/scripts/check_dist_legal_files.py \ diff --git a/.github/workflows/test-parser.yml b/.github/workflows/test-parser.yml index abb8101c..59364b04 100644 --- a/.github/workflows/test-parser.yml +++ b/.github/workflows/test-parser.yml @@ -4,16 +4,20 @@ on: pull_request: paths: - '.github/scripts/build_lambda_layer.py' + - '.github/scripts/check_otel_wheel_dependencies.py' - '.github/scripts/parse_sdk_branch.py' - '.github/scripts/tests/**' - '.github/workflows/opentelemetry-conformance-tests.yml' + - 'packages/aws-durable-execution-sdk-python-otel/pyproject.toml' push: branches: [ main ] paths: - '.github/scripts/build_lambda_layer.py' + - '.github/scripts/check_otel_wheel_dependencies.py' - '.github/scripts/parse_sdk_branch.py' - '.github/scripts/tests/**' - '.github/workflows/opentelemetry-conformance-tests.yml' + - 'packages/aws-durable-execution-sdk-python-otel/pyproject.toml' permissions: contents: read @@ -25,11 +29,12 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install test dependencies - run: python -m pip install pytest + run: python -m pip install packaging pytest - name: Run script tests run: | python -m pytest \ .github/scripts/tests/test_build_lambda_layer.py \ + .github/scripts/tests/test_check_otel_wheel_dependencies.py \ .github/scripts/tests/test_opentelemetry_conformance_workflow.py \ .github/scripts/tests/test_parse_sdk_branch.py diff --git a/packages/aws-durable-execution-sdk-python-otel/README.md b/packages/aws-durable-execution-sdk-python-otel/README.md index 89a58302..4adcf32b 100644 --- a/packages/aws-durable-execution-sdk-python-otel/README.md +++ b/packages/aws-durable-execution-sdk-python-otel/README.md @@ -14,10 +14,27 @@ OpenTelemetry instrumentation plugin for the [AWS Durable Execution SDK for Pyth ## Installation +When using an ADOT or community OpenTelemetry Lambda layer: + ```bash pip install aws-durable-execution-sdk-python-otel ``` +The base package intentionally does not install OpenTelemetry libraries. The +Lambda layer supplies a version-aligned API, SDK, exporter, and propagators, +preventing packages in the function artifact from shadowing parts of the layer. + +For an application that configures its own OpenTelemetry provider instead of +using a Lambda layer: + +```bash +pip install "aws-durable-execution-sdk-python-otel[standalone]" +``` + +The `standalone` extra installs the OpenTelemetry API, SDK, and AWS X-Ray +propagator. The application remains responsible for configuring its provider, +processors, and exporter. + ## Quick Start using X-Ray/CloudWatch Tracing 1. Add the [ADOT Lambda Layer](#1-adot-lambda-layer) to your function and set `AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-instrument` @@ -306,8 +323,7 @@ setups. - Python >= 3.11 - `aws-durable-execution-sdk-python` >= 1.8.0 -- `opentelemetry-api` >= 1.20.0 -- `opentelemetry-sdk` >= 1.20.0 +- An ADOT/community OpenTelemetry Lambda layer, or the `standalone` extra ## License diff --git a/packages/aws-durable-execution-sdk-python-otel/pyproject.toml b/packages/aws-durable-execution-sdk-python-otel/pyproject.toml index 9df29594..380fc473 100644 --- a/packages/aws-durable-execution-sdk-python-otel/pyproject.toml +++ b/packages/aws-durable-execution-sdk-python-otel/pyproject.toml @@ -23,15 +23,21 @@ classifiers = [ ] dependencies = [ "aws-durable-execution-sdk-python>=1.8.0", - "opentelemetry-api>=1.20.0", - "opentelemetry-sdk>=1.20.0", - "opentelemetry-propagator-aws-xray", ] [project.entry-points."aws_durable_execution.plugins"] otel-invocation = "aws_durable_execution_sdk_python_otel.plugin_provider:INVOCATION_OTEL_PLUGIN_PROVIDER" otel-execution = "aws_durable_execution_sdk_python_otel.plugin_provider:EXECUTION_OTEL_PLUGIN_PROVIDER" +[project.optional-dependencies] +# Lambda telemetry layers provide a version-aligned OpenTelemetry distribution. +# Use this extra only when the application configures OpenTelemetry itself. +standalone = [ + "opentelemetry-api>=1.20.0", + "opentelemetry-sdk>=1.20.0", + "opentelemetry-propagator-aws-xray", +] + [project.urls] Documentation = "https://github.com/aws/aws-durable-execution-sdk-python#readme" Issues = "https://github.com/aws/aws-durable-execution-sdk-python/issues" diff --git a/packages/aws-durable-execution-sdk-python-otel/tests/test_package_metadata.py b/packages/aws-durable-execution-sdk-python-otel/tests/test_package_metadata.py index b3daf746..6cb0570c 100644 --- a/packages/aws-durable-execution-sdk-python-otel/tests/test_package_metadata.py +++ b/packages/aws-durable-execution-sdk-python-otel/tests/test_package_metadata.py @@ -5,6 +5,15 @@ PACKAGE_ROOT = Path(__file__).resolve().parents[1] REPOSITORY_ROOT = PACKAGE_ROOT.parents[1] CORE_DEPENDENCY = "aws-durable-execution-sdk-python>=1.8.0" +TEST_OTEL_DEPENDENCIES = { + "opentelemetry-sdk>=1.20.0", + "opentelemetry-propagator-aws-xray", +} +STANDALONE_OTEL_DEPENDENCIES = { + "opentelemetry-api>=1.20.0", + "opentelemetry-sdk>=1.20.0", + "opentelemetry-propagator-aws-xray", +} def _load_pyproject(path: Path) -> dict: @@ -29,6 +38,42 @@ def test_package_requires_compatible_core_sdk() -> None: assert CORE_DEPENDENCY in dependencies +def test_package_relies_on_layer_for_opentelemetry_dependencies() -> None: + dependencies = _load_pyproject(PACKAGE_ROOT / "pyproject.toml")["project"][ + "dependencies" + ] + + assert not any( + dependency.startswith("opentelemetry-") for dependency in dependencies + ) + + +def test_standalone_extra_provides_opentelemetry_dependencies() -> None: + standalone_dependencies = _load_pyproject(PACKAGE_ROOT / "pyproject.toml")[ + "project" + ]["optional-dependencies"]["standalone"] + + assert set(standalone_dependencies) == STANDALONE_OTEL_DEPENDENCIES + + +def test_test_environments_install_layer_provided_dependencies() -> None: + environments = _load_pyproject(REPOSITORY_ROOT / "pyproject.toml")["tool"]["hatch"][ + "envs" + ] + + for environment_name in ( + "test", + "dev-otel", + "dev-examples", + "test-pypi-otel", + "test-pypi-examples", + ): + assert TEST_OTEL_DEPENDENCIES <= set( + environments[environment_name]["dependencies"] + ) + assert TEST_OTEL_DEPENDENCIES <= set(environments["types"]["extra-dependencies"]) + + def test_pypi_compatibility_environment_uses_compatible_core_sdk() -> None: dependencies = _load_pyproject(REPOSITORY_ROOT / "pyproject.toml")["tool"]["hatch"][ "envs" diff --git a/pyproject.toml b/pyproject.toml index 9aca6da7..0d5b48ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "pytest-cov", "pytest-xdist", "opentelemetry-sdk>=1.20.0", + "opentelemetry-propagator-aws-xray", "aws-durable-execution-sdk-python-testing", "PyYAML==6.0.2", ] @@ -49,6 +50,7 @@ extra-dependencies = [ "pytest", "boto3-stubs[lambda]", "opentelemetry-sdk>=1.20.0", + "opentelemetry-propagator-aws-xray", ] [tool.hatch.envs.types.scripts] @@ -80,6 +82,7 @@ dependencies = [ "pytest-cov", "coverage[toml]", "opentelemetry-sdk>=1.20.0", + "opentelemetry-propagator-aws-xray", "mypy>=1.0.0", ] @@ -115,6 +118,8 @@ workspace.members = [ dependencies = [ "pytest", "aws-durable-execution-sdk-python-testing", + "opentelemetry-sdk>=1.20.0", + "opentelemetry-propagator-aws-xray", ] [tool.hatch.envs.dev-examples.scripts] @@ -124,6 +129,7 @@ test = "pytest packages/aws-durable-execution-sdk-python-examples/test {args}" dependencies = [ "aws-durable-execution-sdk-python>=1.8.0", "opentelemetry-sdk>=1.20.0", + "opentelemetry-propagator-aws-xray", "pytest", "pytest-cov", "coverage[toml]", @@ -139,6 +145,8 @@ test = "pytest packages/aws-durable-execution-sdk-python-otel/tests {args}" dependencies = [ "aws-durable-execution-sdk-python", "aws-durable-execution-sdk-python-testing", + "opentelemetry-sdk>=1.20.0", + "opentelemetry-propagator-aws-xray", "pytest", ] pre-install-commands = [