chuk-tool-processor 0.8__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.
- chuk_tool_processor/execution/wrappers/caching.py +38 -9
- chuk_tool_processor/execution/wrappers/circuit_breaker.py +29 -2
- chuk_tool_processor/execution/wrappers/rate_limiting.py +31 -1
- chuk_tool_processor/execution/wrappers/retry.py +81 -53
- chuk_tool_processor/mcp/setup_mcp_http_streamable.py +8 -1
- chuk_tool_processor/mcp/setup_mcp_sse.py +8 -1
- chuk_tool_processor/mcp/transport/http_streamable_transport.py +16 -3
- chuk_tool_processor/mcp/transport/sse_transport.py +16 -3
- chuk_tool_processor/observability/__init__.py +30 -0
- chuk_tool_processor/observability/metrics.py +312 -0
- chuk_tool_processor/observability/setup.py +105 -0
- chuk_tool_processor/observability/tracing.py +343 -0
- {chuk_tool_processor-0.8.dist-info → chuk_tool_processor-0.9.dist-info}/METADATA +291 -2
- {chuk_tool_processor-0.8.dist-info → chuk_tool_processor-0.9.dist-info}/RECORD +16 -12
- {chuk_tool_processor-0.8.dist-info → chuk_tool_processor-0.9.dist-info}/WHEEL +0 -0
- {chuk_tool_processor-0.8.dist-info → chuk_tool_processor-0.9.dist-info}/top_level.txt +0 -0
|
@@ -29,6 +29,24 @@ from chuk_tool_processor.models.tool_result import ToolResult
|
|
|
29
29
|
|
|
30
30
|
logger = get_logger("chuk_tool_processor.execution.wrappers.caching")
|
|
31
31
|
|
|
32
|
+
# Optional observability imports
|
|
33
|
+
try:
|
|
34
|
+
from chuk_tool_processor.observability.metrics import get_metrics
|
|
35
|
+
from chuk_tool_processor.observability.tracing import trace_cache_operation
|
|
36
|
+
|
|
37
|
+
_observability_available = True
|
|
38
|
+
except ImportError:
|
|
39
|
+
_observability_available = False
|
|
40
|
+
|
|
41
|
+
# No-op functions when observability not available
|
|
42
|
+
def get_metrics():
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def trace_cache_operation(*_args, **_kwargs):
|
|
46
|
+
from contextlib import nullcontext
|
|
47
|
+
|
|
48
|
+
return nullcontext()
|
|
49
|
+
|
|
32
50
|
|
|
33
51
|
# --------------------------------------------------------------------------- #
|
|
34
52
|
# Cache primitives
|
|
@@ -430,7 +448,15 @@ class CachingToolExecutor:
|
|
|
430
448
|
|
|
431
449
|
# Use idempotency_key if available, otherwise hash arguments
|
|
432
450
|
cache_key = call.idempotency_key or self._hash_arguments(call.arguments)
|
|
433
|
-
|
|
451
|
+
|
|
452
|
+
# Trace cache lookup operation
|
|
453
|
+
with trace_cache_operation("lookup", call.tool):
|
|
454
|
+
cached_val = await self.cache.get(call.tool, cache_key)
|
|
455
|
+
|
|
456
|
+
# Record metrics
|
|
457
|
+
metrics = get_metrics()
|
|
458
|
+
if metrics:
|
|
459
|
+
metrics.record_cache_operation(call.tool, "lookup", hit=(cached_val is not None))
|
|
434
460
|
|
|
435
461
|
if cached_val is None:
|
|
436
462
|
# Cache miss
|
|
@@ -481,6 +507,8 @@ class CachingToolExecutor:
|
|
|
481
507
|
# ------------------------------------------------------------------
|
|
482
508
|
if use_cache:
|
|
483
509
|
cache_tasks = []
|
|
510
|
+
metrics = get_metrics()
|
|
511
|
+
|
|
484
512
|
for (_idx, call), result in zip(uncached, uncached_results, strict=False):
|
|
485
513
|
if result.error is None and self._is_cacheable(call.tool):
|
|
486
514
|
ttl = self._ttl_for(call.tool)
|
|
@@ -489,14 +517,15 @@ class CachingToolExecutor:
|
|
|
489
517
|
# Use idempotency_key if available, otherwise hash arguments
|
|
490
518
|
cache_key = call.idempotency_key or self._hash_arguments(call.arguments)
|
|
491
519
|
|
|
492
|
-
#
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
520
|
+
# Trace and record cache set operation
|
|
521
|
+
# Bind loop variables to avoid B023 error
|
|
522
|
+
async def cache_with_trace(tool=call.tool, key=cache_key, value=result.result, ttl_val=ttl):
|
|
523
|
+
with trace_cache_operation("set", tool, attributes={"ttl": ttl_val}):
|
|
524
|
+
await self.cache.set(tool, key, value, ttl=ttl_val)
|
|
525
|
+
if metrics:
|
|
526
|
+
metrics.record_cache_operation(tool, "set")
|
|
527
|
+
|
|
528
|
+
cache_tasks.append(cache_with_trace())
|
|
500
529
|
|
|
501
530
|
# Flag as non-cached so callers can tell
|
|
502
531
|
if hasattr(result, "cached"):
|
|
@@ -28,6 +28,24 @@ from chuk_tool_processor.models.tool_result import ToolResult
|
|
|
28
28
|
|
|
29
29
|
logger = get_logger("chuk_tool_processor.execution.wrappers.circuit_breaker")
|
|
30
30
|
|
|
31
|
+
# Optional observability imports
|
|
32
|
+
try:
|
|
33
|
+
from chuk_tool_processor.observability.metrics import get_metrics
|
|
34
|
+
from chuk_tool_processor.observability.tracing import trace_circuit_breaker
|
|
35
|
+
|
|
36
|
+
_observability_available = True
|
|
37
|
+
except ImportError:
|
|
38
|
+
_observability_available = False
|
|
39
|
+
|
|
40
|
+
# No-op functions when observability not available
|
|
41
|
+
def get_metrics():
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
def trace_circuit_breaker(*_args, **_kwargs):
|
|
45
|
+
from contextlib import nullcontext
|
|
46
|
+
|
|
47
|
+
return nullcontext()
|
|
48
|
+
|
|
31
49
|
|
|
32
50
|
# --------------------------------------------------------------------------- #
|
|
33
51
|
# Circuit breaker state
|
|
@@ -234,8 +252,14 @@ class CircuitBreakerExecutor:
|
|
|
234
252
|
for call in calls:
|
|
235
253
|
state = await self._get_state(call.tool)
|
|
236
254
|
|
|
237
|
-
#
|
|
238
|
-
|
|
255
|
+
# Record circuit breaker state
|
|
256
|
+
metrics = get_metrics()
|
|
257
|
+
if metrics:
|
|
258
|
+
metrics.record_circuit_breaker_state(call.tool, state.state.value)
|
|
259
|
+
|
|
260
|
+
# Check if circuit allows execution with tracing
|
|
261
|
+
with trace_circuit_breaker(call.tool, state.state.value):
|
|
262
|
+
can_execute = await state.can_execute()
|
|
239
263
|
|
|
240
264
|
if not can_execute:
|
|
241
265
|
# Circuit is OPEN - reject immediately
|
|
@@ -283,6 +307,9 @@ class CircuitBreakerExecutor:
|
|
|
283
307
|
|
|
284
308
|
if is_error or is_timeout:
|
|
285
309
|
await state.record_failure()
|
|
310
|
+
# Record circuit breaker failure metric
|
|
311
|
+
if metrics:
|
|
312
|
+
metrics.record_circuit_breaker_failure(call.tool)
|
|
286
313
|
else:
|
|
287
314
|
await state.record_success()
|
|
288
315
|
|
|
@@ -25,6 +25,24 @@ from chuk_tool_processor.models.tool_result import ToolResult
|
|
|
25
25
|
|
|
26
26
|
logger = get_logger("chuk_tool_processor.execution.wrappers.rate_limiting")
|
|
27
27
|
|
|
28
|
+
# Optional observability imports
|
|
29
|
+
try:
|
|
30
|
+
from chuk_tool_processor.observability.metrics import get_metrics
|
|
31
|
+
from chuk_tool_processor.observability.tracing import trace_rate_limit
|
|
32
|
+
|
|
33
|
+
_observability_available = True
|
|
34
|
+
except ImportError:
|
|
35
|
+
_observability_available = False
|
|
36
|
+
|
|
37
|
+
# No-op functions when observability not available
|
|
38
|
+
def get_metrics():
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
def trace_rate_limit(*_args, **_kwargs):
|
|
42
|
+
from contextlib import nullcontext
|
|
43
|
+
|
|
44
|
+
return nullcontext()
|
|
45
|
+
|
|
28
46
|
|
|
29
47
|
# --------------------------------------------------------------------------- #
|
|
30
48
|
# Core limiter
|
|
@@ -220,8 +238,20 @@ class RateLimitedToolExecutor:
|
|
|
220
238
|
return []
|
|
221
239
|
|
|
222
240
|
# Block for each call *before* dispatching to the wrapped executor
|
|
241
|
+
metrics = get_metrics()
|
|
242
|
+
|
|
223
243
|
for c in calls:
|
|
224
|
-
|
|
244
|
+
# Check limits first for metrics
|
|
245
|
+
global_limited, tool_limited = await self.limiter.check_limits(c.tool)
|
|
246
|
+
allowed = not (global_limited or tool_limited)
|
|
247
|
+
|
|
248
|
+
# Trace rate limit check
|
|
249
|
+
with trace_rate_limit(c.tool, allowed):
|
|
250
|
+
await self.limiter.wait(c.tool)
|
|
251
|
+
|
|
252
|
+
# Record metrics
|
|
253
|
+
if metrics:
|
|
254
|
+
metrics.record_rate_limit_check(c.tool, allowed)
|
|
225
255
|
|
|
226
256
|
# Check if the executor has a use_cache parameter
|
|
227
257
|
if hasattr(self.executor, "execute"):
|
|
@@ -21,6 +21,24 @@ from chuk_tool_processor.models.tool_result import ToolResult
|
|
|
21
21
|
|
|
22
22
|
logger = get_logger("chuk_tool_processor.execution.wrappers.retry")
|
|
23
23
|
|
|
24
|
+
# Optional observability imports
|
|
25
|
+
try:
|
|
26
|
+
from chuk_tool_processor.observability.metrics import get_metrics
|
|
27
|
+
from chuk_tool_processor.observability.tracing import trace_retry_attempt
|
|
28
|
+
|
|
29
|
+
_observability_available = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
_observability_available = False
|
|
32
|
+
|
|
33
|
+
# No-op functions when observability not available
|
|
34
|
+
def get_metrics():
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
def trace_retry_attempt(*_args, **_kwargs):
|
|
38
|
+
from contextlib import nullcontext
|
|
39
|
+
|
|
40
|
+
return nullcontext()
|
|
41
|
+
|
|
24
42
|
|
|
25
43
|
# --------------------------------------------------------------------------- #
|
|
26
44
|
# Retry configuration
|
|
@@ -177,63 +195,73 @@ class RetryableToolExecutor:
|
|
|
177
195
|
# Execute one attempt
|
|
178
196
|
# ---------------------------------------------------------------- #
|
|
179
197
|
start_time = datetime.now(UTC)
|
|
180
|
-
try:
|
|
181
|
-
kwargs = {"timeout": remaining} if remaining is not None else {}
|
|
182
|
-
if hasattr(self.executor, "use_cache"):
|
|
183
|
-
kwargs["use_cache"] = use_cache
|
|
184
|
-
|
|
185
|
-
result = (await self.executor.execute([call], **kwargs))[0]
|
|
186
|
-
pid = result.pid
|
|
187
|
-
machine = result.machine
|
|
188
198
|
|
|
189
|
-
|
|
190
|
-
|
|
199
|
+
# Trace retry attempt
|
|
200
|
+
with trace_retry_attempt(call.tool, attempt, cfg.max_retries):
|
|
201
|
+
try:
|
|
202
|
+
kwargs = {"timeout": remaining} if remaining is not None else {}
|
|
203
|
+
if hasattr(self.executor, "use_cache"):
|
|
204
|
+
kwargs["use_cache"] = use_cache
|
|
205
|
+
|
|
206
|
+
result = (await self.executor.execute([call], **kwargs))[0]
|
|
207
|
+
pid = result.pid
|
|
208
|
+
machine = result.machine
|
|
209
|
+
|
|
210
|
+
# Record retry metrics
|
|
211
|
+
metrics = get_metrics()
|
|
212
|
+
success = result.error is None
|
|
213
|
+
|
|
214
|
+
if metrics:
|
|
215
|
+
metrics.record_retry_attempt(call.tool, attempt, success)
|
|
216
|
+
|
|
217
|
+
# Success?
|
|
218
|
+
if success:
|
|
219
|
+
result.attempts = attempt + 1
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
# Error: decide on retry
|
|
223
|
+
last_error = result.error
|
|
224
|
+
if cfg.should_retry(attempt, error_str=result.error):
|
|
225
|
+
delay = cfg.get_delay(attempt)
|
|
226
|
+
# never overshoot the deadline
|
|
227
|
+
if deadline is not None:
|
|
228
|
+
delay = min(delay, max(deadline - time.monotonic(), 0))
|
|
229
|
+
if delay:
|
|
230
|
+
await asyncio.sleep(delay)
|
|
231
|
+
attempt += 1
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
# No more retries wanted
|
|
235
|
+
result.error = self._wrap_error(last_error, attempt, cfg)
|
|
191
236
|
result.attempts = attempt + 1
|
|
192
237
|
return result
|
|
193
238
|
|
|
194
|
-
#
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
delay = min(delay, max(deadline - time.monotonic(), 0))
|
|
221
|
-
if delay:
|
|
222
|
-
await asyncio.sleep(delay)
|
|
223
|
-
attempt += 1
|
|
224
|
-
continue
|
|
225
|
-
|
|
226
|
-
end_time = datetime.now(UTC)
|
|
227
|
-
return ToolResult(
|
|
228
|
-
tool=call.tool,
|
|
229
|
-
result=None,
|
|
230
|
-
error=self._wrap_error(err_str, attempt, cfg),
|
|
231
|
-
start_time=start_time,
|
|
232
|
-
end_time=end_time,
|
|
233
|
-
machine=machine,
|
|
234
|
-
pid=pid,
|
|
235
|
-
attempts=attempt + 1,
|
|
236
|
-
)
|
|
239
|
+
# ---------------------------------------------------------------- #
|
|
240
|
+
# Exception path
|
|
241
|
+
# ---------------------------------------------------------------- #
|
|
242
|
+
except Exception as exc: # noqa: BLE001
|
|
243
|
+
err_str = str(exc)
|
|
244
|
+
last_error = err_str
|
|
245
|
+
if cfg.should_retry(attempt, error=exc, error_str=err_str):
|
|
246
|
+
delay = cfg.get_delay(attempt)
|
|
247
|
+
if deadline is not None:
|
|
248
|
+
delay = min(delay, max(deadline - time.monotonic(), 0))
|
|
249
|
+
if delay:
|
|
250
|
+
await asyncio.sleep(delay)
|
|
251
|
+
attempt += 1
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
end_time = datetime.now(UTC)
|
|
255
|
+
return ToolResult(
|
|
256
|
+
tool=call.tool,
|
|
257
|
+
result=None,
|
|
258
|
+
error=self._wrap_error(err_str, attempt, cfg),
|
|
259
|
+
start_time=start_time,
|
|
260
|
+
end_time=end_time,
|
|
261
|
+
machine=machine,
|
|
262
|
+
pid=pid,
|
|
263
|
+
attempts=attempt + 1,
|
|
264
|
+
)
|
|
237
265
|
|
|
238
266
|
# --------------------------------------------------------------------- #
|
|
239
267
|
# Helpers
|
|
@@ -110,10 +110,17 @@ async def setup_mcp_http_streamable(
|
|
|
110
110
|
|
|
111
111
|
# Define OAuth error patterns that should NOT be retried at this level
|
|
112
112
|
# These will be handled by the transport layer's OAuth refresh mechanism
|
|
113
|
+
# Based on RFC 6750 (Bearer Token Usage) and MCP OAuth spec
|
|
113
114
|
oauth_error_patterns = [
|
|
114
|
-
|
|
115
|
+
# RFC 6750 Section 3.1 - Standard Bearer token errors
|
|
116
|
+
"invalid_token", # Token expired, revoked, malformed, or invalid
|
|
117
|
+
"insufficient_scope", # Request requires higher privileges (403 Forbidden)
|
|
118
|
+
# OAuth 2.1 token refresh errors
|
|
119
|
+
"invalid_grant", # Refresh token errors
|
|
120
|
+
# MCP spec - OAuth validation failures (401 Unauthorized)
|
|
115
121
|
"oauth validation",
|
|
116
122
|
"unauthorized",
|
|
123
|
+
# Common OAuth error descriptions
|
|
117
124
|
"expired token",
|
|
118
125
|
"token expired",
|
|
119
126
|
"authentication failed",
|
|
@@ -89,10 +89,17 @@ async def setup_mcp_sse( # noqa: C901 - long but just a config facade
|
|
|
89
89
|
|
|
90
90
|
# Define OAuth error patterns that should NOT be retried at this level
|
|
91
91
|
# These will be handled by the transport layer's OAuth refresh mechanism
|
|
92
|
+
# Based on RFC 6750 (Bearer Token Usage) and MCP OAuth spec
|
|
92
93
|
oauth_error_patterns = [
|
|
93
|
-
|
|
94
|
+
# RFC 6750 Section 3.1 - Standard Bearer token errors
|
|
95
|
+
"invalid_token", # Token expired, revoked, malformed, or invalid
|
|
96
|
+
"insufficient_scope", # Request requires higher privileges (403 Forbidden)
|
|
97
|
+
# OAuth 2.1 token refresh errors
|
|
98
|
+
"invalid_grant", # Refresh token errors
|
|
99
|
+
# MCP spec - OAuth validation failures (401 Unauthorized)
|
|
94
100
|
"oauth validation",
|
|
95
101
|
"unauthorized",
|
|
102
|
+
# Common OAuth error descriptions
|
|
96
103
|
"expired token",
|
|
97
104
|
"token expired",
|
|
98
105
|
"authentication failed",
|
|
@@ -519,16 +519,29 @@ class HTTPStreamableTransport(MCPBaseTransport):
|
|
|
519
519
|
self._metrics.update_call_metrics(response_time, success)
|
|
520
520
|
|
|
521
521
|
def _is_oauth_error(self, error_msg: str) -> bool:
|
|
522
|
-
"""
|
|
522
|
+
"""
|
|
523
|
+
Detect if error is OAuth-related per RFC 6750 and MCP OAuth spec.
|
|
524
|
+
|
|
525
|
+
Checks for:
|
|
526
|
+
- RFC 6750 Section 3.1 Bearer token errors (invalid_token, insufficient_scope)
|
|
527
|
+
- OAuth 2.1 token refresh errors (invalid_grant)
|
|
528
|
+
- MCP spec OAuth validation failures (401/403 responses)
|
|
529
|
+
"""
|
|
523
530
|
if not error_msg:
|
|
524
531
|
return False
|
|
525
532
|
|
|
526
533
|
error_lower = error_msg.lower()
|
|
527
534
|
oauth_indicators = [
|
|
528
|
-
|
|
529
|
-
"expired
|
|
535
|
+
# RFC 6750 Section 3.1 - Standard Bearer token errors
|
|
536
|
+
"invalid_token", # Token expired, revoked, malformed, or invalid
|
|
537
|
+
"insufficient_scope", # Request requires higher privileges (403 Forbidden)
|
|
538
|
+
# OAuth 2.1 token refresh errors
|
|
539
|
+
"invalid_grant", # Refresh token errors
|
|
540
|
+
# MCP spec - OAuth validation failures (401 Unauthorized)
|
|
530
541
|
"oauth validation",
|
|
531
542
|
"unauthorized",
|
|
543
|
+
# Common OAuth error descriptions
|
|
544
|
+
"expired token",
|
|
532
545
|
"token expired",
|
|
533
546
|
"authentication failed",
|
|
534
547
|
"invalid access token",
|
|
@@ -596,16 +596,29 @@ class SSETransport(MCPBaseTransport):
|
|
|
596
596
|
self._metrics.update_call_metrics(response_time, success)
|
|
597
597
|
|
|
598
598
|
def _is_oauth_error(self, error_msg: str) -> bool:
|
|
599
|
-
"""
|
|
599
|
+
"""
|
|
600
|
+
Detect if error is OAuth-related per RFC 6750 and MCP OAuth spec.
|
|
601
|
+
|
|
602
|
+
Checks for:
|
|
603
|
+
- RFC 6750 Section 3.1 Bearer token errors (invalid_token, insufficient_scope)
|
|
604
|
+
- OAuth 2.1 token refresh errors (invalid_grant)
|
|
605
|
+
- MCP spec OAuth validation failures (401/403 responses)
|
|
606
|
+
"""
|
|
600
607
|
if not error_msg:
|
|
601
608
|
return False
|
|
602
609
|
|
|
603
610
|
error_lower = error_msg.lower()
|
|
604
611
|
oauth_indicators = [
|
|
605
|
-
|
|
606
|
-
"expired
|
|
612
|
+
# RFC 6750 Section 3.1 - Standard Bearer token errors
|
|
613
|
+
"invalid_token", # Token expired, revoked, malformed, or invalid
|
|
614
|
+
"insufficient_scope", # Request requires higher privileges (403 Forbidden)
|
|
615
|
+
# OAuth 2.1 token refresh errors
|
|
616
|
+
"invalid_grant", # Refresh token errors
|
|
617
|
+
# MCP spec - OAuth validation failures (401 Unauthorized)
|
|
607
618
|
"oauth validation",
|
|
608
619
|
"unauthorized",
|
|
620
|
+
# Common OAuth error descriptions
|
|
621
|
+
"expired token",
|
|
609
622
|
"token expired",
|
|
610
623
|
"authentication failed",
|
|
611
624
|
"invalid access token",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenTelemetry observability integration for chuk-tool-processor.
|
|
3
|
+
|
|
4
|
+
This module provides drop-in OpenTelemetry tracing and Prometheus metrics
|
|
5
|
+
for tool execution, making it trivial to instrument your tool pipeline.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
from chuk_tool_processor.observability import setup_observability
|
|
9
|
+
|
|
10
|
+
# Enable both tracing and metrics
|
|
11
|
+
setup_observability(
|
|
12
|
+
service_name="my-tool-service",
|
|
13
|
+
enable_tracing=True,
|
|
14
|
+
enable_metrics=True,
|
|
15
|
+
metrics_port=9090
|
|
16
|
+
)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from .metrics import PrometheusMetrics, get_metrics
|
|
22
|
+
from .setup import setup_observability
|
|
23
|
+
from .tracing import get_tracer
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"setup_observability",
|
|
27
|
+
"get_tracer",
|
|
28
|
+
"get_metrics",
|
|
29
|
+
"PrometheusMetrics",
|
|
30
|
+
]
|