Skip to content

Commit 4ed8968

Browse files
nullishamykhakers
authored andcommitted
Add JSON logging support (modmail-dev#3305)
* Add JSON logging support This adds support for JSON logging, along with the relevant options required. This does not change the default log behaviour, so should be backwards compatible. It is opt in via the LOG_FORMAT option, which can be 'json' to use the new logger, or anything else to fallback to the old behaviour. This is implemented in terms of a custom formatter, which is optionally applied to the stdout stream. The debug stream is unaffected by this. * Allow JSON to be selected when creating handlers * Allow different formats to be selected for streams/files * Remove old / unused code * Add new config opts to helpfile * Formatting, basic typing and reorder for consistency in project. --------- Co-authored-by: Jerrie-Aries <hidzrie@gmail.com> Co-authored-by: Taku <45324516+Taaku18@users.noreply.github.com> (cherry picked from commit 6d61cf2) Signed-off-by: Khakers <22665282+khakers@users.noreply.github.com>
1 parent 36cd0ea commit 4ed8968

File tree

3 files changed

+123
-5
lines changed

3 files changed

+123
-5
lines changed

core/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ class ConfigManager:
177177
"disable_updates": False,
178178
# Logging
179179
"log_level": "INFO",
180+
"stream_log_format": "plain",
181+
"file_log_format": "plain",
180182
"discord_log_level": "INFO",
181183
# data collection
182184
"data_collection": True,

core/config_help.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,6 +1121,22 @@
11211121
"This configuration can only be set through `.env` file or environment (config) variables."
11221122
]
11231123
},
1124+
"stream_log_format": {
1125+
"default": "plain",
1126+
"description": "The logging format when through a stream, can be 'plain' or 'json'",
1127+
"examples": [],
1128+
"notes": [
1129+
"This configuration can only to be set through `.env` file or environment (config) variables."
1130+
]
1131+
},
1132+
"file_log_format": {
1133+
"default": "plain",
1134+
"description": "The logging format when logging to a file, can be 'plain' or 'json'",
1135+
"examples": [],
1136+
"notes": [
1137+
"This configuration can only to be set through `.env` file or environment (config) variables."
1138+
]
1139+
},
11241140
"discord_log_level": {
11251141
"default": "INFO",
11261142
"description": "The `discord.py` library logging level for logging to stdout.",

core/models.py

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import logging
23
import os
34
import re
@@ -7,7 +8,7 @@
78
from logging import FileHandler, Handler, StreamHandler
89
from logging.handlers import RotatingFileHandler
910
from string import Formatter
10-
from typing import Optional
11+
from typing import Dict, Optional
1112

1213
import _string
1314
import discord
@@ -72,6 +73,71 @@ def line(self, level="info"):
7273
)
7374

7475

76+
class JsonFormatter(logging.Formatter):
77+
"""
78+
Formatter that outputs JSON strings after parsing the LogRecord.
79+
80+
Parameters
81+
----------
82+
fmt_dict : Optional[Dict[str, str]]
83+
{key: logging format attribute} pairs. Defaults to {"message": "message"}.
84+
time_format: str
85+
time.strftime() format string. Default: "%Y-%m-%dT%H:%M:%S"
86+
msec_format: str
87+
Microsecond formatting. Appended at the end. Default: "%s.%03dZ"
88+
"""
89+
90+
def __init__(
91+
self,
92+
fmt_dict: Optional[Dict[str, str]] = None,
93+
time_format: str = "%Y-%m-%dT%H:%M:%S",
94+
msec_format: str = "%s.%03dZ",
95+
):
96+
self.fmt_dict: Dict[str, str] = fmt_dict if fmt_dict is not None else {"message": "message"}
97+
self.default_time_format: str = time_format
98+
self.default_msec_format: str = msec_format
99+
self.datefmt: Optional[str] = None
100+
101+
def usesTime(self) -> bool:
102+
"""
103+
Overwritten to look for the attribute in the format dict values instead of the fmt string.
104+
"""
105+
return "asctime" in self.fmt_dict.values()
106+
107+
def formatMessage(self, record) -> Dict[str, str]:
108+
"""
109+
Overwritten to return a dictionary of the relevant LogRecord attributes instead of a string.
110+
KeyError is raised if an unknown attribute is provided in the fmt_dict.
111+
"""
112+
return {fmt_key: record.__dict__[fmt_val] for fmt_key, fmt_val in self.fmt_dict.items()}
113+
114+
def format(self, record) -> str:
115+
"""
116+
Mostly the same as the parent's class method, the difference being that a dict is manipulated and dumped as JSON
117+
instead of a string.
118+
"""
119+
record.message = record.getMessage()
120+
121+
if self.usesTime():
122+
record.asctime = self.formatTime(record, self.datefmt)
123+
124+
message_dict = self.formatMessage(record)
125+
126+
if record.exc_info:
127+
# Cache the traceback text to avoid converting it multiple times
128+
# (it's constant anyway)
129+
if not record.exc_text:
130+
record.exc_text = self.formatException(record.exc_info)
131+
132+
if record.exc_text:
133+
message_dict["exc_info"] = record.exc_text
134+
135+
if record.stack_info:
136+
message_dict["stack_info"] = self.formatStack(record.stack_info)
137+
138+
return json.dumps(message_dict, default=str)
139+
140+
75141
class FileFormatter(logging.Formatter):
76142
ansi_escape = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
77143

@@ -83,11 +149,25 @@ def format(self, record):
83149
log_stream_formatter = logging.Formatter(
84150
"%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", datefmt="%m/%d/%y %H:%M:%S"
85151
)
152+
86153
log_file_formatter = FileFormatter(
87154
"%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s",
88155
datefmt="%Y-%m-%d %H:%M:%S",
89156
)
90157

158+
json_formatter = JsonFormatter(
159+
{
160+
"level": "levelname",
161+
"message": "message",
162+
"loggerName": "name",
163+
"processName": "processName",
164+
"processID": "process",
165+
"threadName": "threadName",
166+
"threadID": "thread",
167+
"timestamp": "asctime",
168+
}
169+
)
170+
91171

92172
def create_log_handler(
93173
filename: Optional[str] = None,
@@ -96,6 +176,7 @@ def create_log_handler(
96176
level: int = logging.DEBUG,
97177
mode: str = "a+",
98178
encoding: str = "utf-8",
179+
format: str = "plain",
99180
maxBytes: int = 28000000,
100181
backupCount: int = 1,
101182
**kwargs,
@@ -122,6 +203,9 @@ def create_log_handler(
122203
encoding : str
123204
If this keyword argument is specified along with filename, its value is used when the `FileHandler` is created,
124205
and thus used when opening the output file. Defaults to 'utf-8'.
206+
format : str
207+
The format to output with, can either be 'json' or 'plain'. Will apply to whichever handler is created,
208+
based on other conditional logic.
125209
maxBytes : int
126210
The max file size before the rollover occurs. Defaults to 28000000 (28MB). Rollover occurs whenever the current
127211
log file is nearly `maxBytes` in length; but if either of `maxBytes` or `backupCount` is zero,
@@ -139,23 +223,28 @@ def create_log_handler(
139223

140224
if filename is None:
141225
handler = StreamHandler(stream=sys.stdout, **kwargs)
142-
handler.setFormatter(log_stream_formatter)
226+
formatter = log_stream_formatter
143227
elif not rotating:
144228
handler = FileHandler(filename, mode=mode, encoding=encoding, **kwargs)
145-
handler.setFormatter(log_file_formatter)
229+
formatter = log_file_formatter
146230
else:
147231
handler = RotatingFileHandler(
148232
filename, mode=mode, encoding=encoding, maxBytes=maxBytes, backupCount=backupCount, **kwargs
149233
)
150-
handler.setFormatter(log_file_formatter)
234+
formatter = log_file_formatter
235+
236+
if format == "json":
237+
formatter = json_formatter
151238

152239
handler.setLevel(level)
240+
handler.setFormatter(formatter)
153241
return handler
154242

155243

156244
logging.setLoggerClass(ModmailLogger)
157245
log_level = logging.INFO
158246
loggers = set()
247+
159248
ch = create_log_handler(level=log_level)
160249
ch_debug: Optional[RotatingFileHandler] = None
161250

@@ -171,7 +260,11 @@ def getLogger(name=None) -> ModmailLogger:
171260

172261

173262
def configure_logging(bot) -> None:
174-
global ch_debug, log_level
263+
global ch_debug, log_level, ch
264+
265+
stream_log_format, file_log_format = bot.config["stream_log_format"], bot.config["file_log_format"]
266+
if stream_log_format == "json":
267+
ch.setFormatter(json_formatter)
175268

176269
logger = getLogger(__name__)
177270
level_text = bot.config["log_level"].upper()
@@ -196,8 +289,15 @@ def configure_logging(bot) -> None:
196289

197290
logger.info("Log file: %s", bot.log_file_path)
198291
ch_debug = create_log_handler(bot.log_file_path, rotating=True)
292+
293+
if file_log_format == "json":
294+
ch_debug.setFormatter(json_formatter)
295+
199296
ch.setLevel(log_level)
200297

298+
logger.info("Stream log format: %s", stream_log_format)
299+
logger.info("File log format: %s", file_log_format)
300+
201301
for log in loggers:
202302
log.setLevel(log_level)
203303
log.addHandler(ch_debug)

0 commit comments

Comments
 (0)