misc: send debug logs to journald (#87556)

- remove all code related to sending/parsing debug logs in
  /var/log/<app>/debug.
- add test on DebugLogFilter.
- configure JournalHandler to take debug logs if settings.DEBUG_LOG is True
This commit is contained in:
Benjamin Dauvergne 2024-02-28 07:42:54 +01:00
parent 4daf3d5137
commit c9b19e9306
7 changed files with 74 additions and 277 deletions

View File

@ -45,11 +45,6 @@ DISABLE_CRON_JOBS = False
# mode for newly updated files # mode for newly updated files
FILE_UPLOAD_PERMISSIONS = 0o644 FILE_UPLOAD_PERMISSIONS = 0o644
DEBUG_LOG_PATH = '/var/log/%s/debug' % PROJECT_NAME
DEBUG_LOG_FORMAT = (
'%(asctime)s \x1f%(tenant)s \x1f%(ip)s \x1f%(user)r \x1f%(request_id)s \x1f'
'%(levelname)s \x1f%(name)s \x1f%(message)s'
)
DEBUG_PROVISIONNING_LOG_PATH = '/var/log/%s/provisionning-debug' % PROJECT_NAME DEBUG_PROVISIONNING_LOG_PATH = '/var/log/%s/provisionning-debug' % PROJECT_NAME
DISABLE_GLOBAL_HANDLERS = os.environ.get('DISABLE_GLOBAL_HANDLERS') == '1' DISABLE_GLOBAL_HANDLERS = os.environ.get('DISABLE_GLOBAL_HANDLERS') == '1'
@ -84,9 +79,6 @@ LOGGING = {
'format': '%(application)s %(levelname)s %(tenant)s %(ip)s %(user)s %(request_id)s' 'format': '%(application)s %(levelname)s %(tenant)s %(ip)s %(user)s %(request_id)s'
' %(message)s', ' %(message)s',
}, },
'debug': {
'format': DEBUG_LOG_FORMAT,
},
'syslog_no_filter': { 'syslog_no_filter': {
'format': '%(levelname)s %(message)s', 'format': '%(levelname)s %(message)s',
}, },
@ -113,16 +105,6 @@ LOGGING = {
'null': { 'null': {
'class': 'logging.NullHandler', 'class': 'logging.NullHandler',
}, },
'debug': {
'level': 'DEBUG',
'class': 'hobo.logger.TimedRotatingFileHandler',
'formatter': 'debug',
'filename': DEBUG_LOG_PATH,
'when': 'midnight',
'backupCount': 1,
'interval': 1,
'filters': ['request_context', 'debug_log'],
},
}, },
'loggers': { 'loggers': {
'django.db': { 'django.db': {
@ -186,23 +168,27 @@ LOGGING = {
}, },
'': { '': {
'level': 'DEBUG', 'level': 'DEBUG',
'filters': ['request_context'], 'filters': ['request_context', 'debug_log'],
'handlers': ([] if DISABLE_GLOBAL_HANDLERS else ['syslog']) + ['mail_admins', 'debug'], 'handlers': ([] if DISABLE_GLOBAL_HANDLERS else ['syslog']) + ['mail_admins'],
}, },
}, },
} }
# Journald support # Journald support
if os.path.exists('/run/systemd/journal/socket') and not DISABLE_GLOBAL_HANDLERS: if os.path.exists('/run/systemd/journal/socket') and not DISABLE_GLOBAL_HANDLERS:
systemd = None
try: try:
from systemd import journal import cysystemd as systemd
except ImportError: except ImportError:
pass try:
else: import systemd
except ImportError:
pass
if systemd:
LOGGING['handlers']['journald'] = { LOGGING['handlers']['journald'] = {
'level': 'INFO', 'level': 'DEBUG',
'class': 'hobo.journal.JournalHandler', 'class': 'hobo.journal.JournalHandler',
'filters': ['request_context'], 'filters': ['request_context', 'debug_log'],
'formatter': 'syslog', 'formatter': 'syslog',
} }
LOGGING['loggers']['']['handlers'].remove('syslog') LOGGING['loggers']['']['handlers'].remove('syslog')

View File

@ -22,8 +22,11 @@ import sys as _sys
import traceback as _traceback import traceback as _traceback
from syslog import LOG_ALERT, LOG_CRIT, LOG_DEBUG, LOG_ERR, LOG_INFO, LOG_WARNING from syslog import LOG_ALERT, LOG_CRIT, LOG_DEBUG, LOG_ERR, LOG_INFO, LOG_WARNING
# noqa pylint: disable=import-error try:
from systemd._journal import sendv # more recent pythong binding is named cysystemd
from cysystemd import journal # noqa pylint: disable=import-error
except ImportError:
from systemd import journal # noqa pylint: disable=import-error
_IDENT_CHARACTER = set('ABCDEFGHIJKLMNOPQRTSUVWXYZ_0123456789') _IDENT_CHARACTER = set('ABCDEFGHIJKLMNOPQRTSUVWXYZ_0123456789')
@ -32,61 +35,6 @@ def _valid_field_name(s):
return not (set(s) - _IDENT_CHARACTER) return not (set(s) - _IDENT_CHARACTER)
def _make_line(field, value):
if isinstance(value, bytes):
return field.encode('utf-8') + b'=' + value
elif isinstance(value, str):
return field + '=' + value
else:
return field + '=' + str(value)
def send(MESSAGE, MESSAGE_ID=None, CODE_FILE=None, CODE_LINE=None, CODE_FUNC=None, **kwargs):
r"""Send a message to the journal.
>>> from systemd import journal
>>> journal.send('Hello world')
>>> journal.send('Hello, again, world', FIELD2='Greetings!')
>>> journal.send('Binary message', BINARY=b'\xde\xad\xbe\xef')
Value of the MESSAGE argument will be used for the MESSAGE= field. MESSAGE
must be a string and will be sent as UTF-8 to the journal.
MESSAGE_ID can be given to uniquely identify the type of message. It must be
a string or a uuid.UUID object.
CODE_LINE, CODE_FILE, and CODE_FUNC can be specified to identify the caller.
Unless at least on of the three is given, values are extracted from the
stack frame of the caller of send(). CODE_FILE and CODE_FUNC must be
strings, CODE_LINE must be an integer.
Additional fields for the journal entry can only be specified as keyword
arguments. The payload can be either a string or bytes. A string will be
sent as UTF-8, and bytes will be sent as-is to the journal.
Other useful fields include PRIORITY, SYSLOG_FACILITY, SYSLOG_IDENTIFIER,
SYSLOG_PID.
"""
args = ['MESSAGE=' + MESSAGE]
if MESSAGE_ID is not None:
id = getattr(MESSAGE_ID, 'hex', MESSAGE_ID)
args.append('MESSAGE_ID=' + id)
if CODE_LINE is CODE_FILE is CODE_FUNC is None:
CODE_FILE, CODE_LINE, CODE_FUNC = _traceback.extract_stack(limit=2)[0][:3]
if CODE_FILE is not None:
args.append('CODE_FILE=' + CODE_FILE)
if CODE_LINE is not None:
args.append(f'CODE_LINE={CODE_LINE:d}')
if CODE_FUNC is not None:
args.append('CODE_FUNC=' + CODE_FUNC)
args.extend(_make_line(key, val) for key, val in kwargs.items())
return sendv(*args)
class JournalHandler(_logging.Handler): class JournalHandler(_logging.Handler):
"""Journal handler class for the Python logging framework. """Journal handler class for the Python logging framework.
@ -139,7 +87,7 @@ class JournalHandler(_logging.Handler):
the `sender_function` parameter. the `sender_function` parameter.
""" """
def __init__(self, level=_logging.NOTSET, sender_function=send, **kwargs): def __init__(self, level=_logging.NOTSET, **kwargs):
super().__init__(level) super().__init__(level)
for name in kwargs: for name in kwargs:
@ -148,7 +96,6 @@ class JournalHandler(_logging.Handler):
if 'SYSLOG_IDENTIFIER' not in kwargs: if 'SYSLOG_IDENTIFIER' not in kwargs:
kwargs['SYSLOG_IDENTIFIER'] = _sys.argv[0] kwargs['SYSLOG_IDENTIFIER'] = _sys.argv[0]
self.send = sender_function
self._extra = kwargs self._extra = kwargs
def emit(self, record): def emit(self, record):
@ -192,8 +139,8 @@ class JournalHandler(_logging.Handler):
continue continue
extras[key.upper()] = value extras[key.upper()] = value
self.send( journal.send(
msg, MESSAGE=msg,
PRIORITY=format(pri), PRIORITY=format(pri),
LOGGER=record.name, LOGGER=record.name,
THREAD_NAME=record.threadName, THREAD_NAME=record.threadName,

View File

@ -14,15 +14,10 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import ast
import datetime
import logging import logging
import logging.handlers import logging.handlers
import os
import time
import warnings import warnings
import pytz
from django.conf import settings from django.conf import settings
from django.db import connection from django.db import connection
@ -124,33 +119,17 @@ class ForceDebugFilter(logging.Filter):
return super().filter(record) return super().filter(record)
class LogRecord(logging.LogRecord):
'''Subclass LogRecord to make multiline log parseable'''
def getMessage(self):
return super().getMessage().replace('\n', '\n ')
class TimedRotatingFileHandler(logging.handlers.TimedRotatingFileHandler):
def format(self, record):
old_class = record.__class__
record.__class__ = LogRecord
try:
return super().format(record)
finally:
record.__class__ = old_class
class DebugLogFilter: class DebugLogFilter:
'''Filter debug log records based on the DEBUG_LOG setting''' '''Filter debug log records based on the DEBUG_LOG setting'''
def filter(self, record): def filter(self, record):
debug_log = getattr(settings, 'DEBUG_LOG', False) debug_log = getattr(settings, 'DEBUG_LOG', False)
if record.levelno > logging.DEBUG:
return True
if not debug_log: if not debug_log:
return False return False
# change class to add space after newlines in message
record.__class__ = LogRecord
if debug_log is True: if debug_log is True:
return True return True
elif hasattr(debug_log, 'encode'): elif hasattr(debug_log, 'encode'):
@ -164,74 +143,6 @@ class DebugLogFilter:
return bool(debug_log) return bool(debug_log)
class DebugLog:
def __init__(self, path):
self.path = path
def _pre_lines(self, cursor=0):
if not os.path.exists(self.path):
return
with open(self.path, 'rb') as fd:
accum = ''
try:
fd.seek(cursor)
except Exception:
return
for line in fd:
size = len(line)
cursor += size
line = line.decode('utf-8')
if not accum:
accum = line
elif not line.startswith(' '):
yield cursor - size, accum
accum = line
else:
accum += line[1:]
if accum:
yield cursor, accum
keys = ['tenant', 'ip', 'user', 'request_id', 'level', 'logger']
def _parse(self, cursor=0):
for cursor, line in self._pre_lines(cursor=cursor):
if line.endswith('\n'):
line = line[:-1]
parts = line.split(' \x1f', settings.DEBUG_LOG_FORMAT.count(' \x1f'))
try:
timestamp = datetime.datetime.strptime(parts[0], '%Y-%m-%d %H:%M:%S,%f')
timestamp = pytz.timezone(time.tzname[0]).localize(timestamp)
except ValueError:
continue
message = parts[-1]
d = {key: value for key, value in zip(self.keys, parts[1:-1])}
if 'user' in d:
try:
d['user'] = ast.literal_eval(d['user'])
except SyntaxError:
pass
d.update(
{
'cursor': cursor,
'timestamp': timestamp,
'message': message,
}
)
yield d
@classmethod
def lines(cls, cursor=0):
debug_log_path = getattr(settings, 'DEBUG_LOG_PATH', None)
if not debug_log_path:
return
if not os.path.exists(debug_log_path):
return
yield from cls(debug_log_path)._parse(cursor=cursor)
class ClampLogLevel(logging.Filter): class ClampLogLevel(logging.Filter):
def __init__(self, level): def __init__(self, level):
self.levelname = level.upper() self.levelname = level.upper()

View File

@ -48,6 +48,3 @@ SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False CSRF_COOKIE_SECURE = False
LANGUAGE_CODE = 'en' LANGUAGE_CODE = 'en'
# noqa pylint: disable=undefined-variable
LOGGING['handlers']['debug']['filename'] = 'debug.log'

View File

@ -14,97 +14,42 @@
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import datetime
import logging import logging
import logging.config import logging.config
import time
import pytest from hobo.logger import ClampLogLevel, DebugLogFilter
import pytz
from tenant_schemas.utils import tenant_context
from hobo.logger import ClampLogLevel, DebugLog
@pytest.fixture def test_debug_log_filter(settings, tmp_path):
def debug_log(settings, tmpdir): log_path = tmp_path / 'debug.log'
debug_log_path = str(tmpdir / 'debug.log')
settings.DEBUG_LOG_PATH = debug_log_path
LOGGING = { settings.DEBUG_LOG = False
'version': 1,
'disable_existing_loggers': True,
'filters': {
'debug_log': {
'()': 'hobo.logger.DebugLogFilter',
},
'request_context': {
'()': 'hobo.logger.RequestContextFilter',
},
},
'formatters': {
'debug': {
'format': settings.DEBUG_LOG_FORMAT,
},
},
'handlers': {
'debug': {
'level': 'DEBUG',
'class': 'hobo.logger.TimedRotatingFileHandler',
'formatter': 'debug',
'filename': debug_log_path,
'when': 'midnight',
'backupCount': 1,
'interval': 1,
'filters': ['request_context', 'debug_log'],
}
},
'loggers': {
'multitenant': {
'level': 'DEBUG',
'handlers': ['debug'],
},
},
}
logging.config.dictConfig(LOGGING)
logger = logging.getLogger('multitenant') logger = logging.getLogger('test')
yield logger handler = logging.FileHandler(str(log_path))
logger.removeHandler(logger.handlers[0]) handler.setLevel(logging.DEBUG)
handler.addFilter(DebugLogFilter())
logger.addHandler(handler)
logger.propagate = False
logger.setLevel(logging.DEBUG)
logger.info('barfoo')
def test_debug_log(tenants, settings, app, rf, debug_log, freezer): logger.debug('foobar')
freezer.move_to('2020-4-20') assert log_path.read_text().count('barfoo') == 1
request = rf.get('/path/') assert log_path.read_text().count('foobar') == 0
debug_log.info('test %s is ok', 1, extra={'request': request, 'tenant': 'yes'})
lines = list(DebugLog.lines())
assert len(lines) == 0
settings.DEBUG_LOG = True settings.DEBUG_LOG = True
with tenant_context(tenants[0]): logger.debug('foobar')
debug_log.info( assert log_path.read_text().count('foobar') == 1
'log %s is \nok', 2, extra={'request': request, 'tenant': 'tenant1', 'user': 'jean darmette'}
)
debug_log.debug('log %s is \nok', 3, extra={'request': request})
lines = list(DebugLog.lines())
assert len(lines) == 2
request_id = hex(id(request))[2:].upper()
assert lines[0] == {
'cursor': 111,
'ip': '127.0.0.1',
'request_id': 'r:' + request_id,
'message': 'log 2 is \nok',
'level': 'INFO',
'tenant': 'tenant1.example.net',
'timestamp': pytz.timezone(time.tzname[0]).localize(datetime.datetime(2020, 4, 20, 2, 0)),
'user': '-',
'logger': 'multitenant',
}
# check that seeking by cursor gives the same lines settings.DEBUG_LOG = 'test.foobar,test.foobar2'
lines2 = list(DebugLog.lines(cursor=lines[0]['cursor'])) logger.debug('foobar')
assert len(lines2) == 1 assert log_path.read_text().count('foobar') == 1
assert lines[1] == lines2[0]
logging.getLogger('test.foobar').debug('foobar')
logging.getLogger('test.foobar2').debug('foobar')
logging.getLogger('test.foobar3').debug('foobar')
assert log_path.read_text().count('foobar') == 3
def test_clamp_log_level(caplog): def test_clamp_log_level(caplog):

View File

@ -1,4 +1,5 @@
import logging import logging
from unittest import mock
import pytest import pytest
from _pytest.logging import LogCaptureHandler from _pytest.logging import LogCaptureHandler
@ -75,13 +76,14 @@ def test_request_context_filter(caplog, settings, tenants, client):
@pytest.fixture @pytest.fixture
def sender(): def sender():
yield MockSender() with mock.patch('hobo.journal.journal.send') as sender:
yield sender
@pytest.fixture @pytest.fixture
def journald_handler(sender): def journald_handler(sender):
root_logger = logging.getLogger() root_logger = logging.getLogger()
journald_handler = JournalHandler(sender_function=sender.send) journald_handler = JournalHandler()
journald_handler.addFilter(RequestContextFilter()) journald_handler.addFilter(RequestContextFilter())
root_logger.handlers.insert(0, journald_handler) # head insert root_logger.handlers.insert(0, journald_handler) # head insert
@ -108,19 +110,29 @@ def test_systemd(settings, tenants, client, journald_handler, sender):
client.login(username='john.doe', password='john.doe') client.login(username='john.doe', password='john.doe')
client.get('/', SERVER_NAME=tenant.domain_url, HTTP_X_FORWARDED_FOR='99.99.99.99, 127.0.0.1') client.get('/', SERVER_NAME=tenant.domain_url, HTTP_X_FORWARDED_FOR='99.99.99.99, 127.0.0.1')
assert len(sender.calls) == 2 assert len(sender.mock_calls) == 2
for tenant, (args, kwargs) in zip(tenants, sender.calls): for tenant, call in zip(tenants, sender.mock_calls):
assert args == ('wat!',) for key, value in [
assert kwargs['IP'] == '99.99.99.99' ('APPLICATION', 'fake-agent'),
assert kwargs['TENANT'] == tenant.domain_url ('IP', '99.99.99.99'),
assert kwargs['PATH'] == '/' ('LEVELNAME', 'ERROR'),
assert kwargs['REQUEST_ID'].startswith('r:') ('LEVELNO', 40),
assert kwargs['USER'] == user.username ('LOGGER', 'hobo.test_urls'),
assert kwargs['USER_EMAIL'] == user.email ('MESSAGE', 'wat!'),
assert kwargs['USER_NAME'] == user.username ('MODULE', 'test_urls'),
assert kwargs['USER_DISPLAY_NAME'] == 'John Doe' ('MSG', 'wat!'),
assert kwargs['USER_UUID'] == 'ab' * 16 ('NAME', 'hobo.test_urls'),
assert kwargs['APPLICATION'] == 'fake-agent' ('PATH', '/'),
('PRIORITY', '3'),
('REQUEST_ID', mock.ANY),
('TENANT', tenant.domain_url),
('USER_DISPLAY_NAME', 'John Doe'),
('USER_EMAIL', 'jodn.doe@example.com'),
('USER', 'john.doe'),
('USER_NAME', 'john.doe'),
('USER_UUID', 'abababababababababababababababab'),
]:
assert call.kwargs[key] == value
def test_debug_log_filter(caplog, settings): def test_debug_log_filter(caplog, settings):

View File

@ -32,4 +32,3 @@ LOGGING['loggers']['suds'] = {
'handlers': ['mail_admins'], 'handlers': ['mail_admins'],
'propagate': True, 'propagate': True,
} }
LOGGING['handlers']['debug']['filename'] = 'debug.log'