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

Add support for 12Factor-style JSON logging #591

Merged
merged 3 commits into from
Jan 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 1 addition & 26 deletions lenskit/lenskit/logging/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,11 @@
Logging, progress, and resource records.
"""

import os
from typing import Any

import structlog

from ._proxy import get_logger
from .config import LoggingConfig, basic_logging
from .progress import Progress, item_progress, set_progress_impl
from .tasks import Task
from .tracing import trace

__all__ = [
"LoggingConfig",
Expand All @@ -22,24 +18,3 @@
"get_logger",
"trace",
]

_trace_debug = os.environ.get("LK_TRACE", "no").lower() == "debug"


def trace(logger: structlog.stdlib.BoundLogger, *args: Any, **kwargs: Any):
"""
Emit a trace-level message, if LensKit tracing is enabled. Trace-level
messages are more fine-grained than debug-level messages, and you usually
don't want them.

This function does not work on the lazy proxies returned by
:func:`get_logger` and similar — it only works on bound loggers.

Stability:
Caller
"""
meth = getattr(logger, "trace", None)
if meth is not None:
meth(*args, **kwargs)
elif _trace_debug:
logger.debug(*args, **kwargs)
39 changes: 34 additions & 5 deletions lenskit/lenskit/logging/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
import logging
import os
import re
import sys
import warnings
from pathlib import Path
from typing import Literal, TypeAlias

import structlog

Expand All @@ -24,6 +26,7 @@
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.MaybeTimeStamper(),
]
LogFormat: TypeAlias = Literal["json", "logfmt", "text"]

_active_config: LoggingConfig | None = None

Expand Down Expand Up @@ -64,8 +67,10 @@ class LoggingConfig: # pragma: nocover
"""

level: int = logging.INFO
stream_json: bool = False
file: Path | None = None
file_level: int | None = None
file_format: LogFormat = "json"

def __init__(self):
# initialize configuration from environment variables
Expand All @@ -85,6 +90,12 @@ def effective_level(self) -> int:
else:
return self.level

def set_term_json(self, flag: bool = True):
"""
Configure logging to stream JSON lines to the stderr (useful for web services).
"""
self.stream_json = flag

def set_verbose(self, verbose: bool | int = True):
"""
Enable verbose logging.
Expand All @@ -108,12 +119,15 @@ def set_verbose(self, verbose: bool | int = True):
else:
self.level = logging.INFO

def log_file(self, path: os.PathLike[str], level: int | None = None):
def set_log_file(
self, path: os.PathLike[str], level: int | None = None, format: LogFormat = "json"
):
"""
Configure a log file.
"""
self.file = Path(path)
self.file_level = level
self.file_format = format

def apply(self):
"""
Expand All @@ -123,8 +137,15 @@ def apply(self):

setup_console()
root = logging.getLogger()
term = ConsoleHandler()
term.setLevel(self.level)

if self.stream_json:
term = logging.StreamHandler(sys.stderr)
term.setLevel(self.level)
proc_fmt = structlog.processors.JSONRenderer()
else:
term = ConsoleHandler()
term.setLevel(self.level)
proc_fmt = structlog.dev.ConsoleRenderer(colors=term.supports_color)

eff_lvl = self.effective_level
structlog.configure(
Expand All @@ -136,7 +157,7 @@ def apply(self):
processors=[
remove_internal,
format_timestamp,
structlog.dev.ConsoleRenderer(colors=term.supports_color),
proc_fmt,
],
foreign_pre_chain=CORE_PROCESSORS,
)
Expand All @@ -147,11 +168,19 @@ def apply(self):
if self.file:
file_level = self.file_level if self.file_level is not None else self.level
file = logging.FileHandler(self.file, mode="w")

if self.file_format == "json":
proc_fmt = structlog.processors.JSONRenderer()
elif self.file_format == "logfmt":
proc_fmt = structlog.processors.LogfmtRenderer(key_order=["event", "timestamp"])
else:
proc_fmt = structlog.processors.KeyValueRenderer(key_order=["event", "timestamp"])

ffmt = structlog.stdlib.ProcessorFormatter(
processors=[
remove_internal,
structlog.processors.ExceptionPrettyPrinter(),
structlog.processors.JSONRenderer(),
proc_fmt,
],
foreign_pre_chain=CORE_PROCESSORS,
)
Expand Down
24 changes: 23 additions & 1 deletion lenskit/lenskit/logging/tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,32 @@
from __future__ import annotations

import logging
import os
from typing import Any

import structlog

_trace_debug = os.environ.get("LK_TRACE", "no").lower() == "debug"


def trace(logger: structlog.stdlib.BoundLogger, *args: Any, **kwargs: Any):
"""
Emit a trace-level message, if LensKit tracing is enabled. Trace-level
messages are more fine-grained than debug-level messages, and you usually
don't want them.

This function does not work on the lazy proxies returned by
:func:`get_logger` and similar — it only works on bound loggers.

Stability:
Caller
"""
meth = getattr(logger, "trace", None)
if meth is not None:
meth(*args, **kwargs)
elif _trace_debug:
logger.debug(*args, **kwargs)

Check warning on line 32 in lenskit/lenskit/logging/tracing.py

View check run for this annotation

Codecov / codecov/patch

lenskit/lenskit/logging/tracing.py#L32

Added line #L32 was not covered by tests


class TracingLogger(structlog.stdlib.BoundLogger):
"""
Expand All @@ -25,7 +47,7 @@
if args:
kw["positional_args"] = args
try:
args, kwargs = self._process_event("trace", event, kw)
args, kwargs = self._process_event("trace", event, kw) # type: ignore

Check warning on line 50 in lenskit/lenskit/logging/tracing.py

View check run for this annotation

Codecov / codecov/patch

lenskit/lenskit/logging/tracing.py#L50

Added line #L50 was not covered by tests
except structlog.DropEvent:
return None
self._logger.debug(*args, **kwargs)
Expand Down
Loading