Coverage for agentos/observability/logging.py: 0%
87 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 08:01 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 08:01 +0800
1"""
2AgentOS Structured Logging — JSON-formatted production logging with context propagation.
4Provides:
5- JsonFormatter: Emits JSON lines with standard fields (timestamp, level, logger, etc.)
6- ContextLogger: Logger wrapper that injects request_id, tenant_id, session_id into every log.
7- configure_logging(): One-call setup for production logging.
9Usage:
10 from agentos.observability.logging import configure_logging, get_logger
12 configure_logging(level="INFO", log_file="/app/logs/agentos.log")
13 logger = get_logger(__name__)
14 logger.info("Agent started", extra={"agent_id": "abc123"})
15"""
17from __future__ import annotations
19import json
20import logging
21import os
22import sys
23import time
24from contextvars import ContextVar
25from datetime import datetime, timezone
26from typing import Any, Optional
28# ── Context propagation ──────────────────────────────────────────────────
30_request_id: ContextVar[str] = ContextVar("request_id", default="")
31_tenant_id: ContextVar[str] = ContextVar("tenant_id", default="")
32_session_id: ContextVar[str] = ContextVar("session_id", default="")
34STANDARD_FIELDS = {
35 "timestamp", "level", "logger", "module", "function", "line",
36 "message", "request_id", "tenant_id", "session_id", "pid",
37}
39_INTERNAL_RECORD_FIELDS = {
40 "name", "msg", "args", "levelname", "levelno", "pathname",
41 "filename", "funcName", "exc_info", "exc_text", "stack_info",
42 "lineno", "created", "msecs", "relativeCreated", "thread",
43 "threadName", "processName", "process", "taskName", "extra",
44}
47class JsonFormatter(logging.Formatter):
48 """JSON Lines formatter for structured logging.
50 Output fields:
51 timestamp, level, logger, module, function, line, message,
52 request_id, tenant_id, session_id, pid, + any extra fields.
53 """
55 def __init__(self, include_extra: bool = True):
56 super().__init__()
57 self.include_extra = include_extra
59 def format(self, record: logging.LogRecord) -> str:
60 log_entry: dict[str, Any] = {
61 "timestamp": datetime.fromtimestamp(
62 record.created, tz=timezone.utc
63 ).isoformat(),
64 "level": record.levelname,
65 "logger": record.name,
66 "module": record.module,
67 "function": record.funcName,
68 "line": record.lineno,
69 "message": record.getMessage(),
70 "request_id": _request_id.get(""),
71 "tenant_id": _tenant_id.get(""),
72 "session_id": _session_id.get(""),
73 "pid": os.getpid(),
74 }
76 # Exception info (only if it's a real sys.exc_info() tuple, not a bool)
77 if record.exc_info and record.exc_info is not True and record.exc_info[1]:
78 log_entry["exception"] = {
79 "type": type(record.exc_info[1]).__name__,
80 "message": str(record.exc_info[1]),
81 }
83 # Stack trace
84 if record.stack_info:
85 log_entry["stack"] = record.stack_info
87 # Extra fields from record.__dict__ (filter out internal log record fields)
88 if self.include_extra:
89 for key, value in record.__dict__.items():
90 if key not in _INTERNAL_RECORD_FIELDS and not key.startswith("_"):
91 try:
92 json.dumps({key: value}) # ensure serializable
93 log_entry[key] = value
94 except (TypeError, ValueError):
95 log_entry[key] = str(value)
97 return json.dumps(log_entry, default=str, ensure_ascii=False)
100class ContextLogger:
101 """Logger wrapper that automatically injects context into extra fields.
103 Usage:
104 logger = ContextLogger(logging.getLogger(__name__))
105 logger.info("Processing task", task_id="t-42")
106 """
108 def __init__(self, logger: logging.Logger):
109 self._logger = logger
111 def _make_extra(self, extra: Optional[dict] = None) -> dict:
112 base = {
113 "request_id": _request_id.get(""),
114 "tenant_id": _tenant_id.get(""),
115 "session_id": _session_id.get(""),
116 }
117 if extra:
118 base.update(extra)
119 return base
121 def debug(self, msg: str, **extra):
122 self._logger.debug(msg, extra=self._make_extra(extra))
124 def info(self, msg: str, **extra):
125 self._logger.info(msg, extra=self._make_extra(extra))
127 def warning(self, msg: str, **extra):
128 self._logger.warning(msg, extra=self._make_extra(extra))
130 def error(self, msg: str, **extra):
131 self._logger.error(msg, extra=self._make_extra(extra))
133 def critical(self, msg: str, **extra):
134 self._logger.critical(msg, extra=self._make_extra(extra))
136 def exception(self, msg: str, **extra):
137 self._logger.exception(msg, extra=self._make_extra(extra))
139 @property
140 def raw(self) -> logging.Logger:
141 return self._logger
144# ── Context management ────────────────────────────────────────────────────
146def set_request_id(rid: str) -> None:
147 _request_id.set(rid)
150def set_tenant_id(tid: str) -> None:
151 _tenant_id.set(tid)
154def set_session_id(sid: str) -> None:
155 _session_id.set(sid)
158def get_request_id() -> str:
159 return _request_id.get("")
162def get_tenant_id() -> str:
163 return _tenant_id.get("")
166def get_session_id() -> str:
167 return _session_id.get("")
170# ── Configuration ─────────────────────────────────────────────────────────
172def configure_logging(
173 level: str = "INFO",
174 log_file: Optional[str] = None,
175 json_format: bool = True,
176 include_extra: bool = True,
177 root_logger_name: str = "agentos",
178) -> None:
179 """Configure structured logging for the entire application.
181 Args:
182 level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
183 log_file: Optional file path for log output. stdout if None.
184 json_format: Use JSON formatter. If False, use standard format.
185 include_extra: Include extra fields in JSON output.
186 root_logger_name: Root logger to configure.
187 """
188 root = logging.getLogger(root_logger_name)
189 root.setLevel(getattr(logging, level.upper(), logging.INFO))
191 # Remove existing handlers
192 root.handlers.clear()
194 # Create handler
195 if log_file:
196 os.makedirs(os.path.dirname(log_file), exist_ok=True)
197 handler = logging.FileHandler(log_file)
198 else:
199 handler = logging.StreamHandler(sys.stdout)
201 # Set formatter
202 if json_format:
203 handler.setFormatter(JsonFormatter(include_extra=include_extra))
204 else:
205 handler.setFormatter(logging.Formatter(
206 "[%(asctime)s] %(levelname)s %(name)s:%(lineno)d - %(message)s",
207 datefmt="%Y-%m-%d %H:%M:%S",
208 ))
210 root.addHandler(handler)
212 # Also configure uvicorn access log
213 uvicorn_logger = logging.getLogger("uvicorn.access")
214 uvicorn_logger.handlers.clear()
215 uvicorn_logger.addHandler(handler)
216 uvicorn_logger.propagate = False
219def get_logger(name: str) -> ContextLogger:
220 """Get a structured context logger."""
221 return ContextLogger(logging.getLogger(name))
224__all__ = [
225 "JsonFormatter",
226 "ContextLogger",
227 "configure_logging",
228 "get_logger",
229 "set_request_id",
230 "set_tenant_id",
231 "set_session_id",
232 "get_request_id",
233 "get_tenant_id",
234 "get_session_id",
235]