-
-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathlogging_config.py
More file actions
330 lines (261 loc) · 11.9 KB
/
Copy pathlogging_config.py
File metadata and controls
330 lines (261 loc) · 11.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
"""
Centralized Logging Configuration
Provides consistent logging configuration across the LEDMatrix application.
Supports structured logging with context information and appropriate log levels.
"""
import copy
import logging
import sys
import os
import json
from typing import Optional, Dict, Any
from datetime import datetime
class StructuredFormatter(logging.Formatter):
"""JSON formatter for structured logging in production."""
def format(self, record: logging.LogRecord) -> str:
"""Format log record as JSON."""
log_data = {
'timestamp': datetime.fromtimestamp(record.created).isoformat(),
'level': record.levelname,
'logger': record.name,
'message': record.getMessage(),
'module': record.module,
'function': record.funcName,
'line': record.lineno,
}
# Add exception info if present
if record.exc_info:
log_data['exception'] = self.formatException(record.exc_info)
# Add extra context if present
if hasattr(record, 'context'):
log_data['context'] = record.context
if hasattr(record, 'plugin_id'):
log_data['plugin_id'] = record.plugin_id
if hasattr(record, 'operation_id'):
log_data['operation_id'] = record.operation_id
# default=str: record.context / extras can hold datetimes, Paths,
# exceptions etc.; without it one such value raised TypeError and
# the whole record was dropped by the handler's error path.
return json.dumps(log_data, default=str)
class ContextualFormatter(logging.Formatter):
"""Human-readable formatter with context information."""
def __init__(self, include_context: bool = True, include_location: bool = False):
"""
Initialize formatter.
Args:
include_context: Include context information in log messages
include_location: Include module/function/line information
"""
if include_location:
fmt = '%(asctime)s.%(msecs)03d - %(levelname)s - %(name)s - %(module)s.%(funcName)s:%(lineno)d - %(message)s'
else:
fmt = '%(asctime)s.%(msecs)03d - %(levelname)s - %(name)s - %(message)s'
super().__init__(fmt=fmt, datefmt='%Y-%m-%d %H:%M:%S')
self.include_context = include_context
def format(self, record: logging.LogRecord) -> str:
"""Format log record with context.
Works on a shallow copy of the record: a record is formatted once
PER HANDLER, so mutating record.msg in place (the old behavior)
prepended the context prefix again for every additional handler.
"""
if self.include_context:
context_parts = []
if hasattr(record, 'plugin_id'):
context_parts.append(f"[Plugin: {record.plugin_id}]")
if hasattr(record, 'operation_id'):
context_parts.append(f"[Op: {record.operation_id}]")
if hasattr(record, 'context') and isinstance(record.context, dict):
for key, value in record.context.items():
context_parts.append(f"[{key}: {value}]")
if context_parts:
record = copy.copy(record)
record.msg = ' '.join(context_parts) + ' ' + str(record.msg)
return super().format(record)
def setup_logging(
level: Optional[int] = None,
format_type: str = 'readable',
include_location: bool = False,
log_file: Optional[str] = None
) -> None:
"""
Set up centralized logging configuration.
Args:
level: Log level (defaults to INFO, or DEBUG if LEDMATRIX_DEBUG is set)
format_type: 'readable' for human-readable, 'json' for structured JSON
include_location: Include module/function/line in readable format
log_file: Optional file path for file logging
"""
# Determine log level
if level is None:
if os.environ.get('LEDMATRIX_DEBUG', '').lower() == 'true':
level = logging.DEBUG
else:
level = logging.INFO
# Get root logger
root_logger = logging.getLogger()
root_logger.setLevel(level)
# Remove existing handlers to avoid duplicates
root_logger.handlers.clear()
# Create formatter based on type
formatter: logging.Formatter
if format_type == 'json':
formatter = StructuredFormatter()
else:
formatter = ContextualFormatter(include_context=True, include_location=include_location)
# Console handler (always add)
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(level)
# Under systemd, tag each line so the journal records the real severity
# rather than filing everything as informational. The file handler below
# keeps the plain formatter: the prefix is meaningful to journald and noise
# anywhere else.
console_handler.setFormatter(
JournalPriorityFormatter(formatter) if _under_systemd() else formatter)
root_logger.addHandler(console_handler)
# File handler (if specified)
if log_file:
try:
file_handler = logging.FileHandler(log_file)
file_handler.setLevel(level)
file_handler.setFormatter(formatter)
root_logger.addHandler(file_handler)
except (IOError, OSError, PermissionError) as e:
# Log to stderr since file logging failed
sys.stderr.write(f"Warning: Could not set up file logging to {log_file}: {e}\n")
#: syslog priorities, which is what systemd parses from a "<N>" prefix on
#: stdout. Mapped from Python's levels.
_SYSLOG_PRIORITY = {
logging.CRITICAL: 2, # LOG_CRIT
logging.ERROR: 3, # LOG_ERR
logging.WARNING: 4, # LOG_WARNING
logging.INFO: 6, # LOG_INFO
logging.DEBUG: 7, # LOG_DEBUG
}
class JournalPriorityFormatter(logging.Formatter):
"""Wraps a formatter, prefixing each line with its syslog priority.
Under systemd everything this process writes to stdout lands in the journal
as PRIORITY=6, whatever the Python level was. Measured on a live rig: 55
ERROR lines and 13 WARNING lines in a day, every one of them recorded as
informational, so `journalctl -p err -u ledmatrix` returned nothing at all
while errors were being logged. Anyone triaging has to grep the message
text instead, which is both slower and wrong -- a search for "oom" matches
the radar logging "zoom=9".
systemd reads a leading "<N>" on each line and uses it as the priority
(sd-daemon(3)), so this needs no extra dependency. Multi-line records get
the prefix on every line, since the journal splits them and an unprefixed
continuation would fall back to the default.
"""
def __init__(self, inner: logging.Formatter):
super().__init__()
self._inner = inner
@property
def inner(self) -> logging.Formatter:
"""The formatter doing the actual work.
Whether journald tagging is applied depends on JOURNAL_STREAM, so it is
on under systemd and off in a terminal -- and anything asserting which
formatter setup_logging() selected would otherwise get a different
answer in CI than on a developer's machine. Exposing the inner one lets
those checks stay about format_type, which is what they mean.
"""
return self._inner
def format(self, record: logging.LogRecord) -> str:
text = self._inner.format(record)
prefix = f"<{_SYSLOG_PRIORITY.get(record.levelno, 6)}>"
return "\n".join(prefix + line for line in text.split("\n"))
def _under_systemd() -> bool:
"""True when stdout really is the journal.
systemd sets JOURNAL_STREAM to "dev:ino" for services whose output it
captures. Presence alone is not enough to act on: the variable is
inherited by child processes and survives redirection, so a subprocess
whose stdout is a pipe or a file still sees it and would emit the "<N>"
priority prefixes as literal noise into that output. systemd's own
guidance is to fstat the descriptor and compare st_dev/st_ino, which is
what distinguishes "the journal is somewhere in my ancestry" from "my
stdout is the journal".
"""
declared = os.environ.get("JOURNAL_STREAM")
if not declared:
return False
try:
dev_text, ino_text = declared.split(":", 1)
declared_ids = (int(dev_text), int(ino_text))
except (ValueError, AttributeError):
return False
try:
stat_result = os.fstat(sys.stdout.fileno())
except (OSError, ValueError, AttributeError):
# No usable stdout: captured by pytest, detached, or already closed.
return False
return (stat_result.st_dev, stat_result.st_ino) == declared_ids
class PluginLoggerAdapter(logging.LoggerAdapter):
"""LoggerAdapter that stamps every record with its plugin_id.
A plain `logging.Logger` attribute (the old approach) is never copied
onto individual `LogRecord`s, so `ContextualFormatter`/`StructuredFormatter`
only ever saw `plugin_id` on calls that explicitly passed
`extra={'plugin_id': ...}` (i.e. `log_with_context`). This adapter injects
it into `extra` on every call, so `self.logger.info(...)` in plugin code
is tagged automatically.
"""
def process(self, msg, kwargs):
extra = dict(kwargs.get('extra') or {})
extra.setdefault('plugin_id', self.extra.get('plugin_id')) # type: ignore[union-attr] # get_logger always passes a dict
kwargs['extra'] = extra
return msg, kwargs
def get_logger(name: str, plugin_id: Optional[str] = None):
"""
Get a logger with consistent configuration.
Args:
name: Logger name (typically __name__)
plugin_id: Optional plugin ID for automatic context
Returns:
Configured logger instance (or a PluginLoggerAdapter when plugin_id
is given, which supports the same .debug/.info/.warning/.error API)
"""
logger = logging.getLogger(name)
if plugin_id:
return PluginLoggerAdapter(logger, {'plugin_id': plugin_id})
return logger
def log_with_context(
logger: logging.Logger,
level: int,
message: str,
context: Optional[Dict[str, Any]] = None,
plugin_id: Optional[str] = None,
operation_id: Optional[str] = None,
exc_info: Optional[Any] = None
) -> None:
"""
Log a message with context information.
Args:
logger: Logger instance
level: Log level (logging.INFO, logging.ERROR, etc.)
message: Log message
context: Optional context dictionary
plugin_id: Optional plugin ID
operation_id: Optional operation ID for request tracking
exc_info: Optional exception info for error logging
"""
extra: Dict[str, Any] = {}
if context:
extra['context'] = context
if plugin_id:
extra['plugin_id'] = plugin_id
if operation_id:
extra['operation_id'] = operation_id
logger.log(level, message, extra=extra, exc_info=exc_info)
# Convenience functions for common log operations
def log_info(logger: logging.Logger, message: str, **kwargs) -> None:
"""Log info message with context."""
log_with_context(logger, logging.INFO, message, **kwargs)
def log_warning(logger: logging.Logger, message: str, **kwargs) -> None:
"""Log warning message with context."""
log_with_context(logger, logging.WARNING, message, **kwargs)
def log_error(logger: logging.Logger, message: str, **kwargs) -> None:
"""Log error message with context. Defaults exc_info=True; a caller
passing exc_info explicitly wins (the old hardcoded keyword raised
TypeError on that duplicate)."""
kwargs.setdefault('exc_info', True)
log_with_context(logger, logging.ERROR, message, **kwargs)
def log_debug(logger: logging.Logger, message: str, **kwargs) -> None:
"""Log debug message with context."""
log_with_context(logger, logging.DEBUG, message, **kwargs)