Skip to content

Commit 8bb1799

Browse files
authored
Merge branch 'main' into fix/otel-context-detach
2 parents 7bf83c4 + d43377c commit 8bb1799

9 files changed

Lines changed: 276 additions & 6 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import email
6+
import zipfile
7+
from pathlib import Path
8+
9+
from packaging.requirements import Requirement
10+
from packaging.utils import canonicalize_name
11+
12+
13+
EXPECTED_STANDALONE_DEPENDENCIES = {
14+
"opentelemetry-api",
15+
"opentelemetry-propagator-aws-xray",
16+
"opentelemetry-sdk",
17+
}
18+
19+
20+
def check_otel_wheel_dependencies(wheel: Path) -> None:
21+
"""Validate that OpenTelemetry dependencies are layer-provided by default."""
22+
requirements = _read_requirements(wheel)
23+
base_otel_dependencies = {
24+
canonicalize_name(requirement.name)
25+
for requirement in requirements
26+
if canonicalize_name(requirement.name).startswith("opentelemetry-")
27+
and (requirement.marker is None or requirement.marker.evaluate({"extra": ""}))
28+
}
29+
if base_otel_dependencies:
30+
names = ", ".join(sorted(base_otel_dependencies))
31+
raise ValueError(
32+
f"{wheel.name} installs OpenTelemetry dependencies by default: {names}"
33+
)
34+
35+
standalone_dependencies = {
36+
canonicalize_name(requirement.name)
37+
for requirement in requirements
38+
if canonicalize_name(requirement.name).startswith("opentelemetry-")
39+
and requirement.marker is not None
40+
and requirement.marker.evaluate({"extra": "standalone"})
41+
}
42+
if standalone_dependencies != EXPECTED_STANDALONE_DEPENDENCIES:
43+
expected = ", ".join(sorted(EXPECTED_STANDALONE_DEPENDENCIES))
44+
actual = ", ".join(sorted(standalone_dependencies)) or "none"
45+
raise ValueError(
46+
f"{wheel.name} standalone OpenTelemetry dependencies must be "
47+
f"{expected}; found {actual}"
48+
)
49+
50+
51+
def _read_requirements(wheel: Path) -> list[Requirement]:
52+
if not wheel.is_file():
53+
raise FileNotFoundError(wheel)
54+
55+
with zipfile.ZipFile(wheel) as archive:
56+
metadata_files = [
57+
name for name in archive.namelist() if name.endswith(".dist-info/METADATA")
58+
]
59+
if len(metadata_files) != 1:
60+
raise ValueError(
61+
f"{wheel.name} must contain exactly one dist-info/METADATA file"
62+
)
63+
metadata = email.message_from_bytes(archive.read(metadata_files[0]))
64+
65+
return [
66+
Requirement(value) for value in metadata.get_all("Requires-Dist", failobj=[])
67+
]
68+
69+
70+
def main(argv: list[str] | None = None) -> int:
71+
parser = argparse.ArgumentParser(
72+
description="Validate the OTel plugin wheel dependency contract."
73+
)
74+
parser.add_argument("wheel", type=Path)
75+
args = parser.parse_args(argv)
76+
77+
check_otel_wheel_dependencies(args.wheel)
78+
print(args.wheel)
79+
return 0
80+
81+
82+
if __name__ == "__main__":
83+
raise SystemExit(main())
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import sys
5+
import zipfile
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
11+
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
12+
13+
from check_otel_wheel_dependencies import check_otel_wheel_dependencies
14+
15+
16+
def _write_wheel(tmp_path: Path, requirements: tuple[str, ...]) -> Path:
17+
wheel = tmp_path / "aws_durable_execution_sdk_python_otel-1.0.0-py3-none-any.whl"
18+
metadata = [
19+
"Metadata-Version: 2.4",
20+
"Name: aws-durable-execution-sdk-python-otel",
21+
"Version: 1.0.0",
22+
*(f"Requires-Dist: {requirement}" for requirement in requirements),
23+
"",
24+
]
25+
with zipfile.ZipFile(wheel, "w") as archive:
26+
archive.writestr(
27+
"aws_durable_execution_sdk_python_otel-1.0.0.dist-info/METADATA",
28+
"\n".join(metadata),
29+
)
30+
return wheel
31+
32+
33+
def test_accepts_layer_provided_dependencies_with_standalone_extra(
34+
tmp_path: Path,
35+
) -> None:
36+
wheel = _write_wheel(
37+
tmp_path,
38+
(
39+
"aws-durable-execution-sdk-python>=1.8.0",
40+
"OpenTelemetry_API>=1.20.0; extra == 'standalone'",
41+
"opentelemetry.sdk>=1.20.0; extra == 'standalone'",
42+
"OpenTelemetry-Propagator_AWS-XRay; extra == 'standalone'",
43+
),
44+
)
45+
46+
check_otel_wheel_dependencies(wheel)
47+
48+
49+
def test_rejects_default_opentelemetry_dependency(tmp_path: Path) -> None:
50+
wheel = _write_wheel(
51+
tmp_path,
52+
(
53+
"aws-durable-execution-sdk-python>=1.8.0",
54+
"opentelemetry-sdk>=1.20.0",
55+
"opentelemetry-api>=1.20.0; extra == 'standalone'",
56+
"opentelemetry-sdk>=1.20.0; extra == 'standalone'",
57+
"opentelemetry-propagator-aws-xray; extra == 'standalone'",
58+
),
59+
)
60+
61+
with pytest.raises(ValueError, match="installs OpenTelemetry dependencies"):
62+
check_otel_wheel_dependencies(wheel)
63+
64+
65+
def test_rejects_noncanonical_default_opentelemetry_dependency(
66+
tmp_path: Path,
67+
) -> None:
68+
wheel = _write_wheel(
69+
tmp_path,
70+
(
71+
"aws-durable-execution-sdk-python>=1.8.0",
72+
"OpenTelemetry_SDK>=1.20.0",
73+
"opentelemetry-api>=1.20.0; extra == 'standalone'",
74+
"opentelemetry-sdk>=1.20.0; extra == 'standalone'",
75+
"opentelemetry-propagator-aws-xray; extra == 'standalone'",
76+
),
77+
)
78+
79+
with pytest.raises(ValueError, match="installs OpenTelemetry dependencies"):
80+
check_otel_wheel_dependencies(wheel)
81+
82+
83+
def test_rejects_incomplete_standalone_extra(tmp_path: Path) -> None:
84+
wheel = _write_wheel(
85+
tmp_path,
86+
(
87+
"aws-durable-execution-sdk-python>=1.8.0",
88+
"opentelemetry-api>=1.20.0; extra == 'standalone'",
89+
"opentelemetry-sdk>=1.20.0; extra == 'standalone'",
90+
),
91+
)
92+
93+
with pytest.raises(
94+
ValueError, match="standalone OpenTelemetry dependencies must be"
95+
):
96+
check_otel_wheel_dependencies(wheel)

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ jobs:
7070
cd "$GITHUB_WORKSPACE"
7171
fi
7272
done
73+
- name: Verify OTel wheel dependency contract
74+
run: |
75+
OTEL_WHEEL=$(find packages/aws-durable-execution-sdk-python-otel/dist \
76+
-name 'aws_durable_execution_sdk_python_otel-*.whl' -print -quit)
77+
python .github/scripts/check_otel_wheel_dependencies.py "$OTEL_WHEEL"
7378
- name: Verify legal files in published distributions
7479
run: |
7580
python .github/scripts/check_dist_legal_files.py \

.github/workflows/lambda-layer-publish.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ jobs:
120120
working-directory: packages/aws-durable-execution-sdk-python-otel
121121
run: hatch build
122122

123+
- name: Verify OTel wheel dependency contract
124+
run: |
125+
OTEL_WHEEL=$(find packages/aws-durable-execution-sdk-python-otel/dist \
126+
-name 'aws_durable_execution_sdk_python_otel-*.whl' -print -quit)
127+
python .github/scripts/check_otel_wheel_dependencies.py "$OTEL_WHEEL"
128+
123129
- name: Verify legal files
124130
run: |
125131
python .github/scripts/check_dist_legal_files.py \

.github/workflows/test-parser.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,20 @@ on:
44
pull_request:
55
paths:
66
- '.github/scripts/build_lambda_layer.py'
7+
- '.github/scripts/check_otel_wheel_dependencies.py'
78
- '.github/scripts/parse_sdk_branch.py'
89
- '.github/scripts/tests/**'
910
- '.github/workflows/opentelemetry-conformance-tests.yml'
11+
- 'packages/aws-durable-execution-sdk-python-otel/pyproject.toml'
1012
push:
1113
branches: [ main ]
1214
paths:
1315
- '.github/scripts/build_lambda_layer.py'
16+
- '.github/scripts/check_otel_wheel_dependencies.py'
1417
- '.github/scripts/parse_sdk_branch.py'
1518
- '.github/scripts/tests/**'
1619
- '.github/workflows/opentelemetry-conformance-tests.yml'
20+
- 'packages/aws-durable-execution-sdk-python-otel/pyproject.toml'
1721

1822
permissions:
1923
contents: read
@@ -25,11 +29,12 @@ jobs:
2529
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
2630

2731
- name: Install test dependencies
28-
run: python -m pip install pytest
32+
run: python -m pip install packaging pytest
2933

3034
- name: Run script tests
3135
run: |
3236
python -m pytest \
3337
.github/scripts/tests/test_build_lambda_layer.py \
38+
.github/scripts/tests/test_check_otel_wheel_dependencies.py \
3439
.github/scripts/tests/test_opentelemetry_conformance_workflow.py \
3540
.github/scripts/tests/test_parse_sdk_branch.py

packages/aws-durable-execution-sdk-python-otel/README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,27 @@ OpenTelemetry instrumentation plugin for the [AWS Durable Execution SDK for Pyth
1414

1515
## Installation
1616

17+
When using an ADOT or community OpenTelemetry Lambda layer:
18+
1719
```bash
1820
pip install aws-durable-execution-sdk-python-otel
1921
```
2022

23+
The base package intentionally does not install OpenTelemetry libraries. The
24+
Lambda layer supplies a version-aligned API, SDK, exporter, and propagators,
25+
preventing packages in the function artifact from shadowing parts of the layer.
26+
27+
For an application that configures its own OpenTelemetry provider instead of
28+
using a Lambda layer:
29+
30+
```bash
31+
pip install "aws-durable-execution-sdk-python-otel[standalone]"
32+
```
33+
34+
The `standalone` extra installs the OpenTelemetry API, SDK, and AWS X-Ray
35+
propagator. The application remains responsible for configuring its provider,
36+
processors, and exporter.
37+
2138
## Quick Start using X-Ray/CloudWatch Tracing
2239

2340
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.
306323

307324
- Python >= 3.11
308325
- `aws-durable-execution-sdk-python` >= 1.8.0
309-
- `opentelemetry-api` >= 1.20.0
310-
- `opentelemetry-sdk` >= 1.20.0
326+
- An ADOT/community OpenTelemetry Lambda layer, or the `standalone` extra
311327

312328
## License
313329

packages/aws-durable-execution-sdk-python-otel/pyproject.toml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,21 @@ classifiers = [
2323
]
2424
dependencies = [
2525
"aws-durable-execution-sdk-python>=1.8.0",
26-
"opentelemetry-api>=1.20.0",
27-
"opentelemetry-sdk>=1.20.0",
28-
"opentelemetry-propagator-aws-xray",
2926
]
3027

3128
[project.entry-points."aws_durable_execution.plugins"]
3229
otel-invocation = "aws_durable_execution_sdk_python_otel.plugin_provider:INVOCATION_OTEL_PLUGIN_PROVIDER"
3330
otel-execution = "aws_durable_execution_sdk_python_otel.plugin_provider:EXECUTION_OTEL_PLUGIN_PROVIDER"
3431

32+
[project.optional-dependencies]
33+
# Lambda telemetry layers provide a version-aligned OpenTelemetry distribution.
34+
# Use this extra only when the application configures OpenTelemetry itself.
35+
standalone = [
36+
"opentelemetry-api>=1.20.0",
37+
"opentelemetry-sdk>=1.20.0",
38+
"opentelemetry-propagator-aws-xray",
39+
]
40+
3541
[project.urls]
3642
Documentation = "https://github.com/aws/aws-durable-execution-sdk-python#readme"
3743
Issues = "https://github.com/aws/aws-durable-execution-sdk-python/issues"

packages/aws-durable-execution-sdk-python-otel/tests/test_package_metadata.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@
55
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
66
REPOSITORY_ROOT = PACKAGE_ROOT.parents[1]
77
CORE_DEPENDENCY = "aws-durable-execution-sdk-python>=1.8.0"
8+
TEST_OTEL_DEPENDENCIES = {
9+
"opentelemetry-sdk>=1.20.0",
10+
"opentelemetry-propagator-aws-xray",
11+
}
12+
STANDALONE_OTEL_DEPENDENCIES = {
13+
"opentelemetry-api>=1.20.0",
14+
"opentelemetry-sdk>=1.20.0",
15+
"opentelemetry-propagator-aws-xray",
16+
}
817

918

1019
def _load_pyproject(path: Path) -> dict:
@@ -29,6 +38,42 @@ def test_package_requires_compatible_core_sdk() -> None:
2938
assert CORE_DEPENDENCY in dependencies
3039

3140

41+
def test_package_relies_on_layer_for_opentelemetry_dependencies() -> None:
42+
dependencies = _load_pyproject(PACKAGE_ROOT / "pyproject.toml")["project"][
43+
"dependencies"
44+
]
45+
46+
assert not any(
47+
dependency.startswith("opentelemetry-") for dependency in dependencies
48+
)
49+
50+
51+
def test_standalone_extra_provides_opentelemetry_dependencies() -> None:
52+
standalone_dependencies = _load_pyproject(PACKAGE_ROOT / "pyproject.toml")[
53+
"project"
54+
]["optional-dependencies"]["standalone"]
55+
56+
assert set(standalone_dependencies) == STANDALONE_OTEL_DEPENDENCIES
57+
58+
59+
def test_test_environments_install_layer_provided_dependencies() -> None:
60+
environments = _load_pyproject(REPOSITORY_ROOT / "pyproject.toml")["tool"]["hatch"][
61+
"envs"
62+
]
63+
64+
for environment_name in (
65+
"test",
66+
"dev-otel",
67+
"dev-examples",
68+
"test-pypi-otel",
69+
"test-pypi-examples",
70+
):
71+
assert TEST_OTEL_DEPENDENCIES <= set(
72+
environments[environment_name]["dependencies"]
73+
)
74+
assert TEST_OTEL_DEPENDENCIES <= set(environments["types"]["extra-dependencies"])
75+
76+
3277
def test_pypi_compatibility_environment_uses_compatible_core_sdk() -> None:
3378
dependencies = _load_pyproject(REPOSITORY_ROOT / "pyproject.toml")["tool"]["hatch"][
3479
"envs"

0 commit comments

Comments
 (0)