Skip to content

Commit c5eba5a

Browse files
committed
Suppress Durable Functions client trace spans
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b872ac61-5bac-40e3-b9c7-daf295725fde
1 parent 341418a commit c5eba5a

7 files changed

Lines changed: 165 additions & 74 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
99

1010
ADDED
1111

12-
- Added `TaskHubGrpcWorker(..., emit_trace_spans=False)` for hosts that own
13-
Durable Task lifecycle spans. In this propagate-only mode, incoming W3C trace
14-
context remains active while orchestrator, activity, and entity user code runs,
15-
without emitting duplicate worker lifecycle spans.
12+
- Added `emit_trace_spans=False` to `TaskHubGrpcWorker`,
13+
`TaskHubGrpcClient`, and `AsyncTaskHubGrpcClient` for hosts that own Durable
14+
Task lifecycle spans. In this propagate-only mode, W3C trace context remains
15+
active and is propagated without emitting duplicate SDK lifecycle spans.
1616
- Added asynchronous `AsyncScheduledTaskClient` / `AsyncScheduleClient` and
1717
`AsyncExportHistoryClient` / `AsyncExportHistoryJobClient` APIs. Applications
1818
using `AsyncTaskHubGrpcClient` can now manage scheduled tasks and history

azure-functions-durable/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ ADDED
1111

1212
- Distributed tracing now correlates OpenTelemetry spans created by orchestrator
1313
user code with the Durable Functions host trace while avoiding duplicate
14-
orchestration, activity, and entity lifecycle spans from the Python worker.
14+
orchestration, activity, entity, client-start, and client-event lifecycle spans
15+
from the Python SDK.
1516
- Added `SyncDurableFunctionsClient`. `DFApp.durable_client_input()` now injects
1617
the synchronous client into synchronous functions and the asynchronous client
1718
into coroutine functions. Both clients support scheduled-task and history-export

azure-functions-durable/azure/durable_functions/client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ def __init__(self, client_as_string: str):
9090
metadata=None,
9191
interceptors=interceptors,
9292
channel_options=channel_options,
93-
data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER)
93+
data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER,
94+
emit_trace_spans=False)
9495

9596
# The gRPC aio channel is bound to the event loop it is created on. A
9697
# ``durable_client_input`` decode runs on the worker's invocation loop,
@@ -531,7 +532,8 @@ def __init__(self, client_as_string: str):
531532
metadata=None,
532533
interceptors=interceptors,
533534
channel_options=channel_options,
534-
data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER)
535+
data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER,
536+
emit_trace_spans=False)
535537

536538
@classmethod
537539
def get_cached(cls, client_as_string: str) -> "SyncDurableFunctionsClient":

durabletask/client.py

Lines changed: 80 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,8 @@ def __init__(self, *,
419419
resiliency_options: GrpcClientResiliencyOptions | None = None,
420420
default_version: str | None = None,
421421
payload_store: PayloadStore | None = None,
422-
data_converter: DataConverter | None = None):
422+
data_converter: DataConverter | None = None,
423+
emit_trace_spans: bool = True):
423424

424425
self._owns_channel = channel is None
425426
self._data_converter = data_converter if data_converter is not None else JsonDataConverter()
@@ -484,6 +485,12 @@ def __init__(self, *,
484485
self._logger = shared.get_logger("client", log_handler, log_formatter)
485486
self.default_version = default_version
486487
self._payload_store = payload_store
488+
self._emit_trace_spans = emit_trace_spans
489+
490+
@property
491+
def emit_trace_spans(self) -> bool:
492+
"""Return whether this client emits Durable Task lifecycle spans."""
493+
return self._emit_trace_spans
487494

488495
def _compose_interceptors(
489496
self,
@@ -619,30 +626,30 @@ def schedule_new_orchestration(self, orchestrator: task.Orchestrator[TInput, TOu
619626
resolved_instance_id = instance_id if instance_id else uuid.uuid4().hex
620627
resolved_version = version if version else self.default_version
621628

622-
with tracing.start_create_orchestration_span(
623-
name, resolved_instance_id, version=resolved_version,
624-
):
625-
req = build_schedule_new_orchestration_req(
626-
orchestrator, input=input, instance_id=instance_id, start_at=start_at,
627-
reuse_id_policy=reuse_id_policy, tags=tags,
628-
version=version if version else self.default_version,
629-
data_converter=self._data_converter)
630-
631-
# Inject the active PRODUCER span context into the request so the sidecar
632-
# stores it in the executionStarted event and the worker can parent all
633-
# orchestration/activity/timer spans under this trace.
634-
parent_trace_ctx = tracing.get_current_trace_context()
635-
if parent_trace_ctx is not None:
636-
req.parentTraceContext.CopyFrom(parent_trace_ctx)
637-
638-
self._logger.info(f"Starting new '{req.name}' instance with ID = '{req.instanceId}'.")
639-
# Externalize any large payloads in the request
640-
if self._payload_store is not None:
641-
payload_helpers.externalize_payloads(
642-
req, self._payload_store, instance_id=req.instanceId,
643-
)
644-
res: pb.CreateInstanceResponse = self._stub.StartInstance(req)
645-
return res.instanceId
629+
with tracing.suppress_span_emission(not self._emit_trace_spans):
630+
with tracing.start_create_orchestration_span(
631+
name, resolved_instance_id, version=resolved_version,
632+
):
633+
req = build_schedule_new_orchestration_req(
634+
orchestrator, input=input, instance_id=instance_id, start_at=start_at,
635+
reuse_id_policy=reuse_id_policy, tags=tags,
636+
version=version if version else self.default_version,
637+
data_converter=self._data_converter)
638+
639+
# Inject the active PRODUCER span context, or the ambient context
640+
# when lifecycle span emission is disabled.
641+
parent_trace_ctx = tracing.get_current_trace_context()
642+
if parent_trace_ctx is not None:
643+
req.parentTraceContext.CopyFrom(parent_trace_ctx)
644+
645+
self._logger.info(f"Starting new '{req.name}' instance with ID = '{req.instanceId}'.")
646+
# Externalize any large payloads in the request
647+
if self._payload_store is not None:
648+
payload_helpers.externalize_payloads(
649+
req, self._payload_store, instance_id=req.instanceId,
650+
)
651+
res: pb.CreateInstanceResponse = self._stub.StartInstance(req)
652+
return res.instanceId
646653

647654
def get_orchestration_state(self, instance_id: str, *, fetch_payloads: bool = True) -> OrchestrationState | None:
648655
req = pb.GetInstanceRequest(instanceId=instance_id, getInputsAndOutputs=fetch_payloads)
@@ -757,14 +764,15 @@ def wait_for_orchestration_completion(self, instance_id: str, *,
757764

758765
def raise_orchestration_event(self, instance_id: str, event_name: str, *,
759766
data: Any | None = None) -> None:
760-
with tracing.start_raise_event_span(event_name, instance_id):
761-
req = build_raise_event_req(instance_id, event_name, data, self._data_converter)
762-
self._logger.info(f"Raising event '{event_name}' for instance '{instance_id}'.")
763-
if self._payload_store is not None:
764-
payload_helpers.externalize_payloads(
765-
req, self._payload_store, instance_id=instance_id,
766-
)
767-
self._stub.RaiseEvent(req)
767+
with tracing.suppress_span_emission(not self._emit_trace_spans):
768+
with tracing.start_raise_event_span(event_name, instance_id):
769+
req = build_raise_event_req(instance_id, event_name, data, self._data_converter)
770+
self._logger.info(f"Raising event '{event_name}' for instance '{instance_id}'.")
771+
if self._payload_store is not None:
772+
payload_helpers.externalize_payloads(
773+
req, self._payload_store, instance_id=instance_id,
774+
)
775+
self._stub.RaiseEvent(req)
768776

769777
def terminate_orchestration(self, instance_id: str, *,
770778
output: Any | None = None,
@@ -940,7 +948,8 @@ def __init__(self, *,
940948
resiliency_options: GrpcClientResiliencyOptions | None = None,
941949
default_version: str | None = None,
942950
payload_store: PayloadStore | None = None,
943-
data_converter: DataConverter | None = None):
951+
data_converter: DataConverter | None = None,
952+
emit_trace_spans: bool = True):
944953

945954
self._owns_channel = channel is None
946955
self._data_converter = data_converter if data_converter is not None else JsonDataConverter()
@@ -1005,6 +1014,12 @@ def __init__(self, *,
10051014
self._logger = shared.get_logger("async_client", log_handler, log_formatter)
10061015
self.default_version = default_version
10071016
self._payload_store = payload_store
1017+
self._emit_trace_spans = emit_trace_spans
1018+
1019+
@property
1020+
def emit_trace_spans(self) -> bool:
1021+
"""Return whether this client emits Durable Task lifecycle spans."""
1022+
return self._emit_trace_spans
10081023

10091024
def _compose_interceptors(
10101025
self,
@@ -1128,27 +1143,28 @@ async def schedule_new_orchestration(self, orchestrator: task.Orchestrator[TInpu
11281143
resolved_instance_id = instance_id if instance_id else uuid.uuid4().hex
11291144
resolved_version = version if version else self.default_version
11301145

1131-
with tracing.start_create_orchestration_span(
1132-
name, resolved_instance_id, version=resolved_version,
1133-
):
1134-
req = build_schedule_new_orchestration_req(
1135-
orchestrator, input=input, instance_id=instance_id, start_at=start_at,
1136-
reuse_id_policy=reuse_id_policy, tags=tags,
1137-
version=version if version else self.default_version,
1138-
data_converter=self._data_converter)
1139-
1140-
parent_trace_ctx = tracing.get_current_trace_context()
1141-
if parent_trace_ctx is not None:
1142-
req.parentTraceContext.CopyFrom(parent_trace_ctx)
1143-
1144-
self._logger.info(f"Starting new '{req.name}' instance with ID = '{req.instanceId}'.")
1145-
# Externalize any large payloads in the request
1146-
if self._payload_store is not None:
1147-
await payload_helpers.externalize_payloads_async(
1148-
req, self._payload_store, instance_id=req.instanceId,
1149-
)
1150-
res: pb.CreateInstanceResponse = await self._stub.StartInstance(req)
1151-
return res.instanceId
1146+
with tracing.suppress_span_emission(not self._emit_trace_spans):
1147+
with tracing.start_create_orchestration_span(
1148+
name, resolved_instance_id, version=resolved_version,
1149+
):
1150+
req = build_schedule_new_orchestration_req(
1151+
orchestrator, input=input, instance_id=instance_id, start_at=start_at,
1152+
reuse_id_policy=reuse_id_policy, tags=tags,
1153+
version=version if version else self.default_version,
1154+
data_converter=self._data_converter)
1155+
1156+
parent_trace_ctx = tracing.get_current_trace_context()
1157+
if parent_trace_ctx is not None:
1158+
req.parentTraceContext.CopyFrom(parent_trace_ctx)
1159+
1160+
self._logger.info(f"Starting new '{req.name}' instance with ID = '{req.instanceId}'.")
1161+
# Externalize any large payloads in the request
1162+
if self._payload_store is not None:
1163+
await payload_helpers.externalize_payloads_async(
1164+
req, self._payload_store, instance_id=req.instanceId,
1165+
)
1166+
res: pb.CreateInstanceResponse = await self._stub.StartInstance(req)
1167+
return res.instanceId
11521168

11531169
async def get_orchestration_state(self, instance_id: str, *,
11541170
fetch_payloads: bool = True) -> OrchestrationState | None:
@@ -1262,14 +1278,15 @@ async def wait_for_orchestration_completion(self, instance_id: str, *,
12621278

12631279
async def raise_orchestration_event(self, instance_id: str, event_name: str, *,
12641280
data: Any | None = None) -> None:
1265-
with tracing.start_raise_event_span(event_name, instance_id):
1266-
req = build_raise_event_req(instance_id, event_name, data, self._data_converter)
1267-
self._logger.info(f"Raising event '{event_name}' for instance '{instance_id}'.")
1268-
if self._payload_store is not None:
1269-
await payload_helpers.externalize_payloads_async(
1270-
req, self._payload_store, instance_id=instance_id,
1271-
)
1272-
await self._stub.RaiseEvent(req)
1281+
with tracing.suppress_span_emission(not self._emit_trace_spans):
1282+
with tracing.start_raise_event_span(event_name, instance_id):
1283+
req = build_raise_event_req(instance_id, event_name, data, self._data_converter)
1284+
self._logger.info(f"Raising event '{event_name}' for instance '{instance_id}'.")
1285+
if self._payload_store is not None:
1286+
await payload_helpers.externalize_payloads_async(
1287+
req, self._payload_store, instance_id=instance_id,
1288+
)
1289+
await self._stub.RaiseEvent(req)
12731290

12741291
async def terminate_orchestration(self, instance_id: str, *,
12751292
output: Any | None = None,

tests/azure-functions-durable/e2e/test_distributed_tracing_e2e.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,7 @@ def test_user_span_correlates_to_host_without_worker_lifecycle_duplicates(
6666

6767
python_lifecycle_spans = [
6868
span for span in spans
69+
if span.get("traceId") == user_span.get("traceId")
6970
if span.get("scopeName") == "durabletask"
70-
and _attribute_value(
71-
span, "durabletask.task.instance_id") == instance_id
7271
]
7372
assert python_lifecycle_spans == []

tests/azure-functions-durable/test_client_compat.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@ def test_client_handles_null_max_grpc_message_size():
4949
assert client.maxGrpcMessageSizeInBytes == 0
5050

5151

52+
async def test_durable_clients_use_propagate_only_tracing():
53+
async_client = _make_client()
54+
sync_client = df.SyncDurableFunctionsClient(_CLIENT_CONFIG)
55+
try:
56+
assert async_client.emit_trace_spans is False
57+
assert sync_client.emit_trace_spans is False
58+
finally:
59+
await async_client.close()
60+
sync_client.close()
61+
62+
5263
def test_client_handles_all_config_fields_sent_as_null():
5364
# Newer host extension bundles serialize the full client configuration and
5465
# can send any field explicitly as ``null``. Every field must collapse to

tests/durabletask/test_tracing.py

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import json
77
import logging
88
from datetime import datetime, timezone
9-
from unittest.mock import patch
9+
from unittest.mock import AsyncMock, MagicMock, patch
1010

1111
import pytest
1212
from google.protobuf import timestamp_pb2, wrappers_pb2
@@ -21,7 +21,7 @@
2121
import durabletask.internal.helpers as helpers
2222
import durabletask.internal.orchestrator_service_pb2 as pb
2323
import durabletask.internal.tracing as tracing
24-
from durabletask import task, worker
24+
from durabletask import client, task, worker
2525

2626
logging.basicConfig(
2727
format='%(asctime)s.%(msecs)03d %(name)s %(levelname)s: %(message)s',
@@ -556,6 +556,67 @@ def test_creates_producer_span(self, otel_setup: InMemorySpanExporter):
556556
assert s.attributes[tracing.ATTR_EVENT_TARGET_INSTANCE_ID] == "inst-456"
557557

558558

559+
class TestClientSpanEmission:
560+
"""Tests for client propagate-only tracing."""
561+
562+
def test_clients_emit_trace_spans_by_default(self):
563+
with patch(
564+
"durabletask.client.stubs.TaskHubSidecarServiceStub",
565+
return_value=MagicMock()):
566+
sync_client = client.TaskHubGrpcClient(channel=MagicMock())
567+
async_client = client.AsyncTaskHubGrpcClient(channel=MagicMock())
568+
569+
assert sync_client.emit_trace_spans is True
570+
assert async_client.emit_trace_spans is True
571+
572+
def test_sync_client_propagates_ambient_context_without_lifecycle_spans(
573+
self, otel_setup: InMemorySpanExporter):
574+
stub = MagicMock()
575+
stub.StartInstance.return_value = pb.CreateInstanceResponse(instanceId="inst-1")
576+
with patch(
577+
"durabletask.client.stubs.TaskHubSidecarServiceStub",
578+
return_value=stub):
579+
grpc_client = client.TaskHubGrpcClient(
580+
channel=MagicMock(), emit_trace_spans=False)
581+
582+
tracer = trace.get_tracer("host")
583+
with tracer.start_as_current_span("host-request") as host_span:
584+
host_span_id = f"{host_span.get_span_context().span_id:016x}"
585+
grpc_client.schedule_new_orchestration(
586+
"orchestrator", instance_id="inst-1")
587+
grpc_client.raise_orchestration_event("inst-1", "event")
588+
589+
start_request = stub.StartInstance.call_args.args[0]
590+
assert start_request.parentTraceContext.spanID == host_span_id
591+
assert [span.name for span in otel_setup.get_finished_spans()] == [
592+
"host-request"]
593+
594+
@pytest.mark.asyncio
595+
async def test_async_client_propagates_ambient_context_without_lifecycle_spans(
596+
self, otel_setup: InMemorySpanExporter):
597+
stub = MagicMock()
598+
stub.StartInstance = AsyncMock(
599+
return_value=pb.CreateInstanceResponse(instanceId="inst-1"))
600+
stub.RaiseEvent = AsyncMock(return_value=pb.RaiseEventResponse())
601+
with patch(
602+
"durabletask.client.stubs.TaskHubSidecarServiceStub",
603+
return_value=stub):
604+
grpc_client = client.AsyncTaskHubGrpcClient(
605+
channel=MagicMock(), emit_trace_spans=False)
606+
607+
tracer = trace.get_tracer("host")
608+
with tracer.start_as_current_span("host-request") as host_span:
609+
host_span_id = f"{host_span.get_span_context().span_id:016x}"
610+
await grpc_client.schedule_new_orchestration(
611+
"orchestrator", instance_id="inst-1")
612+
await grpc_client.raise_orchestration_event("inst-1", "event")
613+
614+
start_request = stub.StartInstance.call_args.args[0]
615+
assert start_request.parentTraceContext.spanID == host_span_id
616+
assert [span.name for span in otel_setup.get_finished_spans()] == [
617+
"host-request"]
618+
619+
559620
class TestOrchestrationServerSpan:
560621
"""Tests for emit_orchestration_span."""
561622

0 commit comments

Comments
 (0)