Log structured fields through a logger adapter
app.logger.info("message", key=value) appends the keyword arguments as
JSON encoded fields, so callers no longer format the values themselves.
All log calls use fixed messages and put variable values into fields.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
39eb7431e3
commit
4a159006aa
8 files changed
+58
-33
No files matched your search
+1
-1
@@ -67,7 +67,7 @@ def main():
|
|||||||
init_auth(app)
|
init_auth(app)
|
||||||
init_routes(app)
|
init_routes(app)
|
||||||
init_security_headers(app)
|
init_security_headers(app)
|
||||||
app.logger.info("Max initialized, version %s.", app.config["VERSION"])
|
app.logger.info("Max initialized", version=app.config["VERSION"])
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-4
@@ -1,11 +1,9 @@
|
|||||||
from flask import current_app, request
|
from flask import current_app, request
|
||||||
|
|
||||||
from max.log_format import format_log_fields
|
|
||||||
|
|
||||||
|
|
||||||
def audit(event, actor=None, target=None, **fields):
|
def audit(event, actor=None, target=None, **fields):
|
||||||
"""
|
"""
|
||||||
Logs a security relevant event as one line, starting with "audit". actor is the
|
Logs a security relevant event as one line with the message "audit". actor is the
|
||||||
acting user, None for anonymous requests. target is the affected user, if any.
|
acting user, None for anonymous requests. target is the affected user, if any.
|
||||||
"""
|
"""
|
||||||
values = dict(
|
values = dict(
|
||||||
@@ -17,4 +15,4 @@ def audit(event, actor=None, target=None, **fields):
|
|||||||
if target is not None:
|
if target is not None:
|
||||||
values.update(target_user_id=target.id, target_email=target.email)
|
values.update(target_user_id=target.id, target_email=target.email)
|
||||||
values.update(fields)
|
values.update(fields)
|
||||||
current_app.logger.info("audit %s", format_log_fields(**values))
|
current_app.logger.info("audit", **values)
|
||||||
@@ -25,4 +25,4 @@ def rehash_password_if_needed(user, password):
|
|||||||
# check_password accepted the password, so hash_password can hash it. The policy for
|
# check_password accepted the password, so hash_password can hash it. The policy for
|
||||||
# new passwords does not apply here.
|
# new passwords does not apply here.
|
||||||
update_user(user.id, passwordhash=hash_password(password))
|
update_user(user.id, passwordhash=hash_password(password))
|
||||||
current_app.logger.info("Rehashed the password of user %s", user.id)
|
current_app.logger.info("Rehashed the password", user_id=user.id)
|
||||||
+1
-4
@@ -3,7 +3,6 @@ from functools import wraps
|
|||||||
from flask import request, session
|
from flask import request, session
|
||||||
|
|
||||||
from max.crypto import constant_time_compare, get_random_string
|
from max.crypto import constant_time_compare, get_random_string
|
||||||
from max.log_format import format_log_fields
|
|
||||||
from max.middlewares import BaseMiddleware, get_real_remote_addr
|
from max.middlewares import BaseMiddleware, get_real_remote_addr
|
||||||
|
|
||||||
CSRF_TOKEN_KEY = "csrf_token"
|
CSRF_TOKEN_KEY = "csrf_token"
|
||||||
@@ -34,14 +33,12 @@ class CsrfMiddleware(BaseMiddleware):
|
|||||||
# Stateless check, so it also protects routes without a session (login)
|
# Stateless check, so it also protects routes without a session (login)
|
||||||
if not self.is_origin_allowed():
|
if not self.is_origin_allowed():
|
||||||
self.app.logger.debug(
|
self.app.logger.debug(
|
||||||
"Blocked cross-origin request %s",
|
"Blocked cross-origin request",
|
||||||
format_log_fields(
|
|
||||||
method=request.method,
|
method=request.method,
|
||||||
path=request.path,
|
path=request.path,
|
||||||
origin=request.headers.get("Origin"),
|
origin=request.headers.get("Origin"),
|
||||||
sec_fetch_site=request.headers.get("Sec-Fetch-Site"),
|
sec_fetch_site=request.headers.get("Sec-Fetch-Site"),
|
||||||
ip=get_real_remote_addr(self.app),
|
ip=get_real_remote_addr(self.app),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
return "Cross-origin request blocked", 403
|
return "Cross-origin request blocked", 403
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class Database:
|
|||||||
def add_query(self, query, args=None):
|
def add_query(self, query, args=None):
|
||||||
if args is None:
|
if args is None:
|
||||||
args = []
|
args = []
|
||||||
self.app.logger.debug('query="%s", args="%s"', query, str(args))
|
self.app.logger.debug("query", query=query, args=args)
|
||||||
self.query_count += 1
|
self.query_count += 1
|
||||||
|
|
||||||
def collect_query_count(self):
|
def collect_query_count(self):
|
||||||
|
|||||||
+48
-8
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -6,10 +7,49 @@ from logging.config import dictConfig
|
|||||||
from flask import g, request, session
|
from flask import g, request, session
|
||||||
|
|
||||||
from max.db import database
|
from max.db import database
|
||||||
from max.log_format import format_log_fields
|
|
||||||
from max.middlewares import get_real_remote_addr
|
from max.middlewares import get_real_remote_addr
|
||||||
from max.translations import TranslationHandler
|
from max.translations import TranslationHandler
|
||||||
|
|
||||||
|
# Keyword arguments handled by logging itself, all others become fields
|
||||||
|
LOGGING_KWARGS = frozenset(("exc_info", "stack_info", "stacklevel", "extra"))
|
||||||
|
|
||||||
|
|
||||||
|
def format_log_fields(**fields):
|
||||||
|
"""
|
||||||
|
Formats the fields as "key=value key=value". Values are JSON encoded, so values from
|
||||||
|
the client cannot break the line or add fields.
|
||||||
|
"""
|
||||||
|
return " ".join(
|
||||||
|
f"{key}={json.dumps(value, ensure_ascii=False, default=str)}"
|
||||||
|
for key, value in fields.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FieldsLogger(logging.LoggerAdapter):
|
||||||
|
"""
|
||||||
|
Turns keyword arguments into fields: logger.info("message", key=value). The message
|
||||||
|
is a fixed text, everything variable goes into the fields.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def process(self, msg, kwargs):
|
||||||
|
fields = {
|
||||||
|
key: kwargs.pop(key) for key in list(kwargs) if key not in LOGGING_KWARGS
|
||||||
|
}
|
||||||
|
if fields:
|
||||||
|
kwargs["extra"] = {**kwargs.get("extra", {}), "fields": fields}
|
||||||
|
return msg, kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class FieldsFormatter(logging.Formatter):
|
||||||
|
"""Appends the fields of a FieldsLogger record to the line"""
|
||||||
|
|
||||||
|
def format(self, record):
|
||||||
|
line = super().format(record)
|
||||||
|
fields = getattr(record, "fields", None)
|
||||||
|
if not fields:
|
||||||
|
return line
|
||||||
|
return f"{line} {format_log_fields(**fields)}"
|
||||||
|
|
||||||
|
|
||||||
class NoDevServerRequestsFilter(logging.Filter):
|
class NoDevServerRequestsFilter(logging.Filter):
|
||||||
"""Drops the request lines of the Werkzeug dev server, the app logs requests itself"""
|
"""Drops the request lines of the Werkzeug dev server, the app logs requests itself"""
|
||||||
@@ -23,7 +63,10 @@ def init_logging(app):
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"formatters": {
|
"formatters": {
|
||||||
"default": {"format": "[%(asctime)s] [%(levelname)s]: %(message)s"}
|
"default": {
|
||||||
|
"()": FieldsFormatter,
|
||||||
|
"fmt": "[%(asctime)s] [%(levelname)s]: %(message)s",
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"handlers": {
|
"handlers": {
|
||||||
"stdout": {
|
"stdout": {
|
||||||
@@ -37,6 +80,7 @@ def init_logging(app):
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger(app.name)
|
logger = logging.getLogger(app.name)
|
||||||
logger.setLevel(logging.DEBUG if app.debug else logging.INFO)
|
logger.setLevel(logging.DEBUG if app.debug else logging.INFO)
|
||||||
|
app.logger = FieldsLogger(logger)
|
||||||
if app.debug:
|
if app.debug:
|
||||||
logging.getLogger("werkzeug").addFilter(NoDevServerRequestsFilter())
|
logging.getLogger("werkzeug").addFilter(NoDevServerRequestsFilter())
|
||||||
|
|
||||||
@@ -63,8 +107,7 @@ def init_request_logging(app):
|
|||||||
time_in_ms = round((time.time() - start) * 1000, 2) if start else None
|
time_in_ms = round((time.time() - start) * 1000, 2) if start else None
|
||||||
|
|
||||||
app.logger.info(
|
app.logger.info(
|
||||||
"%s",
|
"request",
|
||||||
format_log_fields(
|
|
||||||
method=request.method,
|
method=request.method,
|
||||||
url=request.url,
|
url=request.url,
|
||||||
ip=get_real_remote_addr(app),
|
ip=get_real_remote_addr(app),
|
||||||
@@ -72,12 +115,9 @@ def init_request_logging(app):
|
|||||||
referrer=request.referrer,
|
referrer=request.referrer,
|
||||||
db_queries=database.collect_query_count(),
|
db_queries=database.collect_query_count(),
|
||||||
status=response.status_code if response is not None else 500,
|
status=response.status_code if response is not None else 500,
|
||||||
content_length=(
|
content_length=response.content_length if response is not None else None,
|
||||||
response.content_length if response is not None else None
|
|
||||||
),
|
|
||||||
user_id=session.get("user_id"),
|
user_id=session.get("user_id"),
|
||||||
session=session.get_log_id(),
|
session=session.get_log_id(),
|
||||||
lang=TranslationHandler.get_language(),
|
lang=TranslationHandler.get_language(),
|
||||||
time_ms=time_in_ms,
|
time_ms=time_in_ms,
|
||||||
),
|
|
||||||
)
|
)
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
|
|
||||||
def format_log_fields(**fields):
|
|
||||||
"""
|
|
||||||
Formats the fields as "key=value key=value". Values are JSON encoded, so values from
|
|
||||||
the client cannot break the line or add fields.
|
|
||||||
"""
|
|
||||||
return " ".join(
|
|
||||||
f"{key}={json.dumps(value, ensure_ascii=False)}"
|
|
||||||
for key, value in fields.items()
|
|
||||||
)
|
|
||||||
+4
-2
@@ -50,7 +50,7 @@ class TranslationHandler:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def set_language(cls, language):
|
def set_language(cls, language):
|
||||||
if not cls.is_language_available(language):
|
if not cls.is_language_available(language):
|
||||||
cls.app.logger.info(f"Cannot find language {language}. Default to en.")
|
cls.app.logger.info("Unknown language, default to en", language=language)
|
||||||
language = "en"
|
language = "en"
|
||||||
|
|
||||||
g.language = language
|
g.language = language
|
||||||
@@ -68,7 +68,9 @@ class TranslationHandler:
|
|||||||
return string.format(**interpolation)
|
return string.format(**interpolation)
|
||||||
|
|
||||||
if string not in cls.translations[language]:
|
if string not in cls.translations[language]:
|
||||||
cls.app.logger.warning(f'Unknown translation to {language} for "{string}"')
|
cls.app.logger.warning(
|
||||||
|
"Unknown translation", language=language, string=string
|
||||||
|
)
|
||||||
return string.format(**interpolation)
|
return string.format(**interpolation)
|
||||||
|
|
||||||
return cls.translations[language][string].format(**interpolation)
|
return cls.translations[language][string].format(**interpolation)
|
||||||
|
|||||||
Reference in new issue
Block a user