apppy-logger 0.15.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,72 @@
1
+ import logging
2
+ import sys
3
+
4
+ from apppy.logger.filter import SuppressApiLoggingFilter
5
+ from apppy.logger.format import ExtraLoggingFormatter, NewLineTerminator
6
+ from apppy.logger.parser import LogRecordParser
7
+ from apppy.logger.storage import LoggingStorage
8
+
9
+
10
+ def bootstrap_global_logging(level, stdout=False):
11
+ """
12
+ Utility to apply custom logging configuration
13
+ to application loggers. It should be called very
14
+ early in the application boot process.
15
+ """
16
+ if isinstance(level, str):
17
+ level = logging._nameToLevel[level.upper()]
18
+
19
+ logging.root.setLevel(level)
20
+
21
+ if stdout:
22
+ # Configure the logging system to use stdout.
23
+ # This should be used for frameworks with minimal logging configuration (e.g. AWS Lambda)
24
+ # For mature application frameworks (e.g. uvicorn), do not use the stdout flag.
25
+ logging.basicConfig(level=level, handlers=[logging.StreamHandler(sys.stdout)], force=True)
26
+
27
+ logging.getLogger("httpcore").setLevel(logging.INFO)
28
+ logging.getLogger("httpcore.connection").setLevel(logging.INFO)
29
+ logging.getLogger("httpcore.http11").setLevel(logging.INFO)
30
+
31
+ # Setup global singletons
32
+ LogRecordParser.set_global()
33
+ LoggingStorage.set_global()
34
+ # Apply formatters to all loggers so that their
35
+ # output is uniform
36
+ ExtraLoggingFormatter.apply(logging.root)
37
+ ExtraLoggingFormatter.apply_all()
38
+ # Setup the filter to add Logging Storage state
39
+ # to each log record
40
+ LoggingStorage.apply_all()
41
+
42
+ if stdout:
43
+ # If we configured stdout (see comment above) then
44
+ # ensure logs go on separate lines
45
+ NewLineTerminator.apply_all()
46
+
47
+ # A /health endpoint is typically called a lot in deployed environments
48
+ # as part of load balancer checks. This bloats the application logs
49
+ # so this filter allows health checks to be skipped in logs.
50
+ SuppressApiLoggingFilter.apply(logging.getLogger("uvicorn.access"), "/health")
51
+
52
+
53
+ class WithLogger:
54
+ """
55
+ Class decorator that injects a _logger object using a very standard
56
+ naming convention. The convention is based on the class name so that
57
+ log lines can be easily associated with a given class. For example:
58
+
59
+ class MyService(WithLogger):
60
+ ...
61
+ def do_something(self):
62
+ # This logger will be named with the module and class names
63
+ # e.g. 'module.path.to.MyService'
64
+ self._logger.info("I am doing something")
65
+ ...
66
+ """
67
+
68
+ _logger: logging.Logger
69
+
70
+ def __init_subclass__(cls, **kwargs):
71
+ super().__init_subclass__(**kwargs)
72
+ cls._logger = logging.getLogger(f"{cls.__module__}.{cls.__qualname__}")
apppy/logger/filter.py ADDED
@@ -0,0 +1,19 @@
1
+ import logging
2
+
3
+
4
+ class SuppressApiLoggingFilter(logging.Filter):
5
+ """
6
+ Suppress HTTP logs to an API endpoint via its path.
7
+ """
8
+
9
+ def __init__(self, path: str):
10
+ super().__init__(f"SuppressApiLoggingFilter_{path}")
11
+ self._path = path
12
+
13
+ def filter(self, record: logging.LogRecord) -> bool:
14
+ return record.getMessage().find(self._path) == -1
15
+
16
+ @classmethod
17
+ def apply(cls, logger: logging.Logger, path: str):
18
+ filter = cls(path)
19
+ logger.addFilter(filter)
apppy/logger/format.py ADDED
@@ -0,0 +1,62 @@
1
+ import logging
2
+
3
+ from apppy.logger.parser import LogRecordParser
4
+
5
+ fmt_default = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
6
+ datefmt_default = "%Y-%m-%dT%H:%M:%SZ%z"
7
+
8
+
9
+ class ExtraLoggingFormatter(logging.Formatter):
10
+ def __init__(self, fmt=None, datefmt=None, style="%"):
11
+ super().__init__(fmt, datefmt, style)
12
+
13
+ def format(self, record):
14
+ message = super().format(record)
15
+
16
+ state_info = LogRecordParser.get_global().parse_state_info(record)
17
+ if state_info:
18
+ state_str = " | ".join(f"{key}={value}" for key, value in state_info.items())
19
+ message = f"{message} | {state_str}"
20
+
21
+ extra_info = LogRecordParser.get_global().parse_extra_info(record)
22
+ if extra_info:
23
+ extra_str = " | ".join(f"{key}={value}" for key, value in extra_info.items())
24
+ message = f"{message} | {extra_str}"
25
+
26
+ return message
27
+
28
+ @classmethod
29
+ def apply(cls, logger: logging.Logger, fmt: str = fmt_default, datefmt: str = datefmt_default):
30
+ # Check if the logger has any handlers and add a default if none exist
31
+ if not logger.hasHandlers():
32
+ handler = logging.StreamHandler()
33
+ logger.addHandler(handler)
34
+
35
+ # Apply formatter to all handlers
36
+ formatter = cls(fmt=fmt, datefmt=datefmt)
37
+ for h in logger.handlers:
38
+ h.setFormatter(formatter)
39
+
40
+ @classmethod
41
+ def apply_all(cls, fmt: str = fmt_default, datefmt: str = datefmt_default):
42
+ logger_dict = logging.Logger.manager.loggerDict
43
+ for logger_name in logger_dict:
44
+ logger = logger_dict.get(logger_name)
45
+ if isinstance(logger, logging.Logger):
46
+ ExtraLoggingFormatter.apply(logger, fmt=fmt, datefmt=datefmt)
47
+
48
+
49
+ class NewLineTerminator:
50
+ @classmethod
51
+ def apply(cls, logger: logging.Logger):
52
+ for h in logger.handlers:
53
+ if isinstance(h, logging.StreamHandler):
54
+ h.terminator = "\n"
55
+
56
+ @classmethod
57
+ def apply_all(cls):
58
+ logger_dict = logging.Logger.manager.loggerDict
59
+ for logger_name in logger_dict:
60
+ logger = logger_dict.get(logger_name)
61
+ if isinstance(logger, logging.Logger):
62
+ NewLineTerminator.apply(logger)
@@ -0,0 +1,127 @@
1
+ import logging
2
+ from logging import LogRecord
3
+
4
+ from apppy.logger.format import ExtraLoggingFormatter, datefmt_default, fmt_default
5
+ from apppy.logger.parser import LogRecordParser
6
+ from apppy.logger.storage import LoggingStorage
7
+
8
+ LogRecordParser.set_global()
9
+ LoggingStorage.set_global()
10
+
11
+
12
+ def test_format_no_extra():
13
+ formatter: ExtraLoggingFormatter = ExtraLoggingFormatter(
14
+ fmt=fmt_default, datefmt=datefmt_default
15
+ )
16
+
17
+ logger = logging.Logger("test_format_no_extra")
18
+ record: LogRecord = logger.makeRecord(
19
+ name="test_format_no_extra",
20
+ level=logging.INFO,
21
+ fn="test_format_no_extra",
22
+ lno=19,
23
+ msg="test_format_no_extra message",
24
+ args=None,
25
+ exc_info=None,
26
+ )
27
+ msg = formatter.format(record)
28
+ assert msg.endswith(
29
+ "| INFO | test_format_no_extra | test_format_no_extra message"
30
+ ), f"Unexpected formatted msg: {msg}"
31
+
32
+
33
+ def test_format_with_extra():
34
+ formatter: ExtraLoggingFormatter = ExtraLoggingFormatter(
35
+ fmt=fmt_default, datefmt=datefmt_default
36
+ )
37
+
38
+ logger = logging.Logger("test_format_with_extra")
39
+ record: LogRecord = logger.makeRecord(
40
+ name="test_format_with_extra",
41
+ level=logging.INFO,
42
+ fn="test_format_with_extra",
43
+ lno=19,
44
+ msg="test_format_with_extra message",
45
+ args=None,
46
+ exc_info=None,
47
+ extra={"extra": "value"},
48
+ )
49
+ msg = formatter.format(record)
50
+ assert msg.endswith(
51
+ "| INFO | test_format_with_extra | test_format_with_extra message | extra=value"
52
+ ), f"Unexpected formatted msg: {msg}"
53
+
54
+
55
+ def test_format_with_extra_multiple():
56
+ formatter: ExtraLoggingFormatter = ExtraLoggingFormatter(
57
+ fmt=fmt_default, datefmt=datefmt_default
58
+ )
59
+
60
+ logger = logging.Logger("test_format_with_extra_multiple")
61
+ record: LogRecord = logger.makeRecord(
62
+ name="test_format_with_extra_multiple",
63
+ level=logging.INFO,
64
+ fn="test_format_with_extra_multiple",
65
+ lno=19,
66
+ msg="test_format_with_extra_multiple message",
67
+ args=None,
68
+ exc_info=None,
69
+ extra={"extra1": "value1", "extra2": "value2"},
70
+ )
71
+ msg = formatter.format(record)
72
+ assert msg.endswith(
73
+ "| INFO | test_format_with_extra_multiple | test_format_with_extra_multiple message | extra1=value1 | extra2=value2" # noqa: E501
74
+ ), f"Unexpected formatted msg: {msg}"
75
+
76
+
77
+ def test_format_with_storage_state():
78
+ formatter: ExtraLoggingFormatter = ExtraLoggingFormatter(
79
+ fmt=fmt_default, datefmt=datefmt_default
80
+ )
81
+
82
+ logger = logging.Logger("test_format_with_storage_state")
83
+ record: LogRecord = logger.makeRecord(
84
+ name="test_format_with_storage_state",
85
+ level=logging.INFO,
86
+ fn="test_format_with_storage_state",
87
+ lno=19,
88
+ msg="test_format_with_storage_state message",
89
+ args=None,
90
+ exc_info=None,
91
+ )
92
+
93
+ LoggingStorage.get_global().add_request_id("test_format_with_storage_state_request")
94
+ LoggingStorage.get_global().filter(record)
95
+ msg = formatter.format(record)
96
+ LoggingStorage.get_global().reset()
97
+
98
+ assert msg.endswith(
99
+ "| INFO | test_format_with_storage_state | test_format_with_storage_state message | request_id=test_format_with_storage_state_request" # noqa: E501
100
+ ), f"Unexpected formatted msg: {msg}"
101
+
102
+
103
+ def test_format_with_storage_state_and_extra():
104
+ formatter: ExtraLoggingFormatter = ExtraLoggingFormatter(
105
+ fmt=fmt_default, datefmt=datefmt_default
106
+ )
107
+
108
+ logger = logging.Logger("test_format_with_storage_state_and_extra")
109
+ record: LogRecord = logger.makeRecord(
110
+ name="test_format_with_storage_state_and_extra",
111
+ level=logging.INFO,
112
+ fn="test_format_with_storage_state_and_extra",
113
+ lno=19,
114
+ msg="test_format_with_storage_state_and_extra message",
115
+ args=None,
116
+ exc_info=None,
117
+ extra={"extra": "value"},
118
+ )
119
+
120
+ LoggingStorage.get_global().add_request_id("test_format_with_storage_state_and_extra_request")
121
+ LoggingStorage.get_global().filter(record)
122
+ msg = formatter.format(record)
123
+ LoggingStorage.get_global().reset()
124
+
125
+ assert msg.endswith(
126
+ "| INFO | test_format_with_storage_state_and_extra | test_format_with_storage_state_and_extra message | request_id=test_format_with_storage_state_and_extra_request | extra=value" # noqa: E501
127
+ ), f"Unexpected formatted msg: {msg}"
apppy/logger/parser.py ADDED
@@ -0,0 +1,54 @@
1
+ import logging
2
+ from typing import Any, Optional, cast
3
+
4
+
5
+ class LogRecordParser:
6
+ _global_instance: Optional["LogRecordParser"] = None
7
+
8
+ def __init__(self):
9
+ # Standard LogRecord fields that should not be considered extra
10
+ self._standard_attrs = list(
11
+ logging.LogRecord("", 0, "", 0, "", (), None, "").__dict__.keys()
12
+ )
13
+ self._standard_attrs.append("asctime")
14
+ self._standard_attrs.append("message")
15
+ # The state field is add via LoggingStorageLoggingFilter
16
+ # and we handle that separately
17
+ self._standard_attrs.append("state")
18
+
19
+ def parse_extra_info(self, log_record: logging.LogRecord) -> dict[str, Any]:
20
+ """
21
+ Parse extra data from the given log record that is not considered
22
+ standard. That is, which is added at logging time. For example, for
23
+ the log line:
24
+
25
+ self._logger.info("My log message", extra={'user': user_id})
26
+
27
+ This method will return {'user': user_id}
28
+ """
29
+ extra_info = {
30
+ key: value
31
+ for key, value in log_record.__dict__.items()
32
+ if key not in self._standard_attrs
33
+ }
34
+ return extra_info
35
+
36
+ def parse_state_info(self, log_record: logging.LogRecord) -> dict[str, Any]:
37
+ """
38
+ Parse state data from the given log record that is added via LoggingStorage.
39
+ """
40
+ if hasattr(log_record, "state") and isinstance(log_record.state, dict):
41
+ return cast(dict[str, Any], log_record.state)
42
+
43
+ return {}
44
+
45
+ @classmethod
46
+ def get_global(cls) -> "LogRecordParser":
47
+ if cls._global_instance is None:
48
+ raise RuntimeError("LogRecordParser has not been initialized.")
49
+ return cls._global_instance
50
+
51
+ @classmethod
52
+ def set_global(cls) -> None:
53
+ if cls._global_instance is None:
54
+ cls._global_instance = LogRecordParser()
@@ -0,0 +1,65 @@
1
+ import contextvars
2
+ import logging
3
+ from typing import Any, Literal, Optional
4
+
5
+ LoggingStorageKey = Literal["request_id"]
6
+
7
+ # Define a context variable per key
8
+ _global_logging_vars: dict[str, contextvars.ContextVar[Any | None]] = {
9
+ "request_id": contextvars.ContextVar("request_id", default=None),
10
+ }
11
+
12
+
13
+ class LoggingStorage(logging.Filter):
14
+ _global_instance: Optional["LoggingStorage"] = None
15
+
16
+ def add_request_id(self, request_id: str) -> str:
17
+ _global_logging_vars["request_id"].set(request_id)
18
+ return request_id
19
+
20
+ def filter(self, record: logging.LogRecord) -> bool:
21
+ stored_state = self.read_all()
22
+ if hasattr(record, "state") and isinstance(record.state, dict):
23
+ record.state.update(stored_state) # type: ignore[invalid-assignment]
24
+ else:
25
+ record.state = stored_state
26
+
27
+ return True
28
+
29
+ def read(self, key: LoggingStorageKey) -> Any | None:
30
+ return _global_logging_vars[key].get()
31
+
32
+ def read_all(self) -> dict[str, Any]:
33
+ return {
34
+ key: value
35
+ for key in _global_logging_vars
36
+ if (value := _global_logging_vars[key].get()) is not None
37
+ }
38
+
39
+ def reset(self):
40
+ # contextvars can't be "deleted", so just reset to None
41
+ for key in _global_logging_vars:
42
+ _global_logging_vars[key].set(None)
43
+
44
+ @classmethod
45
+ def get_global(cls) -> "LoggingStorage":
46
+ if cls._global_instance is None:
47
+ raise RuntimeError("LoggingStorage has not been initialized.")
48
+ return cls._global_instance
49
+
50
+ @classmethod
51
+ def set_global(cls) -> None:
52
+ if cls._global_instance is None:
53
+ cls._global_instance = LoggingStorage()
54
+
55
+ @staticmethod
56
+ def apply(logger: logging.Logger):
57
+ logger.addFilter(LoggingStorage.get_global())
58
+
59
+ @staticmethod
60
+ def apply_all():
61
+ logger_dict = logging.Logger.manager.loggerDict
62
+ for logger_name in logger_dict:
63
+ logger = logger_dict.get(logger_name)
64
+ if isinstance(logger, logging.Logger):
65
+ LoggingStorage.apply(logger)
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: apppy-logger
3
+ Version: 0.15.1
4
+ Summary: Python logger extensions for server development
5
+ Project-URL: Homepage, https://github.com/spals/apppy
6
+ Author: Tim Kral
7
+ License: MIT
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Requires-Python: >=3.11
@@ -0,0 +1,9 @@
1
+ apppy/logger/__init__.py,sha256=x7m4v_p5bK0etKD8QcLEdiAVZk2z-Vr_ZdW2B2uA1Ls,2658
2
+ apppy/logger/filter.py,sha256=2FTt_ADwc2phniY541_xKX1zR6K8BY3kCFRdJ5FEoBI,509
3
+ apppy/logger/format.py,sha256=JTOJBDzWQzA4axacseibjDAGA0wQspd7byvK-kHH-34,2257
4
+ apppy/logger/format_unit_test.py,sha256=51Jhr8mzaFxvEC0tCdkUP5V-noefDI4fR0jH8FQSnBA,4411
5
+ apppy/logger/parser.py,sha256=QmGm7eaH3lORot8PbAB5Bee_tyHvgC8i80JL_3P4VRc,1898
6
+ apppy/logger/storage.py,sha256=HJ3oYzknBAq7GR7tCrkD18lrP3IEprLMkXJIGIbMirI,2103
7
+ apppy_logger-0.15.1.dist-info/METADATA,sha256=h-5tJ0DAhxIOfmYHXrPEIPbHLVK--AIGllgAqcQKhMg,321
8
+ apppy_logger-0.15.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
9
+ apppy_logger-0.15.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any