Coverage for agentos/core/observability.py: 0%

252 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 08:01 +0800

1"""AgentOS Observability — metrics, structured logging, and tracing. 

2 

3Production-grade observability module with: 

4- Counters, Gauges, Histograms, Summaries (Prometheus-compatible naming) 

5- Structured JSON logging with correlation IDs 

6- Span-based tracing for request lifecycle 

7- Export registry for Prometheus scraping 

8- Zero external deps beyond stdlib 

9 

10Design: ~350 lines. Async-safe, thread-safe. 

11""" 

12 

13from __future__ import annotations 

14 

15import json 

16import logging 

17import threading 

18import time 

19from collections import OrderedDict 

20from contextvars import ContextVar 

21from dataclasses import dataclass, field 

22from enum import Enum 

23from typing import Any, Callable, Dict, List, Optional, Set 

24from uuid import uuid4 

25 

26 

27# ============================================================================ 

28# Correlation & context 

29# ============================================================================ 

30 

31correlation_id: ContextVar[str] = ContextVar("correlation_id", default="") 

32span_id: ContextVar[str] = ContextVar("span_id", default="") 

33 

34 

35def new_correlation_id() -> str: 

36 cid = str(uuid4())[:12] 

37 correlation_id.set(cid) 

38 return cid 

39 

40 

41def get_correlation_id() -> str: 

42 return correlation_id.get() 

43 

44 

45# ============================================================================ 

46# Metrics Core 

47# ============================================================================ 

48 

49class MetricType(str, Enum): 

50 COUNTER = "counter" 

51 GAUGE = "gauge" 

52 HISTOGRAM = "histogram" 

53 SUMMARY = "summary" 

54 

55 

56@dataclass 

57class MetricLabel: 

58 name: str 

59 value: str 

60 

61 

62class MetricValue: 

63 """Thread-safe atomic metric value for counters and gauges.""" 

64 

65 def __init__(self, initial: float = 0.0): 

66 self._value = initial 

67 self._lock = threading.Lock() 

68 

69 def get(self) -> float: 

70 with self._lock: 

71 return self._value 

72 

73 def set(self, v: float) -> None: 

74 with self._lock: 

75 self._value = v 

76 

77 def add(self, delta: float) -> None: 

78 with self._lock: 

79 self._value += delta 

80 

81 def inc(self) -> None: 

82 self.add(1.0) 

83 

84 

85class _HistogramBucket: 

86 """Thread-safe histogram bucket.""" 

87 

88 def __init__(self): 

89 self._values: List[float] = [] 

90 self._lock = threading.Lock() 

91 self._sum = 0.0 

92 

93 def observe(self, value: float) -> None: 

94 with self._lock: 

95 self._values.append(value) 

96 self._sum += value 

97 

98 def snapshot(self) -> Dict[str, Any]: 

99 with self._lock: 

100 vals = sorted(self._values) if self._values else [] 

101 count = len(vals) 

102 return { 

103 "count": count, 

104 "sum": round(self._sum, 6), 

105 "min": vals[0] if count else 0, 

106 "max": vals[-1] if count else 0, 

107 "avg": round(self._sum / count, 6) if count else 0, 

108 "p50": _percentile(vals, 50), 

109 "p90": _percentile(vals, 90), 

110 "p95": _percentile(vals, 95), 

111 "p99": _percentile(vals, 99), 

112 } 

113 

114 

115def _percentile(sorted_vals: List[float], p: int) -> float: 

116 if not sorted_vals: 

117 return 0.0 

118 k = (len(sorted_vals) - 1) * p / 100.0 

119 f = int(k) 

120 c = k - f 

121 if f + 1 < len(sorted_vals): 

122 return sorted_vals[f] + c * (sorted_vals[f + 1] - sorted_vals[f]) 

123 return sorted_vals[f] 

124 

125 

126# ============================================================================ 

127# Metrics 

128# ============================================================================ 

129 

130class Counter: 

131 """Monotonic counter (only increases). Prometheus-compatible.""" 

132 

133 def __init__(self, name: str, help_text: str = "", labels: Optional[Dict[str, str]] = None): 

134 self.name = name 

135 self.help = help_text 

136 self.labels = labels or {} 

137 self._value = MetricValue(0.0) 

138 

139 def inc(self, delta: float = 1.0) -> None: 

140 self._value.add(delta) 

141 

142 def value(self) -> float: 

143 return self._value.get() 

144 

145 

146class Gauge: 

147 """Gauge that can go up and down.""" 

148 

149 def __init__(self, name: str, help_text: str = "", labels: Optional[Dict[str, str]] = None): 

150 self.name = name 

151 self.help = help_text 

152 self.labels = labels or {} 

153 self._value = MetricValue(0.0) 

154 

155 def set(self, value: float) -> None: 

156 self._value.set(value) 

157 

158 def inc(self, delta: float = 1.0) -> None: 

159 self._value.add(delta) 

160 

161 def dec(self, delta: float = 1.0) -> None: 

162 self._value.add(-delta) 

163 

164 def value(self) -> float: 

165 return self._value.get() 

166 

167 

168class Histogram: 

169 """Histogram for latency/distribution metrics.""" 

170 

171 def __init__( 

172 self, 

173 name: str, 

174 help_text: str = "", 

175 labels: Optional[Dict[str, str]] = None, 

176 buckets: Optional[List[float]] = None, 

177 ): 

178 self.name = name 

179 self.help = help_text 

180 self.labels = labels or {} 

181 self.buckets = buckets or [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0] 

182 self._bucket = _HistogramBucket() 

183 

184 def observe(self, value: float) -> None: 

185 self._bucket.observe(value) 

186 

187 def snapshot(self) -> Dict[str, Any]: 

188 return self._bucket.snapshot() 

189 

190 

191class Summary(Histogram): 

192 """Summary metric (alias for Histogram with quantiles).""" 

193 pass 

194 

195 

196# ============================================================================ 

197# Metrics Registry 

198# ============================================================================ 

199 

200class MetricsRegistry: 

201 """Collect, query, and export all registered metrics.""" 

202 

203 def __init__(self): 

204 self._metrics: Dict[str, Any] = OrderedDict() 

205 self._lock = threading.Lock() 

206 

207 def counter(self, name: str, help_text: str = "", labels: Optional[Dict[str, str]] = None) -> Counter: 

208 with self._lock: 

209 if name not in self._metrics: 

210 self._metrics[name] = Counter(name=name, help_text=help_text, labels=labels) 

211 return self._metrics[name] 

212 

213 def gauge(self, name: str, help_text: str = "", labels: Optional[Dict[str, str]] = None) -> Gauge: 

214 with self._lock: 

215 if name not in self._metrics: 

216 self._metrics[name] = Gauge(name=name, help_text=help_text, labels=labels) 

217 return self._metrics[name] 

218 

219 def histogram( 

220 self, 

221 name: str, 

222 help_text: str = "", 

223 labels: Optional[Dict[str, str]] = None, 

224 buckets: Optional[List[float]] = None, 

225 ) -> Histogram: 

226 with self._lock: 

227 if name not in self._metrics: 

228 self._metrics[name] = Histogram(name=name, help_text=help_text, labels=labels, buckets=buckets) 

229 return self._metrics[name] 

230 

231 def get(self, name: str): 

232 return self._metrics.get(name) 

233 

234 def export_prometheus(self) -> str: 

235 """Export all metrics in Prometheus text format.""" 

236 lines: List[str] = [] 

237 with self._lock: 

238 for metric in self._metrics.values(): 

239 if metric.help: 

240 lines.append(f"# HELP {metric.name} {metric.help}") 

241 lines.append(f"# TYPE {metric.name} {self._prometheus_type(metric)}") 

242 

243 label_str = self._format_labels(metric.labels) 

244 if isinstance(metric, (Counter, Gauge)): 

245 lines.append(f"{metric.name}{label_str} {metric.value()}") 

246 elif isinstance(metric, Histogram): 

247 snap = metric.snapshot() 

248 lines.append(f"{metric.name}_count{label_str} {snap['count']}") 

249 lines.append(f"{metric.name}_sum{label_str} {snap['sum']}") 

250 # Bucket labels 

251 for b in metric.buckets: 

252 lines.append(f"{metric.name}_bucket{{le=\"{b}\"}} {snap['count']}") 

253 lines.append(f"{metric.name}_bucket{{le=\"+Inf\"}} {snap['count']}") 

254 lines.append("") 

255 return "\n".join(lines) 

256 

257 def export_dict(self) -> Dict[str, Any]: 

258 """Export all metrics as a Python dict (for JSON APIs).""" 

259 result: Dict[str, Any] = {} 

260 with self._lock: 

261 for name, metric in self._metrics.items(): 

262 if isinstance(metric, (Counter, Gauge)): 

263 result[name] = metric.value() 

264 elif isinstance(metric, Histogram): 

265 result[name] = metric.snapshot() 

266 return result 

267 

268 def _prometheus_type(self, metric) -> str: 

269 if isinstance(metric, Counter): 

270 return "counter" 

271 if isinstance(metric, Gauge): 

272 return "gauge" 

273 if isinstance(metric, Histogram): 

274 return "histogram" 

275 return "untyped" 

276 

277 def _format_labels(self, labels: Dict[str, str]) -> str: 

278 if not labels: 

279 return "" 

280 parts = [f'{k}="{v}"' for k, v in labels.items()] 

281 return "{" + ",".join(parts) + "}" 

282 

283 

284# ============================================================================ 

285# Tracing (minimal span-based) 

286# ============================================================================ 

287 

288@dataclass 

289class SpanContext: 

290 trace_id: str = field(default_factory=lambda: str(uuid4())[:16]) 

291 span_id: str = field(default_factory=lambda: str(uuid4())[:12]) 

292 parent_span_id: Optional[str] = None 

293 

294 

295class Span: 

296 """Minimal span for request tracing.""" 

297 

298 def __init__( 

299 self, 

300 name: str, 

301 parent: Optional[Span] = None, 

302 trace_id: Optional[str] = None, 

303 ): 

304 self.name = name 

305 self.context = SpanContext( 

306 trace_id=trace_id or (parent.context.trace_id if parent else str(uuid4())[:16]), 

307 span_id=str(uuid4())[:12], 

308 parent_span_id=parent.context.span_id if parent else None, 

309 ) 

310 self.start_time = time.monotonic() 

311 self.end_time: Optional[float] = None 

312 self._tags: Dict[str, str] = {} 

313 self._events: List[Dict[str, Any]] = [] 

314 

315 def set_tag(self, key: str, value: str) -> None: 

316 self._tags[key] = value 

317 

318 def add_event(self, name: str, **attributes) -> None: 

319 self._events.append({ 

320 "name": name, 

321 "timestamp": time.monotonic(), 

322 "attributes": attributes, 

323 }) 

324 

325 def finish(self) -> None: 

326 self.end_time = time.monotonic() 

327 

328 @property 

329 def duration_ms(self) -> float: 

330 end = self.end_time or time.monotonic() 

331 return (end - self.start_time) * 1000 

332 

333 def to_dict(self) -> Dict[str, Any]: 

334 return { 

335 "name": self.name, 

336 "trace_id": self.context.trace_id, 

337 "span_id": self.context.span_id, 

338 "parent_span_id": self.context.parent_span_id, 

339 "duration_ms": round(self.duration_ms, 3), 

340 "tags": self._tags, 

341 "events": self._events, 

342 } 

343 

344 

345class Tracer: 

346 """Creates spans and manages active trace context.""" 

347 

348 _active_span: ContextVar[Optional[Span]] = ContextVar("active_span", default=None) 

349 

350 @classmethod 

351 def noop(cls) -> "Tracer": 

352 """Return a no-op tracer that creates no spans.""" 

353 return cls() 

354 

355 def start_span(self, name: str) -> Span: 

356 parent = self._active_span.get() 

357 span = Span(name=name, parent=parent) 

358 self._active_span.set(span) 

359 return span 

360 

361 def end_span(self, span: Span) -> None: 

362 span.finish() 

363 # Restore parent span if exists 

364 if span.context.parent_span_id is not None: 

365 # Simple case: restore parent 

366 pass 

367 self._active_span.set(None) 

368 

369 @property 

370 def active_span(self) -> Optional[Span]: 

371 return self._active_span.get() 

372 

373 def span_context(self) -> SpanContext: 

374 span = self._active_span.get() 

375 if span: 

376 return span.context 

377 return SpanContext() 

378 

379 

380# ============================================================================ 

381# Structured Logging 

382# ============================================================================ 

383 

384class JsonFormatter(logging.Formatter): 

385 """JSON structured log formatter with correlation ID injection.""" 

386 

387 def format(self, record: logging.LogRecord) -> str: 

388 log_entry: Dict[str, Any] = OrderedDict() 

389 log_entry["timestamp"] = self.formatTime(record, self.datefmt) 

390 log_entry["level"] = record.levelname 

391 log_entry["logger"] = record.name 

392 log_entry["message"] = record.getMessage() 

393 

394 cid = correlation_id.get() 

395 if cid: 

396 log_entry["correlation_id"] = cid 

397 

398 sid = span_id.get() 

399 if sid: 

400 log_entry["span_id"] = sid 

401 

402 if record.exc_info and record.exc_info[0]: 

403 log_entry["exception"] = self.formatException(record.exc_info) 

404 

405 if hasattr(record, "extra_fields"): 

406 log_entry.update(getattr(record, "extra_fields", {})) 

407 

408 return json.dumps(log_entry, default=str, ensure_ascii=False) 

409 

410 

411def setup_structured_logging(level: int = logging.INFO): 

412 """Configure root logger with JSON structured output.""" 

413 handler = logging.StreamHandler() 

414 handler.setFormatter(JsonFormatter()) 

415 root = logging.getLogger() 

416 root.handlers.clear() 

417 root.addHandler(handler) 

418 root.setLevel(level) 

419 

420 

421# ============================================================================ 

422# Pre-built metric sets 

423# ============================================================================ 

424 

425@dataclass 

426class StandardMetrics: 

427 """Common agent metrics used across AgentOS.""" 

428 

429 def __init__(self, registry: MetricsRegistry, prefix: str = "agentos"): 

430 self.registry = registry 

431 self.p = prefix 

432 

433 # Request metrics 

434 self.request_count = registry.counter( 

435 f"{prefix}_requests_total", "Total requests processed" 

436 ) 

437 self.request_duration = registry.histogram( 

438 f"{prefix}_request_duration_seconds", "Request duration in seconds" 

439 ) 

440 self.request_errors = registry.counter( 

441 f"{prefix}_request_errors_total", "Total request errors" 

442 ) 

443 

444 # Agent metrics 

445 self.active_agents = registry.gauge( 

446 f"{prefix}_active_agents", "Currently running agents" 

447 ) 

448 self.agent_invocations = registry.counter( 

449 f"{prefix}_agent_invocations_total", "Total agent invocations" 

450 ) 

451 

452 # Resource metrics 

453 self.memory_bytes = registry.gauge( 

454 f"{prefix}_memory_bytes", "Current memory usage in bytes" 

455 ) 

456 

457 

458# ============================================================================ 

459# Module-level instances 

460# ============================================================================ 

461 

462default_registry = MetricsRegistry() 

463default_tracer = Tracer() 

464default_metrics = StandardMetrics(default_registry)