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.
- respan_instrumentation_temporal-0.1.0/PKG-INFO +67 -0
- respan_instrumentation_temporal-0.1.0/README.md +46 -0
- respan_instrumentation_temporal-0.1.0/pyproject.toml +30 -0
- respan_instrumentation_temporal-0.1.0/src/respan_instrumentation_temporal/__init__.py +3 -0
- respan_instrumentation_temporal-0.1.0/src/respan_instrumentation_temporal/_constants.py +39 -0
- respan_instrumentation_temporal-0.1.0/src/respan_instrumentation_temporal/_instrumentation.py +516 -0
|
@@ -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,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")
|