runtime-memory 3.0.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.
- runtime_memory/__init__.py +28 -0
- runtime_memory/claude_code/__init__.py +48 -0
- runtime_memory/claude_code/commands.py +698 -0
- runtime_memory/claude_code/daemon.py +852 -0
- runtime_memory/claude_code/hooks.py +722 -0
- runtime_memory/cli/__init__.py +8 -0
- runtime_memory/cli/main.py +1936 -0
- runtime_memory/core/__init__.py +216 -0
- runtime_memory/core/config.py +473 -0
- runtime_memory/core/embeddings.py +908 -0
- runtime_memory/core/engine.py +1007 -0
- runtime_memory/core/exceptions.py +547 -0
- runtime_memory/core/legacy_env.py +39 -0
- runtime_memory/core/logging.py +160 -0
- runtime_memory/core/models.py +1051 -0
- runtime_memory/core/observability.py +725 -0
- runtime_memory/core/paths.py +30 -0
- runtime_memory/core/resilience.py +511 -0
- runtime_memory/core/retrieval.py +819 -0
- runtime_memory/core/storage.py +1105 -0
- runtime_memory/extraction/__init__.py +36 -0
- runtime_memory/extraction/extractor.py +1143 -0
- runtime_memory/hermes/__init__.py +39 -0
- runtime_memory/hermes/_base.py +154 -0
- runtime_memory/hermes/bridge.py +119 -0
- runtime_memory/hermes/plugin.yaml +13 -0
- runtime_memory/hermes/provider.py +536 -0
- runtime_memory/hermes/tools.py +230 -0
- runtime_memory/hermes/trace.py +177 -0
- runtime_memory/plugin/__init__.py +646 -0
- runtime_memory/sdk/__init__.py +97 -0
- runtime_memory/sdk/client.py +1577 -0
- runtime_memory/server/__init__.py +75 -0
- runtime_memory/server/api.py +1665 -0
- runtime_memory/server/mcp.py +1574 -0
- runtime_memory/server/static/css/styles.css +1110 -0
- runtime_memory/server/static/index.html +264 -0
- runtime_memory/server/static/js/api.js +294 -0
- runtime_memory/server/static/js/app.js +771 -0
- runtime_memory/tasks/__init__.py +114 -0
- runtime_memory/tasks/adapter.py +501 -0
- runtime_memory/tasks/claude_code_adapter.py +495 -0
- runtime_memory/tasks/claude_code_parser.py +339 -0
- runtime_memory/tasks/cli_bridge.py +415 -0
- runtime_memory/tasks/linking.py +397 -0
- runtime_memory/tasks/models.py +520 -0
- runtime_memory/tasks/outcomes.py +320 -0
- runtime_memory/tasks/parser.py +305 -0
- runtime_memory/tasks/unified_adapter.py +661 -0
- runtime_memory-3.0.0.dist-info/METADATA +497 -0
- runtime_memory-3.0.0.dist-info/RECORD +54 -0
- runtime_memory-3.0.0.dist-info/WHEEL +4 -0
- runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
- runtime_memory-3.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Observability utilities for Runtime Memory.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- Structured logging (JSON format)
|
|
6
|
+
- Metrics collection (Prometheus format)
|
|
7
|
+
- Health check utilities
|
|
8
|
+
- Tracing helpers
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
from collections.abc import Awaitable, Callable
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from functools import wraps
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, ParamSpec, TypeVar
|
|
25
|
+
|
|
26
|
+
P = ParamSpec("P")
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# =============================================================================
|
|
31
|
+
# Structured Logging
|
|
32
|
+
# =============================================================================
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class JsonFormatter(logging.Formatter):
|
|
36
|
+
"""JSON log formatter for structured logging.
|
|
37
|
+
|
|
38
|
+
Outputs log records as JSON objects with consistent fields:
|
|
39
|
+
- timestamp: ISO 8601 format
|
|
40
|
+
- level: Log level name
|
|
41
|
+
- logger: Logger name
|
|
42
|
+
- message: Log message
|
|
43
|
+
- extra: Any additional fields passed to the logger
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
47
|
+
"""Format the log record as JSON."""
|
|
48
|
+
log_data = {
|
|
49
|
+
"timestamp": datetime.fromtimestamp(
|
|
50
|
+
record.created, tz=timezone.utc
|
|
51
|
+
).isoformat(),
|
|
52
|
+
"level": record.levelname,
|
|
53
|
+
"logger": record.name,
|
|
54
|
+
"message": record.getMessage(),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Add exception info if present
|
|
58
|
+
if record.exc_info:
|
|
59
|
+
log_data["exception"] = self.formatException(record.exc_info)
|
|
60
|
+
|
|
61
|
+
# Add extra fields from record
|
|
62
|
+
extra_fields = {}
|
|
63
|
+
for key, value in record.__dict__.items():
|
|
64
|
+
if key not in {
|
|
65
|
+
"name",
|
|
66
|
+
"msg",
|
|
67
|
+
"args",
|
|
68
|
+
"created",
|
|
69
|
+
"filename",
|
|
70
|
+
"funcName",
|
|
71
|
+
"levelname",
|
|
72
|
+
"levelno",
|
|
73
|
+
"lineno",
|
|
74
|
+
"module",
|
|
75
|
+
"msecs",
|
|
76
|
+
"pathname",
|
|
77
|
+
"process",
|
|
78
|
+
"processName",
|
|
79
|
+
"relativeCreated",
|
|
80
|
+
"stack_info",
|
|
81
|
+
"exc_info",
|
|
82
|
+
"exc_text",
|
|
83
|
+
"thread",
|
|
84
|
+
"threadName",
|
|
85
|
+
"taskName",
|
|
86
|
+
"message",
|
|
87
|
+
}:
|
|
88
|
+
extra_fields[key] = value
|
|
89
|
+
|
|
90
|
+
if extra_fields:
|
|
91
|
+
log_data["extra"] = extra_fields
|
|
92
|
+
|
|
93
|
+
return json.dumps(log_data, default=str)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class StructuredLogger:
|
|
97
|
+
"""Logger wrapper that supports structured fields.
|
|
98
|
+
|
|
99
|
+
Example:
|
|
100
|
+
logger = StructuredLogger("runtime_memory")
|
|
101
|
+
logger.info("Memory created", memory_id="mem-123", category="pattern")
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, name: str):
|
|
105
|
+
self._logger = logging.getLogger(name)
|
|
106
|
+
|
|
107
|
+
def _log(
|
|
108
|
+
self,
|
|
109
|
+
level: int,
|
|
110
|
+
message: str,
|
|
111
|
+
**kwargs: Any,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""Log with extra fields."""
|
|
114
|
+
self._logger.log(level, message, extra=kwargs)
|
|
115
|
+
|
|
116
|
+
def debug(self, message: str, **kwargs: Any) -> None:
|
|
117
|
+
"""Log debug message with optional fields."""
|
|
118
|
+
self._log(logging.DEBUG, message, **kwargs)
|
|
119
|
+
|
|
120
|
+
def info(self, message: str, **kwargs: Any) -> None:
|
|
121
|
+
"""Log info message with optional fields."""
|
|
122
|
+
self._log(logging.INFO, message, **kwargs)
|
|
123
|
+
|
|
124
|
+
def warning(self, message: str, **kwargs: Any) -> None:
|
|
125
|
+
"""Log warning message with optional fields."""
|
|
126
|
+
self._log(logging.WARNING, message, **kwargs)
|
|
127
|
+
|
|
128
|
+
def error(self, message: str, **kwargs: Any) -> None:
|
|
129
|
+
"""Log error message with optional fields."""
|
|
130
|
+
self._log(logging.ERROR, message, **kwargs)
|
|
131
|
+
|
|
132
|
+
def exception(self, message: str, **kwargs: Any) -> None:
|
|
133
|
+
"""Log exception with traceback."""
|
|
134
|
+
self._logger.exception(message, extra=kwargs)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def setup_structured_logging(
|
|
138
|
+
level: str = "INFO",
|
|
139
|
+
format: str = "text",
|
|
140
|
+
log_file: Path | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Configure structured logging for the application.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
level: Log level (DEBUG, INFO, WARNING, ERROR)
|
|
146
|
+
format: Log format ("text" or "json")
|
|
147
|
+
log_file: Optional log file path
|
|
148
|
+
"""
|
|
149
|
+
root_logger = logging.getLogger("runtime_memory")
|
|
150
|
+
root_logger.setLevel(getattr(logging, level.upper()))
|
|
151
|
+
|
|
152
|
+
# Remove existing handlers
|
|
153
|
+
root_logger.handlers.clear()
|
|
154
|
+
|
|
155
|
+
# Create formatter
|
|
156
|
+
if format.lower() == "json":
|
|
157
|
+
formatter = JsonFormatter()
|
|
158
|
+
else:
|
|
159
|
+
formatter = logging.Formatter(
|
|
160
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Console handler
|
|
164
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
165
|
+
console_handler.setFormatter(formatter)
|
|
166
|
+
root_logger.addHandler(console_handler)
|
|
167
|
+
|
|
168
|
+
# File handler (optional)
|
|
169
|
+
if log_file:
|
|
170
|
+
file_handler = logging.FileHandler(log_file)
|
|
171
|
+
file_handler.setFormatter(formatter)
|
|
172
|
+
root_logger.addHandler(file_handler)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_structured_logger(name: str) -> StructuredLogger:
|
|
176
|
+
"""Get a structured logger instance.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
name: Logger name (usually module name)
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
StructuredLogger instance
|
|
183
|
+
"""
|
|
184
|
+
return StructuredLogger(f"runtime_memory.{name}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# =============================================================================
|
|
188
|
+
# Metrics Collection
|
|
189
|
+
# =============================================================================
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class MetricType(str, Enum):
|
|
193
|
+
"""Types of metrics."""
|
|
194
|
+
|
|
195
|
+
COUNTER = "counter"
|
|
196
|
+
GAUGE = "gauge"
|
|
197
|
+
HISTOGRAM = "histogram"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class Metric:
|
|
202
|
+
"""A single metric value."""
|
|
203
|
+
|
|
204
|
+
name: str
|
|
205
|
+
type: MetricType
|
|
206
|
+
value: float
|
|
207
|
+
labels: dict[str, str] = field(default_factory=dict)
|
|
208
|
+
description: str = ""
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class MetricsCollector:
|
|
212
|
+
"""Collects and exports application metrics.
|
|
213
|
+
|
|
214
|
+
Metrics are stored in memory and can be exported in Prometheus format.
|
|
215
|
+
|
|
216
|
+
Example:
|
|
217
|
+
collector = MetricsCollector()
|
|
218
|
+
collector.increment("requests_total", labels={"method": "GET"})
|
|
219
|
+
collector.set_gauge("active_connections", 42)
|
|
220
|
+
collector.observe_histogram("request_duration", 0.123)
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
def __init__(self):
|
|
224
|
+
self._counters: dict[str, float] = {}
|
|
225
|
+
self._gauges: dict[str, float] = {}
|
|
226
|
+
self._histograms: dict[str, list[float]] = {}
|
|
227
|
+
self._descriptions: dict[str, str] = {}
|
|
228
|
+
|
|
229
|
+
def _key(self, name: str, labels: dict[str, str] | None = None) -> str:
|
|
230
|
+
"""Create a unique key from name and labels."""
|
|
231
|
+
if not labels:
|
|
232
|
+
return name
|
|
233
|
+
label_str = ",".join(f'{k}="{v}"' for k, v in sorted(labels.items()))
|
|
234
|
+
return f"{name}{{{label_str}}}"
|
|
235
|
+
|
|
236
|
+
def increment(
|
|
237
|
+
self,
|
|
238
|
+
name: str,
|
|
239
|
+
value: float = 1,
|
|
240
|
+
labels: dict[str, str] | None = None,
|
|
241
|
+
description: str = "",
|
|
242
|
+
) -> None:
|
|
243
|
+
"""Increment a counter metric.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
name: Metric name
|
|
247
|
+
value: Amount to increment (default 1)
|
|
248
|
+
labels: Optional labels
|
|
249
|
+
description: Metric description
|
|
250
|
+
"""
|
|
251
|
+
key = self._key(name, labels)
|
|
252
|
+
self._counters[key] = self._counters.get(key, 0) + value
|
|
253
|
+
if description:
|
|
254
|
+
self._descriptions[name] = description
|
|
255
|
+
|
|
256
|
+
def set_gauge(
|
|
257
|
+
self,
|
|
258
|
+
name: str,
|
|
259
|
+
value: float,
|
|
260
|
+
labels: dict[str, str] | None = None,
|
|
261
|
+
description: str = "",
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Set a gauge metric.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
name: Metric name
|
|
267
|
+
value: Gauge value
|
|
268
|
+
labels: Optional labels
|
|
269
|
+
description: Metric description
|
|
270
|
+
"""
|
|
271
|
+
key = self._key(name, labels)
|
|
272
|
+
self._gauges[key] = value
|
|
273
|
+
if description:
|
|
274
|
+
self._descriptions[name] = description
|
|
275
|
+
|
|
276
|
+
def observe_histogram(
|
|
277
|
+
self,
|
|
278
|
+
name: str,
|
|
279
|
+
value: float,
|
|
280
|
+
labels: dict[str, str] | None = None,
|
|
281
|
+
description: str = "",
|
|
282
|
+
) -> None:
|
|
283
|
+
"""Observe a histogram value.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
name: Metric name
|
|
287
|
+
value: Observed value
|
|
288
|
+
labels: Optional labels
|
|
289
|
+
description: Metric description
|
|
290
|
+
"""
|
|
291
|
+
key = self._key(name, labels)
|
|
292
|
+
if key not in self._histograms:
|
|
293
|
+
self._histograms[key] = []
|
|
294
|
+
self._histograms[key].append(value)
|
|
295
|
+
if description:
|
|
296
|
+
self._descriptions[name] = description
|
|
297
|
+
|
|
298
|
+
def get_counter(
|
|
299
|
+
self,
|
|
300
|
+
name: str,
|
|
301
|
+
labels: dict[str, str] | None = None,
|
|
302
|
+
) -> float:
|
|
303
|
+
"""Get a counter value."""
|
|
304
|
+
key = self._key(name, labels)
|
|
305
|
+
return self._counters.get(key, 0)
|
|
306
|
+
|
|
307
|
+
def get_gauge(
|
|
308
|
+
self,
|
|
309
|
+
name: str,
|
|
310
|
+
labels: dict[str, str] | None = None,
|
|
311
|
+
) -> float:
|
|
312
|
+
"""Get a gauge value."""
|
|
313
|
+
key = self._key(name, labels)
|
|
314
|
+
return self._gauges.get(key, 0)
|
|
315
|
+
|
|
316
|
+
def to_prometheus(self) -> str:
|
|
317
|
+
"""Export metrics in Prometheus text format.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Prometheus-formatted metrics string
|
|
321
|
+
"""
|
|
322
|
+
lines = []
|
|
323
|
+
|
|
324
|
+
# Export counters
|
|
325
|
+
for key, value in sorted(self._counters.items()):
|
|
326
|
+
name = key.split("{")[0]
|
|
327
|
+
desc = self._descriptions.get(name, "")
|
|
328
|
+
if desc and f"# HELP {name}" not in "\n".join(lines):
|
|
329
|
+
lines.append(f"# HELP {name} {desc}")
|
|
330
|
+
lines.append(f"# TYPE {name} counter")
|
|
331
|
+
lines.append(f"{key} {value}")
|
|
332
|
+
|
|
333
|
+
# Export gauges
|
|
334
|
+
for key, value in sorted(self._gauges.items()):
|
|
335
|
+
name = key.split("{")[0]
|
|
336
|
+
desc = self._descriptions.get(name, "")
|
|
337
|
+
if desc and f"# HELP {name}" not in "\n".join(lines):
|
|
338
|
+
lines.append(f"# HELP {name} {desc}")
|
|
339
|
+
lines.append(f"# TYPE {name} gauge")
|
|
340
|
+
lines.append(f"{key} {value}")
|
|
341
|
+
|
|
342
|
+
# Export histogram summaries
|
|
343
|
+
for key, values in sorted(self._histograms.items()):
|
|
344
|
+
name = key.split("{")[0]
|
|
345
|
+
desc = self._descriptions.get(name, "")
|
|
346
|
+
if desc and f"# HELP {name}" not in "\n".join(lines):
|
|
347
|
+
lines.append(f"# HELP {name} {desc}")
|
|
348
|
+
lines.append(f"# TYPE {name} histogram")
|
|
349
|
+
if values:
|
|
350
|
+
lines.append(f"{key}_count {len(values)}")
|
|
351
|
+
lines.append(f"{key}_sum {sum(values)}")
|
|
352
|
+
|
|
353
|
+
return "\n".join(lines)
|
|
354
|
+
|
|
355
|
+
def reset(self) -> None:
|
|
356
|
+
"""Reset all metrics."""
|
|
357
|
+
self._counters.clear()
|
|
358
|
+
self._gauges.clear()
|
|
359
|
+
self._histograms.clear()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# Global metrics collector
|
|
363
|
+
_metrics_collector: MetricsCollector | None = None
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def get_metrics_collector() -> MetricsCollector:
|
|
367
|
+
"""Get the global metrics collector instance."""
|
|
368
|
+
global _metrics_collector
|
|
369
|
+
if _metrics_collector is None:
|
|
370
|
+
_metrics_collector = MetricsCollector()
|
|
371
|
+
return _metrics_collector
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# =============================================================================
|
|
375
|
+
# Health Checks
|
|
376
|
+
# =============================================================================
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class HealthStatus(str, Enum):
|
|
380
|
+
"""Health check status."""
|
|
381
|
+
|
|
382
|
+
HEALTHY = "healthy"
|
|
383
|
+
DEGRADED = "degraded"
|
|
384
|
+
UNHEALTHY = "unhealthy"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@dataclass
|
|
388
|
+
class HealthCheckResult:
|
|
389
|
+
"""Result of a health check."""
|
|
390
|
+
|
|
391
|
+
name: str
|
|
392
|
+
status: HealthStatus
|
|
393
|
+
message: str = ""
|
|
394
|
+
duration_ms: float = 0.0
|
|
395
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
396
|
+
|
|
397
|
+
def to_dict(self) -> dict[str, Any]:
|
|
398
|
+
"""Convert to dictionary."""
|
|
399
|
+
return {
|
|
400
|
+
"name": self.name,
|
|
401
|
+
"status": self.status.value,
|
|
402
|
+
"message": self.message,
|
|
403
|
+
"duration_ms": round(self.duration_ms, 2),
|
|
404
|
+
"details": self.details,
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@dataclass
|
|
409
|
+
class HealthReport:
|
|
410
|
+
"""Aggregated health report."""
|
|
411
|
+
|
|
412
|
+
status: HealthStatus
|
|
413
|
+
checks: list[HealthCheckResult]
|
|
414
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
415
|
+
|
|
416
|
+
def to_dict(self) -> dict[str, Any]:
|
|
417
|
+
"""Convert to dictionary."""
|
|
418
|
+
return {
|
|
419
|
+
"status": self.status.value,
|
|
420
|
+
"timestamp": self.timestamp.isoformat(),
|
|
421
|
+
"checks": [c.to_dict() for c in self.checks],
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
HealthCheckFn = Callable[[], Awaitable[HealthCheckResult]]
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
class HealthChecker:
|
|
429
|
+
"""Manages and runs health checks.
|
|
430
|
+
|
|
431
|
+
Example:
|
|
432
|
+
checker = HealthChecker()
|
|
433
|
+
checker.register("database", check_database)
|
|
434
|
+
checker.register("embedding", check_embedding_model)
|
|
435
|
+
report = await checker.run_all()
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
def __init__(self):
|
|
439
|
+
self._checks: dict[str, HealthCheckFn] = {}
|
|
440
|
+
|
|
441
|
+
def register(self, name: str, check: HealthCheckFn) -> None:
|
|
442
|
+
"""Register a health check.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
name: Check name
|
|
446
|
+
check: Async function that returns HealthCheckResult
|
|
447
|
+
"""
|
|
448
|
+
self._checks[name] = check
|
|
449
|
+
|
|
450
|
+
def unregister(self, name: str) -> None:
|
|
451
|
+
"""Unregister a health check."""
|
|
452
|
+
self._checks.pop(name, None)
|
|
453
|
+
|
|
454
|
+
async def run_check(self, name: str) -> HealthCheckResult:
|
|
455
|
+
"""Run a single health check.
|
|
456
|
+
|
|
457
|
+
Args:
|
|
458
|
+
name: Check name
|
|
459
|
+
|
|
460
|
+
Returns:
|
|
461
|
+
Health check result
|
|
462
|
+
"""
|
|
463
|
+
if name not in self._checks:
|
|
464
|
+
return HealthCheckResult(
|
|
465
|
+
name=name,
|
|
466
|
+
status=HealthStatus.UNHEALTHY,
|
|
467
|
+
message=f"Unknown health check: {name}",
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
check = self._checks[name]
|
|
471
|
+
start = time.monotonic()
|
|
472
|
+
try:
|
|
473
|
+
result = await check()
|
|
474
|
+
result.duration_ms = (time.monotonic() - start) * 1000
|
|
475
|
+
return result
|
|
476
|
+
except Exception as e:
|
|
477
|
+
return HealthCheckResult(
|
|
478
|
+
name=name,
|
|
479
|
+
status=HealthStatus.UNHEALTHY,
|
|
480
|
+
message=f"Check failed: {e}",
|
|
481
|
+
duration_ms=(time.monotonic() - start) * 1000,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
async def run_all(self) -> HealthReport:
|
|
485
|
+
"""Run all health checks.
|
|
486
|
+
|
|
487
|
+
Returns:
|
|
488
|
+
Aggregated health report
|
|
489
|
+
"""
|
|
490
|
+
results = await asyncio.gather(
|
|
491
|
+
*[self.run_check(name) for name in self._checks],
|
|
492
|
+
return_exceptions=False,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
# Determine overall status
|
|
496
|
+
if all(r.status == HealthStatus.HEALTHY for r in results):
|
|
497
|
+
overall = HealthStatus.HEALTHY
|
|
498
|
+
elif any(r.status == HealthStatus.UNHEALTHY for r in results):
|
|
499
|
+
overall = HealthStatus.UNHEALTHY
|
|
500
|
+
else:
|
|
501
|
+
overall = HealthStatus.DEGRADED
|
|
502
|
+
|
|
503
|
+
return HealthReport(status=overall, checks=results)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# Global health checker
|
|
507
|
+
_health_checker: HealthChecker | None = None
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def get_health_checker() -> HealthChecker:
|
|
511
|
+
"""Get the global health checker instance."""
|
|
512
|
+
global _health_checker
|
|
513
|
+
if _health_checker is None:
|
|
514
|
+
_health_checker = HealthChecker()
|
|
515
|
+
return _health_checker
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
# =============================================================================
|
|
519
|
+
# Common Health Checks
|
|
520
|
+
# =============================================================================
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
async def check_database(db_path: Path) -> HealthCheckResult:
|
|
524
|
+
"""Check database connectivity.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
db_path: Path to SQLite database
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
Health check result
|
|
531
|
+
"""
|
|
532
|
+
import aiosqlite
|
|
533
|
+
|
|
534
|
+
try:
|
|
535
|
+
if not db_path.exists() and str(db_path) != ":memory:":
|
|
536
|
+
return HealthCheckResult(
|
|
537
|
+
name="database",
|
|
538
|
+
status=HealthStatus.UNHEALTHY,
|
|
539
|
+
message=f"Database file not found: {db_path}",
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
async with aiosqlite.connect(db_path) as db:
|
|
543
|
+
cursor = await db.execute("SELECT COUNT(*) FROM memories")
|
|
544
|
+
count = (await cursor.fetchone())[0]
|
|
545
|
+
return HealthCheckResult(
|
|
546
|
+
name="database",
|
|
547
|
+
status=HealthStatus.HEALTHY,
|
|
548
|
+
message="Database connected",
|
|
549
|
+
details={"memory_count": count},
|
|
550
|
+
)
|
|
551
|
+
except Exception as e:
|
|
552
|
+
return HealthCheckResult(
|
|
553
|
+
name="database",
|
|
554
|
+
status=HealthStatus.UNHEALTHY,
|
|
555
|
+
message=f"Database error: {e}",
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
async def check_embedding_model() -> HealthCheckResult:
|
|
560
|
+
"""Check embedding model availability.
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
Health check result
|
|
564
|
+
"""
|
|
565
|
+
try:
|
|
566
|
+
from sentence_transformers import SentenceTransformer
|
|
567
|
+
|
|
568
|
+
model = SentenceTransformer("all-MiniLM-L6-v2")
|
|
569
|
+
# Quick test embedding
|
|
570
|
+
_ = model.encode("test")
|
|
571
|
+
return HealthCheckResult(
|
|
572
|
+
name="embedding",
|
|
573
|
+
status=HealthStatus.HEALTHY,
|
|
574
|
+
message="Embedding model loaded",
|
|
575
|
+
)
|
|
576
|
+
except ImportError:
|
|
577
|
+
return HealthCheckResult(
|
|
578
|
+
name="embedding",
|
|
579
|
+
status=HealthStatus.DEGRADED,
|
|
580
|
+
message="sentence-transformers not installed",
|
|
581
|
+
)
|
|
582
|
+
except Exception as e:
|
|
583
|
+
return HealthCheckResult(
|
|
584
|
+
name="embedding",
|
|
585
|
+
status=HealthStatus.UNHEALTHY,
|
|
586
|
+
message=f"Embedding model error: {e}",
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
async def check_api_key(key_name: str, key_value: str | None) -> HealthCheckResult:
|
|
591
|
+
"""Check if API key is configured.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
key_name: Name of the API key (for display)
|
|
595
|
+
key_value: The key value (or None if not set)
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
Health check result
|
|
599
|
+
"""
|
|
600
|
+
if key_value:
|
|
601
|
+
# Mask the key for display
|
|
602
|
+
masked = key_value[:8] + "..." + key_value[-4:] if len(key_value) > 12 else "***"
|
|
603
|
+
return HealthCheckResult(
|
|
604
|
+
name=f"api_key_{key_name.lower()}",
|
|
605
|
+
status=HealthStatus.HEALTHY,
|
|
606
|
+
message=f"{key_name} configured",
|
|
607
|
+
details={"key_preview": masked},
|
|
608
|
+
)
|
|
609
|
+
return HealthCheckResult(
|
|
610
|
+
name=f"api_key_{key_name.lower()}",
|
|
611
|
+
status=HealthStatus.DEGRADED,
|
|
612
|
+
message=f"{key_name} not configured",
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
# =============================================================================
|
|
617
|
+
# Timing Decorators
|
|
618
|
+
# =============================================================================
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def timed(
|
|
622
|
+
metric_name: str | None = None,
|
|
623
|
+
log_level: int = logging.DEBUG,
|
|
624
|
+
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
|
|
625
|
+
"""Decorator to time async function execution.
|
|
626
|
+
|
|
627
|
+
Records duration as a histogram metric and optionally logs it.
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
metric_name: Optional metric name (defaults to function name)
|
|
631
|
+
log_level: Logging level for timing output
|
|
632
|
+
|
|
633
|
+
Example:
|
|
634
|
+
@timed("search_duration")
|
|
635
|
+
async def search(query: str):
|
|
636
|
+
...
|
|
637
|
+
"""
|
|
638
|
+
|
|
639
|
+
def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
|
|
640
|
+
name = metric_name or f"{func.__module__}.{func.__name__}"
|
|
641
|
+
|
|
642
|
+
@wraps(func)
|
|
643
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
644
|
+
start = time.monotonic()
|
|
645
|
+
try:
|
|
646
|
+
return await func(*args, **kwargs)
|
|
647
|
+
finally:
|
|
648
|
+
duration = time.monotonic() - start
|
|
649
|
+
get_metrics_collector().observe_histogram(
|
|
650
|
+
f"{name}_seconds",
|
|
651
|
+
duration,
|
|
652
|
+
description=f"Duration of {func.__name__}",
|
|
653
|
+
)
|
|
654
|
+
logging.getLogger("runtime_memory.timing").log(
|
|
655
|
+
log_level,
|
|
656
|
+
f"{func.__name__} completed in {duration*1000:.2f}ms",
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
return wrapper
|
|
660
|
+
|
|
661
|
+
return decorator
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def timed_sync(
|
|
665
|
+
metric_name: str | None = None,
|
|
666
|
+
log_level: int = logging.DEBUG,
|
|
667
|
+
) -> Callable[[Callable[P, T]], Callable[P, T]]:
|
|
668
|
+
"""Decorator to time sync function execution."""
|
|
669
|
+
|
|
670
|
+
def decorator(func: Callable[P, T]) -> Callable[P, T]:
|
|
671
|
+
name = metric_name or f"{func.__module__}.{func.__name__}"
|
|
672
|
+
|
|
673
|
+
@wraps(func)
|
|
674
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
675
|
+
start = time.monotonic()
|
|
676
|
+
try:
|
|
677
|
+
return func(*args, **kwargs)
|
|
678
|
+
finally:
|
|
679
|
+
duration = time.monotonic() - start
|
|
680
|
+
get_metrics_collector().observe_histogram(
|
|
681
|
+
f"{name}_seconds",
|
|
682
|
+
duration,
|
|
683
|
+
description=f"Duration of {func.__name__}",
|
|
684
|
+
)
|
|
685
|
+
logging.getLogger("runtime_memory.timing").log(
|
|
686
|
+
log_level,
|
|
687
|
+
f"{func.__name__} completed in {duration*1000:.2f}ms",
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
return wrapper
|
|
691
|
+
|
|
692
|
+
return decorator
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def counted(
|
|
696
|
+
metric_name: str | None = None,
|
|
697
|
+
labels: dict[str, str] | None = None,
|
|
698
|
+
) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]:
|
|
699
|
+
"""Decorator to count function calls.
|
|
700
|
+
|
|
701
|
+
Args:
|
|
702
|
+
metric_name: Optional metric name (defaults to function name)
|
|
703
|
+
labels: Optional labels for the counter
|
|
704
|
+
|
|
705
|
+
Example:
|
|
706
|
+
@counted("api_requests", labels={"endpoint": "search"})
|
|
707
|
+
async def search(query: str):
|
|
708
|
+
...
|
|
709
|
+
"""
|
|
710
|
+
|
|
711
|
+
def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]:
|
|
712
|
+
name = metric_name or f"{func.__module__}.{func.__name__}_total"
|
|
713
|
+
|
|
714
|
+
@wraps(func)
|
|
715
|
+
async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
716
|
+
get_metrics_collector().increment(
|
|
717
|
+
name,
|
|
718
|
+
labels=labels,
|
|
719
|
+
description=f"Total calls to {func.__name__}",
|
|
720
|
+
)
|
|
721
|
+
return await func(*args, **kwargs)
|
|
722
|
+
|
|
723
|
+
return wrapper
|
|
724
|
+
|
|
725
|
+
return decorator
|