1+ import json
12import logging
23import os
34import re
78from logging import FileHandler , Handler , StreamHandler
89from logging .handlers import RotatingFileHandler
910from string import Formatter
10- from typing import Optional
11+ from typing import Dict , Optional
1112
12- import _string
13+ import _string # type: ignore
1314import discord
1415from 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+
75140class 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
92170def 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
170256def 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