Skip to content

Commit 1b0fb77

Browse files
Alex Wangwangyb-A
authored andcommitted
test: add x_ray assertion
1 parent 48d0dd8 commit 1b0fb77

3 files changed

Lines changed: 213 additions & 8 deletions

File tree

packages/aws-durable-execution-sdk-python-examples/test/conftest.py

Lines changed: 113 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,24 @@
55
import logging
66
import os
77
import sys
8+
from datetime import datetime
89
from enum import StrEnum
910
from pathlib import Path
1011
from typing import Any
1112

1213
import pytest
13-
from aws_durable_execution_sdk_python.lambda_service import (
14-
ErrorObject,
15-
OperationPayload,
16-
)
17-
from aws_durable_execution_sdk_python.serdes import ExtendedTypeSerDes
18-
1914
from aws_durable_execution_sdk_python_testing.runner import (
2015
DurableFunctionCloudTestRunner,
2116
DurableFunctionTestResult,
2217
DurableFunctionTestRunner,
2318
)
2419

20+
from aws_durable_execution_sdk_python.lambda_service import (
21+
ErrorObject,
22+
OperationPayload,
23+
)
24+
from aws_durable_execution_sdk_python.serdes import ExtendedTypeSerDes
25+
2526

2627
# Add examples/src to Python path for imports
2728
examples_src = Path(__file__).parent.parent / "src"
@@ -266,3 +267,109 @@ def _get_deployed_function_name(
266267
pytest.skip(
267268
f"Test '{lambda_function_name}' doesn't match LAMBDA_FUNCTION_TEST_NAME '{env_function_name}'"
268269
)
270+
271+
272+
# X-Ray ingestion is eventually consistent; give the backend time to receive and
273+
# index spans before querying, then retry a few times.
274+
_XRAY_QUERY_RETRIES = 3
275+
_XRAY_RETRY_DELAY_SECONDS = 10
276+
277+
278+
class XRaySpanFetcher:
279+
"""Encapsulates all AWS X-Ray interaction for span-validation tests.
280+
281+
Wraps a boto3 X-Ray client and exposes a single high-level operation that
282+
queries trace summaries in a time window (with retries for eventual
283+
consistency), batch-fetches the full traces, and locates the trace whose
284+
segment documents reference a marker span name.
285+
"""
286+
287+
def __init__(self, client: Any):
288+
"""Initialize with a boto3 X-Ray client."""
289+
self._client = client
290+
291+
def _query_trace_summaries(
292+
self, start_time: datetime, end_time: datetime
293+
) -> list[dict]:
294+
"""Query trace summaries in a window, retrying for consistency."""
295+
import time
296+
297+
for attempt in range(_XRAY_QUERY_RETRIES):
298+
response = self._client.get_trace_summaries(
299+
StartTime=start_time,
300+
EndTime=end_time,
301+
TimeRangeType="Event",
302+
Sampling=False,
303+
)
304+
summaries = response.get("TraceSummaries", [])
305+
if summaries:
306+
return summaries
307+
308+
logger.info(
309+
"X-Ray query returned 0 traces, retrying in %ss (attempt %d/%d)",
310+
_XRAY_RETRY_DELAY_SECONDS,
311+
attempt + 1,
312+
_XRAY_QUERY_RETRIES,
313+
)
314+
time.sleep(_XRAY_RETRY_DELAY_SECONDS)
315+
return []
316+
317+
def fetch_trace_with_span(
318+
self,
319+
start_time: datetime,
320+
end_time: datetime,
321+
marker_span: str,
322+
) -> tuple[str, str]:
323+
"""Find the trace containing ``marker_span`` and return its segment text.
324+
325+
Queries trace summaries in the window, then batch-fetches full traces
326+
(X-Ray caps BatchGetTraces at 5 trace IDs per call) and locates the
327+
trace whose segment documents reference the marker span name.
328+
329+
Args:
330+
start_time: Start of the X-Ray query window.
331+
end_time: End of the X-Ray query window.
332+
marker_span: A span name expected to appear in the target trace.
333+
334+
Returns:
335+
A tuple of (trace_id, concatenated segment-document JSON text).
336+
"""
337+
summaries = self._query_trace_summaries(start_time, end_time)
338+
assert summaries, "Expected at least one trace in X-Ray after execution"
339+
340+
trace_ids = [s["Id"] for s in summaries]
341+
342+
for i in range(0, len(trace_ids), 5):
343+
batch = trace_ids[i : i + 5]
344+
result = self._client.batch_get_traces(TraceIds=batch)
345+
for trace in result.get("Traces", []):
346+
documents = [
347+
seg.get("Document", "") for seg in trace.get("Segments", [])
348+
]
349+
segment_text = "\n".join(documents)
350+
if marker_span in segment_text:
351+
return trace["Id"], segment_text
352+
353+
pytest.fail(
354+
f"Did not find a trace containing span '{marker_span}' in the time "
355+
f"window across {len(trace_ids)} trace(s)"
356+
)
357+
358+
359+
@pytest.fixture
360+
def xray_spans(request):
361+
"""Provide an XRaySpanFetcher for cloud-mode span validation tests.
362+
363+
The underlying boto3 X-Ray client is created in the same region as the
364+
cloud runner (AWS_REGION, default us-west-2). In local mode there is no
365+
X-Ray backend, so the fixture skips the test, mirroring the cloud-only
366+
gating of the durable_runner cloud path.
367+
"""
368+
runner_mode: str = request.config.getoption("--runner-mode")
369+
if runner_mode != RunnerMode.CLOUD:
370+
pytest.skip("X-Ray span validation only runs in cloud mode")
371+
372+
import boto3
373+
374+
region = os.environ.get("AWS_REGION", "us-west-2")
375+
return XRaySpanFetcher(boto3.client("xray", region_name=region))

packages/aws-durable-execution-sdk-python-examples/test/otel/test_otel_logger_example.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Tests for the OTel-enriched logger example."""
22

3+
import time
4+
from datetime import UTC, datetime
5+
36
import pytest
47

58
from aws_durable_execution_sdk_python.execution import InvocationStatus
@@ -8,6 +11,11 @@
811
from test.conftest import deserialize_operation_payload
912

1013

14+
# X-Ray ingestion is eventually consistent; wait before querying so the backend
15+
# has received and indexed the exported spans.
16+
_XRAY_INGESTION_DELAY_SECONDS = 20
17+
18+
1119
@pytest.mark.example
1220
@pytest.mark.durable_execution(
1321
handler=otel_logger_example.handler,
@@ -30,3 +38,35 @@ def test_otel_logger_example(durable_runner):
3038
op for op in result.operations if op.operation_type is OperationType.CONTEXT
3139
]
3240
assert len(context_ops) >= 1
41+
42+
43+
@pytest.mark.example
44+
@pytest.mark.durable_execution(
45+
handler=otel_logger_example.handler,
46+
lambda_function_name="Otel Logger Example",
47+
)
48+
def test_otel_logger_example_spans_in_xray(durable_runner, xray_spans):
49+
"""Single-invocation example: spans land in one X-Ray trace.
50+
51+
Runs only in cloud mode;
52+
"""
53+
start_time = datetime.now(UTC)
54+
55+
with durable_runner:
56+
result = durable_runner.run(input="{}", timeout=60)
57+
58+
assert result.status is InvocationStatus.SUCCEEDED
59+
assert deserialize_operation_payload(result.result) == "hello world | hello nested"
60+
61+
# Allow X-Ray time to ingest the exported spans.
62+
time.sleep(_XRAY_INGESTION_DELAY_SECONDS)
63+
64+
_trace_id, segment_text = xray_spans.fetch_trace_with_span(
65+
start_time, datetime.now(UTC), marker_span="top-greet"
66+
)
67+
68+
# Expected spans for the single-invocation example.
69+
assert "invocation" in segment_text
70+
assert "top-greet" in segment_text
71+
assert "child-context" in segment_text
72+
assert "child-greet" in segment_text

packages/aws-durable-execution-sdk-python-examples/test/plugin/test_otel_plugin.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
1-
"""Tests for step example."""
1+
"""Tests for the OTel plugin example (execution_with_otel)."""
2+
3+
import time
4+
from datetime import UTC, datetime
25

36
import pytest
4-
from aws_durable_execution_sdk_python.execution import InvocationStatus
57

8+
from aws_durable_execution_sdk_python.execution import InvocationStatus
69
from src.plugin import execution_with_otel
710
from test.conftest import deserialize_operation_payload
811

912

13+
# X-Ray ingestion is eventually consistent; wait before querying so the backend
14+
# has received and indexed the exported spans.
15+
_XRAY_INGESTION_DELAY_SECONDS = 20
16+
17+
18+
def _count_occurrences(text: str, substring: str) -> int:
19+
"""Count non-overlapping occurrences of ``substring`` in ``text``."""
20+
count = 0
21+
index = 0
22+
while (index := text.find(substring, index)) != -1:
23+
count += 1
24+
index += len(substring)
25+
return count
26+
27+
1028
@pytest.mark.example
1129
@pytest.mark.durable_execution(
1230
handler=execution_with_otel.handler,
@@ -22,3 +40,43 @@ def test_plugin(durable_runner):
2240

2341
step_result = result.get_step("final-step")
2442
assert deserialize_operation_payload(step_result.result) == 23
43+
44+
45+
@pytest.mark.example
46+
@pytest.mark.durable_execution(
47+
handler=execution_with_otel.handler,
48+
lambda_function_name="Otel Plugin",
49+
)
50+
def test_plugin_spans_in_xray_across_invocations(durable_runner, xray_spans):
51+
"""Multi-invocation example: spans from all invocations share one trace."""
52+
start_time = datetime.now(UTC)
53+
54+
with durable_runner:
55+
result = durable_runner.run(input="{}", timeout=120)
56+
57+
assert result.status is InvocationStatus.SUCCEEDED
58+
assert deserialize_operation_payload(result.result) == 23
59+
60+
# Multi-invocation executions take longer to fully export; give extra time.
61+
time.sleep(_XRAY_INGESTION_DELAY_SECONDS + 5)
62+
63+
trace_id, segment_text = xray_spans.fetch_trace_with_span(
64+
start_time, datetime.now(UTC), marker_span="final-step"
65+
)
66+
67+
# Spans from every child context plus the final top-level step.
68+
for i in range(3):
69+
assert f"context-{i}" in segment_text, f"missing span context-{i}"
70+
assert f"step-{i}" in segment_text, f"missing span step-{i}"
71+
assert f"wait-{i}" in segment_text, f"missing span wait-{i}"
72+
assert "final-step" in segment_text
73+
74+
# The waits force multiple Lambda invocations -> multiple invocation spans.
75+
invocation_count = _count_occurrences(segment_text, "invocation")
76+
assert invocation_count >= 2, (
77+
f"Expected at least 2 invocation spans (multi-invocation), "
78+
f"got {invocation_count}"
79+
)
80+
81+
# All segments belong to one trace -> deterministic trace ID worked.
82+
assert trace_id, "Expected a single unified trace ID across invocations"

0 commit comments

Comments
 (0)