Skip to content

Commit 4e6e865

Browse files
committed
Implement modmail-dev#3305
1 parent 099c3f4 commit 4e6e865

File tree

3 files changed

+122
-7
lines changed

3 files changed

+122
-7
lines changed

core/config.py

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

core/config_help.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,6 +1190,22 @@
11901190
],
11911191
"notes": []
11921192
},
1193+
"stream_log_format": {
1194+
"default": "plain",
1195+
"description": "The logging format when through a stream, can be 'plain' or 'json'",
1196+
"examples": [],
1197+
"notes": [
1198+
"This configuration can only to be set through `.env` file or environment (config) variables."
1199+
]
1200+
},
1201+
"file_log_format": {
1202+
"default": "plain",
1203+
"description": "The logging format when logging to a file, can be 'plain' or 'json'",
1204+
"examples": [],
1205+
"notes": [
1206+
"This configuration can only to be set through `.env` file or environment (config) variables."
1207+
]
1208+
},
11931209
"discord_log_level": {
11941210
"default": "INFO",
11951211
"description": "The `discord.py` library logging level for logging to stdout.",

core/models.py

Lines changed: 104 additions & 7 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,9 +8,9 @@
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

12-
import _string
13+
import _string # type: ignore
1314
import discord
1415
from discord.ext import commands
1516

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

@@ -88,6 +153,19 @@ def format(self, record):
88153
datefmt="%Y-%m-%d %H:%M:%S",
89154
)
90155

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

92170
def create_log_handler(
93171
filename: Optional[str] = None,
@@ -96,6 +174,7 @@ def create_log_handler(
96174
level: int = logging.DEBUG,
97175
mode: str = "a+",
98176
encoding: str = "utf-8",
177+
format: str = "plain",
99178
maxBytes: int = 28000000,
100179
backupCount: int = 1,
101180
**kwargs,
@@ -120,6 +199,9 @@ def create_log_handler(
120199
encoding : str
121200
If this keyword argument is specified along with filename, its value is used when the `FileHandler` is created,
122201
and thus used when opening the output file. Defaults to 'utf-8'.
202+
format : str
203+
The format to output with, can either be 'json' or 'plain'. Will apply to whichever handler is created,
204+
based on other conditional logic.
123205
maxBytes : int
124206
The max file size before the rollover occurs. Defaults to 28000000 (28MB). Rollover occurs whenever the current
125207
log file is nearly `maxBytes` in length; but if either of `maxBytes` or `backupCount` is zero,
@@ -136,17 +218,21 @@ def create_log_handler(
136218

137219
if filename is None:
138220
handler = StreamHandler(stream=sys.stdout, **kwargs)
139-
handler.setFormatter(log_stream_formatter)
221+
formatter = log_stream_formatter
140222
elif not rotating:
141223
handler = FileHandler(filename, mode=mode, encoding=encoding, **kwargs)
142-
handler.setFormatter(log_file_formatter)
224+
formatter = log_file_formatter
143225
else:
144226
handler = RotatingFileHandler(
145227
filename, mode=mode, encoding=encoding, maxBytes=maxBytes, backupCount=backupCount, **kwargs
146228
)
147-
handler.setFormatter(log_file_formatter)
229+
formatter = log_file_formatter
230+
231+
if format == "json":
232+
formatter = json_formatter
148233

149234
handler.setLevel(level)
235+
handler.setFormatter(formatter)
150236
return handler
151237

152238

@@ -168,7 +254,11 @@ def getLogger(name=None) -> ModmailLogger:
168254

169255

170256
def configure_logging(bot) -> None:
171-
global ch_debug, log_level
257+
global ch_debug, log_level, ch
258+
259+
stream_log_format, file_log_format = bot.config["stream_log_format"], bot.config["file_log_format"]
260+
if stream_log_format == "json":
261+
ch.setFormatter(json_formatter)
172262
logger = getLogger(__name__)
173263
level_text = bot.config["log_level"].upper()
174264
logging_levels = {
@@ -192,8 +282,15 @@ def configure_logging(bot) -> None:
192282

193283
logger.info("Log file: %s", bot.log_file_path)
194284
ch_debug = create_log_handler(bot.log_file_path, rotating=True)
285+
286+
if file_log_format == "json":
287+
ch_debug.setFormatter(json_formatter)
288+
195289
ch.setLevel(log_level)
196290

291+
logger.info("Stream log format: %s", stream_log_format)
292+
logger.info("File log format: %s", file_log_format)
293+
197294
for log in loggers:
198295
log.setLevel(log_level)
199296
log.addHandler(ch_debug)
@@ -210,7 +307,7 @@ def configure_logging(bot) -> None:
210307
d_logger.setLevel(d_level)
211308

212309
non_verbose_log_level = max(d_level, logging.INFO)
213-
stream_handler = create_log_handler(level=non_verbose_log_level)
310+
stream_handler = create_log_handler(level=non_verbose_log_level, format=file_log_format)
214311
if non_verbose_log_level != d_level:
215312
logger.info("Discord logging level (stdout): %s.", logging.getLevelName(non_verbose_log_level))
216313
logger.info("Discord logging level (logfile): %s.", logging.getLevelName(d_level))

0 commit comments

Comments
 (0)