aws-durable-execution-sdk-python-otel 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. {aws_durable_execution_sdk_python_otel-0.1.0 → aws_durable_execution_sdk_python_otel-0.2.0}/PKG-INFO +40 -3
  2. aws_durable_execution_sdk_python_otel-0.2.0/README.md +41 -0
  3. {aws_durable_execution_sdk_python_otel-0.1.0 → aws_durable_execution_sdk_python_otel-0.2.0}/pyproject.toml +51 -2
  4. {aws_durable_execution_sdk_python_otel-0.1.0 → aws_durable_execution_sdk_python_otel-0.2.0}/src/aws_durable_execution_sdk_python_otel/__about__.py +1 -1
  5. aws_durable_execution_sdk_python_otel-0.2.0/src/aws_durable_execution_sdk_python_otel/__init__.py +30 -0
  6. aws_durable_execution_sdk_python_otel-0.2.0/src/aws_durable_execution_sdk_python_otel/context_extractors.py +40 -0
  7. aws_durable_execution_sdk_python_otel-0.2.0/src/aws_durable_execution_sdk_python_otel/deterministic_id_generator.py +126 -0
  8. aws_durable_execution_sdk_python_otel-0.2.0/src/aws_durable_execution_sdk_python_otel/log_filter.py +105 -0
  9. aws_durable_execution_sdk_python_otel-0.2.0/src/aws_durable_execution_sdk_python_otel/plugin.py +488 -0
  10. aws_durable_execution_sdk_python_otel-0.2.0/src/aws_durable_execution_sdk_python_otel/py.typed +0 -0
  11. aws_durable_execution_sdk_python_otel-0.2.0/tests/__init__.py +0 -0
  12. aws_durable_execution_sdk_python_otel-0.2.0/tests/test_context_extractors.py +66 -0
  13. aws_durable_execution_sdk_python_otel-0.2.0/tests/test_deterministic_id_generator.py +282 -0
  14. aws_durable_execution_sdk_python_otel-0.2.0/tests/test_init.py +18 -0
  15. aws_durable_execution_sdk_python_otel-0.2.0/tests/test_log_filter.py +230 -0
  16. aws_durable_execution_sdk_python_otel-0.2.0/tests/test_plugin.py +493 -0
  17. aws_durable_execution_sdk_python_otel-0.1.0/README.md +0 -9
  18. aws_durable_execution_sdk_python_otel-0.1.0/src/aws_durable_execution_sdk_python_otel/__init__.py +0 -6
  19. {aws_durable_execution_sdk_python_otel-0.1.0 → aws_durable_execution_sdk_python_otel-0.2.0}/.gitignore +0 -0
  20. {aws_durable_execution_sdk_python_otel-0.1.0 → aws_durable_execution_sdk_python_otel-0.2.0}/LICENSE +0 -0
  21. {aws_durable_execution_sdk_python_otel-0.1.0 → aws_durable_execution_sdk_python_otel-0.2.0}/NOTICE +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-durable-execution-sdk-python-otel
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: OpenTelemetry instrumentation for AWS Durable Execution SDK for Python
5
5
  Project-URL: Documentation, https://github.com/aws/aws-durable-execution-sdk-python#readme
6
6
  Project-URL: Issues, https://github.com/aws/aws-durable-execution-sdk-python/issues
@@ -8,7 +8,7 @@ Project-URL: Source, https://github.com/aws/aws-durable-execution-sdk-python
8
8
  Author-email: AWS durable-execution-dev <durable-execution-dev@amazon.com>
9
9
  License-Expression: Apache-2.0
10
10
  Keywords: durable-execution,observability,opentelemetry,tracing
11
- Classifier: Development Status :: 1 - Planning
11
+ Classifier: Development Status :: 4 - Beta
12
12
  Classifier: Programming Language :: Python
13
13
  Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
@@ -17,13 +17,50 @@ Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Programming Language :: Python :: Implementation :: CPython
18
18
  Classifier: Programming Language :: Python :: Implementation :: PyPy
19
19
  Requires-Python: >=3.11
20
+ Requires-Dist: aws-durable-execution-sdk-python>=1.5.0
21
+ Requires-Dist: opentelemetry-api>=1.20.0
22
+ Requires-Dist: opentelemetry-exporter-otlp
23
+ Requires-Dist: opentelemetry-propagator-aws-xray
24
+ Requires-Dist: opentelemetry-sdk>=1.20.0
20
25
  Description-Content-Type: text/markdown
21
26
 
22
27
  # aws-durable-execution-sdk-python-otel
23
28
 
24
29
  OpenTelemetry instrumentation for the [AWS Durable Execution SDK for Python](https://github.com/aws/aws-durable-execution-sdk-python).
25
30
 
26
- > **Note:** This release reserves the package name. Full instrumentation is coming soon.
31
+ > **Note:** v0.1.0 reserves the package name. Instrumentation lands in v0.2.0.
32
+
33
+ ## Overview
34
+
35
+ This package will provide automatic OpenTelemetry tracing for durable execution workflows, giving you visibility into step execution, waits, retries, and overall workflow performance.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install aws-durable-execution-sdk-python-otel
41
+ ```
42
+
43
+ ## Quick Start
44
+
45
+ ```python
46
+ from aws_durable_execution_sdk_python_otel import __version__
47
+
48
+ print(__version__)
49
+ ```
50
+
51
+ ## Planned Features (v0.2.0)
52
+
53
+ - Automatic span creation for steps, waits, invokes, and child contexts
54
+ - Replay-aware tracing (distinguishes fresh executions from replays)
55
+ - Error recording with proper OTel status codes
56
+ - Configurable span attributes and naming
57
+
58
+ ## Requirements
59
+
60
+ - Python >= 3.11
61
+ - `aws-durable-execution-sdk-python` >= 1.5.0
62
+ - `opentelemetry-api` >= 1.20.0
63
+ - `opentelemetry-sdk` >= 1.20.0
27
64
 
28
65
  ## License
29
66
 
@@ -0,0 +1,41 @@
1
+ # aws-durable-execution-sdk-python-otel
2
+
3
+ OpenTelemetry instrumentation for the [AWS Durable Execution SDK for Python](https://github.com/aws/aws-durable-execution-sdk-python).
4
+
5
+ > **Note:** v0.1.0 reserves the package name. Instrumentation lands in v0.2.0.
6
+
7
+ ## Overview
8
+
9
+ This package will provide automatic OpenTelemetry tracing for durable execution workflows, giving you visibility into step execution, waits, retries, and overall workflow performance.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pip install aws-durable-execution-sdk-python-otel
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```python
20
+ from aws_durable_execution_sdk_python_otel import __version__
21
+
22
+ print(__version__)
23
+ ```
24
+
25
+ ## Planned Features (v0.2.0)
26
+
27
+ - Automatic span creation for steps, waits, invokes, and child contexts
28
+ - Replay-aware tracing (distinguishes fresh executions from replays)
29
+ - Error recording with proper OTel status codes
30
+ - Configurable span attributes and naming
31
+
32
+ ## Requirements
33
+
34
+ - Python >= 3.11
35
+ - `aws-durable-execution-sdk-python` >= 1.5.0
36
+ - `opentelemetry-api` >= 1.20.0
37
+ - `opentelemetry-sdk` >= 1.20.0
38
+
39
+ ## License
40
+
41
+ Apache-2.0
@@ -12,7 +12,7 @@ license = "Apache-2.0"
12
12
  keywords = ["opentelemetry", "tracing", "observability", "durable-execution"]
13
13
  authors = [{ name = "AWS durable-execution-dev", email = "durable-execution-dev@amazon.com" }]
14
14
  classifiers = [
15
- "Development Status :: 1 - Planning",
15
+ "Development Status :: 4 - Beta",
16
16
  "Programming Language :: Python",
17
17
  "Programming Language :: Python :: 3.11",
18
18
  "Programming Language :: Python :: 3.12",
@@ -21,7 +21,13 @@ classifiers = [
21
21
  "Programming Language :: Python :: Implementation :: CPython",
22
22
  "Programming Language :: Python :: Implementation :: PyPy",
23
23
  ]
24
- dependencies = []
24
+ dependencies = [
25
+ "aws-durable-execution-sdk-python>=1.5.0",
26
+ "opentelemetry-api>=1.20.0",
27
+ "opentelemetry-sdk>=1.20.0",
28
+ "opentelemetry-exporter-otlp",
29
+ "opentelemetry-propagator-aws-xray",
30
+ ]
25
31
 
26
32
  [project.urls]
27
33
  Documentation = "https://github.com/aws/aws-durable-execution-sdk-python#readme"
@@ -41,3 +47,46 @@ packages = ["src/aws_durable_execution_sdk_python_otel"]
41
47
 
42
48
  [tool.hatch.version]
43
49
  path = "src/aws_durable_execution_sdk_python_otel/__about__.py"
50
+
51
+ [tool.hatch.publish.index]
52
+ disable = true
53
+
54
+ [tool.coverage.run]
55
+ source_pkgs = ["aws_durable_execution_sdk_python_otel"]
56
+ branch = true
57
+ parallel = true
58
+ omit = ["src/aws_durable_execution_sdk_python_otel/__about__.py"]
59
+
60
+ [tool.coverage.paths]
61
+ aws_durable_execution_sdk_python_otel = [
62
+ "src/aws_durable_execution_sdk_python_otel",
63
+ "*/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel",
64
+ ]
65
+
66
+ [tool.coverage.report]
67
+ exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
68
+
69
+ [tool.ruff]
70
+ line-length = 88
71
+ target-version = "py311"
72
+
73
+ [tool.ruff.lint]
74
+ preview = true
75
+ select = ["TID252"]
76
+
77
+ [tool.ruff.lint.isort]
78
+ known-first-party = ["aws_durable_execution_sdk_python_otel"]
79
+ force-single-line = false
80
+ lines-after-imports = 2
81
+
82
+ [tool.ruff.lint.per-file-ignores]
83
+ "tests/**" = [
84
+ "ARG001",
85
+ "ARG002",
86
+ "ARG005",
87
+ "S101",
88
+ "PLR2004",
89
+ "PLR6301",
90
+ "SIM117",
91
+ "TRY301",
92
+ ]
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2025-present Amazon.com, Inc. or its affiliates.
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
- __version__ = "0.1.0"
4
+ __version__ = "0.2.0"
@@ -0,0 +1,30 @@
1
+ """OpenTelemetry instrumentation for AWS Lambda Durable Executions Python SDK."""
2
+
3
+ from aws_durable_execution_sdk_python_otel.__about__ import __version__
4
+ from aws_durable_execution_sdk_python_otel.context_extractors import (
5
+ ContextExtractor,
6
+ w3c_client_context_extractor,
7
+ xray_context_extractor,
8
+ )
9
+ from aws_durable_execution_sdk_python_otel.deterministic_id_generator import (
10
+ DeterministicIdGenerator,
11
+ )
12
+ from aws_durable_execution_sdk_python_otel.log_filter import (
13
+ OtelContextLogFilter,
14
+ install_log_filter,
15
+ )
16
+ from aws_durable_execution_sdk_python_otel.plugin import (
17
+ DurableExecutionOtelPlugin,
18
+ )
19
+
20
+
21
+ __all__ = [
22
+ "__version__",
23
+ "ContextExtractor",
24
+ "DeterministicIdGenerator",
25
+ "DurableExecutionOtelPlugin",
26
+ "OtelContextLogFilter",
27
+ "install_log_filter",
28
+ "w3c_client_context_extractor",
29
+ "xray_context_extractor",
30
+ ]
@@ -0,0 +1,40 @@
1
+ """Context extractors for propagating trace context into durable executions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import TYPE_CHECKING, Callable
7
+
8
+ from opentelemetry import context as otel_context, propagate
9
+
10
+
11
+ if TYPE_CHECKING:
12
+ from opentelemetry.context import Context
13
+
14
+ from aws_durable_execution_sdk_python.plugin import InvocationStartInfo
15
+
16
+ ContextExtractor = Callable[["InvocationStartInfo"], "Context"]
17
+
18
+
19
+ def xray_context_extractor(info: "InvocationStartInfo") -> "Context":
20
+ """Read the X-Ray trace header from the _X_AMZN_TRACE_ID environment variable.
21
+
22
+ The durable execution backend propagates the same Root trace ID to every
23
+ invocation, so all invocations share one traceId.
24
+ """
25
+ trace_header = os.environ.get("_X_AMZN_TRACE_ID")
26
+ if not trace_header:
27
+ return otel_context.get_current()
28
+ return propagate.extract(
29
+ carrier={"X-Amzn-Trace-Id": trace_header},
30
+ context=otel_context.get_current(),
31
+ )
32
+
33
+
34
+ def w3c_client_context_extractor(info: "InvocationStartInfo") -> "Context":
35
+ """Read W3C traceparent from context.clientContext.custom.traceparent.
36
+
37
+ Requires the backend clientContext propagation to be enabled.
38
+ This extractor is a placeholder for when backend propagation is supported.
39
+ """
40
+ return otel_context.get_current()
@@ -0,0 +1,126 @@
1
+ """Deterministic ID generator for OpenTelemetry spans in durable executions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextvars
6
+ import hashlib
7
+ import os
8
+ import re
9
+ from datetime import UTC, datetime
10
+
11
+ from opentelemetry.sdk.trace import IdGenerator, RandomIdGenerator
12
+
13
+
14
+ HASHED_ID_PATTERN = re.compile(r"^[0-9a-f]{16}$")
15
+
16
+ # Scoping the pending span ID to the execution context ensures concurrent
17
+ # operations cannot consume each other's deterministic span ID.
18
+ _next_span_id: contextvars.ContextVar[int | None] = contextvars.ContextVar(
19
+ "next_span_id", default=None
20
+ )
21
+
22
+
23
+ def _parse_xray_root_trace_id(trace_header: str | None) -> str | None:
24
+ """Parse the Root trace ID from an X-Ray trace header string.
25
+
26
+ The header format is:
27
+ Root=1-<8 hex>-<24 hex>;Parent=<16 hex>;Sampled=0|1
28
+
29
+ Returns the root value (e.g. "1-5759e988-bd862e3fe1be46a994272793")
30
+ or None if the header is missing or malformed.
31
+ """
32
+ if not trace_header:
33
+ return None
34
+ match = re.search(r"Root=(1-[0-9a-fA-F]{8}-[0-9a-fA-F]{24})", trace_header)
35
+ return match.group(1) if match else None
36
+
37
+
38
+ def _xray_trace_id_to_otel(xray_trace_id: str) -> int:
39
+ """Convert an X-Ray trace ID to the W3C/OpenTelemetry 32-char hex format.
40
+
41
+ X-Ray format: "1-<8hex>-<24hex>" (36 chars with prefix and dashes)
42
+ OTel format: "<8hex><24hex>" (32 lowercase hex chars)
43
+ """
44
+ otel_id = xray_trace_id.replace("1-", "", 1).replace("-", "").lower()
45
+ return int(otel_id, 16)
46
+
47
+
48
+ def _to_otel_trace_id(execution_arn: str, start_timestamp: datetime | None) -> int:
49
+ """Build an OTel-compatible trace ID (128 bits)
50
+
51
+ First attempts to read the trace ID from the _X_AMZN_TRACE_ID environment
52
+ variable that Lambda populates on each invocation. This ties the durable
53
+ execution spans to the same trace that X-Ray is already tracking.
54
+
55
+ Falls back to generating a deterministic trace ID from the execution ARN
56
+ and timestamp when the environment variable is not set (e.g. in tests or
57
+ non-Lambda environments).
58
+ """
59
+ env_trace_id = _parse_xray_root_trace_id(os.environ.get("_X_AMZN_TRACE_ID"))
60
+ if env_trace_id:
61
+ return _xray_trace_id_to_otel(env_trace_id)
62
+
63
+ # Fallback: deterministic ID from execution ARN + timestamp
64
+ time_part = format(int((start_timestamp or datetime.now(UTC)).timestamp()), "08x")
65
+ hash_part = hashlib.blake2b(execution_arn.encode()).hexdigest()[:24] # noqa: S324
66
+ return int(f"{time_part}{hash_part}", 16)
67
+
68
+
69
+ def operation_id_to_span_id(operation_id: str) -> int:
70
+ """Derive a deterministic span ID (64 bits) from an operation ID."""
71
+ hashed_operation_id = hashlib.blake2b(operation_id.encode()).hexdigest()[:16]
72
+ return int(hashed_operation_id, 16)
73
+
74
+
75
+ class DeterministicIdGenerator(RandomIdGenerator):
76
+ """An ID generator that produces deterministic span IDs when a pending
77
+ operation ID is set, and falls back to the provided generator otherwise.
78
+
79
+ Trace IDs are deterministic when an execution ARN is set, ensuring all
80
+ invocations of the same durable execution share a single trace. When no
81
+ deterministic ID is available, generation is delegated to the fallback
82
+ generator (the tracer provider's original ID generator by default).
83
+
84
+ Trace IDs embed a real timestamp so they satisfy the X-Ray format
85
+ requirement (first 8 hex chars = Unix epoch seconds).
86
+
87
+ Args:
88
+ fallback_id_generator: Generator used when no deterministic ID is
89
+ available. Defaults to a new ``RandomIdGenerator``.
90
+ """
91
+
92
+ def __init__(self, fallback_id_generator: IdGenerator | None = None) -> None:
93
+ self._execution_trace_id: int | None = None
94
+ self._fallback_id_generator = fallback_id_generator or RandomIdGenerator()
95
+
96
+ def set_next_span_id(self, span_id: int | None) -> None:
97
+ """Set the operation ID to use for the next span's ID.
98
+
99
+ After one span is created, it resets to random.
100
+ """
101
+ _next_span_id.set(span_id)
102
+
103
+ def set_trace_id(
104
+ self, execution_arn: str, start_timestamp: datetime | None
105
+ ) -> None:
106
+ """Compute and cache the deterministic trace ID for this execution.
107
+
108
+ Args:
109
+ execution_arn: The durable execution ARN (used for the hash portion).
110
+ start_timestamp: start time of invocation
111
+ """
112
+ self._execution_trace_id = _to_otel_trace_id(execution_arn, start_timestamp)
113
+
114
+ def generate_trace_id(self) -> int:
115
+ """Generate a 128-bit trace ID."""
116
+ return (
117
+ self._execution_trace_id or self._fallback_id_generator.generate_trace_id()
118
+ )
119
+
120
+ def generate_span_id(self) -> int:
121
+ """Generate a 64-bit span ID."""
122
+ span_id = _next_span_id.get()
123
+ # Consume once: the deterministic ID applies only to the next span
124
+ # created in this context; subsequent spans fall back to random.
125
+ _next_span_id.set(None)
126
+ return span_id or self._fallback_id_generator.generate_span_id()
@@ -0,0 +1,105 @@
1
+ """Root-logger filter that stamps OTel trace context onto every log record.
2
+
3
+ The filter attaches to a stdlib logging handler and enriches *every* record
4
+ that flows through it: direct ``logging.getLogger().info(...)`` calls,
5
+ child-logger records that propagate to root, and third-party library logs.
6
+
7
+ The span/trace identifiers are added as ``LogRecord`` attributes (using
8
+ underscore names, since dotted names are not valid record attributes):
9
+
10
+ - ``otel_trace_id``: 32-char hex trace identifier
11
+ - ``otel_span_id``: 16-char hex span identifier
12
+ - ``otel_trace_sampled``: boolean indicating if the trace is sampled
13
+
14
+ These attributes are only set when a valid span context is active. Records
15
+ emitted outside an active invocation (e.g. during Lambda teardown) pass through
16
+ unmodified, so any log formatter or schema must treat the fields as optional.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from typing import TYPE_CHECKING
23
+
24
+ from opentelemetry.trace import TraceFlags
25
+
26
+
27
+ if TYPE_CHECKING:
28
+ from aws_durable_execution_sdk_python_otel.plugin import DurableExecutionOtelPlugin
29
+
30
+
31
+ class OtelContextLogFilter(logging.Filter):
32
+ """Logging filter that injects the active OTel span context onto records.
33
+
34
+ The filter is a pure reader of the plugin's current span context. It
35
+ resolves the span at emit time, on the thread that emits the record, via
36
+ ``plugin.get_current_span_context()``. That method returns the active
37
+ operation span inside steps and child contexts (attached to the worker
38
+ thread's OTel context) and falls back to the invocation span for top-level
39
+ handler code.
40
+
41
+ The filter never caches identifiers and always returns ``True`` so it never
42
+ drops a record.
43
+
44
+ Args:
45
+ plugin: The OTel plugin instance that resolves the current span context.
46
+ """
47
+
48
+ def __init__(self, plugin: DurableExecutionOtelPlugin) -> None:
49
+ super().__init__()
50
+ self._plugin = plugin
51
+
52
+ def filter(self, record: logging.LogRecord) -> bool:
53
+ """Stamp the active span context onto the record, then allow it through."""
54
+ span_context = self._plugin.get_current_span_context()
55
+ if span_context and span_context.is_valid:
56
+ record.otel_trace_id = format(span_context.trace_id, "032x")
57
+ record.otel_span_id = format(span_context.span_id, "016x")
58
+ record.otel_trace_sampled = bool(
59
+ span_context.trace_flags & TraceFlags.SAMPLED
60
+ )
61
+ return True
62
+
63
+
64
+ def install_log_filter(
65
+ plugin: DurableExecutionOtelPlugin,
66
+ target_logger: logging.Logger | None = None,
67
+ ) -> OtelContextLogFilter | None:
68
+ """Attach an OtelContextLogFilter to a logger's handlers, idempotently.
69
+
70
+ The filter is attached to each handler on ``target_logger`` (the root logger
71
+ by default). Attaching to handlers rather than the logger itself ensures
72
+ records propagated from child loggers are also enriched, since handler
73
+ filters run for every record reaching the handler.
74
+
75
+ This is safe to call on every invocation: if a handler already has an
76
+ OtelContextLogFilter, it is left as-is, so warm Lambda reuse will not stack
77
+ duplicate filters. A single shared filter instance is reused across all
78
+ handlers.
79
+
80
+ Args:
81
+ plugin: The OTel plugin that resolves the current span context.
82
+ target_logger: Logger whose handlers receive the filter. Defaults to the
83
+ root logger, which in AWS Lambda is where runtime log handlers live.
84
+
85
+ Returns:
86
+ The filter instance attached to the handlers, or ``None`` if the target
87
+ logger has no handlers to attach to.
88
+ """
89
+ logger = target_logger if target_logger is not None else logging.getLogger()
90
+
91
+ context_filter: OtelContextLogFilter | None = None
92
+ for handler in logger.handlers:
93
+ existing = next(
94
+ (f for f in handler.filters if isinstance(f, OtelContextLogFilter)),
95
+ None,
96
+ )
97
+ if existing is not None:
98
+ # Reuse the already-installed filter so a single instance is shared.
99
+ context_filter = existing
100
+ continue
101
+ if context_filter is None:
102
+ context_filter = OtelContextLogFilter(plugin)
103
+ handler.addFilter(context_filter)
104
+
105
+ return context_filter