respan-instrumentation-temporal 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: respan-instrumentation-temporal
3
+ Version: 0.1.0
4
+ Summary: Respan instrumentation plugin for Temporal Python
5
+ License: Apache 2.0
6
+ Author: Respan
7
+ Author-email: team@respan.ai
8
+ Requires-Python: >=3.11,<3.14
9
+ Classifier: License :: Other/Proprietary License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Dist: opentelemetry-semantic-conventions-ai (>=0.4.1)
15
+ Requires-Dist: respan-sdk (>=2.6.1)
16
+ Requires-Dist: respan-tracing (>=2.17.0,<3.0.0)
17
+ Requires-Dist: temporalio (>=1.30.0,<2.0.0)
18
+ Requires-Dist: wrapt (>=1.16.0)
19
+ Description-Content-Type: text/markdown
20
+
21
+ # respan-instrumentation-temporal
22
+
23
+ Respan instrumentation for the Temporal Python SDK. It adapts Temporal's official replay-aware OpenTelemetry interceptor instead of patching workflow execution directly, preserving Temporal context propagation and replay semantics while emitting canonical Respan workflow and task fields.
24
+
25
+ The instrumentor injects its interceptor into `Client.connect()` automatically. The same interceptor is exposed as `instrumentor.interceptor` for Temporal test environments and custom client factories that accept an explicit `interceptors` list.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install respan-ai respan-instrumentation-temporal temporalio
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ```python
36
+ from temporalio.client import Client
37
+ from respan import Respan
38
+ from respan_instrumentation_temporal import TemporalInstrumentor
39
+
40
+ instrumentor = TemporalInstrumentor()
41
+ respan = Respan(instrumentations=[instrumentor])
42
+
43
+ # The interceptor is appended automatically and is inherited by workers
44
+ # created from this client.
45
+ client = await Client.connect("localhost:7233")
46
+ ```
47
+
48
+ For `WorkflowEnvironment`:
49
+
50
+ ```python
51
+ from temporalio.testing import WorkflowEnvironment
52
+
53
+ environment = await WorkflowEnvironment.start_time_skipping(
54
+ interceptors=[instrumentor.interceptor]
55
+ )
56
+ ```
57
+
58
+ If an explicit Temporal `TracingInterceptor` is already present, Respan does not append a second tracing interceptor, avoiding duplicate spans.
59
+
60
+ ## Content capture
61
+
62
+ ```python
63
+ TemporalInstrumentor(capture_content=False)
64
+ ```
65
+
66
+ Content capture includes workflow/activity arguments and Temporal identifiers when the relevant interceptor input exposes them. With capture disabled, spans retain stable operation and workflow/activity type information but omit arguments and IDs. Temporal headers are never captured. Errors record OpenTelemetry error status plus backend-visible `status_code` and `error.message` fields. `activate()` and `deactivate()` are idempotent.
67
+
@@ -0,0 +1,46 @@
1
+ # respan-instrumentation-temporal
2
+
3
+ Respan instrumentation for the Temporal Python SDK. It adapts Temporal's official replay-aware OpenTelemetry interceptor instead of patching workflow execution directly, preserving Temporal context propagation and replay semantics while emitting canonical Respan workflow and task fields.
4
+
5
+ The instrumentor injects its interceptor into `Client.connect()` automatically. The same interceptor is exposed as `instrumentor.interceptor` for Temporal test environments and custom client factories that accept an explicit `interceptors` list.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install respan-ai respan-instrumentation-temporal temporalio
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ from temporalio.client import Client
17
+ from respan import Respan
18
+ from respan_instrumentation_temporal import TemporalInstrumentor
19
+
20
+ instrumentor = TemporalInstrumentor()
21
+ respan = Respan(instrumentations=[instrumentor])
22
+
23
+ # The interceptor is appended automatically and is inherited by workers
24
+ # created from this client.
25
+ client = await Client.connect("localhost:7233")
26
+ ```
27
+
28
+ For `WorkflowEnvironment`:
29
+
30
+ ```python
31
+ from temporalio.testing import WorkflowEnvironment
32
+
33
+ environment = await WorkflowEnvironment.start_time_skipping(
34
+ interceptors=[instrumentor.interceptor]
35
+ )
36
+ ```
37
+
38
+ If an explicit Temporal `TracingInterceptor` is already present, Respan does not append a second tracing interceptor, avoiding duplicate spans.
39
+
40
+ ## Content capture
41
+
42
+ ```python
43
+ TemporalInstrumentor(capture_content=False)
44
+ ```
45
+
46
+ Content capture includes workflow/activity arguments and Temporal identifiers when the relevant interceptor input exposes them. With capture disabled, spans retain stable operation and workflow/activity type information but omit arguments and IDs. Temporal headers are never captured. Errors record OpenTelemetry error status plus backend-visible `status_code` and `error.message` fields. `activate()` and `deactivate()` are idempotent.
@@ -0,0 +1,30 @@
1
+ [tool.poetry]
2
+ name = "respan-instrumentation-temporal"
3
+ version = "0.1.0"
4
+ description = "Respan instrumentation plugin for Temporal Python"
5
+ authors = ["Respan <team@respan.ai>"]
6
+ license = "Apache 2.0"
7
+ readme = "README.md"
8
+ packages = [
9
+ { include = "respan_instrumentation_temporal", from = "./src" },
10
+ ]
11
+
12
+ [tool.poetry.dependencies]
13
+ python = ">=3.11,<3.14"
14
+ respan-tracing = "^2.17.0"
15
+ respan-sdk = ">=2.6.1"
16
+ temporalio = ">=1.30.0,<2.0.0"
17
+ opentelemetry-semantic-conventions-ai = ">=0.4.1"
18
+ wrapt = ">=1.16.0"
19
+
20
+ [tool.poetry.plugins."respan.instrumentations"]
21
+ temporal = "respan_instrumentation_temporal:TemporalInstrumentor"
22
+
23
+ [tool.pytest.ini_options]
24
+ markers = [
25
+ "integration: Temporal server integration tests",
26
+ ]
27
+
28
+ [build-system]
29
+ requires = ["poetry-core"]
30
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,3 @@
1
+ from respan_instrumentation_temporal._instrumentation import TemporalInstrumentor
2
+
3
+ __all__ = ["TemporalInstrumentor"]
@@ -0,0 +1,39 @@
1
+ """Temporal instrumentation-local constants."""
2
+
3
+ from respan_sdk.constants.llm_logging import LOG_TYPE_TASK, LOG_TYPE_WORKFLOW
4
+
5
+
6
+ TEMPORAL_INSTRUMENTATION_NAME = "temporal"
7
+ TEMPORAL_CLIENT_MODULE = "temporalio.client"
8
+ TEMPORAL_CLIENT_CONNECT_TARGET = "Client.connect"
9
+ TEMPORAL_OTEL_MODULE = "temporalio.contrib.opentelemetry"
10
+
11
+ # This key only moves a Python object between our interceptor subclass and tracer
12
+ # proxy. It is removed before OpenTelemetry sees the attributes.
13
+ TEMPORAL_CAPTURED_INPUT = "__respan_temporal_captured_input__"
14
+
15
+ TEMPORAL_RAW_ATTRIBUTE_KEYS = frozenset(
16
+ {
17
+ "temporalWorkflowID",
18
+ "temporalRunID",
19
+ "temporalActivityID",
20
+ "temporalActivityType",
21
+ "temporalUpdateID",
22
+ }
23
+ )
24
+
25
+ WORKFLOW_OPERATION_PREFIXES = frozenset(
26
+ {
27
+ "StartWorkflow",
28
+ "SignalWithStartWorkflow",
29
+ "RunWorkflow",
30
+ "CompleteWorkflow",
31
+ "StartChildWorkflow",
32
+ }
33
+ )
34
+
35
+ TASK_LOG_TYPE = LOG_TYPE_TASK
36
+ WORKFLOW_LOG_TYPE = LOG_TYPE_WORKFLOW
37
+ MAX_ATTRIBUTE_CHARS = 16_000
38
+ MAX_COLLECTION_ITEMS = 30
39
+ MAX_SERIALIZATION_DEPTH = 8
@@ -0,0 +1,516 @@
1
+ """Temporal's official tracing interceptor adapted to the Respan contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import json
7
+ import logging
8
+ import re
9
+ from collections.abc import Mapping, Sequence
10
+ from contextlib import contextmanager
11
+ from typing import Any
12
+
13
+ from opentelemetry import trace
14
+ from opentelemetry.instrumentation.utils import unwrap
15
+ from opentelemetry.semconv_ai import SpanAttributes
16
+ from opentelemetry.trace import Status, StatusCode
17
+ from respan_instrumentation_temporal._constants import (
18
+ MAX_ATTRIBUTE_CHARS,
19
+ MAX_COLLECTION_ITEMS,
20
+ MAX_SERIALIZATION_DEPTH,
21
+ TASK_LOG_TYPE,
22
+ TEMPORAL_CAPTURED_INPUT,
23
+ TEMPORAL_CLIENT_CONNECT_TARGET,
24
+ TEMPORAL_CLIENT_MODULE,
25
+ TEMPORAL_INSTRUMENTATION_NAME,
26
+ TEMPORAL_OTEL_MODULE,
27
+ TEMPORAL_RAW_ATTRIBUTE_KEYS,
28
+ WORKFLOW_LOG_TYPE,
29
+ WORKFLOW_OPERATION_PREFIXES,
30
+ )
31
+ from respan_sdk.constants.span_attributes import RESPAN_LOG_TYPE
32
+ from respan_tracing.core.tracer import RespanTracer
33
+ from wrapt import wrap_function_wrapper
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ _CAMEL_BOUNDARY_1 = re.compile(r"(.)([A-Z][a-z]+)")
38
+ _CAMEL_BOUNDARY_2 = re.compile(r"([a-z0-9])([A-Z])")
39
+ _SAFE_DETAIL = re.compile(r"[^a-zA-Z0-9_.-]+")
40
+ _MISSING = object()
41
+
42
+
43
+ def _to_jsonable(value: Any, *, depth: int = 0) -> Any:
44
+ if depth > MAX_SERIALIZATION_DEPTH:
45
+ return repr(value)
46
+ if value is None or isinstance(value, (str, int, float, bool)):
47
+ return value
48
+ if isinstance(value, bytes):
49
+ return {"type": "bytes", "length": len(value)}
50
+ if isinstance(value, Mapping):
51
+ items = list(value.items())
52
+ payload = {
53
+ str(key): _to_jsonable(item, depth=depth + 1)
54
+ for key, item in items[:MAX_COLLECTION_ITEMS]
55
+ if str(key).lower() not in {"headers", "rpc_metadata"}
56
+ }
57
+ if len(items) > MAX_COLLECTION_ITEMS:
58
+ payload["_respan_truncated_items"] = len(items) - MAX_COLLECTION_ITEMS
59
+ return payload
60
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
61
+ items = list(value)
62
+ payload = [
63
+ _to_jsonable(item, depth=depth + 1) for item in items[:MAX_COLLECTION_ITEMS]
64
+ ]
65
+ if len(items) > MAX_COLLECTION_ITEMS:
66
+ payload.append(
67
+ {"_respan_truncated_items": len(items) - MAX_COLLECTION_ITEMS}
68
+ )
69
+ return payload
70
+ model_dump = getattr(value, "model_dump", None)
71
+ if callable(model_dump):
72
+ try:
73
+ return _to_jsonable(model_dump(mode="json"), depth=depth + 1)
74
+ except TypeError:
75
+ return _to_jsonable(model_dump(), depth=depth + 1)
76
+ except Exception:
77
+ return repr(value)
78
+ if hasattr(value, "__dict__"):
79
+ return {
80
+ key: _to_jsonable(item, depth=depth + 1)
81
+ for key, item in vars(value).items()
82
+ if not key.startswith("_")
83
+ and key.lower() not in {"headers", "rpc_metadata"}
84
+ and not callable(item)
85
+ }
86
+ return repr(value)
87
+
88
+
89
+ def _json_dumps(value: Any, *, max_chars: int = MAX_ATTRIBUTE_CHARS) -> str:
90
+ serialized = json.dumps(
91
+ _to_jsonable(value),
92
+ default=str,
93
+ ensure_ascii=False,
94
+ sort_keys=True,
95
+ )
96
+ if len(serialized) <= max_chars:
97
+ return serialized
98
+ return json.dumps(
99
+ {
100
+ "truncated": True,
101
+ "original_characters": len(serialized),
102
+ "preview": serialized[:max_chars],
103
+ },
104
+ ensure_ascii=False,
105
+ sort_keys=True,
106
+ )
107
+
108
+
109
+ def _snake_case(value: str) -> str:
110
+ value = _CAMEL_BOUNDARY_1.sub(r"\1_\2", value)
111
+ value = _CAMEL_BOUNDARY_2.sub(r"\1_\2", value)
112
+ return value.replace("-", "_").lower()
113
+
114
+
115
+ def _span_parts(name: str) -> tuple[str, str | None]:
116
+ operation, separator, detail = name.partition(":")
117
+ return operation, detail if separator and detail else None
118
+
119
+
120
+ def _safe_detail(detail: str | None) -> str | None:
121
+ if not detail:
122
+ return None
123
+ cleaned = _SAFE_DETAIL.sub("_", detail).strip("_.-")
124
+ return cleaned[:120] or None
125
+
126
+
127
+ def _extract_temporal_input(value: Any) -> dict[str, Any]:
128
+ payload: dict[str, Any] = {}
129
+ for field_name in (
130
+ "args",
131
+ "arg",
132
+ "id",
133
+ "workflow",
134
+ "workflow_type",
135
+ "activity",
136
+ "activity_type",
137
+ "query",
138
+ "signal",
139
+ "update",
140
+ "update_id",
141
+ "task_queue",
142
+ ):
143
+ item = getattr(value, field_name, _MISSING)
144
+ if item is not _MISSING and item is not None and not callable(item):
145
+ payload[field_name] = item
146
+ return payload
147
+
148
+
149
+ def _canonical_attributes(
150
+ name: str,
151
+ attributes: Mapping[str, Any] | None,
152
+ *,
153
+ capture_content: bool,
154
+ max_attribute_chars: int,
155
+ ) -> dict[str, Any]:
156
+ source = dict(attributes or {})
157
+ captured_input = source.pop(TEMPORAL_CAPTURED_INPUT, None)
158
+ temporal_attributes = {
159
+ key: source.pop(key)
160
+ for key in tuple(source)
161
+ if key in TEMPORAL_RAW_ATTRIBUTE_KEYS
162
+ }
163
+ operation, detail = _span_parts(name)
164
+ safe_detail = _safe_detail(detail)
165
+ operation_name = _snake_case(operation)
166
+ entity_name = f"temporal.{operation_name}"
167
+ if safe_detail:
168
+ entity_name = f"{entity_name}.{safe_detail}"
169
+ log_type = (
170
+ WORKFLOW_LOG_TYPE if operation in WORKFLOW_OPERATION_PREFIXES else TASK_LOG_TYPE
171
+ )
172
+
173
+ input_payload: dict[str, Any] = {
174
+ "operation": operation_name,
175
+ "detail": detail,
176
+ "content_captured": capture_content,
177
+ }
178
+ if capture_content:
179
+ if temporal_attributes:
180
+ input_payload["temporal"] = temporal_attributes
181
+ if captured_input:
182
+ input_payload["input"] = captured_input
183
+
184
+ source[RESPAN_LOG_TYPE] = log_type
185
+ source[SpanAttributes.TRACELOOP_ENTITY_NAME] = entity_name
186
+ source[SpanAttributes.TRACELOOP_ENTITY_PATH] = entity_name
187
+ source[SpanAttributes.TRACELOOP_ENTITY_INPUT] = _json_dumps(
188
+ input_payload, max_chars=max_attribute_chars
189
+ )
190
+ source[SpanAttributes.TRACELOOP_ENTITY_OUTPUT] = _json_dumps(
191
+ {"status": "completed", "content_captured": capture_content},
192
+ max_chars=max_attribute_chars,
193
+ )
194
+ return source
195
+
196
+
197
+ def _set_success_output(span: Any, *, capture_content: bool, max_chars: int) -> None:
198
+ span.set_attribute("status_code", 200)
199
+ span.set_attribute(
200
+ SpanAttributes.TRACELOOP_ENTITY_OUTPUT,
201
+ _json_dumps(
202
+ {"status": "completed", "content_captured": capture_content},
203
+ max_chars=max_chars,
204
+ ),
205
+ )
206
+
207
+
208
+ def _set_error_output(
209
+ span: Any,
210
+ exc: BaseException,
211
+ *,
212
+ capture_content: bool,
213
+ max_chars: int,
214
+ record_exception: bool,
215
+ ) -> None:
216
+ message = str(exc) if capture_content else type(exc).__name__
217
+ if capture_content and record_exception and isinstance(exc, Exception):
218
+ span.record_exception(exc)
219
+ span.set_status(Status(StatusCode.ERROR, message))
220
+ span.set_attribute("status_code", 500)
221
+ span.set_attribute("error.message", message)
222
+ span.set_attribute(
223
+ SpanAttributes.TRACELOOP_ENTITY_OUTPUT,
224
+ _json_dumps(
225
+ {
226
+ "status": "error",
227
+ "error": type(exc).__name__,
228
+ "message": message,
229
+ "content_captured": capture_content,
230
+ },
231
+ max_chars=max_chars,
232
+ ),
233
+ )
234
+
235
+
236
+ class _CanonicalSpanProxy:
237
+ def __init__(
238
+ self, span: Any, *, capture_content: bool, max_attribute_chars: int
239
+ ) -> None:
240
+ self._span = span
241
+ self._capture_content = capture_content
242
+ self._max_attribute_chars = max_attribute_chars
243
+ self._has_error = False
244
+
245
+ def record_exception(self, exception: Exception, *args: Any, **kwargs: Any) -> None:
246
+ self._has_error = True
247
+ _set_error_output(
248
+ self._span,
249
+ exception,
250
+ capture_content=self._capture_content,
251
+ max_chars=self._max_attribute_chars,
252
+ record_exception=False,
253
+ )
254
+ if self._capture_content:
255
+ self._span.record_exception(exception, *args, **kwargs)
256
+
257
+ def end(self, *args: Any, **kwargs: Any) -> None:
258
+ if not self._has_error:
259
+ _set_success_output(
260
+ self._span,
261
+ capture_content=self._capture_content,
262
+ max_chars=self._max_attribute_chars,
263
+ )
264
+ self._span.end(*args, **kwargs)
265
+
266
+ def __getattr__(self, name: str) -> Any:
267
+ return getattr(self._span, name)
268
+
269
+
270
+ class _CanonicalTracer:
271
+ def __init__(
272
+ self, tracer: Any, *, capture_content: bool, max_attribute_chars: int
273
+ ) -> None:
274
+ self._tracer = tracer
275
+ self._capture_content = capture_content
276
+ self._max_attribute_chars = max_attribute_chars
277
+
278
+ @contextmanager
279
+ def start_as_current_span(self, name: str, *args: Any, **kwargs: Any):
280
+ kwargs["attributes"] = _canonical_attributes(
281
+ name,
282
+ kwargs.get("attributes"),
283
+ capture_content=self._capture_content,
284
+ max_attribute_chars=self._max_attribute_chars,
285
+ )
286
+ with self._tracer.start_as_current_span(name, *args, **kwargs) as span:
287
+ try:
288
+ yield span
289
+ except BaseException as exc:
290
+ _set_error_output(
291
+ span,
292
+ exc,
293
+ capture_content=self._capture_content,
294
+ max_chars=self._max_attribute_chars,
295
+ record_exception=True,
296
+ )
297
+ raise
298
+ else:
299
+ _set_success_output(
300
+ span,
301
+ capture_content=self._capture_content,
302
+ max_chars=self._max_attribute_chars,
303
+ )
304
+
305
+ def start_span(self, name: str, *args: Any, **kwargs: Any) -> _CanonicalSpanProxy:
306
+ kwargs["attributes"] = _canonical_attributes(
307
+ name,
308
+ kwargs.get("attributes"),
309
+ capture_content=self._capture_content,
310
+ max_attribute_chars=self._max_attribute_chars,
311
+ )
312
+ span = self._tracer.start_span(name, *args, **kwargs)
313
+ return _CanonicalSpanProxy(
314
+ span,
315
+ capture_content=self._capture_content,
316
+ max_attribute_chars=self._max_attribute_chars,
317
+ )
318
+
319
+ def __getattr__(self, name: str) -> Any:
320
+ return getattr(self._tracer, name)
321
+
322
+
323
+ def _build_interceptor(
324
+ base_class: type,
325
+ *,
326
+ tracer: Any,
327
+ capture_content: bool,
328
+ max_attribute_chars: int,
329
+ always_create_workflow_spans: bool,
330
+ ) -> Any:
331
+ canonical_tracer = _CanonicalTracer(
332
+ tracer,
333
+ capture_content=capture_content,
334
+ max_attribute_chars=max_attribute_chars,
335
+ )
336
+
337
+ class RespanTemporalTracingInterceptor(base_class):
338
+ @contextmanager
339
+ def _start_as_current_span(
340
+ self,
341
+ name: str,
342
+ *,
343
+ attributes: Mapping[str, Any] | None,
344
+ input_with_headers: Any = None,
345
+ input_with_ctx: Any = None,
346
+ kind: Any,
347
+ context: Any = None,
348
+ ):
349
+ enriched = dict(attributes or {})
350
+ if capture_content:
351
+ captured: dict[str, Any] = {}
352
+ if input_with_headers is not None:
353
+ captured.update(_extract_temporal_input(input_with_headers))
354
+ if input_with_ctx is not None:
355
+ captured.update(_extract_temporal_input(input_with_ctx))
356
+ if captured:
357
+ enriched[TEMPORAL_CAPTURED_INPUT] = captured
358
+ with super()._start_as_current_span(
359
+ name,
360
+ attributes=enriched,
361
+ input_with_headers=input_with_headers,
362
+ input_with_ctx=input_with_ctx,
363
+ kind=kind,
364
+ context=context,
365
+ ):
366
+ yield None
367
+
368
+ RespanTemporalTracingInterceptor.__name__ = "RespanTemporalTracingInterceptor"
369
+ return RespanTemporalTracingInterceptor(
370
+ tracer=canonical_tracer,
371
+ always_create_workflow_spans=always_create_workflow_spans,
372
+ )
373
+
374
+
375
+ class TemporalInstrumentor:
376
+ """Inject a canonicalized official Temporal tracing interceptor."""
377
+
378
+ name = TEMPORAL_INSTRUMENTATION_NAME
379
+ _patches_applied = False
380
+ _activation_count = 0
381
+ _patched_targets: list[tuple[str, str]] = []
382
+
383
+ def __init__(
384
+ self,
385
+ *,
386
+ capture_content: bool = True,
387
+ always_create_workflow_spans: bool = False,
388
+ max_attribute_chars: int = MAX_ATTRIBUTE_CHARS,
389
+ ) -> None:
390
+ self._capture_content = capture_content
391
+ self._always_create_workflow_spans = always_create_workflow_spans
392
+ self._max_attribute_chars = max(512, int(max_attribute_chars))
393
+ self._is_instrumented = False
394
+ self._interceptor: Any = None
395
+ self._base_interceptor_class: type | None = None
396
+
397
+ @staticmethod
398
+ def _is_respan_tracing_enabled() -> bool:
399
+ tracer = getattr(RespanTracer, "_instance", None)
400
+ if tracer is None:
401
+ return True
402
+ return bool(getattr(tracer, "is_enabled", True))
403
+
404
+ def _ensure_interceptor(self) -> Any:
405
+ if self._interceptor is not None:
406
+ return self._interceptor
407
+ otel_module = importlib.import_module(TEMPORAL_OTEL_MODULE)
408
+ self._base_interceptor_class = getattr(otel_module, "TracingInterceptor")
409
+ self._interceptor = _build_interceptor(
410
+ self._base_interceptor_class,
411
+ tracer=trace.get_tracer(__name__),
412
+ capture_content=self._capture_content,
413
+ max_attribute_chars=self._max_attribute_chars,
414
+ always_create_workflow_spans=self._always_create_workflow_spans,
415
+ )
416
+ return self._interceptor
417
+
418
+ @property
419
+ def interceptor(self) -> Any:
420
+ """The interceptor for explicit Temporal client/test-environment wiring."""
421
+ return self._ensure_interceptor()
422
+
423
+ async def _connect(
424
+ self,
425
+ wrapped: Any,
426
+ args: tuple[Any, ...],
427
+ kwargs: dict[str, Any],
428
+ ) -> Any:
429
+ interceptor = self._ensure_interceptor()
430
+ connect_kwargs = dict(kwargs)
431
+ interceptors = list(connect_kwargs.get("interceptors") or ())
432
+ base_class = self._base_interceptor_class
433
+ has_temporal_tracing = bool(
434
+ base_class is not None
435
+ and any(isinstance(candidate, base_class) for candidate in interceptors)
436
+ )
437
+ if not has_temporal_tracing:
438
+ interceptors.append(interceptor)
439
+ connect_kwargs["interceptors"] = interceptors
440
+ return await wrapped(*args, **connect_kwargs)
441
+
442
+ def activate(self) -> None:
443
+ """Patch `Client.connect` to inject the Respan Temporal interceptor."""
444
+ cls = type(self)
445
+ if self._is_instrumented:
446
+ return
447
+ if not self._is_respan_tracing_enabled():
448
+ logger.info(
449
+ "Temporal instrumentation skipped because Respan tracing is disabled"
450
+ )
451
+ return
452
+ if cls._patches_applied:
453
+ cls._activation_count += 1
454
+ self._is_instrumented = True
455
+ return
456
+ try:
457
+ client_module = importlib.import_module(TEMPORAL_CLIENT_MODULE)
458
+ client_class = getattr(client_module, "Client", None)
459
+ if client_class is None or not hasattr(client_class, "connect"):
460
+ logger.warning("Temporal Client.connect is unavailable")
461
+ return
462
+ self._ensure_interceptor()
463
+
464
+ async def traced_connect(
465
+ wrapped: Any,
466
+ instance: Any,
467
+ args: tuple[Any, ...],
468
+ kwargs: dict[str, Any],
469
+ ) -> Any:
470
+ return await self._connect(wrapped, args, kwargs)
471
+
472
+ wrap_function_wrapper(
473
+ TEMPORAL_CLIENT_MODULE,
474
+ TEMPORAL_CLIENT_CONNECT_TARGET,
475
+ traced_connect,
476
+ )
477
+ cls._patched_targets.append(
478
+ (TEMPORAL_CLIENT_MODULE, TEMPORAL_CLIENT_CONNECT_TARGET)
479
+ )
480
+ except ImportError as exc:
481
+ logger.warning(
482
+ "Failed to activate Temporal instrumentation - missing dependency: %s",
483
+ exc,
484
+ )
485
+ return
486
+ except Exception:
487
+ logger.exception("Failed to activate Temporal instrumentation")
488
+ self.deactivate()
489
+ return
490
+ cls._patches_applied = True
491
+ cls._activation_count = 1
492
+ self._is_instrumented = True
493
+ logger.info("Temporal instrumentation activated")
494
+
495
+ def deactivate(self) -> None:
496
+ """Restore `Client.connect`; existing clients retain their interceptor."""
497
+ cls = type(self)
498
+ if not self._is_instrumented:
499
+ if cls._patches_applied or not cls._patched_targets:
500
+ return
501
+ else:
502
+ self._is_instrumented = False
503
+ cls._activation_count = max(cls._activation_count - 1, 0)
504
+ if cls._activation_count:
505
+ return
506
+ for module_path, target in reversed(cls._patched_targets):
507
+ try:
508
+ unwrap(module_path, target)
509
+ except Exception:
510
+ logger.debug(
511
+ "Failed to unwrap %s.%s", module_path, target, exc_info=True
512
+ )
513
+ cls._patched_targets.clear()
514
+ cls._patches_applied = False
515
+ cls._activation_count = 0
516
+ logger.info("Temporal instrumentation deactivated")