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

87 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-05 22:09 +0800

1""" 

2AgentOS Structured Logging — JSON-formatted production logging with context propagation. 

3 

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. 

8 

9Usage: 

10 from agentos.observability.logging import configure_logging, get_logger 

11 

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""" 

16 

17from __future__ import annotations 

18 

19import json 

20import logging 

21import os 

22import sys 

23import time 

24from contextvars import ContextVar 

25from datetime import datetime, timezone 

26from typing import Any, Optional 

27 

28# ── Context propagation ────────────────────────────────────────────────── 

29 

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="") 

33 

34STANDARD_FIELDS = { 

35 "timestamp", "level", "logger", "module", "function", "line", 

36 "message", "request_id", "tenant_id", "session_id", "pid", 

37} 

38 

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} 

45 

46 

47class JsonFormatter(logging.Formatter): 

48 """JSON Lines formatter for structured logging. 

49 

50 Output fields: 

51 timestamp, level, logger, module, function, line, message, 

52 request_id, tenant_id, session_id, pid, + any extra fields. 

53 """ 

54 

55 def __init__(self, include_extra: bool = True): 

56 super().__init__() 

57 self.include_extra = include_extra 

58 

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 } 

75 

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 } 

82 

83 # Stack trace 

84 if record.stack_info: 

85 log_entry["stack"] = record.stack_info 

86 

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) 

96 

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

98 

99 

100class ContextLogger: 

101 """Logger wrapper that automatically injects context into extra fields. 

102 

103 Usage: 

104 logger = ContextLogger(logging.getLogger(__name__)) 

105 logger.info("Processing task", task_id="t-42") 

106 """ 

107 

108 def __init__(self, logger: logging.Logger): 

109 self._logger = logger 

110 

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 

120 

121 def debug(self, msg: str, **extra): 

122 self._logger.debug(msg, extra=self._make_extra(extra)) 

123 

124 def info(self, msg: str, **extra): 

125 self._logger.info(msg, extra=self._make_extra(extra)) 

126 

127 def warning(self, msg: str, **extra): 

128 self._logger.warning(msg, extra=self._make_extra(extra)) 

129 

130 def error(self, msg: str, **extra): 

131 self._logger.error(msg, extra=self._make_extra(extra)) 

132 

133 def critical(self, msg: str, **extra): 

134 self._logger.critical(msg, extra=self._make_extra(extra)) 

135 

136 def exception(self, msg: str, **extra): 

137 self._logger.exception(msg, extra=self._make_extra(extra)) 

138 

139 @property 

140 def raw(self) -> logging.Logger: 

141 return self._logger 

142 

143 

144# ── Context management ──────────────────────────────────────────────────── 

145 

146def set_request_id(rid: str) -> None: 

147 _request_id.set(rid) 

148 

149 

150def set_tenant_id(tid: str) -> None: 

151 _tenant_id.set(tid) 

152 

153 

154def set_session_id(sid: str) -> None: 

155 _session_id.set(sid) 

156 

157 

158def get_request_id() -> str: 

159 return _request_id.get("") 

160 

161 

162def get_tenant_id() -> str: 

163 return _tenant_id.get("") 

164 

165 

166def get_session_id() -> str: 

167 return _session_id.get("") 

168 

169 

170# ── Configuration ───────────────────────────────────────────────────────── 

171 

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. 

180 

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)) 

190 

191 # Remove existing handlers 

192 root.handlers.clear() 

193 

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) 

200 

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 )) 

209 

210 root.addHandler(handler) 

211 

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 

217 

218 

219def get_logger(name: str) -> ContextLogger: 

220 """Get a structured context logger.""" 

221 return ContextLogger(logging.getLogger(name)) 

222 

223 

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]