Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding plaintext and JSON logging formatters #1426

Draft
wants to merge 32 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5c94a69
Adding plaintext and JSON logging formatters
Alsheh Apr 20, 2021
88b3804
putting formatter creation logic in a class
Alsheh Apr 20, 2021
489ffa3
Merge pull request #1 from Alsheh/logformat-formatter-factory
Alsheh Apr 20, 2021
62ace15
Adding the ability to add default values for required fields in the l…
Alsheh Apr 21, 2021
67c6c7f
Avoid overwriting record fields with default values.
Alsheh Apr 21, 2021
6bedd36
Merge pull request #2 from Alsheh/logformat-default-value-parser
Alsheh Apr 21, 2021
5f1b906
Adding the only supervisor dependency: python-json-logger
Alsheh Apr 21, 2021
407b37f
Merge pull request #3 from Alsheh/logging-formatters-dependency
Alsheh Apr 21, 2021
45803b7
Adding log attributes to DummyPConfig object
Alsheh Apr 21, 2021
cee1eb0
Merge pull request #4 from Alsheh/logging-formatters-ci-test
Alsheh Apr 21, 2021
30ab58f
Adding logging formatters options tests
Alsheh Apr 21, 2021
f8e4f93
Adding the ability to set level by description
Alsheh Apr 21, 2021
5853312
Merge pull request #6 from Alsheh/logging-formatters-local
Alsheh Apr 21, 2021
f52b2e2
Adding the ability to set level by number
Alsheh Apr 21, 2021
01dfa8d
Merge pull request #7 from Alsheh/logging-formatters-local
Alsheh Apr 21, 2021
646f19d
Reverting commit f52b2e2fa518f1e0df9272dc7d21bba55d06a207
Alsheh Apr 21, 2021
a3bb1d1
Removing unintended unit testing
Alsheh Apr 21, 2021
c2654e8
Merge pull request #8 from Alsheh/logging-formatters-local
Alsheh Apr 22, 2021
771e101
Fxing a typo
Alsheh Apr 22, 2021
9fbe7e6
Using for/else for simplicity
Alsheh Apr 22, 2021
29fcd9a
Refactoring formatters factory conditionals
Alsheh Apr 22, 2021
1f59e79
Removing duplicate code
Alsheh Apr 22, 2021
618b16b
Code restructuring and CI tests updates
Alsheh Apr 28, 2021
e2c1d80
Merge pull request #9 from Alsheh/logging-formatters-local
Alsheh Apr 28, 2021
959c928
Making the logger backward compatible
Alsheh Apr 28, 2021
ae76fb3
Merge pull request #10 from Alsheh/logging-formatters-local
Alsheh Apr 28, 2021
b573bd3
Removing jsonformatter dependency
Alsheh Apr 29, 2021
871b55a
Merge pull request #11 from Alsheh/logging-formatters-local
Alsheh Apr 29, 2021
b9cde7d
loggers updates
Alsheh Apr 29, 2021
2a50bb3
Merge pull request #14 from Alsheh/logging-formatters-local
Alsheh Apr 29, 2021
5736268
Fixing an issue where numbers are considered valid JSON by the praser
Alsheh May 4, 2021
b623852
Merge pull request #17 from Alsheh/logging-formatters-local
Alsheh May 5, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions supervisor/dispatchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,19 @@ def _init_normallog(self):
maxbytes = getattr(config, '%s_logfile_maxbytes' % channel)
backups = getattr(config, '%s_logfile_backups' % channel)
to_syslog = getattr(config, '%s_syslog' % channel)
loglevel = getattr(config, 'loglevel')
logformat = getattr(config, 'logformat')
logformatter = getattr(config, 'logformatter')

if logfile or to_syslog:
self.normallog = config.options.getLogger()
self.normallog = config.options.getLogger(loglevel)

if logfile:
loggers.handle_file(
self.normallog,
filename=logfile,
fmt='%(message)s',
fmt=logformat,
formatter=logformatter,
rotating=not not maxbytes, # optimization
maxbytes=maxbytes,
backups=backups
Expand Down
227 changes: 227 additions & 0 deletions supervisor/jsonformatter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
# Including python-json-logger module here as advised in logger.py
# https://github.com/madzak/python-json-logger/blob/master/src/pythonjsonlogger/jsonlogger.py
'''
This library is provided to allow standard python logging
to output log data as JSON formatted strings
'''
import logging
import json
import re
from datetime import date, datetime, time
import traceback
import importlib

from inspect import istraceback

from collections import OrderedDict

# skip natural LogRecord attributes
# http://docs.python.org/library/logging.html#logrecord-attributes
RESERVED_ATTRS = (
'args', 'asctime', 'created', 'exc_info', 'exc_text', 'filename',
'funcName', 'levelname', 'levelno', 'lineno', 'module',
'msecs', 'message', 'msg', 'name', 'pathname', 'process',
'processName', 'relativeCreated', 'stack_info', 'thread', 'threadName')

try:
from datetime import timezone
time_converter = lambda timestamp: datetime.fromtimestamp(timestamp, tz=timezone.utc)
except ImportError:
# Python 2
time_converter = lambda timestamp: datetime.utcfromtimestamp(timestamp)

def merge_record_extra(record, target, reserved):
"""
Merges extra attributes from LogRecord object into target dictionary

:param record: logging.LogRecord
:param target: dict to update
:param reserved: dict or list with reserved keys to skip
"""
for key, value in record.__dict__.items():
# this allows to have numeric keys
if (key not in reserved
and not (hasattr(key, "startswith")
and key.startswith('_'))):
target[key] = value
return target


class JsonEncoder(json.JSONEncoder):
"""
A custom encoder extending the default JSONEncoder
"""

def default(self, obj):
if isinstance(obj, (date, datetime, time)):
return self.format_datetime_obj(obj)

elif istraceback(obj):
return ''.join(traceback.format_tb(obj)).strip()

elif type(obj) == Exception \
or isinstance(obj, Exception) \
or type(obj) == type:
return str(obj)

try:
return super(JsonEncoder, self).default(obj)

except TypeError:
try:
return str(obj)

except Exception:
return None

def format_datetime_obj(self, obj):
return obj.isoformat()


class JsonFormatter(logging.Formatter):
"""
A custom formatter to format logging records as json strings.
Extra values will be formatted as str() if not supported by
json default encoder
"""

def __init__(self, *args, **kwargs):
"""
:param json_default: a function for encoding non-standard objects
as outlined in http://docs.python.org/2/library/json.html
:param json_encoder: optional custom encoder
:param json_serializer: a :meth:`json.dumps`-compatible callable
that will be used to serialize the log record.
:param json_indent: an optional :meth:`json.dumps`-compatible numeric value
that will be used to customize the indent of the output json.
:param prefix: an optional string prefix added at the beginning of
the formatted string
:param rename_fields: an optional dict, used to rename field names in the output.
Rename message to @message: {'message': '@message'}
:param json_indent: indent parameter for json.dumps
:param json_ensure_ascii: ensure_ascii parameter for json.dumps
:param reserved_attrs: an optional list of fields that will be skipped when
outputting json log record. Defaults to all log record attributes:
http://docs.python.org/library/logging.html#logrecord-attributes
:param timestamp: an optional string/boolean field to add a timestamp when
outputting the json log record. If string is passed, timestamp will be added
to log record using string as key. If True boolean is passed, timestamp key
will be "timestamp". Defaults to False/off.
"""
self.json_default = self._str_to_fn(kwargs.pop("json_default", None))
self.json_encoder = self._str_to_fn(kwargs.pop("json_encoder", None))
self.json_serializer = self._str_to_fn(kwargs.pop("json_serializer", json.dumps))
self.json_indent = kwargs.pop("json_indent", None)
self.json_ensure_ascii = kwargs.pop("json_ensure_ascii", True)
self.prefix = kwargs.pop("prefix", "")
self.rename_fields = kwargs.pop("rename_fields", {})
reserved_attrs = kwargs.pop("reserved_attrs", RESERVED_ATTRS)
self.reserved_attrs = dict(zip(reserved_attrs, reserved_attrs))
self.timestamp = kwargs.pop("timestamp", False)

# super(JsonFormatter, self).__init__(*args, **kwargs)
logging.Formatter.__init__(self, *args, **kwargs)
if not self.json_encoder and not self.json_default:
self.json_encoder = JsonEncoder

self._required_fields = self.parse()
self._skip_fields = dict(zip(self._required_fields,
self._required_fields))
self._skip_fields.update(self.reserved_attrs)

def _str_to_fn(self, fn_as_str):
"""
If the argument is not a string, return whatever was passed in.
Parses a string such as package.module.function, imports the module
and returns the function.

:param fn_as_str: The string to parse. If not a string, return it.
"""
if not isinstance(fn_as_str, str):
return fn_as_str

path, _, function = fn_as_str.rpartition('.')
module = importlib.import_module(path)
return getattr(module, function)

def parse(self):
"""
Parses format string looking for substitutions

This method is responsible for returning a list of fields (as strings)
to include in all log messages.
"""
standard_formatters = re.compile(r'\((.+?)\)', re.IGNORECASE)
return standard_formatters.findall(self._fmt)

def add_fields(self, log_record, record, message_dict):
"""
Override this method to implement custom logic for adding fields.
"""
for field in self._required_fields:
if field in self.rename_fields:
log_record[self.rename_fields[field]] = record.__dict__.get(field)
else:
log_record[field] = record.__dict__.get(field)
log_record.update(message_dict)
merge_record_extra(record, log_record, reserved=self._skip_fields)

if self.timestamp:
key = self.timestamp if type(self.timestamp) == str else 'timestamp'
log_record[key] = time_converter(record.created)

def process_log_record(self, log_record):
"""
Override this method to implement custom logic
on the possibly ordered dictionary.
"""
return log_record

def jsonify_log_record(self, log_record):
"""Returns a json string of the log record."""
return self.json_serializer(log_record,
default=self.json_default,
cls=self.json_encoder,
indent=self.json_indent,
ensure_ascii=self.json_ensure_ascii)

def serialize_log_record(self, log_record):
"""Returns the final representation of the log record."""
return "%s%s" % (self.prefix, self.jsonify_log_record(log_record))

def format(self, record):
"""Formats a log record and serializes to json"""
message_dict = {}
if isinstance(record.msg, dict):
message_dict = record.msg
record.message = None
else:
record.message = record.getMessage()
# only format time if needed
if "asctime" in self._required_fields:
record.asctime = self.formatTime(record, self.datefmt)

# Display formatted exception, but allow overriding it in the
# user-supplied dict.
if record.exc_info and not message_dict.get('exc_info'):
message_dict['exc_info'] = self.formatException(record.exc_info)
if not message_dict.get('exc_info') and record.exc_text:
message_dict['exc_info'] = record.exc_text
# Display formatted record of stack frames
# default format is a string returned from :func:`traceback.print_stack`
try:
if record.stack_info and not message_dict.get('stack_info'):
message_dict['stack_info'] = self.formatStack(record.stack_info)
except AttributeError:
# Python2.7 doesn't have stack_info.
pass

try:
log_record = OrderedDict()
except NameError:
log_record = {}

self.add_fields(log_record, record, message_dict)
log_record = self.process_log_record(log_record)

return self.serialize_log_record(log_record)
Loading