Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading