diff --git a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/deterministic_id_generator.py b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/deterministic_id_generator.py index 84f6ba36..3ad3ed3e 100644 --- a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/deterministic_id_generator.py +++ b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/deterministic_id_generator.py @@ -7,7 +7,7 @@ from collections.abc import Iterator from contextlib import contextmanager from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import datetime from typing import TYPE_CHECKING from opentelemetry.sdk.trace import IdGenerator, RandomIdGenerator @@ -23,14 +23,19 @@ class _IdOverride: span_id: int | None -def _to_otel_trace_id(execution_arn: str, start_timestamp: datetime | None) -> int: +def _to_otel_trace_id(execution_arn: str, start_timestamp: datetime) -> int: """Build a deterministic OTel-compatible execution trace ID (128 bits). The ID is independent of ambient Lambda or X-Ray trace context so the parentless Workflow span remains the only root of the durable execution trace. Invocation spans inherit ambient context separately. + + Raises: + ValueError: If the execution start timestamp is missing. """ - time_part = format(int((start_timestamp or datetime.now(UTC)).timestamp()), "08x") + if start_timestamp is None: + raise ValueError("execution start time is required to derive a trace ID") + time_part = format(int(start_timestamp.timestamp()), "08x") hash_part = hashlib.blake2b(execution_arn.encode()).hexdigest()[:24] # noqa: S324 return int(f"{time_part}{hash_part}", 16) diff --git a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/execution_plugin.py b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/execution_plugin.py index 27511809..f10c4bda 100644 --- a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/execution_plugin.py +++ b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/execution_plugin.py @@ -273,6 +273,13 @@ def _invocation_parent_context(self) -> Context: def on_invocation_start(self, info: InvocationStartInfo) -> None: logger.debug("Durable invocation started: %s", info) self._reset_state() + if info.execution_start_time is None: + logger.warning( + "ExecutionOtelPlugin requires InvocationStartInfo.execution_start_time " + "to derive a deterministic trace ID; telemetry is disabled for this " + "invocation." + ) + return self._tracing_enabled = self._bind_sdk_tracer() if not self._tracing_enabled: logger.warning( @@ -307,9 +314,6 @@ def _start_workflow_span(self, info: InvocationStartInfo) -> None: if not self._execution_arn: logger.warning("No execution ARN; skipping Workflow span creation") return - start_time = _to_otel_timestamp( - info.execution_start_time - ) or _to_otel_timestamp(datetime.datetime.now(datetime.UTC)) # Empty context => root span with no parent. with self._id_generator.use_ids( trace_id=self._execution_trace_id, @@ -319,7 +323,7 @@ def _start_workflow_span(self, info: InvocationStartInfo) -> None: name=self._workflow_span_name, kind=SpanKind.INTERNAL, attributes={"durable.execution.arn": self._execution_arn}, - start_time=start_time, + start_time=_to_otel_timestamp(info.execution_start_time), context=Context(), ) diff --git a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/invocation_plugin.py b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/invocation_plugin.py index 9e31b0df..182631b4 100644 --- a/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/invocation_plugin.py +++ b/packages/aws-durable-execution-sdk-python-otel/src/aws_durable_execution_sdk_python_otel/invocation_plugin.py @@ -395,6 +395,13 @@ def on_invocation_start(self, info: InvocationStartInfo) -> None: """Called at the start of each invocation. Creates the invocation span.""" logger.debug("Durable invocation started: %s", info) self._reset_state() + if info.execution_start_time is None: + logger.warning( + "InvocationOtelPlugin requires InvocationStartInfo.execution_start_time " + "to derive a deterministic trace ID; telemetry is disabled for this " + "invocation." + ) + return self._tracing_enabled = self._bind_sdk_tracer() if not self._tracing_enabled: logger.warning( @@ -434,8 +441,7 @@ def _start_workflow_span(self, info: InvocationStartInfo) -> None: if not self._execution_arn: logger.warning("No execution ARN; skipping Workflow span creation") return - # Empty context => root span with no parent. _to_otel_timestamp falls - # back to now() when execution_start_time is None. + # Empty context => root span with no parent. with self._id_generator.use_ids( trace_id=self._execution_trace_id, span_id=derive_workflow_span_id(self._execution_arn), diff --git a/packages/aws-durable-execution-sdk-python-otel/tests/test_deterministic_id_generator.py b/packages/aws-durable-execution-sdk-python-otel/tests/test_deterministic_id_generator.py index 38dbe08a..25230d95 100644 --- a/packages/aws-durable-execution-sdk-python-otel/tests/test_deterministic_id_generator.py +++ b/packages/aws-durable-execution-sdk-python-otel/tests/test_deterministic_id_generator.py @@ -59,6 +59,11 @@ def test_to_otel_trace_id_uses_timestamp_and_execution_arn(): ) +def test_to_otel_trace_id_requires_execution_start_time() -> None: + with pytest.raises(ValueError, match="execution start time is required"): + _to_otel_trace_id("execution-arn", None) # type: ignore[arg-type] + + def test_operation_id_to_span_id_returns_deterministic_64_bit_id(): """Verify execution and operation IDs map to stable 64-bit span IDs.""" execution_arn = "arn:aws:lambda:us-west-2:123456789012:function:workflow:$LATEST" diff --git a/packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin.py b/packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin.py index 1fb2c9db..63548eea 100644 --- a/packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin.py +++ b/packages/aws-durable-execution-sdk-python-otel/tests/test_execution_plugin.py @@ -175,6 +175,24 @@ def test_workflow_and_invocation_are_separate_roots_without_ambient_parent(): assert invocation.context.trace_id != workflow.context.trace_id +def test_invocation_start_without_execution_start_time_disables_tracing( + caplog: pytest.LogCaptureFixture, +) -> None: + plugin, exporter = _create_plugin() + info = InvocationStartInfo( + request_id="request-1", + execution_arn=EXECUTION_ARN, + execution_start_time=None, + is_first_invocation=True, + ) + + plugin.on_invocation_start(info) + plugin.on_invocation_end(_invocation_end_info()) + + assert "requires InvocationStartInfo.execution_start_time" in caplog.text + assert exporter.get_finished_spans() == () + + def test_explicit_mode_invocation_span_parented_to_ambient_span(): plugin, exporter = _create_plugin() diff --git a/packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin.py b/packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin.py index 6e7e6742..c7901b5d 100644 --- a/packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin.py +++ b/packages/aws-durable-execution-sdk-python-otel/tests/test_invocation_plugin.py @@ -203,6 +203,24 @@ def test_invocation_start_and_end_emit_invocation_span(): assert plugin._get_span(None) is None +def test_invocation_start_without_execution_start_time_disables_tracing( + caplog: pytest.LogCaptureFixture, +) -> None: + plugin, exporter = _create_plugin() + info = InvocationStartInfo( + request_id="request-1", + execution_arn=EXECUTION_ARN, + execution_start_time=None, + is_first_invocation=True, + ) + + plugin.on_invocation_start(info) + plugin.on_invocation_end(_invocation_end_info()) + + assert "requires InvocationStartInfo.execution_start_time" in caplog.text + assert exporter.get_finished_spans() == () + + def test_invocation_span_parents_to_ambient_span(): plugin, exporter = _create_plugin()