Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/scripts/check_otel_wheel_dependencies.py
Original file line number Diff line number Diff line change
@@ -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}"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex AI review

[P2] Avoid host-dependent marker evaluation

Marker.evaluate() fills unspecified values such as platform_machine from the CI runner. A default dependency guarded by platform_machine == "aarch64" therefore passes this check on x86 while still installing OpenTelemetry on ARM Lambda, recreating the layer-shadowing problem. Validate that each OTel marker logically requires extra == "standalone" (or evaluate every supported target environment), and add a platform-marker test.


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())
96 changes: 96 additions & 0 deletions .github/scripts/tests/test_check_otel_wheel_dependencies.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/lambda-layer-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/test-parser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
20 changes: 18 additions & 2 deletions packages/aws-durable-execution-sdk-python-otel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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

Expand Down
12 changes: 9 additions & 3 deletions packages/aws-durable-execution-sdk-python-otel/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"
Expand Down
Loading
Loading