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

86 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-08 13:14 +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 

23from contextvars import ContextVar 

24from datetime import UTC, datetime 

25from typing import Any 

26 

27# ── Context propagation ────────────────────────────────────────────────── 

28 

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

32 

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} 

46 

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} 

70 

71 

72class JsonFormatter(logging.Formatter): 

73 """JSON Lines formatter for structured logging. 

74 

75 Output fields: 

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

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

78 """ 

79 

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

81 super().__init__() 

82 self.include_extra = include_extra 

83 

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 } 

98 

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 } 

105 

106 # Stack trace 

107 if record.stack_info: 

108 log_entry["stack"] = record.stack_info 

109 

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) 

119 

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

121 

122 

123class ContextLogger: 

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

125 

126 Usage: 

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

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

129 """ 

130 

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

132 self._logger = logger 

133 

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 

143 

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

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

146 

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

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

149 

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

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

152 

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

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

155 

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

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

158 

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

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

161 

162 @property 

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

164 return self._logger 

165 

166 

167# ── Context management ──────────────────────────────────────────────────── 

168 

169 

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

171 _request_id.set(rid) 

172 

173 

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

175 _tenant_id.set(tid) 

176 

177 

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

179 _session_id.set(sid) 

180 

181 

182def get_request_id() -> str: 

183 return _request_id.get("") 

184 

185 

186def get_tenant_id() -> str: 

187 return _tenant_id.get("") 

188 

189 

190def get_session_id() -> str: 

191 return _session_id.get("") 

192 

193 

194# ── Configuration ───────────────────────────────────────────────────────── 

195 

196 

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. 

205 

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

215 

216 # Remove existing handlers 

217 root.handlers.clear() 

218 

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) 

225 

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 ) 

236 

237 root.addHandler(handler) 

238 

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 

244 

245 

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

247 """Get a structured context logger.""" 

248 return ContextLogger(logging.getLogger(name)) 

249 

250 

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]