Skip to content

Commit 450b312

Browse files
committed
Add propagate-only distributed tracing
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b872ac61-5bac-40e3-b9c7-daf295725fde
1 parent b871df8 commit 450b312

17 files changed

Lines changed: 564 additions & 84 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +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.
1216
- Added asynchronous `AsyncScheduledTaskClient` / `AsyncScheduleClient` and
1317
`AsyncExportHistoryClient` / `AsyncExportHistoryJobClient` APIs. Applications
1418
using `AsyncTaskHubGrpcClient` can now manage scheduled tasks and history

azure-functions-durable/CHANGELOG.md

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
ADDED
1111

12+
- Distributed tracing now correlates OpenTelemetry spans created by orchestrator
13+
user code with the Durable Functions host trace while avoiding duplicate
14+
orchestration, activity, and entity lifecycle spans from the Python worker.
1215
- Added `SyncDurableFunctionsClient`. `DFApp.durable_client_input()` now injects
1316
the synchronous client into synchronous functions and the asynchronous client
1417
into coroutine functions. Both clients support scheduled-task and history-export
@@ -178,8 +181,3 @@ code:
178181
`show_history_output` flags for signature compatibility but ignore them, so
179182
the returned status has no `historyEvents`. Use
180183
`get_orchestration_history(...)` to retrieve history.
181-
- Distributed tracing is not yet wired up. The Durable Functions host delivers
182-
the parent trace context and emits the orchestration/activity spans itself,
183-
so orchestrator user-code spans in the Python worker are not yet correlated
184-
to it, and durabletask's own span emission is intentionally left disabled to
185-
avoid duplicating the host's spans.

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ def __init__(self) -> None:
4040
# The Functions converter routes payload serialization through the
4141
# azure-functions codec (df_dumps/df_loads) so user types round-trip in
4242
# the wire format the Durable Functions host extension expects.
43-
super().__init__(data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER)
43+
super().__init__(
44+
data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER,
45+
emit_trace_spans=False,
46+
)
4447

4548
def add_named_orchestrator(self, name: str, func: task.Orchestrator[Any, Any]) -> None:
4649
self._registry.add_named_orchestrator(name, func)

azure-functions-durable/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ requires-python = ">=3.13"
2727
license = {file = "LICENSE"}
2828
readme = "README.md"
2929
dependencies = [
30-
"durabletask>=1.8.0",
30+
"durabletask[opentelemetry]>=1.9.0",
3131
"azure-identity>=1.19.0",
3232
"azure-functions>=2.3.0b2"
3333
]

durabletask/internal/tracing.py

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import random
1919
import time
2020
from contextlib import contextmanager
21+
from contextvars import ContextVar
2122
from datetime import datetime
2223
from typing import TYPE_CHECKING, Any
2324

@@ -85,6 +86,10 @@ class _StatusCodeStub:
8586

8687
# The instrumentation scope name used when creating spans.
8788
_TRACER_NAME = "durabletask"
89+
_SPAN_EMISSION_SUPPRESSED: ContextVar[bool] = ContextVar(
90+
"durabletask_span_emission_suppressed",
91+
default=False,
92+
)
8893

8994

9095
# ---------------------------------------------------------------------------
@@ -226,6 +231,35 @@ def extract_trace_context(proto_ctx: pb.TraceContext | None) -> Any | None:
226231
return ctx
227232

228233

234+
@contextmanager
235+
def use_trace_context(trace_context: pb.TraceContext | None):
236+
"""Make a protobuf trace context current without creating a span."""
237+
parent_ctx = extract_trace_context(trace_context)
238+
if parent_ctx is None:
239+
yield
240+
return
241+
242+
token = otel_context.attach(parent_ctx)
243+
try:
244+
yield
245+
finally:
246+
otel_context.detach(token)
247+
248+
249+
@contextmanager
250+
def suppress_span_emission(suppress: bool = True):
251+
"""Temporarily suppress spans emitted by this module."""
252+
if not suppress:
253+
yield
254+
return
255+
256+
token = _SPAN_EMISSION_SUPPRESSED.set(True)
257+
try:
258+
yield
259+
finally:
260+
_SPAN_EMISSION_SUPPRESSED.reset(token)
261+
262+
229263
@contextmanager
230264
def start_span(
231265
name: str,
@@ -254,6 +288,11 @@ def start_span(
254288
yield None
255289
return
256290

291+
if _SPAN_EMISSION_SUPPRESSED.get():
292+
with use_trace_context(trace_context):
293+
yield None
294+
return
295+
257296
parent_ctx = extract_trace_context(trace_context)
258297

259298
if kind is None:
@@ -313,7 +352,7 @@ def emit_orchestration_span(
313352
Falls back to ``tracer.start_span()`` (which generates its own
314353
span ID) when *orchestration_trace_context* is ``None``.
315354
"""
316-
if not _OTEL_AVAILABLE:
355+
if not _OTEL_AVAILABLE or _SPAN_EMISSION_SUPPRESSED.get():
317356
return
318357

319358
span_name = create_span_name(TASK_TYPE_ORCHESTRATION, name, version)
@@ -529,7 +568,7 @@ def generate_client_trace_context(
529568
returned the caller should fall back to the orchestration's own
530569
trace context as the parent for downstream spans.
531570
"""
532-
if not _OTEL_AVAILABLE:
571+
if not _OTEL_AVAILABLE or _SPAN_EMISSION_SUPPRESSED.get():
533572
return None
534573
if parent_trace_context is None:
535574
return None
@@ -588,7 +627,7 @@ def emit_client_span(
588627
SERVER span remains connected to the orchestration span via the
589628
fallback parenting established in :func:`generate_client_trace_context`.
590629
"""
591-
if not _OTEL_AVAILABLE:
630+
if not _OTEL_AVAILABLE or _SPAN_EMISSION_SUPPRESSED.get():
592631
return
593632

594633
# SDK-internal imports — see _is_deferred_span_capable() for details.
@@ -698,7 +737,7 @@ def emit_timer_span(
698737
When *parent_trace_context* is provided the span is created as a
699738
child of that context; otherwise it inherits the ambient context.
700739
"""
701-
if not _OTEL_AVAILABLE:
740+
if not _OTEL_AVAILABLE or _SPAN_EMISSION_SUPPRESSED.get():
702741
return
703742

704743
span_name = create_timer_span_name(orchestration_name)
@@ -741,7 +780,7 @@ def emit_event_raised_span(
741780
When *parent_trace_context* is provided the span is created as a
742781
child of that context; otherwise it inherits the ambient context.
743782
"""
744-
if not _OTEL_AVAILABLE:
783+
if not _OTEL_AVAILABLE or _SPAN_EMISSION_SUPPRESSED.get():
745784
return
746785

747786
span_name = create_span_name(SPAN_TYPE_ORCHESTRATION_EVENT, event_name)
@@ -787,7 +826,7 @@ def start_create_orchestration_span(
787826
Yields the span; caller should capture the trace context after entering
788827
the span context so it can be injected into the gRPC request.
789828
"""
790-
if not _OTEL_AVAILABLE:
829+
if not _OTEL_AVAILABLE or _SPAN_EMISSION_SUPPRESSED.get():
791830
yield None
792831
return
793832

@@ -815,7 +854,7 @@ def start_raise_event_span(
815854
target_instance_id: str,
816855
):
817856
"""Context manager for a Producer span when raising an event from the client."""
818-
if not _OTEL_AVAILABLE:
857+
if not _OTEL_AVAILABLE or _SPAN_EMISSION_SUPPRESSED.get():
819858
yield None
820859
return
821860

@@ -845,7 +884,7 @@ def reconstruct_trace_context(
845884
the span ID with *span_id*. This is used to reuse a pre-determined
846885
orchestration span ID across replays.
847886
"""
848-
if not _OTEL_AVAILABLE:
887+
if not _OTEL_AVAILABLE or _SPAN_EMISSION_SUPPRESSED.get():
849888
return None
850889

851890
parsed = _parse_traceparent(parent_trace_context.traceParent)
@@ -869,7 +908,7 @@ def build_orchestration_trace_context(
869908
replays so that all dispatches produce a consistent orchestration
870909
SERVER span.
871910
"""
872-
if start_time_ns is None:
911+
if start_time_ns is None or _SPAN_EMISSION_SUPPRESSED.get():
873912
return None
874913

875914
ctx = pb.OrchestrationTraceContext()

0 commit comments

Comments
 (0)