Coverage for agentos/observability/logging.py: 0%
86 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 20:40 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 20:40 +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
23from contextvars import ContextVar
24from datetime import UTC, datetime
25from typing import Any
27# ── Context propagation ──────────────────────────────────────────────────
29_request_id: ContextVar[str] = ContextVar("request_id", default="")
30_tenant_id: ContextVar[str] = ContextVar("tenant_id", default="")
31_session_id: ContextVar[str] = ContextVar("session_id", default="")
33STANDARD_FIELDS = {
34 "timestamp",
35 "level",
36 "logger",
37 "module",
38 "function",
39 "line",
40 "message",
41 "request_id",
42 "tenant_id",
43 "session_id",
44 "pid",
45}
47_INTERNAL_RECORD_FIELDS = {
48 "name",
49 "msg",
50 "args",
51 "levelname",
52 "levelno",
53 "pathname",
54 "filename",
55 "funcName",
56 "exc_info",
57 "exc_text",
58 "stack_info",
59 "lineno",
60 "created",
61 "msecs",
62 "relativeCreated",
63 "thread",
64 "threadName",
65 "processName",
66 "process",
67 "taskName",
68 "extra",
69}
72class JsonFormatter(logging.Formatter):
73 """JSON Lines formatter for structured logging.
75 Output fields:
76 timestamp, level, logger, module, function, line, message,
77 request_id, tenant_id, session_id, pid, + any extra fields.
78 """
80 def __init__(self, include_extra: bool = True):
81 super().__init__()
82 self.include_extra = include_extra
84 def format(self, record: logging.LogRecord) -> str:
85 log_entry: dict[str, Any] = {
86 "timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
87 "level": record.levelname,
88 "logger": record.name,
89 "module": record.module,
90 "function": record.funcName,
91 "line": record.lineno,
92 "message": record.getMessage(),
93 "request_id": _request_id.get(""),
94 "tenant_id": _tenant_id.get(""),
95 "session_id": _session_id.get(""),
96 "pid": os.getpid(),
97 }
99 # Exception info (only if it's a real sys.exc_info() tuple, not a bool)
100 if record.exc_info and record.exc_info is not True and record.exc_info[1]:
101 log_entry["exception"] = {
102 "type": type(record.exc_info[1]).__name__,
103 "message": str(record.exc_info[1]),
104 }
106 # Stack trace
107 if record.stack_info:
108 log_entry["stack"] = record.stack_info
110 # Extra fields from record.__dict__ (filter out internal log record fields)
111 if self.include_extra:
112 for key, value in record.__dict__.items():
113 if key not in _INTERNAL_RECORD_FIELDS and not key.startswith("_"):
114 try:
115 json.dumps({key: value}) # ensure serializable
116 log_entry[key] = value
117 except (TypeError, ValueError):
118 log_entry[key] = str(value)
120 return json.dumps(log_entry, default=str, ensure_ascii=False)
123class ContextLogger:
124 """Logger wrapper that automatically injects context into extra fields.
126 Usage:
127 logger = ContextLogger(logging.getLogger(__name__))
128 logger.info("Processing task", task_id="t-42")
129 """
131 def __init__(self, logger: logging.Logger):
132 self._logger = logger
134 def _make_extra(self, extra: dict | None = None) -> dict:
135 base = {
136 "request_id": _request_id.get(""),
137 "tenant_id": _tenant_id.get(""),
138 "session_id": _session_id.get(""),
139 }
140 if extra:
141 base.update(extra)
142 return base
144 def debug(self, msg: str, **extra):
145 self._logger.debug(msg, extra=self._make_extra(extra))
147 def info(self, msg: str, **extra):
148 self._logger.info(msg, extra=self._make_extra(extra))
150 def warning(self, msg: str, **extra):
151 self._logger.warning(msg, extra=self._make_extra(extra))
153 def error(self, msg: str, **extra):
154 self._logger.error(msg, extra=self._make_extra(extra))
156 def critical(self, msg: str, **extra):
157 self._logger.critical(msg, extra=self._make_extra(extra))
159 def exception(self, msg: str, **extra):
160 self._logger.exception(msg, extra=self._make_extra(extra))
162 @property
163 def raw(self) -> logging.Logger:
164 return self._logger
167# ── Context management ────────────────────────────────────────────────────
170def set_request_id(rid: str) -> None:
171 _request_id.set(rid)
174def set_tenant_id(tid: str) -> None:
175 _tenant_id.set(tid)
178def set_session_id(sid: str) -> None:
179 _session_id.set(sid)
182def get_request_id() -> str:
183 return _request_id.get("")
186def get_tenant_id() -> str:
187 return _tenant_id.get("")
190def get_session_id() -> str:
191 return _session_id.get("")
194# ── Configuration ─────────────────────────────────────────────────────────
197def configure_logging(
198 level: str = "INFO",
199 log_file: str | None = None,
200 json_format: bool = True,
201 include_extra: bool = True,
202 root_logger_name: str = "agentos",
203) -> None:
204 """Configure structured logging for the entire application.
206 Args:
207 level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
208 log_file: Optional file path for log output. stdout if None.
209 json_format: Use JSON formatter. If False, use standard format.
210 include_extra: Include extra fields in JSON output.
211 root_logger_name: Root logger to configure.
212 """
213 root = logging.getLogger(root_logger_name)
214 root.setLevel(getattr(logging, level.upper(), logging.INFO))
216 # Remove existing handlers
217 root.handlers.clear()
219 # Create handler
220 if log_file:
221 os.makedirs(os.path.dirname(log_file), exist_ok=True)
222 handler = logging.FileHandler(log_file)
223 else:
224 handler = logging.StreamHandler(sys.stdout)
226 # Set formatter
227 if json_format:
228 handler.setFormatter(JsonFormatter(include_extra=include_extra))
229 else:
230 handler.setFormatter(
231 logging.Formatter(
232 "[%(asctime)s] %(levelname)s %(name)s:%(lineno)d - %(message)s",
233 datefmt="%Y-%m-%d %H:%M:%S",
234 )
235 )
237 root.addHandler(handler)
239 # Also configure uvicorn access log
240 uvicorn_logger = logging.getLogger("uvicorn.access")
241 uvicorn_logger.handlers.clear()
242 uvicorn_logger.addHandler(handler)
243 uvicorn_logger.propagate = False
246def get_logger(name: str) -> ContextLogger:
247 """Get a structured context logger."""
248 return ContextLogger(logging.getLogger(name))
251__all__ = [
252 "JsonFormatter",
253 "ContextLogger",
254 "configure_logging",
255 "get_logger",
256 "set_request_id",
257 "set_tenant_id",
258 "set_session_id",
259 "get_request_id",
260 "get_tenant_id",
261 "get_session_id",
262]