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.
Files changed (54) hide show
  1. runtime_memory/__init__.py +28 -0
  2. runtime_memory/claude_code/__init__.py +48 -0
  3. runtime_memory/claude_code/commands.py +698 -0
  4. runtime_memory/claude_code/daemon.py +852 -0
  5. runtime_memory/claude_code/hooks.py +722 -0
  6. runtime_memory/cli/__init__.py +8 -0
  7. runtime_memory/cli/main.py +1936 -0
  8. runtime_memory/core/__init__.py +216 -0
  9. runtime_memory/core/config.py +473 -0
  10. runtime_memory/core/embeddings.py +908 -0
  11. runtime_memory/core/engine.py +1007 -0
  12. runtime_memory/core/exceptions.py +547 -0
  13. runtime_memory/core/legacy_env.py +39 -0
  14. runtime_memory/core/logging.py +160 -0
  15. runtime_memory/core/models.py +1051 -0
  16. runtime_memory/core/observability.py +725 -0
  17. runtime_memory/core/paths.py +30 -0
  18. runtime_memory/core/resilience.py +511 -0
  19. runtime_memory/core/retrieval.py +819 -0
  20. runtime_memory/core/storage.py +1105 -0
  21. runtime_memory/extraction/__init__.py +36 -0
  22. runtime_memory/extraction/extractor.py +1143 -0
  23. runtime_memory/hermes/__init__.py +39 -0
  24. runtime_memory/hermes/_base.py +154 -0
  25. runtime_memory/hermes/bridge.py +119 -0
  26. runtime_memory/hermes/plugin.yaml +13 -0
  27. runtime_memory/hermes/provider.py +536 -0
  28. runtime_memory/hermes/tools.py +230 -0
  29. runtime_memory/hermes/trace.py +177 -0
  30. runtime_memory/plugin/__init__.py +646 -0
  31. runtime_memory/sdk/__init__.py +97 -0
  32. runtime_memory/sdk/client.py +1577 -0
  33. runtime_memory/server/__init__.py +75 -0
  34. runtime_memory/server/api.py +1665 -0
  35. runtime_memory/server/mcp.py +1574 -0
  36. runtime_memory/server/static/css/styles.css +1110 -0
  37. runtime_memory/server/static/index.html +264 -0
  38. runtime_memory/server/static/js/api.js +294 -0
  39. runtime_memory/server/static/js/app.js +771 -0
  40. runtime_memory/tasks/__init__.py +114 -0
  41. runtime_memory/tasks/adapter.py +501 -0
  42. runtime_memory/tasks/claude_code_adapter.py +495 -0
  43. runtime_memory/tasks/claude_code_parser.py +339 -0
  44. runtime_memory/tasks/cli_bridge.py +415 -0
  45. runtime_memory/tasks/linking.py +397 -0
  46. runtime_memory/tasks/models.py +520 -0
  47. runtime_memory/tasks/outcomes.py +320 -0
  48. runtime_memory/tasks/parser.py +305 -0
  49. runtime_memory/tasks/unified_adapter.py +661 -0
  50. runtime_memory-3.0.0.dist-info/METADATA +497 -0
  51. runtime_memory-3.0.0.dist-info/RECORD +54 -0
  52. runtime_memory-3.0.0.dist-info/WHEEL +4 -0
  53. runtime_memory-3.0.0.dist-info/entry_points.txt +6 -0
  54. 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