chuk-tool-processor 0.7.0__py3-none-any.whl → 0.10__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.

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