chuk-tool-processor 0.7.0__py3-none-any.whl → 0.9__py3-none-any.whl

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.

Potentially problematic release.


This version of chuk-tool-processor might be problematic. Click here for more details.

@@ -0,0 +1,343 @@
1
+ """
2
+ OpenTelemetry tracing integration for chuk-tool-processor.
3
+
4
+ Provides drop-in distributed tracing with standardized span names and attributes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from contextlib import contextmanager
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from chuk_tool_processor.logging import get_logger
13
+
14
+ if TYPE_CHECKING:
15
+ from opentelemetry.trace import Span, Tracer
16
+
17
+ logger = get_logger("chuk_tool_processor.observability.tracing")
18
+
19
+ # Global tracer instance
20
+ _tracer: Tracer | None = None
21
+ _tracing_enabled = False
22
+
23
+
24
+ def init_tracer(service_name: str = "chuk-tool-processor") -> Tracer | NoOpTracer:
25
+ """
26
+ Initialize OpenTelemetry tracer with best-practice configuration.
27
+
28
+ Args:
29
+ service_name: Service name for tracing
30
+
31
+ Returns:
32
+ Configured OpenTelemetry tracer or NoOpTracer if initialization fails
33
+ """
34
+ global _tracer, _tracing_enabled
35
+
36
+ try:
37
+ from opentelemetry import trace
38
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
39
+ from opentelemetry.sdk.resources import Resource
40
+ from opentelemetry.sdk.trace import TracerProvider
41
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
42
+
43
+ # Create resource with service name
44
+ resource = Resource.create({"service.name": service_name})
45
+
46
+ # Create tracer provider
47
+ provider = TracerProvider(resource=resource)
48
+
49
+ # Add OTLP exporter (exports to OTEL collector)
50
+ otlp_exporter = OTLPSpanExporter()
51
+ provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
52
+
53
+ # Set as global tracer provider
54
+ trace.set_tracer_provider(provider)
55
+
56
+ _tracer = trace.get_tracer(__name__)
57
+ _tracing_enabled = True
58
+
59
+ logger.info(f"OpenTelemetry tracing initialized for service: {service_name}")
60
+ return _tracer
61
+
62
+ except ImportError as e:
63
+ logger.warning(f"OpenTelemetry packages not installed: {e}. Tracing disabled.")
64
+ _tracing_enabled = False
65
+ return NoOpTracer()
66
+
67
+
68
+ def get_tracer() -> Tracer | NoOpTracer:
69
+ """
70
+ Get the current tracer instance.
71
+
72
+ Returns:
73
+ OpenTelemetry tracer or no-op tracer if not initialized
74
+ """
75
+ if _tracer is None:
76
+ return NoOpTracer()
77
+ return _tracer
78
+
79
+
80
+ def is_tracing_enabled() -> bool:
81
+ """Check if tracing is enabled."""
82
+ return _tracing_enabled
83
+
84
+
85
+ @contextmanager
86
+ def trace_tool_execution(
87
+ tool: str,
88
+ namespace: str | None = None,
89
+ attributes: dict[str, Any] | None = None,
90
+ ):
91
+ """
92
+ Context manager for tracing tool execution.
93
+
94
+ Creates a span with name "tool.execute" and standard attributes.
95
+
96
+ Args:
97
+ tool: Tool name
98
+ namespace: Optional tool namespace
99
+ attributes: Additional span attributes
100
+
101
+ Example:
102
+ with trace_tool_execution("calculator", attributes={"operation": "add"}):
103
+ result = await tool.execute(a=5, b=3)
104
+ """
105
+ if not _tracing_enabled or _tracer is None:
106
+ yield None
107
+ return
108
+
109
+ span_name = "tool.execute"
110
+ span_attributes: dict[str, str | int | float | bool] = {
111
+ "tool.name": tool,
112
+ }
113
+
114
+ if namespace:
115
+ span_attributes["tool.namespace"] = namespace
116
+
117
+ if attributes:
118
+ # Flatten attributes with "tool." prefix
119
+ for key, value in attributes.items():
120
+ # Convert value to string for OTEL compatibility
121
+ if isinstance(value, (str, int, float, bool)):
122
+ span_attributes[f"tool.{key}"] = value
123
+ else:
124
+ span_attributes[f"tool.{key}"] = str(value)
125
+
126
+ with _tracer.start_as_current_span(span_name, attributes=span_attributes) as span:
127
+ yield span
128
+
129
+
130
+ @contextmanager
131
+ def trace_cache_operation(
132
+ operation: str,
133
+ tool: str,
134
+ hit: bool | None = None,
135
+ attributes: dict[str, Any] | None = None,
136
+ ):
137
+ """
138
+ Context manager for tracing cache operations.
139
+
140
+ Args:
141
+ operation: Cache operation (lookup, set, invalidate)
142
+ tool: Tool name
143
+ hit: Whether cache hit (for lookup operations)
144
+ attributes: Additional span attributes
145
+
146
+ Example:
147
+ with trace_cache_operation("lookup", "calculator", hit=True):
148
+ result = await cache.get(tool, key)
149
+ """
150
+ if not _tracing_enabled or _tracer is None:
151
+ yield None
152
+ return
153
+
154
+ span_name = f"tool.cache.{operation}"
155
+ span_attributes: dict[str, str | int | float | bool] = {
156
+ "tool.name": tool,
157
+ "cache.operation": operation,
158
+ }
159
+
160
+ if hit is not None:
161
+ span_attributes["cache.hit"] = hit
162
+
163
+ if attributes:
164
+ for key, value in attributes.items():
165
+ if isinstance(value, (str, int, float, bool)):
166
+ span_attributes[f"cache.{key}"] = value
167
+ else:
168
+ span_attributes[f"cache.{key}"] = str(value)
169
+
170
+ with _tracer.start_as_current_span(span_name, attributes=span_attributes) as span:
171
+ yield span
172
+
173
+
174
+ @contextmanager
175
+ def trace_retry_attempt(
176
+ tool: str,
177
+ attempt: int,
178
+ max_retries: int,
179
+ attributes: dict[str, Any] | None = None,
180
+ ):
181
+ """
182
+ Context manager for tracing retry attempts.
183
+
184
+ Args:
185
+ tool: Tool name
186
+ attempt: Current attempt number (0-indexed)
187
+ max_retries: Maximum retry attempts
188
+ attributes: Additional span attributes
189
+
190
+ Example:
191
+ with trace_retry_attempt("api_tool", attempt=1, max_retries=3):
192
+ result = await executor.execute([call])
193
+ """
194
+ if not _tracing_enabled or _tracer is None:
195
+ yield None
196
+ return
197
+
198
+ span_name = "tool.retry.attempt"
199
+ span_attributes: dict[str, str | int | float | bool] = {
200
+ "tool.name": tool,
201
+ "retry.attempt": attempt,
202
+ "retry.max_attempts": max_retries,
203
+ }
204
+
205
+ if attributes:
206
+ for key, value in attributes.items():
207
+ if isinstance(value, (str, int, float, bool)):
208
+ span_attributes[f"retry.{key}"] = value
209
+ else:
210
+ span_attributes[f"retry.{key}"] = str(value)
211
+
212
+ with _tracer.start_as_current_span(span_name, attributes=span_attributes) as span:
213
+ yield span
214
+
215
+
216
+ @contextmanager
217
+ def trace_circuit_breaker(
218
+ tool: str,
219
+ state: str,
220
+ attributes: dict[str, Any] | None = None,
221
+ ):
222
+ """
223
+ Context manager for tracing circuit breaker operations.
224
+
225
+ Args:
226
+ tool: Tool name
227
+ state: Circuit breaker state (CLOSED, OPEN, HALF_OPEN)
228
+ attributes: Additional span attributes
229
+
230
+ Example:
231
+ with trace_circuit_breaker("api_tool", state="OPEN"):
232
+ can_execute = await breaker.can_execute()
233
+ """
234
+ if not _tracing_enabled or _tracer is None:
235
+ yield None
236
+ return
237
+
238
+ span_name = "tool.circuit_breaker.check"
239
+ span_attributes: dict[str, str | int | float | bool] = {
240
+ "tool.name": tool,
241
+ "circuit.state": state,
242
+ }
243
+
244
+ if attributes:
245
+ for key, value in attributes.items():
246
+ if isinstance(value, (str, int, float, bool)):
247
+ span_attributes[f"circuit.{key}"] = value
248
+ else:
249
+ span_attributes[f"circuit.{key}"] = str(value)
250
+
251
+ with _tracer.start_as_current_span(span_name, attributes=span_attributes) as span:
252
+ yield span
253
+
254
+
255
+ @contextmanager
256
+ def trace_rate_limit(
257
+ tool: str,
258
+ allowed: bool,
259
+ attributes: dict[str, Any] | None = None,
260
+ ):
261
+ """
262
+ Context manager for tracing rate limiting.
263
+
264
+ Args:
265
+ tool: Tool name
266
+ allowed: Whether request was allowed
267
+ attributes: Additional span attributes
268
+
269
+ Example:
270
+ with trace_rate_limit("api_tool", allowed=True):
271
+ await rate_limiter.acquire()
272
+ """
273
+ if not _tracing_enabled or _tracer is None:
274
+ yield None
275
+ return
276
+
277
+ span_name = "tool.rate_limit.check"
278
+ span_attributes: dict[str, str | int | float | bool] = {
279
+ "tool.name": tool,
280
+ "rate_limit.allowed": allowed,
281
+ }
282
+
283
+ if attributes:
284
+ for key, value in attributes.items():
285
+ if isinstance(value, (str, int, float, bool)):
286
+ span_attributes[f"rate_limit.{key}"] = value
287
+ else:
288
+ span_attributes[f"rate_limit.{key}"] = str(value)
289
+
290
+ with _tracer.start_as_current_span(span_name, attributes=span_attributes) as span:
291
+ yield span
292
+
293
+
294
+ def add_span_event(span: Span | None, name: str, attributes: dict[str, Any] | None = None) -> None:
295
+ """
296
+ Add an event to the current span.
297
+
298
+ Args:
299
+ span: Span to add event to (can be None)
300
+ name: Event name
301
+ attributes: Event attributes
302
+ """
303
+ if span is None or not _tracing_enabled:
304
+ return
305
+
306
+ try:
307
+ span.add_event(name, attributes=attributes or {})
308
+ except Exception as e:
309
+ logger.debug(f"Error adding span event: {e}")
310
+
311
+
312
+ def set_span_error(span: Span | None, error: Exception | str) -> None:
313
+ """
314
+ Mark span as error and record exception details.
315
+
316
+ Args:
317
+ span: Span to mark as error (can be None)
318
+ error: Error to record
319
+ """
320
+ if span is None or not _tracing_enabled:
321
+ return
322
+
323
+ try:
324
+ from opentelemetry.trace import Status, StatusCode
325
+
326
+ span.set_status(Status(StatusCode.ERROR, str(error)))
327
+
328
+ if isinstance(error, Exception):
329
+ span.record_exception(error)
330
+ else:
331
+ span.add_event("error", {"error.message": str(error)})
332
+
333
+ except Exception as e:
334
+ logger.debug(f"Error setting span error: {e}")
335
+
336
+
337
+ class NoOpTracer:
338
+ """No-op tracer when OpenTelemetry is not available."""
339
+
340
+ @contextmanager
341
+ def start_as_current_span(self, _name: str, **_kwargs):
342
+ """No-op span context manager."""
343
+ yield None