|
| 1 | +"""OpenTelemetry trace-context propagation across Temporal boundaries. |
| 2 | +
|
| 3 | +Temporal serializes ``start_workflow`` / ``execute_activity`` across (potentially |
| 4 | +cross-process) boundaries, and does NOT carry the active W3C ``traceparent`` by |
| 5 | +default. So any span created inside a workflow or activity becomes a **new |
| 6 | +detached root** -- the trace shatters at every Temporal hop. |
| 7 | +
|
| 8 | +This bites agentex directly: ``adk.tracing.span`` runs span creation as a |
| 9 | +Temporal activity when ``in_temporal_workflow()`` is true, so without propagation |
| 10 | +those business spans detach from the turn's obs trace. |
| 11 | +
|
| 12 | +Wiring temporalio's first-party ``TracingInterceptor`` onto the Temporal client |
| 13 | +and worker injects the active span context into Temporal headers on the caller |
| 14 | +side and extracts + continues it on the workflow/activity side, using the global |
| 15 | +OpenTelemetry propagator -- so ``client -> workflow -> activity`` is one trace. |
| 16 | +
|
| 17 | +Enabled by DEFAULT. Set ``AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED=false`` |
| 18 | +(also accepts ``0`` / ``no`` / ``off``) to turn it off. It also degrades to a |
| 19 | +no-op -- and never raises -- if temporalio's OpenTelemetry contrib isn't |
| 20 | +importable, so enabling it by default can't break a worker. |
| 21 | +""" |
| 22 | + |
| 23 | +from __future__ import annotations |
| 24 | + |
| 25 | +import os |
| 26 | +from typing import Any |
| 27 | + |
| 28 | +from agentex.lib.utils.logging import make_logger |
| 29 | + |
| 30 | +logger = make_logger(__name__) |
| 31 | + |
| 32 | +_ENABLE_ENV = "AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED" |
| 33 | +_FALSEY = {"0", "false", "no", "off"} |
| 34 | + |
| 35 | + |
| 36 | +def temporal_trace_interceptor_enabled() -> bool: |
| 37 | + """Whether the Temporal OTel trace interceptor should be installed. |
| 38 | +
|
| 39 | + Defaults to True; disabled only when ``AGENTEX_TEMPORAL_TRACE_INTERCEPTOR_ENABLED`` |
| 40 | + is set to a falsy value (``0`` / ``false`` / ``no`` / ``off``).""" |
| 41 | + return os.environ.get(_ENABLE_ENV, "true").strip().lower() not in _FALSEY |
| 42 | + |
| 43 | + |
| 44 | +def temporal_tracing_interceptors() -> list[Any]: |
| 45 | + """Interceptors that propagate OpenTelemetry trace context across Temporal. |
| 46 | +
|
| 47 | + Returns ``[TracingInterceptor()]`` (enabled by default) so callers can splat |
| 48 | + it into a client's / worker's ``interceptors=`` list. Returns ``[]`` when |
| 49 | + disabled via env, or when temporalio's OpenTelemetry contrib is not |
| 50 | + importable. Never raises -- observability wiring must not break a worker. |
| 51 | +
|
| 52 | + ``TracingInterceptor`` implements both the client and worker interceptor |
| 53 | + interfaces, so the same call is used on both sides: |
| 54 | + - on the **client**, it injects context on outbound ``start_workflow`` / |
| 55 | + ``execute_activity`` calls; |
| 56 | + - on the **worker**, it extracts context and roots the workflow / activity |
| 57 | + execution spans under it. |
| 58 | + """ |
| 59 | + if not temporal_trace_interceptor_enabled(): |
| 60 | + logger.info("Temporal OTel trace interceptor disabled via %s", _ENABLE_ENV) |
| 61 | + return [] |
| 62 | + try: |
| 63 | + from temporalio.contrib.opentelemetry import TracingInterceptor |
| 64 | + |
| 65 | + # Construct inside the try so a constructor failure (not just a missing |
| 66 | + # contrib) also falls back to a no-op instead of aborting worker startup. |
| 67 | + return [TracingInterceptor()] |
| 68 | + except Exception as exc: # contrib unavailable OR constructor failure -> no-op, never raise |
| 69 | + logger.warning( |
| 70 | + "Temporal OTel trace interceptor unavailable (%s); traces will not propagate across Temporal boundaries.", |
| 71 | + exc, |
| 72 | + ) |
| 73 | + return [] |
0 commit comments