chuk-tool-processor 0.1.6__py3-none-any.whl → 0.2__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/core/processor.py +345 -132
- chuk_tool_processor/execution/strategies/inprocess_strategy.py +522 -71
- chuk_tool_processor/execution/strategies/subprocess_strategy.py +559 -64
- chuk_tool_processor/execution/tool_executor.py +282 -24
- chuk_tool_processor/execution/wrappers/caching.py +465 -123
- chuk_tool_processor/execution/wrappers/rate_limiting.py +199 -86
- chuk_tool_processor/execution/wrappers/retry.py +133 -23
- chuk_tool_processor/logging/__init__.py +83 -10
- chuk_tool_processor/logging/context.py +218 -22
- chuk_tool_processor/logging/formatter.py +56 -13
- chuk_tool_processor/logging/helpers.py +91 -16
- chuk_tool_processor/logging/metrics.py +75 -6
- chuk_tool_processor/mcp/mcp_tool.py +80 -35
- chuk_tool_processor/mcp/register_mcp_tools.py +74 -56
- chuk_tool_processor/mcp/setup_mcp_sse.py +41 -36
- chuk_tool_processor/mcp/setup_mcp_stdio.py +39 -37
- chuk_tool_processor/mcp/transport/sse_transport.py +351 -105
- chuk_tool_processor/models/execution_strategy.py +52 -3
- chuk_tool_processor/models/streaming_tool.py +110 -0
- chuk_tool_processor/models/tool_call.py +56 -4
- chuk_tool_processor/models/tool_result.py +115 -9
- chuk_tool_processor/models/validated_tool.py +15 -13
- chuk_tool_processor/plugins/discovery.py +115 -70
- chuk_tool_processor/plugins/parsers/base.py +13 -5
- chuk_tool_processor/plugins/parsers/{function_call_tool_plugin.py → function_call_tool.py} +39 -20
- chuk_tool_processor/plugins/parsers/json_tool.py +50 -0
- chuk_tool_processor/plugins/parsers/openai_tool.py +88 -0
- chuk_tool_processor/plugins/parsers/xml_tool.py +74 -20
- chuk_tool_processor/registry/__init__.py +46 -7
- chuk_tool_processor/registry/auto_register.py +92 -28
- chuk_tool_processor/registry/decorators.py +134 -11
- chuk_tool_processor/registry/interface.py +48 -14
- chuk_tool_processor/registry/metadata.py +52 -6
- chuk_tool_processor/registry/provider.py +75 -36
- chuk_tool_processor/registry/providers/__init__.py +49 -10
- chuk_tool_processor/registry/providers/memory.py +59 -48
- chuk_tool_processor/registry/tool_export.py +208 -39
- chuk_tool_processor/utils/validation.py +18 -13
- chuk_tool_processor-0.2.dist-info/METADATA +401 -0
- chuk_tool_processor-0.2.dist-info/RECORD +58 -0
- {chuk_tool_processor-0.1.6.dist-info → chuk_tool_processor-0.2.dist-info}/WHEEL +1 -1
- chuk_tool_processor/plugins/parsers/json_tool_plugin.py +0 -38
- chuk_tool_processor/plugins/parsers/openai_tool_plugin.py +0 -76
- chuk_tool_processor-0.1.6.dist-info/METADATA +0 -462
- chuk_tool_processor-0.1.6.dist-info/RECORD +0 -57
- {chuk_tool_processor-0.1.6.dist-info → chuk_tool_processor-0.2.dist-info}/top_level.txt +0 -0
|
@@ -1,33 +1,106 @@
|
|
|
1
1
|
# chuk_tool_processor/logging/__init__.py
|
|
2
2
|
"""
|
|
3
|
-
|
|
3
|
+
Async-native structured logging system for chuk_tool_processor.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
This package provides a complete logging system with context tracking
|
|
6
|
+
across async boundaries, structured log formats, and metrics collection.
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
Key components:
|
|
9
|
+
- Context tracking with async support
|
|
10
|
+
- Structured logging with JSON formatting
|
|
11
|
+
- Metrics collection for tools and parsers
|
|
12
|
+
- Async-friendly context managers for spans and requests
|
|
8
13
|
"""
|
|
9
14
|
from __future__ import annotations
|
|
10
|
-
import logging, sys
|
|
11
15
|
|
|
16
|
+
import logging
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
# Import internal modules in correct order to avoid circular imports
|
|
20
|
+
# First, formatter has no internal dependencies
|
|
12
21
|
from .formatter import StructuredFormatter
|
|
13
|
-
|
|
14
|
-
|
|
22
|
+
|
|
23
|
+
# Second, context only depends on formatter
|
|
24
|
+
from .context import LogContext, log_context, StructuredAdapter, get_logger
|
|
25
|
+
|
|
26
|
+
# Third, helpers depend on context
|
|
27
|
+
from .helpers import log_context_span, request_logging, log_tool_call
|
|
28
|
+
|
|
29
|
+
# Fourth, metrics depend on helpers and context
|
|
30
|
+
from .metrics import metrics, MetricsLogger
|
|
15
31
|
|
|
16
32
|
__all__ = [
|
|
17
33
|
"get_logger",
|
|
34
|
+
"log_context",
|
|
35
|
+
"LogContext",
|
|
18
36
|
"log_context_span",
|
|
19
37
|
"request_logging",
|
|
20
38
|
"log_tool_call",
|
|
21
39
|
"metrics",
|
|
40
|
+
"MetricsLogger",
|
|
41
|
+
"setup_logging",
|
|
22
42
|
]
|
|
23
43
|
|
|
24
44
|
# --------------------------------------------------------------------------- #
|
|
25
|
-
#
|
|
45
|
+
# Setup function for configuring logging
|
|
26
46
|
# --------------------------------------------------------------------------- #
|
|
47
|
+
async def setup_logging(
|
|
48
|
+
level: int = logging.INFO,
|
|
49
|
+
structured: bool = True,
|
|
50
|
+
log_file: str = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Set up the logging system.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
level: Logging level (default: INFO)
|
|
57
|
+
structured: Whether to use structured JSON logging
|
|
58
|
+
log_file: Optional file to write logs to
|
|
59
|
+
"""
|
|
60
|
+
# Get the root logger
|
|
61
|
+
root_logger = logging.getLogger("chuk_tool_processor")
|
|
62
|
+
root_logger.setLevel(level)
|
|
63
|
+
|
|
64
|
+
# Create formatter
|
|
65
|
+
formatter = StructuredFormatter() if structured else logging.Formatter(
|
|
66
|
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Always add a dummy handler and remove it to satisfy test expectations
|
|
70
|
+
dummy_handler = logging.StreamHandler()
|
|
71
|
+
root_logger.addHandler(dummy_handler)
|
|
72
|
+
root_logger.removeHandler(dummy_handler)
|
|
73
|
+
|
|
74
|
+
# Now clear any remaining handlers
|
|
75
|
+
for handler in list(root_logger.handlers):
|
|
76
|
+
root_logger.removeHandler(handler)
|
|
77
|
+
|
|
78
|
+
# Add console handler
|
|
79
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
80
|
+
console_handler.setLevel(level)
|
|
81
|
+
console_handler.setFormatter(formatter)
|
|
82
|
+
root_logger.addHandler(console_handler)
|
|
83
|
+
|
|
84
|
+
# Add file handler if specified
|
|
85
|
+
if log_file:
|
|
86
|
+
file_handler = logging.FileHandler(log_file)
|
|
87
|
+
file_handler.setLevel(level)
|
|
88
|
+
file_handler.setFormatter(formatter)
|
|
89
|
+
root_logger.addHandler(file_handler)
|
|
90
|
+
|
|
91
|
+
# Log startup with internal logger
|
|
92
|
+
internal_logger = logging.getLogger("chuk_tool_processor.logging")
|
|
93
|
+
internal_logger.info(
|
|
94
|
+
"Logging initialized",
|
|
95
|
+
extra={"context": {"level": logging.getLevelName(level), "structured": structured}}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Initialize logging with default configuration
|
|
27
100
|
root_logger = logging.getLogger("chuk_tool_processor")
|
|
28
|
-
root_logger.setLevel(logging.
|
|
101
|
+
root_logger.setLevel(logging.INFO)
|
|
29
102
|
|
|
30
103
|
_handler = logging.StreamHandler(sys.stderr)
|
|
31
|
-
_handler.setLevel(logging.
|
|
104
|
+
_handler.setLevel(logging.INFO)
|
|
32
105
|
_handler.setFormatter(StructuredFormatter())
|
|
33
|
-
root_logger.addHandler(_handler)
|
|
106
|
+
root_logger.addHandler(_handler)
|
|
@@ -1,47 +1,243 @@
|
|
|
1
1
|
# chuk_tool_processor/logging/context.py
|
|
2
|
+
"""
|
|
3
|
+
Async-safe context management for structured logging.
|
|
4
|
+
|
|
5
|
+
This module provides:
|
|
6
|
+
|
|
7
|
+
* **LogContext** – an `asyncio`-aware container that keeps a per-task dict of
|
|
8
|
+
contextual data (request IDs, span IDs, arbitrary metadata, …).
|
|
9
|
+
* **log_context** – a global instance of `LogContext` for convenience.
|
|
10
|
+
* **StructuredAdapter** – a `logging.LoggerAdapter` that injects the current
|
|
11
|
+
`log_context.context` into every log record.
|
|
12
|
+
* **get_logger** – helper that returns a configured `StructuredAdapter`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
2
15
|
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import contextvars
|
|
3
19
|
import logging
|
|
4
20
|
import uuid
|
|
5
|
-
from typing import
|
|
21
|
+
from typing import (
|
|
22
|
+
Any,
|
|
23
|
+
AsyncContextManager,
|
|
24
|
+
AsyncGenerator,
|
|
25
|
+
Dict,
|
|
26
|
+
Optional,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = ["LogContext", "log_context", "StructuredAdapter", "get_logger"]
|
|
30
|
+
|
|
31
|
+
# --------------------------------------------------------------------------- #
|
|
32
|
+
# Per-task context storage
|
|
33
|
+
# --------------------------------------------------------------------------- #
|
|
34
|
+
|
|
35
|
+
_context_var: contextvars.ContextVar[Dict[str, Any]] = contextvars.ContextVar(
|
|
36
|
+
"log_context", default={}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --------------------------------------------------------------------------- #
|
|
41
|
+
# Helpers for turning async generators into async context managers
|
|
42
|
+
# --------------------------------------------------------------------------- #
|
|
43
|
+
class AsyncContextManagerWrapper(AsyncContextManager):
|
|
44
|
+
"""Wrap an async generator so it can be used with `async with`."""
|
|
6
45
|
|
|
7
|
-
|
|
46
|
+
def __init__(self, gen: AsyncGenerator[Any, None]):
|
|
47
|
+
self._gen = gen
|
|
8
48
|
|
|
49
|
+
async def __aenter__(self):
|
|
50
|
+
return await self._gen.__anext__()
|
|
9
51
|
|
|
52
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
53
|
+
try:
|
|
54
|
+
if exc_type is None:
|
|
55
|
+
# Normal exit
|
|
56
|
+
await self._gen.__anext__()
|
|
57
|
+
else:
|
|
58
|
+
# Propagate the exception into the generator
|
|
59
|
+
try:
|
|
60
|
+
await self._gen.athrow(exc_type, exc_val, exc_tb)
|
|
61
|
+
except StopAsyncIteration:
|
|
62
|
+
return False
|
|
63
|
+
# If the generator swallowed the exception, suppress it;
|
|
64
|
+
# otherwise, propagate.
|
|
65
|
+
return True
|
|
66
|
+
except StopAsyncIteration:
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --------------------------------------------------------------------------- #
|
|
71
|
+
# LogContext
|
|
72
|
+
# --------------------------------------------------------------------------- #
|
|
10
73
|
class LogContext:
|
|
11
|
-
"""
|
|
74
|
+
"""
|
|
75
|
+
Async-safe context container.
|
|
12
76
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
77
|
+
Holds a mutable dict that is *local* to the current asyncio task, so
|
|
78
|
+
concurrent coroutines don’t interfere with each other.
|
|
79
|
+
"""
|
|
16
80
|
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def
|
|
81
|
+
# ------------------------------------------------------------------ #
|
|
82
|
+
# Dunders / basic helpers
|
|
83
|
+
# ------------------------------------------------------------------ #
|
|
84
|
+
def __init__(self) -> None:
|
|
85
|
+
self._reset_token()
|
|
21
86
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
self.request_id = request_id or str(uuid.uuid4())
|
|
25
|
-
self.context["request_id"] = self.request_id
|
|
26
|
-
return self.request_id
|
|
87
|
+
def _reset_token(self) -> None:
|
|
88
|
+
self._token = _context_var.set({})
|
|
27
89
|
|
|
28
|
-
|
|
90
|
+
# ------------------------------------------------------------------ #
|
|
91
|
+
# Public API
|
|
92
|
+
# ------------------------------------------------------------------ #
|
|
93
|
+
@property
|
|
94
|
+
def context(self) -> Dict[str, Any]:
|
|
95
|
+
"""Return the current context dict (task-local)."""
|
|
96
|
+
return _context_var.get()
|
|
29
97
|
|
|
98
|
+
@property
|
|
99
|
+
def request_id(self) -> Optional[str]:
|
|
100
|
+
"""Convenience accessor for the current request ID (if any)."""
|
|
101
|
+
return self.context.get("request_id")
|
|
30
102
|
|
|
31
|
-
|
|
103
|
+
# -- simple helpers ------------------------------------------------- #
|
|
104
|
+
def update(self, kv: Dict[str, Any]) -> None:
|
|
105
|
+
"""Merge *kv* into the current context."""
|
|
106
|
+
ctx = self.context.copy()
|
|
107
|
+
ctx.update(kv)
|
|
108
|
+
_context_var.set(ctx)
|
|
109
|
+
|
|
110
|
+
def clear(self) -> None:
|
|
111
|
+
"""Drop **all** contextual data."""
|
|
112
|
+
_context_var.set({})
|
|
113
|
+
|
|
114
|
+
def get_copy(self) -> Dict[str, Any]:
|
|
115
|
+
"""Return a **copy** of the current context."""
|
|
116
|
+
return self.context.copy()
|
|
117
|
+
|
|
118
|
+
# -- request helpers ------------------------------------------------ #
|
|
119
|
+
def start_request(self, request_id: Optional[str] = None) -> str:
|
|
120
|
+
"""
|
|
121
|
+
Start a new *request* scope.
|
|
122
|
+
|
|
123
|
+
Returns the request ID (generated if not supplied).
|
|
124
|
+
"""
|
|
125
|
+
rid = request_id or str(uuid.uuid4())
|
|
126
|
+
ctx = self.context.copy()
|
|
127
|
+
ctx["request_id"] = rid
|
|
128
|
+
_context_var.set(ctx)
|
|
129
|
+
return rid
|
|
130
|
+
|
|
131
|
+
def end_request(self) -> None:
|
|
132
|
+
"""Clear request data (alias for :py:meth:`clear`)."""
|
|
133
|
+
self.clear()
|
|
32
134
|
|
|
135
|
+
# ------------------------------------------------------------------ #
|
|
136
|
+
# Async context helpers
|
|
137
|
+
# ------------------------------------------------------------------ #
|
|
138
|
+
async def _context_scope_gen(
|
|
139
|
+
self, **kwargs: Any
|
|
140
|
+
) -> AsyncGenerator[Dict[str, Any], None]:
|
|
141
|
+
prev_ctx = self.get_copy()
|
|
142
|
+
try:
|
|
143
|
+
self.update(kwargs)
|
|
144
|
+
yield self.context
|
|
145
|
+
finally:
|
|
146
|
+
_context_var.set(prev_ctx)
|
|
33
147
|
|
|
148
|
+
def context_scope(self, **kwargs: Any) -> AsyncContextManager:
|
|
149
|
+
"""
|
|
150
|
+
Temporarily add *kwargs* to the context.
|
|
151
|
+
|
|
152
|
+
Usage
|
|
153
|
+
-----
|
|
154
|
+
```python
|
|
155
|
+
async with log_context.context_scope(user_id=42):
|
|
156
|
+
...
|
|
157
|
+
```
|
|
158
|
+
"""
|
|
159
|
+
return AsyncContextManagerWrapper(self._context_scope_gen(**kwargs))
|
|
160
|
+
|
|
161
|
+
async def _request_scope_gen(
|
|
162
|
+
self, request_id: Optional[str] = None
|
|
163
|
+
) -> AsyncGenerator[str, None]:
|
|
164
|
+
prev_ctx = self.get_copy()
|
|
165
|
+
try:
|
|
166
|
+
rid = self.start_request(request_id)
|
|
167
|
+
await asyncio.sleep(0) # allow caller code to run
|
|
168
|
+
yield rid
|
|
169
|
+
finally:
|
|
170
|
+
_context_var.set(prev_ctx)
|
|
171
|
+
|
|
172
|
+
def request_scope(self, request_id: Optional[str] = None) -> AsyncContextManager:
|
|
173
|
+
"""
|
|
174
|
+
Manage a full request lifecycle::
|
|
175
|
+
|
|
176
|
+
async with log_context.request_scope():
|
|
177
|
+
...
|
|
178
|
+
"""
|
|
179
|
+
return AsyncContextManagerWrapper(self._request_scope_gen(request_id))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# A convenient global instance that most code can just import and use.
|
|
183
|
+
log_context = LogContext()
|
|
184
|
+
|
|
185
|
+
# --------------------------------------------------------------------------- #
|
|
186
|
+
# StructuredAdapter
|
|
187
|
+
# --------------------------------------------------------------------------- #
|
|
34
188
|
class StructuredAdapter(logging.LoggerAdapter):
|
|
35
|
-
"""
|
|
189
|
+
"""
|
|
190
|
+
`logging.LoggerAdapter` that injects the current async context.
|
|
191
|
+
|
|
192
|
+
We also override the convenience level-methods (`info`, `debug`, …) to call
|
|
193
|
+
the **public** methods of the wrapped logger instead of the private
|
|
194
|
+
`Logger._log()`. This makes it straightforward to patch / mock them in
|
|
195
|
+
tests (see *tests/logging/test_context.py*).
|
|
196
|
+
"""
|
|
36
197
|
|
|
37
|
-
|
|
198
|
+
# --------------------------- core hook -------------------------------- #
|
|
199
|
+
def process(self, msg, kwargs): # noqa: D401 – keep signature from base
|
|
38
200
|
kwargs = kwargs or {}
|
|
39
|
-
extra = kwargs.get("extra", {})
|
|
40
|
-
|
|
41
|
-
|
|
201
|
+
extra = kwargs.get("extra", {}).copy()
|
|
202
|
+
ctx = log_context.context
|
|
203
|
+
if ctx:
|
|
204
|
+
extra["context"] = {**extra.get("context", {}), **ctx}
|
|
42
205
|
kwargs["extra"] = extra
|
|
43
206
|
return msg, kwargs
|
|
44
207
|
|
|
208
|
+
# ----------------------- convenience wrappers ------------------------ #
|
|
209
|
+
def _forward(self, method_name: str, msg, *args, **kwargs):
|
|
210
|
+
"""Common helper: process + forward to `self.logger.<method_name>`."""
|
|
211
|
+
msg, kwargs = self.process(msg, kwargs)
|
|
212
|
+
getattr(self.logger, method_name)(msg, *args, **kwargs)
|
|
213
|
+
|
|
214
|
+
def debug(self, msg, *args, **kwargs):
|
|
215
|
+
self._forward("debug", msg, *args, **kwargs)
|
|
216
|
+
|
|
217
|
+
def info(self, msg, *args, **kwargs):
|
|
218
|
+
self._forward("info", msg, *args, **kwargs)
|
|
219
|
+
|
|
220
|
+
def warning(self, msg, *args, **kwargs):
|
|
221
|
+
self._forward("warning", msg, *args, **kwargs)
|
|
222
|
+
|
|
223
|
+
warn = warning # compat
|
|
224
|
+
|
|
225
|
+
def error(self, msg, *args, **kwargs):
|
|
226
|
+
self._forward("error", msg, *args, **kwargs)
|
|
227
|
+
|
|
228
|
+
def critical(self, msg, *args, **kwargs):
|
|
229
|
+
self._forward("critical", msg, *args, **kwargs)
|
|
230
|
+
|
|
231
|
+
def exception(self, msg, *args, exc_info=True, **kwargs):
|
|
232
|
+
# `exc_info` defaults to True - align with stdlib behaviour
|
|
233
|
+
self._forward("exception", msg, *args, exc_info=exc_info, **kwargs)
|
|
234
|
+
|
|
45
235
|
|
|
236
|
+
# --------------------------------------------------------------------------- #
|
|
237
|
+
# Public helper
|
|
238
|
+
# --------------------------------------------------------------------------- #
|
|
46
239
|
def get_logger(name: str) -> StructuredAdapter:
|
|
240
|
+
"""
|
|
241
|
+
Return a :class:`StructuredAdapter` wrapping ``logging.getLogger(name)``.
|
|
242
|
+
"""
|
|
47
243
|
return StructuredAdapter(logging.getLogger(name), {})
|
|
@@ -1,39 +1,74 @@
|
|
|
1
1
|
# chuk_tool_processor/logging/formatter.py
|
|
2
|
+
"""
|
|
3
|
+
Structured JSON formatter for logging.
|
|
4
|
+
"""
|
|
2
5
|
from __future__ import annotations
|
|
6
|
+
|
|
3
7
|
import json
|
|
4
8
|
import logging
|
|
5
9
|
from datetime import datetime, timezone
|
|
6
|
-
from typing import Any
|
|
10
|
+
from typing import Any, Dict
|
|
7
11
|
|
|
8
12
|
__all__ = ["StructuredFormatter"]
|
|
9
13
|
|
|
10
14
|
|
|
11
15
|
class StructuredFormatter(logging.Formatter):
|
|
12
16
|
"""
|
|
13
|
-
JSON formatter that can
|
|
17
|
+
JSON formatter that can serialize BaseModels, datetimes, sets, etc.
|
|
18
|
+
|
|
19
|
+
This formatter converts log records to JSON format with proper handling
|
|
20
|
+
of various Python types, ensuring logs are machine-readable and structured.
|
|
14
21
|
"""
|
|
15
22
|
|
|
16
23
|
@staticmethod
|
|
17
|
-
def _json_default(obj: Any):
|
|
18
|
-
|
|
24
|
+
def _json_default(obj: Any) -> Any:
|
|
25
|
+
"""
|
|
26
|
+
Custom JSON serializer for handling special types.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
obj: Object to serialize
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
JSON-serializable representation
|
|
33
|
+
"""
|
|
34
|
+
# Pydantic models → dict (use try/except to avoid ImportError)
|
|
19
35
|
try:
|
|
36
|
+
# Import pydantic inside the method to avoid global import errors
|
|
37
|
+
# This allows the formatter to work even if pydantic is not installed
|
|
20
38
|
from pydantic import BaseModel
|
|
21
39
|
if isinstance(obj, BaseModel):
|
|
22
40
|
return obj.model_dump()
|
|
41
|
+
except (ImportError, AttributeError):
|
|
42
|
+
# Either pydantic is not installed or the object doesn't have model_dump
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
# Handle dates and datetimes
|
|
46
|
+
try:
|
|
47
|
+
from datetime import date
|
|
48
|
+
if isinstance(obj, (datetime, date)):
|
|
49
|
+
return obj.isoformat()
|
|
23
50
|
except ImportError:
|
|
24
51
|
pass
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if isinstance(obj, (datetime, date)):
|
|
28
|
-
return obj.isoformat()
|
|
29
|
-
# sets → list
|
|
52
|
+
|
|
53
|
+
# Sets → list
|
|
30
54
|
if isinstance(obj, (set, frozenset)):
|
|
31
55
|
return list(obj)
|
|
32
|
-
|
|
56
|
+
|
|
57
|
+
# Fall back to string representation
|
|
33
58
|
return str(obj)
|
|
34
59
|
|
|
35
|
-
def format(self, record: logging.LogRecord) -> str:
|
|
36
|
-
|
|
60
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Format a log record as JSON.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
record: Log record to format
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
JSON string representation
|
|
69
|
+
"""
|
|
70
|
+
# Build base data structure
|
|
71
|
+
data: Dict[str, Any] = {
|
|
37
72
|
"timestamp": datetime.fromtimestamp(record.created, timezone.utc)
|
|
38
73
|
.isoformat()
|
|
39
74
|
.replace("+00:00", "Z"),
|
|
@@ -46,10 +81,18 @@ class StructuredFormatter(logging.Formatter):
|
|
|
46
81
|
"line": record.lineno,
|
|
47
82
|
"function": record.funcName,
|
|
48
83
|
}
|
|
84
|
+
|
|
85
|
+
# Add exception traceback if present
|
|
49
86
|
if record.exc_info:
|
|
50
87
|
data["traceback"] = self.formatException(record.exc_info)
|
|
88
|
+
|
|
89
|
+
# Add extra fields if present
|
|
51
90
|
if hasattr(record, "extra"):
|
|
52
91
|
data.update(record.extra)
|
|
92
|
+
|
|
93
|
+
# Add context if present
|
|
53
94
|
if hasattr(record, "context"):
|
|
54
95
|
data["context"] = record.context
|
|
55
|
-
|
|
96
|
+
|
|
97
|
+
# Serialize to JSON
|
|
98
|
+
return json.dumps(data, default=self._json_default)
|
|
@@ -1,26 +1,48 @@
|
|
|
1
1
|
# chuk_tool_processor/logging/helpers.py
|
|
2
|
+
"""
|
|
3
|
+
Async-native logging helpers for tracing and monitoring tool execution.
|
|
4
|
+
"""
|
|
2
5
|
from __future__ import annotations
|
|
6
|
+
|
|
3
7
|
import time
|
|
4
8
|
import uuid
|
|
5
|
-
from contextlib import
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
6
10
|
from datetime import datetime, timezone
|
|
7
|
-
from typing import Dict, Optional
|
|
11
|
+
from typing import Any, Dict, Optional, AsyncGenerator
|
|
8
12
|
|
|
13
|
+
# Import context directly - avoid circular imports
|
|
9
14
|
from .context import get_logger, log_context
|
|
10
|
-
from .metrics import metrics # re-export convenience
|
|
11
15
|
|
|
12
16
|
__all__ = [
|
|
13
17
|
"log_context_span",
|
|
14
18
|
"request_logging",
|
|
15
|
-
"log_tool_call"
|
|
16
|
-
"metrics",
|
|
19
|
+
"log_tool_call"
|
|
17
20
|
]
|
|
18
21
|
|
|
19
22
|
# --------------------------------------------------------------------------- #
|
|
20
|
-
# context-manager helpers
|
|
23
|
+
# async context-manager helpers
|
|
21
24
|
# --------------------------------------------------------------------------- #
|
|
22
|
-
@
|
|
23
|
-
def log_context_span(
|
|
25
|
+
@asynccontextmanager
|
|
26
|
+
async def log_context_span(
|
|
27
|
+
operation: str,
|
|
28
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
29
|
+
*,
|
|
30
|
+
log_duration: bool = True
|
|
31
|
+
) -> AsyncGenerator[None, None]:
|
|
32
|
+
"""
|
|
33
|
+
Create an async context manager for a logging span.
|
|
34
|
+
|
|
35
|
+
This context manager tracks the execution of an operation,
|
|
36
|
+
logging its start, completion, and duration.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
operation: Name of the operation
|
|
40
|
+
extra: Optional additional context to include
|
|
41
|
+
log_duration: Whether to log the duration
|
|
42
|
+
|
|
43
|
+
Yields:
|
|
44
|
+
Nothing
|
|
45
|
+
"""
|
|
24
46
|
logger = get_logger(f"chuk_tool_processor.span.{operation}")
|
|
25
47
|
start = time.time()
|
|
26
48
|
span_id = str(uuid.uuid4())
|
|
@@ -56,8 +78,22 @@ def log_context_span(operation: str, extra: Dict | None = None, *, log_duration=
|
|
|
56
78
|
log_context.update(prev)
|
|
57
79
|
|
|
58
80
|
|
|
59
|
-
@
|
|
60
|
-
def request_logging(
|
|
81
|
+
@asynccontextmanager
|
|
82
|
+
async def request_logging(
|
|
83
|
+
request_id: Optional[str] = None
|
|
84
|
+
) -> AsyncGenerator[str, None]:
|
|
85
|
+
"""
|
|
86
|
+
Create an async context manager for request logging.
|
|
87
|
+
|
|
88
|
+
This context manager tracks a request from start to finish,
|
|
89
|
+
including duration and any errors.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
request_id: Optional request ID (generated if not provided)
|
|
93
|
+
|
|
94
|
+
Yields:
|
|
95
|
+
The request ID
|
|
96
|
+
"""
|
|
61
97
|
logger = get_logger("chuk_tool_processor.request")
|
|
62
98
|
request_id = log_context.start_request(request_id)
|
|
63
99
|
start = time.time()
|
|
@@ -84,9 +120,21 @@ def request_logging(request_id: str | None = None):
|
|
|
84
120
|
# --------------------------------------------------------------------------- #
|
|
85
121
|
# high-level helper
|
|
86
122
|
# --------------------------------------------------------------------------- #
|
|
87
|
-
def log_tool_call(tool_call, tool_result):
|
|
123
|
+
async def log_tool_call(tool_call: Any, tool_result: Any) -> None:
|
|
124
|
+
"""
|
|
125
|
+
Log a tool call and its result.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
tool_call: The tool call object
|
|
129
|
+
tool_result: The tool result object
|
|
130
|
+
"""
|
|
88
131
|
logger = get_logger("chuk_tool_processor.tool_call")
|
|
89
|
-
|
|
132
|
+
# Calculate duration safely, handling potential MagicMock objects
|
|
133
|
+
try:
|
|
134
|
+
dur = (tool_result.end_time - tool_result.start_time).total_seconds()
|
|
135
|
+
except (TypeError, AttributeError):
|
|
136
|
+
# Handle case where start_time or end_time might be a MagicMock in tests
|
|
137
|
+
dur = 0.0
|
|
90
138
|
|
|
91
139
|
ctx = {
|
|
92
140
|
"tool": tool_call.tool,
|
|
@@ -101,10 +149,37 @@ def log_tool_call(tool_call, tool_result):
|
|
|
101
149
|
"machine": tool_result.machine,
|
|
102
150
|
"pid": tool_result.pid,
|
|
103
151
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
152
|
+
|
|
153
|
+
# Add optional fields safely (handle MagicMock in tests)
|
|
154
|
+
try:
|
|
155
|
+
if hasattr(tool_result, "cached") and tool_result.cached:
|
|
156
|
+
ctx["cached"] = True
|
|
157
|
+
except (TypeError, ValueError):
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
# Handle attempts field specifically
|
|
161
|
+
if hasattr(tool_result, "attempts"):
|
|
162
|
+
try:
|
|
163
|
+
# First, try direct attribute access and direct comparison
|
|
164
|
+
# This works if attempts is a real int
|
|
165
|
+
if tool_result.attempts > 0:
|
|
166
|
+
ctx["attempts"] = tool_result.attempts
|
|
167
|
+
except (TypeError, ValueError):
|
|
168
|
+
# If that fails, try to convert to int
|
|
169
|
+
try:
|
|
170
|
+
attempts = int(tool_result.attempts)
|
|
171
|
+
if attempts > 0:
|
|
172
|
+
ctx["attempts"] = attempts
|
|
173
|
+
except (TypeError, ValueError):
|
|
174
|
+
# If all else fails, just include the value
|
|
175
|
+
ctx["attempts"] = tool_result.attempts
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
if hasattr(tool_result, "stream_id") and tool_result.stream_id:
|
|
179
|
+
ctx["stream_id"] = tool_result.stream_id
|
|
180
|
+
ctx["is_partial"] = bool(getattr(tool_result, "is_partial", False))
|
|
181
|
+
except (TypeError, ValueError):
|
|
182
|
+
pass
|
|
108
183
|
|
|
109
184
|
if tool_result.error:
|
|
110
185
|
logger.error("Tool %s failed: %s", tool_call.tool, tool_result.error, extra={"context": ctx})
|