Skip to content

Commit bcbf9b4

Browse files
nullishamyJerrie-AriesTaaku18
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>
1 parent 53f0425 commit bcbf9b4

File tree

3 files changed

+125
-5
lines changed

3 files changed

+125
-5
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: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,6 +1129,24 @@
11291129
"This configuration can only to be set through `.env` file or environment (config) variables."
11301130
]
11311131
},
1132+
"stream_log_format": {
1133+
"default": "plain",
1134+
"description": "The logging format when through a stream, can be 'plain' or 'json'",
1135+
"examples": [
1136+
],
1137+
"notes": [
1138+
"This configuration can only to be set through `.env` file or environment (config) variables."
1139+
]
1140+
},
1141+
"file_log_format": {
1142+
"default": "plain",
1143+
"description": "The logging format when logging to a file, can be 'plain' or 'json'",
1144+
"examples": [
1145+
],
1146+
"notes": [
1147+
"This configuration can only to be set through `.env` file or environment (config) variables."
1148+
]
1149+
},
11321150
"discord_log_level": {
11331151
"default": "INFO",
11341152
"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
@@ -9,7 +10,7 @@
910
from logging import FileHandler, StreamHandler, Handler
1011
from logging.handlers import RotatingFileHandler
1112
from string import Formatter
12-
from typing import Optional
13+
from typing import Dict, Optional
1314

1415
import discord
1516
from discord.ext import commands
@@ -74,6 +75,71 @@ def line(self, level="info"):
7475
)
7576

7677

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

@@ -85,11 +151,25 @@ def format(self, record):
85151
log_stream_formatter = logging.Formatter(
86152
"%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s", datefmt="%m/%d/%y %H:%M:%S"
87153
)
154+
88155
log_file_formatter = FileFormatter(
89156
"%(asctime)s %(name)s[%(lineno)d] - %(levelname)s: %(message)s",
90157
datefmt="%Y-%m-%d %H:%M:%S",
91158
)
92159

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

94174
def create_log_handler(
95175
filename: Optional[str] = None,
@@ -98,6 +178,7 @@ def create_log_handler(
98178
level: int = logging.DEBUG,
99179
mode: str = "a+",
100180
encoding: str = "utf-8",
181+
format: str = "plain",
101182
maxBytes: int = 28000000,
102183
backupCount: int = 1,
103184
**kwargs,
@@ -124,6 +205,9 @@ def create_log_handler(
124205
encoding : str
125206
If this keyword argument is specified along with filename, its value is used when the `FileHandler` is created,
126207
and thus used when opening the output file. Defaults to 'utf-8'.
208+
format : str
209+
The format to output with, can either be 'json' or 'plain'. Will apply to whichever handler is created,
210+
based on other conditional logic.
127211
maxBytes : int
128212
The max file size before the rollover occurs. Defaults to 28000000 (28MB). Rollover occurs whenever the current
129213
log file is nearly `maxBytes` in length; but if either of `maxBytes` or `backupCount` is zero,
@@ -141,23 +225,28 @@ def create_log_handler(
141225

142226
if filename is None:
143227
handler = StreamHandler(stream=sys.stdout, **kwargs)
144-
handler.setFormatter(log_stream_formatter)
228+
formatter = log_stream_formatter
145229
elif not rotating:
146230
handler = FileHandler(filename, mode=mode, encoding=encoding, **kwargs)
147-
handler.setFormatter(log_file_formatter)
231+
formatter = log_file_formatter
148232
else:
149233
handler = RotatingFileHandler(
150234
filename, mode=mode, encoding=encoding, maxBytes=maxBytes, backupCount=backupCount, **kwargs
151235
)
152-
handler.setFormatter(log_file_formatter)
236+
formatter = log_file_formatter
237+
238+
if format == "json":
239+
formatter = json_formatter
153240

154241
handler.setLevel(level)
242+
handler.setFormatter(formatter)
155243
return handler
156244

157245

158246
logging.setLoggerClass(ModmailLogger)
159247
log_level = logging.INFO
160248
loggers = set()
249+
161250
ch = create_log_handler(level=log_level)
162251
ch_debug: Optional[RotatingFileHandler] = None
163252

@@ -173,7 +262,11 @@ def getLogger(name=None) -> ModmailLogger:
173262

174263

175264
def configure_logging(bot) -> None:
176-
global ch_debug, log_level
265+
global ch_debug, log_level, ch
266+
267+
stream_log_format, file_log_format = bot.config["stream_log_format"], bot.config["file_log_format"]
268+
if stream_log_format == "json":
269+
ch.setFormatter(json_formatter)
177270

178271
logger = getLogger(__name__)
179272
level_text = bot.config["log_level"].upper()
@@ -198,8 +291,15 @@ def configure_logging(bot) -> None:
198291

199292
logger.info("Log file: %s", bot.log_file_path)
200293
ch_debug = create_log_handler(bot.log_file_path, rotating=True)
294+
295+
if file_log_format == "json":
296+
ch_debug.setFormatter(json_formatter)
297+
201298
ch.setLevel(log_level)
202299

300+
logger.info("Stream log format: %s", stream_log_format)
301+
logger.info("File log format: %s", file_log_format)
302+
203303
for log in loggers:
204304
log.setLevel(log_level)
205305
log.addHandler(ch_debug)

0 commit comments

Comments
 (0)