python-corekit 0.2.0__py3-none-any.whl → 0.4.0__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.
- corekit/api/application.py +47 -9
- corekit/api/lifespan.py +26 -3
- corekit/concurrency/__init__.py +2 -2
- corekit/concurrency/decorators.py +32 -5
- corekit/concurrency/thread_local.py +2 -2
- corekit/concurrency/worker.py +9 -0
- corekit/config/loader.py +42 -5
- corekit/config/settings.py +11 -1
- corekit/connections/__init__.py +7 -1
- corekit/connections/connectable.py +45 -4
- corekit/connections/redis/connection.py +53 -10
- corekit/connections/sql/__init__.py +2 -1
- corekit/connections/sql/connection.py +39 -5
- corekit/connections/sql/fields/__init__.py +2 -2
- corekit/connections/sql/fields/jsonb.py +13 -6
- corekit/connections/sql/migration/__init__.py +4 -0
- corekit/connections/sql/migration/operations.py +69 -2
- corekit/connections/sql/operations/base.py +11 -2
- corekit/connections/sql/operations/statements.py +25 -5
- corekit/connections/sql/table.py +7 -29
- corekit/crypto/__init__.py +3 -1
- corekit/crypto/constants.py +2 -2
- corekit/crypto/hasher.py +9 -4
- corekit/data/dataset.py +8 -2
- corekit/data/expressions/__init__.py +3 -3
- corekit/data/expressions/comparison.py +19 -80
- corekit/data/expressions/expression.py +0 -32
- corekit/data/expressions/operator.py +13 -28
- corekit/data/stats.py +3 -0
- corekit/decorators/exception_handling.py +36 -8
- corekit/docker/watchdog.py +50 -31
- corekit/etl/__init__.py +2 -1
- corekit/etl/connection.py +14 -12
- corekit/etl/extract/extractor.py +6 -13
- corekit/etl/orchestrator.py +19 -2
- corekit/etl/schemas.py +2 -2
- corekit/etl/transform/transformer.py +4 -1
- corekit/events/publisher.py +1 -1
- corekit/events/reader.py +26 -21
- corekit/events/sse.py +4 -1
- corekit/events/websocket.py +24 -11
- corekit/exceptions/__init__.py +24 -9
- corekit/exceptions/base.py +139 -10
- corekit/exceptions/enum.py +17 -0
- corekit/exceptions/types.py +6 -6
- corekit/files/__init__.py +2 -4
- corekit/files/base.py +15 -2
- corekit/files/enum.py +0 -5
- corekit/files/json.py +16 -2
- corekit/http/__init__.py +48 -5
- corekit/http/api.py +24 -0
- corekit/http/client.py +133 -75
- corekit/http/exceptions.py +140 -0
- corekit/http/response.py +50 -1
- corekit/http/status.py +89 -0
- corekit/http/stream.py +110 -0
- corekit/jobs/runner.py +12 -1
- corekit/jobs/task.py +23 -2
- corekit/llm/__init__.py +134 -0
- corekit/llm/client.py +179 -0
- corekit/llm/enum.py +123 -0
- corekit/llm/events.py +96 -0
- corekit/llm/messages.py +173 -0
- corekit/llm/prompts/__init__.py +19 -0
- corekit/llm/prompts/enum.py +54 -0
- corekit/llm/prompts/exceptions.py +22 -0
- corekit/llm/prompts/loader.py +139 -0
- corekit/llm/prompts/template.py +53 -0
- corekit/llm/protocols.py +65 -0
- corekit/llm/streaming.py +149 -0
- corekit/llm/tools/__init__.py +19 -0
- corekit/llm/tools/base.py +118 -0
- corekit/llm/tools/detection.py +99 -0
- corekit/llm/tools/loop.py +255 -0
- corekit/llm/tools/registry.py +103 -0
- corekit/llm/wire.py +199 -0
- corekit/log_monitor/models.py +8 -2
- corekit/log_monitor/service.py +77 -38
- corekit/notifications/base.py +18 -10
- corekit/observability/__init__.py +12 -5
- corekit/observability/benchmarkable.py +37 -5
- corekit/observability/loggable.py +21 -0
- corekit/observability/request_context.py +55 -2
- corekit/observability/timing/split.py +14 -0
- corekit/observability/timing/timer.py +33 -9
- corekit/registry/__init__.py +2 -2
- corekit/registry/registry.py +55 -14
- corekit/schemas/__init__.py +2 -1
- corekit/schemas/enum.py +22 -1
- corekit/schemas/types.py +6 -1
- corekit/schemas/version.py +58 -0
- corekit/serialization/__init__.py +2 -0
- corekit/serialization/pickle_file.py +61 -0
- corekit/serialization/serializable.py +22 -2
- corekit/serialization/serializer.py +9 -2
- corekit/utils/__init__.py +2 -1
- corekit/utils/collections.py +38 -14
- corekit/utils/payload.py +12 -0
- {python_corekit-0.2.0.dist-info → python_corekit-0.4.0.dist-info}/METADATA +38 -9
- python_corekit-0.4.0.dist-info/RECORD +165 -0
- corekit/constants.py +0 -45
- corekit/exceptions/http/exceptions.py +0 -37
- corekit/files/pickle.py +0 -12
- python_corekit-0.2.0.dist-info/RECORD +0 -143
- {python_corekit-0.2.0.dist-info → python_corekit-0.4.0.dist-info}/WHEEL +0 -0
- {python_corekit-0.2.0.dist-info → python_corekit-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {python_corekit-0.2.0.dist-info → python_corekit-0.4.0.dist-info}/top_level.txt +0 -0
corekit/llm/wire.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Structured OpenAI completion-chunk parsing and tool-call accumulation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from corekit.llm.enum import WireField
|
|
14
|
+
from corekit.llm.events import UsageEvent
|
|
15
|
+
from corekit.llm.tools.base import ToolCall
|
|
16
|
+
from corekit.utils import safe_dict, safe_string
|
|
17
|
+
from corekit.utils.collections import attr_or_key
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ChoiceDelta",
|
|
21
|
+
"CompletionTurn",
|
|
22
|
+
"FunctionCallDelta",
|
|
23
|
+
"ToolCallAccumulator",
|
|
24
|
+
"ToolCallDelta",
|
|
25
|
+
"UsageInfo",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FunctionCallDelta(BaseModel):
|
|
32
|
+
"""
|
|
33
|
+
Incremental ``function`` fields on a streamed tool-call delta.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
name: str = ""
|
|
37
|
+
arguments: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ToolCallDelta(BaseModel):
|
|
41
|
+
"""
|
|
42
|
+
One streamed tool-call fragment (may arrive across many chunks).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
index: int = 0
|
|
46
|
+
id: str = ""
|
|
47
|
+
function: FunctionCallDelta = Field(default_factory=FunctionCallDelta)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_raw(cls, raw: Any) -> ToolCallDelta | None:
|
|
51
|
+
"""
|
|
52
|
+
Parse a dict or SDK object into a delta; return None if index is missing.
|
|
53
|
+
"""
|
|
54
|
+
index = attr_or_key(raw, WireField.INDEX)
|
|
55
|
+
if index is None:
|
|
56
|
+
return None
|
|
57
|
+
function_raw = safe_dict(attr_or_key(raw, WireField.FUNCTION))
|
|
58
|
+
return cls(
|
|
59
|
+
index=int(index),
|
|
60
|
+
id=safe_string(attr_or_key(raw, WireField.ID)),
|
|
61
|
+
function=FunctionCallDelta(
|
|
62
|
+
name=safe_string(attr_or_key(function_raw, WireField.NAME)),
|
|
63
|
+
arguments=safe_string(attr_or_key(function_raw, WireField.ARGUMENTS)),
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ToolCallAccumulator(BaseModel):
|
|
69
|
+
"""
|
|
70
|
+
Merges streamed tool-call deltas for a single index into one call.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
id: str = ""
|
|
74
|
+
name: str = ""
|
|
75
|
+
arguments: str = ""
|
|
76
|
+
|
|
77
|
+
def merge(self, delta: ToolCallDelta) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Append name/argument fragments from one delta.
|
|
80
|
+
"""
|
|
81
|
+
if delta.id:
|
|
82
|
+
self.id = delta.id
|
|
83
|
+
if delta.function.name:
|
|
84
|
+
self.name += delta.function.name
|
|
85
|
+
if delta.function.arguments:
|
|
86
|
+
self.arguments += delta.function.arguments
|
|
87
|
+
|
|
88
|
+
def to_tool_call(self) -> ToolCall:
|
|
89
|
+
"""
|
|
90
|
+
Parse accumulated argument JSON into a ``ToolCall``.
|
|
91
|
+
"""
|
|
92
|
+
parsed: dict[str, Any] = {}
|
|
93
|
+
try:
|
|
94
|
+
parsed = json.loads(self.arguments)
|
|
95
|
+
except json.JSONDecodeError:
|
|
96
|
+
logger.warning("Bad tool args for %r: %r", self.name, self.arguments)
|
|
97
|
+
return ToolCall(id=self.id, name=self.name, arguments=safe_dict(parsed))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ChoiceDelta(BaseModel):
|
|
101
|
+
"""
|
|
102
|
+
``choices[0].delta`` fields we care about from one chunk.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
content: str | None = None
|
|
106
|
+
reasoning_content: str | None = None
|
|
107
|
+
tool_calls: list[ToolCallDelta] = Field(default_factory=list)
|
|
108
|
+
finish_reason: str | None = None
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_chunk(cls, chunk: Any) -> ChoiceDelta | None:
|
|
112
|
+
"""
|
|
113
|
+
Extract the first choice's delta (and finish_reason) from a chunk.
|
|
114
|
+
"""
|
|
115
|
+
choices = attr_or_key(chunk, WireField.CHOICES)
|
|
116
|
+
if not choices:
|
|
117
|
+
return None
|
|
118
|
+
choice = choices[0]
|
|
119
|
+
delta_raw = attr_or_key(choice, WireField.DELTA)
|
|
120
|
+
if delta_raw is None:
|
|
121
|
+
return cls(finish_reason=attr_or_key(choice, WireField.FINISH_REASON))
|
|
122
|
+
|
|
123
|
+
tool_calls: list[ToolCallDelta] = []
|
|
124
|
+
for raw_tc in attr_or_key(delta_raw, WireField.TOOL_CALLS) or []:
|
|
125
|
+
parsed = ToolCallDelta.from_raw(raw_tc)
|
|
126
|
+
if parsed is not None:
|
|
127
|
+
tool_calls.append(parsed)
|
|
128
|
+
|
|
129
|
+
return cls(
|
|
130
|
+
content=attr_or_key(delta_raw, WireField.CONTENT),
|
|
131
|
+
reasoning_content=attr_or_key(delta_raw, WireField.REASONING_CONTENT),
|
|
132
|
+
tool_calls=tool_calls,
|
|
133
|
+
finish_reason=attr_or_key(choice, WireField.FINISH_REASON),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class UsageInfo(BaseModel):
|
|
138
|
+
"""
|
|
139
|
+
Token usage from a completion chunk or final response.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
prompt_tokens: int | None = None
|
|
143
|
+
completion_tokens: int | None = None
|
|
144
|
+
total_tokens: int | None = None
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_chunk(cls, chunk: Any) -> UsageInfo | None:
|
|
148
|
+
"""
|
|
149
|
+
Read ``usage`` from a chunk when present.
|
|
150
|
+
"""
|
|
151
|
+
usage = attr_or_key(chunk, WireField.USAGE)
|
|
152
|
+
if usage is None:
|
|
153
|
+
return None
|
|
154
|
+
return cls(
|
|
155
|
+
prompt_tokens=attr_or_key(usage, WireField.PROMPT_TOKENS),
|
|
156
|
+
completion_tokens=attr_or_key(usage, WireField.COMPLETION_TOKENS),
|
|
157
|
+
total_tokens=attr_or_key(usage, WireField.TOTAL_TOKENS),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def to_event(self) -> UsageEvent:
|
|
161
|
+
"""
|
|
162
|
+
Convert to a stream ``UsageEvent``.
|
|
163
|
+
"""
|
|
164
|
+
return UsageEvent(
|
|
165
|
+
total_tokens=self.total_tokens,
|
|
166
|
+
prompt_tokens=self.prompt_tokens,
|
|
167
|
+
completion_tokens=self.completion_tokens,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class CompletionTurn(BaseModel):
|
|
172
|
+
"""
|
|
173
|
+
Mutable state accumulated while streaming one ``complete()`` call.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
tool_calls: dict[int, ToolCallAccumulator] = Field(default_factory=dict)
|
|
177
|
+
usage: UsageInfo | None = None
|
|
178
|
+
finish_reason: str | None = None
|
|
179
|
+
first_token_ms: float | None = None
|
|
180
|
+
|
|
181
|
+
def absorb_delta(self, delta: ChoiceDelta) -> None:
|
|
182
|
+
"""
|
|
183
|
+
Merge one choice delta into this turn.
|
|
184
|
+
"""
|
|
185
|
+
if delta.finish_reason:
|
|
186
|
+
self.finish_reason = delta.finish_reason
|
|
187
|
+
for tool_delta in delta.tool_calls:
|
|
188
|
+
bucket = self.tool_calls.setdefault(tool_delta.index, ToolCallAccumulator())
|
|
189
|
+
bucket.merge(tool_delta)
|
|
190
|
+
|
|
191
|
+
def absorb_usage(self, usage: UsageInfo | None) -> None:
|
|
192
|
+
if usage is not None:
|
|
193
|
+
self.usage = usage
|
|
194
|
+
|
|
195
|
+
def parsed_tool_calls(self) -> list[ToolCall]:
|
|
196
|
+
"""
|
|
197
|
+
Finalize accumulated tool-call indices into ``ToolCall`` values.
|
|
198
|
+
"""
|
|
199
|
+
return [self.tool_calls[idx].to_tool_call() for idx in sorted(self.tool_calls)]
|
corekit/log_monitor/models.py
CHANGED
|
@@ -75,10 +75,16 @@ class PauseContainerAction(BaseAction):
|
|
|
75
75
|
|
|
76
76
|
|
|
77
77
|
class ExecuteCommandAction(BaseAction):
|
|
78
|
-
"""
|
|
78
|
+
"""
|
|
79
|
+
Run a command as an argument list.
|
|
80
|
+
|
|
81
|
+
``command`` is split like a shell command line, then ``{container}`` is
|
|
82
|
+
filled in. It is not passed to a shell, so pipes and redirects are not
|
|
83
|
+
available.
|
|
84
|
+
"""
|
|
79
85
|
|
|
80
86
|
type: Literal[ActionType.EXECUTE_COMMAND] = ActionType.EXECUTE_COMMAND
|
|
81
|
-
command: str = Field(..., description="
|
|
87
|
+
command: str = Field(..., description="Argument list template with a {container} placeholder")
|
|
82
88
|
|
|
83
89
|
|
|
84
90
|
# Union type for all actions
|
corekit/log_monitor/service.py
CHANGED
|
@@ -11,6 +11,7 @@ per container so a crash loop cannot become a restart loop.
|
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
import re
|
|
14
|
+
import shlex
|
|
14
15
|
import signal
|
|
15
16
|
import subprocess
|
|
16
17
|
import threading
|
|
@@ -51,7 +52,7 @@ class LogMonitor(Watchdog):
|
|
|
51
52
|
config_path: str | None = None,
|
|
52
53
|
config: LogMonitorConfig | None = None,
|
|
53
54
|
notification_service: BaseNotificationService | None = None,
|
|
54
|
-
enforce_label: bool =
|
|
55
|
+
enforce_label: bool = True,
|
|
55
56
|
max_workers: int | None = None,
|
|
56
57
|
docker_host: str | None = None,
|
|
57
58
|
handle_signals: bool = False,
|
|
@@ -61,6 +62,7 @@ class LogMonitor(Watchdog):
|
|
|
61
62
|
:param config: an already-built configuration, instead of a path.
|
|
62
63
|
:param notification_service: where notifications go; logs by default.
|
|
63
64
|
:param enforce_label: only act on containers carrying the managed label.
|
|
65
|
+
On by default, matching Watchdog.
|
|
64
66
|
:param handle_signals: install SIGTERM and SIGINT handlers. Off by
|
|
65
67
|
default because installing them is process-global and only the
|
|
66
68
|
program's entry point should decide that; ``run()`` turns it on.
|
|
@@ -117,12 +119,31 @@ class LogMonitor(Watchdog):
|
|
|
117
119
|
}
|
|
118
120
|
return mapping.get(severity, NotificationType.INFO)
|
|
119
121
|
|
|
120
|
-
def _send_notification(
|
|
121
|
-
|
|
122
|
+
def _send_notification(
|
|
123
|
+
self,
|
|
124
|
+
message: str,
|
|
125
|
+
severity: Severity,
|
|
126
|
+
container_name: str,
|
|
127
|
+
throttle: float = 0,
|
|
128
|
+
) -> bool:
|
|
129
|
+
"""
|
|
130
|
+
Send one notification for a container, honoring its throttle.
|
|
131
|
+
|
|
132
|
+
This is the only delivery path. A YAML notify action and a rule's
|
|
133
|
+
``send_notification`` flag both come through here, so the throttle
|
|
134
|
+
cannot be recorded in one place and ignored in another.
|
|
135
|
+
"""
|
|
136
|
+
last_notify = self.last_notify_times.get(container_name, 0.0)
|
|
137
|
+
if throttle and time.time() - last_notify < throttle:
|
|
138
|
+
self.debug(f"Notification throttled for {container_name}")
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
self.last_notify_times[container_name] = time.time()
|
|
122
142
|
notification = Notification(
|
|
123
143
|
message=message, type=self._severity_to_notification_type(severity), meta={"source": "LogMonitor"}
|
|
124
144
|
)
|
|
125
145
|
self._notification_service.notify(notification)
|
|
146
|
+
return True
|
|
126
147
|
|
|
127
148
|
def check_rate_limit(self, action_type: ActionType) -> bool:
|
|
128
149
|
"""Check if action is within rate limits"""
|
|
@@ -145,14 +166,17 @@ class LogMonitor(Watchdog):
|
|
|
145
166
|
message = action.message.format(container=container_name, text=matched_text)
|
|
146
167
|
self.info(f"[LogAction] {message}")
|
|
147
168
|
|
|
148
|
-
def _execute_notify_action(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
169
|
+
def _execute_notify_action(
|
|
170
|
+
self,
|
|
171
|
+
action: NotifyAction,
|
|
172
|
+
container_name: str,
|
|
173
|
+
matched_text: str,
|
|
174
|
+
severity: Severity,
|
|
175
|
+
rule_name: str,
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Deliver a notification, applying this action's throttle."""
|
|
178
|
+
message = f"*{rule_name}* in `{container_name}`\n```{matched_text.strip()}```"
|
|
179
|
+
self._send_notification(message, severity, container_name, throttle=action.throttle)
|
|
156
180
|
|
|
157
181
|
def _execute_restart_action(self, action: RestartContainerAction, container_name: str) -> None:
|
|
158
182
|
"""Execute restart action - delegate to Watchdog parent"""
|
|
@@ -172,42 +196,51 @@ class LogMonitor(Watchdog):
|
|
|
172
196
|
self.info(f"Waiting {action.delay}s before restarting {container_name}")
|
|
173
197
|
time.sleep(action.delay)
|
|
174
198
|
|
|
175
|
-
|
|
176
|
-
# Delegate to parent Watchdog method
|
|
177
|
-
container = self._client.containers.get(container_name)
|
|
178
|
-
container.restart()
|
|
199
|
+
if self.restart_container_by_name(container_name):
|
|
179
200
|
self.restart_counts[container_name].append(time.time())
|
|
180
|
-
self.info(f"Restarted container: {container_name}")
|
|
181
|
-
except NotFound:
|
|
182
|
-
self.error(f"Container '{container_name}' not found")
|
|
183
|
-
except APIError as e:
|
|
184
|
-
self.error(f"Failed to restart {container_name}: {e}")
|
|
185
201
|
|
|
186
202
|
def _execute_pause_action(self, action: PauseContainerAction, container_name: str) -> None:
|
|
187
203
|
"""Execute pause action - delegate to Watchdog parent"""
|
|
188
204
|
self.pause_container_by_name(container_name)
|
|
189
205
|
|
|
190
206
|
def _execute_command_action(self, action: ExecuteCommandAction, container_name: str) -> None:
|
|
191
|
-
"""
|
|
192
|
-
command
|
|
207
|
+
"""
|
|
208
|
+
Run a command as an argument list. The config string is not a shell.
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
argv = [part.format(container=container_name) for part in shlex.split(action.command)]
|
|
212
|
+
except ValueError as exc:
|
|
213
|
+
self.error(f"Invalid command for {container_name}: {exc}")
|
|
214
|
+
return
|
|
215
|
+
if not argv:
|
|
216
|
+
self.error(f"Empty command for {container_name}")
|
|
217
|
+
return
|
|
193
218
|
try:
|
|
194
219
|
result = subprocess.run(
|
|
195
|
-
|
|
196
|
-
shell=
|
|
220
|
+
argv,
|
|
221
|
+
shell=False,
|
|
197
222
|
capture_output=True,
|
|
198
223
|
text=True,
|
|
199
|
-
timeout=30,
|
|
224
|
+
timeout=30,
|
|
200
225
|
)
|
|
226
|
+
rendered = " ".join(argv)
|
|
201
227
|
if result.returncode == 0:
|
|
202
|
-
self.info(f"Executed: {
|
|
228
|
+
self.info(f"Executed: {rendered}\nOutput: {result.stdout}")
|
|
203
229
|
else:
|
|
204
|
-
self.error(f"Command failed: {
|
|
230
|
+
self.error(f"Command failed: {rendered}\nError: {result.stderr}")
|
|
205
231
|
except subprocess.TimeoutExpired:
|
|
206
|
-
self.error(f"Command timed out: {command}")
|
|
232
|
+
self.error(f"Command timed out: {action.command}")
|
|
207
233
|
except Exception as e:
|
|
208
234
|
self.error(f"Failed to execute command: {e}")
|
|
209
235
|
|
|
210
|
-
def execute_action(
|
|
236
|
+
def execute_action(
|
|
237
|
+
self,
|
|
238
|
+
action: Action,
|
|
239
|
+
container_name: str,
|
|
240
|
+
matched_text: str = "",
|
|
241
|
+
severity: Severity = Severity.INFO,
|
|
242
|
+
rule_name: str = "",
|
|
243
|
+
) -> None:
|
|
211
244
|
"""Execute configured actions by delegating to specific action handlers"""
|
|
212
245
|
action_type = action.type
|
|
213
246
|
|
|
@@ -221,7 +254,7 @@ class LogMonitor(Watchdog):
|
|
|
221
254
|
if action_type is ActionType.LOG:
|
|
222
255
|
self._execute_log_action(action, container_name, matched_text)
|
|
223
256
|
elif action_type is ActionType.NOTIFY:
|
|
224
|
-
self._execute_notify_action(action, container_name)
|
|
257
|
+
self._execute_notify_action(action, container_name, matched_text, severity, rule_name)
|
|
225
258
|
elif action_type is ActionType.RESTART_CONTAINER:
|
|
226
259
|
self._execute_restart_action(action, container_name)
|
|
227
260
|
elif action_type is ActionType.PAUSE_CONTAINER:
|
|
@@ -246,14 +279,20 @@ class LogMonitor(Watchdog):
|
|
|
246
279
|
else:
|
|
247
280
|
self.info(log_message)
|
|
248
281
|
|
|
249
|
-
#
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
self.
|
|
282
|
+
# A notify flag is the same delivery path as a notify action.
|
|
283
|
+
# Do not send beside it: that path used to ignore the throttle.
|
|
284
|
+
actions = list(rule.actions)
|
|
285
|
+
if rule.send_notification and not any(action.type is ActionType.NOTIFY for action in actions):
|
|
286
|
+
actions.append(NotifyAction())
|
|
287
|
+
|
|
288
|
+
for action in actions:
|
|
289
|
+
self.execute_action(
|
|
290
|
+
action,
|
|
291
|
+
container_name,
|
|
292
|
+
line,
|
|
293
|
+
severity=rule.severity,
|
|
294
|
+
rule_name=rule.name,
|
|
295
|
+
)
|
|
257
296
|
|
|
258
297
|
# FIXME: does this actually make more sense being part of watchdog?
|
|
259
298
|
def monitor_container(self, container_name: str, rules: list[Rule]) -> None:
|
corekit/notifications/base.py
CHANGED
|
@@ -8,13 +8,15 @@ Subclass ``BaseNotificationService`` and implement ``_send``::
|
|
|
8
8
|
Sends notifications by email.
|
|
9
9
|
'''
|
|
10
10
|
|
|
11
|
-
def _send(self,
|
|
12
|
-
smtp.send(message)
|
|
11
|
+
def _send(self, notification: Notification) -> None:
|
|
12
|
+
smtp.send(notification.message, extra=notification.meta)
|
|
13
13
|
|
|
14
14
|
service.notify(Notification(message="disk full", type=NotificationType.ERROR))
|
|
15
15
|
|
|
16
|
-
Override ``_send``, not ``
|
|
17
|
-
``
|
|
16
|
+
Override ``_send``, not ``notify``, if you only want to replace the transport.
|
|
17
|
+
``notify`` hands the whole ``Notification`` to ``_send``, including ``meta``.
|
|
18
|
+
Events are a different job: they fan a payload out over Redis, they do not
|
|
19
|
+
alert a person.
|
|
18
20
|
"""
|
|
19
21
|
|
|
20
22
|
from corekit.notifications.models import Notification
|
|
@@ -36,16 +38,22 @@ class BaseNotificationService(Loggable):
|
|
|
36
38
|
"""
|
|
37
39
|
Render a notification as the text a transport will send.
|
|
38
40
|
"""
|
|
39
|
-
|
|
41
|
+
text = f"[{notification.type} Notification]: {notification.message}"
|
|
42
|
+
if notification.meta:
|
|
43
|
+
return f"{text} {notification.meta}"
|
|
44
|
+
return text
|
|
40
45
|
|
|
41
|
-
def _send(self,
|
|
46
|
+
def _send(self, notification: Notification) -> None:
|
|
42
47
|
"""
|
|
43
|
-
Deliver
|
|
48
|
+
Deliver a notification. Override this in a subclass.
|
|
49
|
+
|
|
50
|
+
The argument is the notification, not a preformatted string, so a
|
|
51
|
+
transport can use ``message``, ``type``, and ``meta``.
|
|
44
52
|
"""
|
|
45
|
-
self.warning(
|
|
53
|
+
self.warning(self._format(notification))
|
|
46
54
|
|
|
47
55
|
def notify(self, notification: Notification) -> None:
|
|
48
56
|
"""
|
|
49
|
-
|
|
57
|
+
Hand a notification to the transport, including its meta.
|
|
50
58
|
"""
|
|
51
|
-
self._send(
|
|
59
|
+
self._send(notification)
|
|
@@ -8,16 +8,23 @@ are the same concern -- knowing what a running system is doing.
|
|
|
8
8
|
|
|
9
9
|
class Importer(Benchmarkable):
|
|
10
10
|
def run(self) -> None:
|
|
11
|
-
self.
|
|
12
|
-
self.
|
|
11
|
+
self.reset_timing()
|
|
12
|
+
self.timing("started")
|
|
13
13
|
...
|
|
14
|
-
self.timing("finished")
|
|
14
|
+
self.timing("finished") # total since reset; latest since started
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
17
|
from corekit.observability.benchmarkable import Benchmarkable
|
|
18
18
|
from corekit.observability.loggable import Loggable
|
|
19
|
-
from corekit.observability.request_context import BaseRequestContext
|
|
19
|
+
from corekit.observability.request_context import BaseRequestContext, RequestContextFilter
|
|
20
20
|
from corekit.observability.timing.split import Split
|
|
21
21
|
from corekit.observability.timing.timer import Timer
|
|
22
22
|
|
|
23
|
-
__all__ = [
|
|
23
|
+
__all__ = [
|
|
24
|
+
"BaseRequestContext",
|
|
25
|
+
"Benchmarkable",
|
|
26
|
+
"Loggable",
|
|
27
|
+
"RequestContextFilter",
|
|
28
|
+
"Split",
|
|
29
|
+
"Timer",
|
|
30
|
+
]
|
|
@@ -1,12 +1,44 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
1
3
|
from corekit.observability.loggable import Loggable
|
|
4
|
+
from corekit.observability.timing.split import Split
|
|
2
5
|
from corekit.observability.timing.timer import Timer
|
|
3
6
|
|
|
4
7
|
|
|
5
8
|
class Benchmarkable(Loggable):
|
|
6
|
-
|
|
7
|
-
|
|
9
|
+
"""
|
|
10
|
+
``Loggable`` plus split timing.
|
|
11
|
+
|
|
12
|
+
``__init__`` forwards ``*args``/``**kwargs`` the same way ``Loggable``
|
|
13
|
+
does, so a service can sit in a cooperative ``super()`` chain without
|
|
14
|
+
this class swallowing the arguments the next base expects.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
18
|
+
super().__init__(*args, **kwargs)
|
|
8
19
|
self._timing = Timer()
|
|
9
20
|
|
|
10
|
-
def
|
|
11
|
-
|
|
12
|
-
|
|
21
|
+
def reset_timing(self) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Restart the underlying timer for a new unit of work.
|
|
24
|
+
"""
|
|
25
|
+
self._timing.reset()
|
|
26
|
+
|
|
27
|
+
def elapsed_ms(self) -> float:
|
|
28
|
+
"""
|
|
29
|
+
Milliseconds since start (or last ``reset_timing``), without a split.
|
|
30
|
+
"""
|
|
31
|
+
return self._timing.elapsed() * 1000
|
|
32
|
+
|
|
33
|
+
def timing(self, split_name: str | None = None) -> Split:
|
|
34
|
+
"""
|
|
35
|
+
Log the time since the last split and return it.
|
|
36
|
+
|
|
37
|
+
``Split.total`` / ``Split.total_ms`` are since start (or last reset).
|
|
38
|
+
``Split.latest`` / ``Split.latest_ms`` are since the previous split.
|
|
39
|
+
The log line is unchanged. The return value is for a caller that wants
|
|
40
|
+
the numbers without parsing that line.
|
|
41
|
+
"""
|
|
42
|
+
split = self._timing.split(split_name=split_name)
|
|
43
|
+
self.info(str(split))
|
|
44
|
+
return split
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
|
+
from corekit.observability.request_context import RequestContextFilter
|
|
5
|
+
|
|
4
6
|
|
|
5
7
|
class Loggable:
|
|
6
8
|
"""
|
|
@@ -8,10 +10,16 @@ class Loggable:
|
|
|
8
10
|
|
|
9
11
|
Accepts and ignores ``*args``/``**kwargs`` so it can sit anywhere in a
|
|
10
12
|
cooperative ``super().__init__()`` chain.
|
|
13
|
+
|
|
14
|
+
The logger name is the class name, not ``module.Class``. Consuming
|
|
15
|
+
projects filter on that name; renaming it is a log-config change, not a
|
|
16
|
+
local cleanup. Request context is attached as record attributes instead,
|
|
17
|
+
so a formatter can include it without renaming the logger.
|
|
11
18
|
"""
|
|
12
19
|
|
|
13
20
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
14
21
|
self.logger = logging.getLogger(self.__class__.__name__)
|
|
22
|
+
_attach_request_context(self.logger)
|
|
15
23
|
|
|
16
24
|
def debug(self, message: str, **kwargs: Any) -> None:
|
|
17
25
|
self.logger.debug(message, **kwargs)
|
|
@@ -27,3 +35,16 @@ class Loggable:
|
|
|
27
35
|
|
|
28
36
|
def exception(self, message: str, **kwargs: Any) -> None:
|
|
29
37
|
self.logger.exception(message, **kwargs)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _attach_request_context(logger: logging.Logger) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Attach the request-context filter once per logger name.
|
|
43
|
+
|
|
44
|
+
Loggers are process-global and keyed by name, so every instance of a
|
|
45
|
+
class shares one. Adding the filter again would stamp the same fields
|
|
46
|
+
twice.
|
|
47
|
+
"""
|
|
48
|
+
if any(isinstance(existing, RequestContextFilter) for existing in logger.filters):
|
|
49
|
+
return
|
|
50
|
+
logger.addFilter(RequestContextFilter())
|
|
@@ -27,13 +27,14 @@ Each subclass gets its own storage, so one application's context cannot be read
|
|
|
27
27
|
through another's class.
|
|
28
28
|
"""
|
|
29
29
|
|
|
30
|
+
import logging
|
|
30
31
|
from contextlib import contextmanager
|
|
31
32
|
from contextvars import ContextVar, Token
|
|
32
|
-
from typing import Any, Iterator, Self
|
|
33
|
+
from typing import Any, ClassVar, Iterator, Self
|
|
33
34
|
|
|
34
35
|
from pydantic import BaseModel
|
|
35
36
|
|
|
36
|
-
__all__ = ["BaseRequestContext"]
|
|
37
|
+
__all__ = ["BaseRequestContext", "RequestContextFilter"]
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
class BaseRequestContext(BaseModel):
|
|
@@ -45,12 +46,18 @@ class BaseRequestContext(BaseModel):
|
|
|
45
46
|
between them leaks the context into whatever runs next on that task.
|
|
46
47
|
"""
|
|
47
48
|
|
|
49
|
+
#: Subclasses that own storage. The logging filter walks this rather than
|
|
50
|
+
#: guessing which application's context class is in use.
|
|
51
|
+
_known_subclasses: ClassVar[list[type["BaseRequestContext"]]] = []
|
|
52
|
+
|
|
48
53
|
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
49
54
|
"""
|
|
50
55
|
Give every subclass its own ContextVar.
|
|
51
56
|
"""
|
|
52
57
|
super().__init_subclass__(**kwargs)
|
|
53
58
|
cls._context_var = ContextVar(f"{cls.__module__}.{cls.__name__}", default=None)
|
|
59
|
+
if cls not in BaseRequestContext._known_subclasses:
|
|
60
|
+
BaseRequestContext._known_subclasses.append(cls)
|
|
54
61
|
|
|
55
62
|
@classmethod
|
|
56
63
|
def _var(cls) -> ContextVar[Any]:
|
|
@@ -133,3 +140,49 @@ class BaseRequestContext(BaseModel):
|
|
|
133
140
|
yield type(self).current()
|
|
134
141
|
finally:
|
|
135
142
|
type(self).deactivate(token)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class RequestContextFilter(logging.Filter):
|
|
146
|
+
"""
|
|
147
|
+
Stamp the active request context onto each log record.
|
|
148
|
+
|
|
149
|
+
The message is left alone, so existing log lines and anything that
|
|
150
|
+
matches them stay stable. Fields land on the record for a formatter
|
|
151
|
+
that opts in: ``%(request_context)s`` is a compact summary, and
|
|
152
|
+
``request_context_fields`` is the dumped model. Both are always set,
|
|
153
|
+
to an empty string and an empty dict when nothing is active, so a
|
|
154
|
+
format string that names them does not raise.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
158
|
+
fields: dict[str, Any] = {}
|
|
159
|
+
parts: list[str] = []
|
|
160
|
+
for cls in BaseRequestContext._known_subclasses:
|
|
161
|
+
current = cls.current()
|
|
162
|
+
if current is None:
|
|
163
|
+
continue
|
|
164
|
+
dumped = _dump_context(current)
|
|
165
|
+
if not dumped:
|
|
166
|
+
continue
|
|
167
|
+
fields[cls.__name__] = dumped
|
|
168
|
+
rendered = ", ".join(f"{key}={value}" for key, value in dumped.items())
|
|
169
|
+
parts.append(f"{cls.__name__}({rendered})")
|
|
170
|
+
|
|
171
|
+
record.request_context = " ".join(parts)
|
|
172
|
+
record.request_context_fields = fields
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _dump_context(context: BaseRequestContext) -> dict[str, Any]:
|
|
177
|
+
"""
|
|
178
|
+
JSON-safe fields, or stringified values if a field will not dump.
|
|
179
|
+
"""
|
|
180
|
+
try:
|
|
181
|
+
dumped = context.model_dump(mode="json")
|
|
182
|
+
except Exception:
|
|
183
|
+
dumped = {key: getattr(context, key, None) for key in type(context).model_fields}
|
|
184
|
+
return {key: value if _is_log_safe(value) else str(value) for key, value in dumped.items()}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _is_log_safe(value: Any) -> bool:
|
|
188
|
+
return value is None or isinstance(value, (str, int, float, bool))
|