Skip to content

Commit bcfde0a

Browse files
committed
fix(otel): rely on Lambda layer dependencies
1 parent 20e1f84 commit bcfde0a

9 files changed

Lines changed: 249 additions & 6 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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+
11+
12+
EXPECTED_STANDALONE_DEPENDENCIES = {
13+
"opentelemetry-api",
14+
"opentelemetry-propagator-aws-xray",
15+
"opentelemetry-sdk",
16+
}
17+
18+
19+
def check_otel_wheel_dependencies(wheel: Path) -> None:
20+
"""Validate that OpenTelemetry dependencies are layer-provided by default."""
21+
requirements = _read_requirements(wheel)
22+
base_otel_dependencies = {
23+
requirement.name
24+
for requirement in requirements
25+
if requirement.name.startswith("opentelemetry-")
26+
and (requirement.marker is None or requirement.marker.evaluate({"extra": ""}))
27+
}
28+
if base_otel_dependencies:
29+
names = ", ".join(sorted(base_otel_dependencies))
30+
raise ValueError(
31+
f"{wheel.name} installs OpenTelemetry dependencies by default: {names}"
32+
)
33+
34+
standalone_dependencies = {
35+
requirement.name
36+
for requirement in requirements
37+
if requirement.name.startswith("opentelemetry-")
38+
and requirement.marker is not None
39+
and requirement.marker.evaluate({"extra": "standalone"})
40+
}
41+
if standalone_dependencies != EXPECTED_STANDALONE_DEPENDENCIES:
42+
expected = ", ".join(sorted(EXPECTED_STANDALONE_DEPENDENCIES))
43+
actual = ", ".join(sorted(standalone_dependencies)) or "none"
44+
raise ValueError(
45+
f"{wheel.name} standalone OpenTelemetry dependencies must be "
46+
f"{expected}; found {actual}"
47+
)
48+
49+
50+
def _read_requirements(wheel: Path) -> list[Requirement]:
51+
if not wheel.is_file():
52+
raise FileNotFoundError(wheel)
53+
54+
with zipfile.ZipFile(wheel) as archive:
55+
metadata_files = [
56+
name for name in archive.namelist() if name.endswith(".dist-info/METADATA")
57+
]
58+
if len(metadata_files) != 1:
59+
raise ValueError(
60+
f"{wheel.name} must contain exactly one dist-info/METADATA file"
61+
)
62+
metadata = email.message_from_bytes(archive.read(metadata_files[0]))
63+
64+
return [
65+
Requirement(value) for value in metadata.get_all("Requires-Dist", failobj=[])
66+
]
67+
68+
69+
def main(argv: list[str] | None = None) -> int:
70+
parser = argparse.ArgumentParser(
71+
description="Validate the OTel plugin wheel dependency contract."
72+
)
73+
parser.add_argument("wheel", type=Path)
74+
args = parser.parse_args(argv)
75+
76+
check_otel_wheel_dependencies(args.wheel)
77+
print(args.wheel)
78+
return 0
79+
80+
81+
if __name__ == "__main__":
82+
raise SystemExit(main())
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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_incomplete_standalone_extra(tmp_path: Path) -> None:
66+
wheel = _write_wheel(
67+
tmp_path,
68+
(
69+
"aws-durable-execution-sdk-python>=1.8.0",
70+
"opentelemetry-api>=1.20.0; extra == 'standalone'",
71+
"opentelemetry-sdk>=1.20.0; extra == 'standalone'",
72+
),
73+
)
74+
75+
with pytest.raises(
76+
ValueError, match="standalone OpenTelemetry dependencies must be"
77+
):
78+
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: 39 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,36 @@ 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 ("test", "dev-otel", "dev-examples", "test-pypi-otel"):
65+
assert TEST_OTEL_DEPENDENCIES <= set(
66+
environments[environment_name]["dependencies"]
67+
)
68+
assert TEST_OTEL_DEPENDENCIES <= set(environments["types"]["extra-dependencies"])
69+
70+
3271
def test_pypi_compatibility_environment_uses_compatible_core_sdk() -> None:
3372
dependencies = _load_pyproject(REPOSITORY_ROOT / "pyproject.toml")["tool"]["hatch"][
3473
"envs"

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ dependencies = [
1515
"pytest-cov",
1616
"pytest-xdist",
1717
"opentelemetry-sdk>=1.20.0",
18+
"opentelemetry-propagator-aws-xray",
1819
"aws-durable-execution-sdk-python-testing",
1920
"PyYAML==6.0.2",
2021
]
@@ -49,6 +50,7 @@ extra-dependencies = [
4950
"pytest",
5051
"boto3-stubs[lambda]",
5152
"opentelemetry-sdk>=1.20.0",
53+
"opentelemetry-propagator-aws-xray",
5254
]
5355

5456
[tool.hatch.envs.types.scripts]
@@ -80,6 +82,7 @@ dependencies = [
8082
"pytest-cov",
8183
"coverage[toml]",
8284
"opentelemetry-sdk>=1.20.0",
85+
"opentelemetry-propagator-aws-xray",
8386
"mypy>=1.0.0",
8487
]
8588

@@ -115,6 +118,8 @@ workspace.members = [
115118
dependencies = [
116119
"pytest",
117120
"aws-durable-execution-sdk-python-testing",
121+
"opentelemetry-sdk>=1.20.0",
122+
"opentelemetry-propagator-aws-xray",
118123
]
119124

120125
[tool.hatch.envs.dev-examples.scripts]
@@ -124,6 +129,7 @@ test = "pytest packages/aws-durable-execution-sdk-python-examples/test {args}"
124129
dependencies = [
125130
"aws-durable-execution-sdk-python>=1.8.0",
126131
"opentelemetry-sdk>=1.20.0",
132+
"opentelemetry-propagator-aws-xray",
127133
"pytest",
128134
"pytest-cov",
129135
"coverage[toml]",

0 commit comments

Comments
 (0)