Skip to content

Commit 181afce

Browse files
committed
feature: support logfmt log formatting
1 parent 5a4f5f7 commit 181afce

7 files changed

Lines changed: 107 additions & 20 deletions

File tree

datashare-python/datashare_python/config.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from enum import StrEnum
12
from pathlib import Path
23
from typing import Literal
34

@@ -78,13 +79,21 @@ async def to_client(self) -> TemporalClient:
7879
LogLevel = Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"]
7980

8081

82+
class LogFormat(StrEnum):
83+
JSON = "json"
84+
LOGFMT = "logfmt"
85+
DEFAULT = "default"
86+
87+
8188
class LoggingConfig(BaseModel):
82-
log_in_json: bool = False
89+
format: LogFormat = LogFormat.DEFAULT
8390
loggers: dict[str, LogLevel]
8491

8592

8693
_DEFAULT_LOGGERS = {datashare_python.__name__: "INFO"}
87-
_DEFAULT_LOGGING_CONFIG = LoggingConfig(log_in_json=True, loggers=_DEFAULT_LOGGERS)
94+
_DEFAULT_LOGGING_CONFIG = LoggingConfig(
95+
format=LogFormat.DEFAULT, loggers=_DEFAULT_LOGGERS
96+
)
8897

8998

9099
class WorkerConfig(ICIJSettings, BaseModel):

datashare-python/datashare_python/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from datashare_python.config import (
1515
DatashareClientConfig,
16+
LogFormat,
1617
LoggingConfig,
1718
TemporalClientConfig,
1819
WorkerConfig,
@@ -93,7 +94,7 @@ def event_loop(
9394
@pytest.fixture(scope="session")
9495
def test_worker_config() -> WorkerConfig:
9596
logging_config = LoggingConfig(
96-
log_in_json=False,
97+
format=LogFormat.DEFAULT,
9798
loggers={
9899
"datashare_python": "DEBUG",
99100
"icij_common": "DEBUG",

datashare-python/datashare_python/dependencies.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def set_loggers(
4040
worker_config: WorkerConfig, worker_id: str, loggers: dict[str, LogLevel]
4141
) -> None:
4242
setup_worker_loggers(
43-
loggers=loggers, worker_id=worker_id, in_json=worker_config.logging.log_in_json
43+
loggers=loggers, worker_id=worker_id, format=worker_config.logging.format
4444
)
4545
logger.info("worker loggers ready to log 💬")
4646

datashare-python/datashare_python/logging_.py

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,39 @@
11
import logging
2+
import numbers
23
import sys
34
from copy import copy
5+
from typing import Any
46

7+
import orjson
58
from icij_common.logging_utils import DATE_FMT, STREAM_HANDLER_FMT
6-
from pythonjsonlogger.core import RESERVED_ATTRS, BaseJsonFormatter
9+
from pythonjsonlogger.core import BaseJsonFormatter
710
from pythonjsonlogger.orjson import OrjsonFormatter
811
from temporalio import activity, workflow
912

10-
from .config import LogLevel
13+
from .config import LogFormat, LogLevel
1114
from .interceptors import get_trace_context
1215

16+
_BASE_ATTRS = [
17+
"asctime",
18+
"exc_info",
19+
"filename",
20+
"funcName",
21+
"levelname",
22+
"levelno",
23+
"lineno",
24+
"module",
25+
"msecs",
26+
"message",
27+
"msg",
28+
"name",
29+
"pathname",
30+
]
1331
_ACT_LOGGER_ATTRS = ["activity_type", "activity_id", "activity_run_id"]
1432
_WF_LOGGED_ATTRS = ["workflow_type", "workflow_id", "workflow_run_id"]
1533
_TRACE_CONTEXT_ATTRS = ["trace_id", "parent_id", "traceparent"]
34+
1635
_LOGGED_ATTRIBUTES = (
17-
copy(RESERVED_ATTRS)
36+
copy(_BASE_ATTRS)
1837
+ _WF_LOGGED_ATTRS
1938
+ _ACT_LOGGER_ATTRS
2039
+ _TRACE_CONTEXT_ATTRS
@@ -28,15 +47,15 @@
2847

2948

3049
def setup_worker_loggers(
31-
loggers: dict[str, LogLevel], *, worker_id: str | None, in_json: bool
50+
loggers: dict[str, LogLevel], *, worker_id: str | None, format: LogFormat
3251
) -> None:
3352
worker_filter = WorkerFilter(worker_id)
3453
for logger_name, level_str in loggers.items():
3554
level = getattr(logging, level_str)
3655
logger = logging.getLogger(logger_name)
3756
logger.setLevel(level)
3857
logger.handlers = []
39-
for handler in _get_worker_handlers(level, worker_filter, in_json=in_json):
58+
for handler in _get_worker_handlers(level, worker_filter, format=format):
4059
logger.addHandler(handler)
4160

4261

@@ -64,23 +83,50 @@ def filter(self, record: logging.LogRecord) -> bool:
6483

6584

6685
def _get_worker_handlers(
67-
level: int, worker_filter: WorkerFilter, *, in_json: bool
86+
level: int, worker_filter: WorkerFilter, *, format: LogFormat
6887
) -> list[logging.Handler]:
6988
stream_handler = logging.StreamHandler(sys.stderr)
70-
if in_json:
71-
fmt = _json_formatter(datefmt=DATE_FMT)
72-
else:
73-
if worker_filter.worker_id is not None:
74-
fmt = _STREAM_HANDLER_FMT_WITH_WORKER_ID
75-
else:
76-
fmt = STREAM_HANDLER_FMT
77-
fmt = logging.Formatter(fmt, DATE_FMT)
89+
match format:
90+
case LogFormat.JSON:
91+
fmt = _json_formatter(datefmt=DATE_FMT)
92+
case LogFormat.LOGFMT:
93+
fmt = LogFmtFormatter(datefmt=DATE_FMT)
94+
case LogFormat.DEFAULT:
95+
if worker_filter.worker_id is not None:
96+
fmt = _STREAM_HANDLER_FMT_WITH_WORKER_ID
97+
else:
98+
fmt = STREAM_HANDLER_FMT
99+
fmt = logging.Formatter(fmt, DATE_FMT)
100+
case _:
101+
raise NotImplementedError(f"invalid log format: {format}")
78102
stream_handler.setFormatter(fmt)
79103
stream_handler.setLevel(level)
80104
stream_handler.addFilter(worker_filter)
81105
return [stream_handler]
82106

83107

108+
class LogFmtFormatter(logging.Formatter):
109+
def format(self, record: logging.LogRecord) -> str:
110+
logged = dict()
111+
if record.exc_info and not record.exc_text:
112+
record.exc_text = self.formatException(record.exc_info)
113+
logged["exc_info"] = record.exc_text
114+
for k, v in record.__dict__.items():
115+
if k in _LOGGED_ATTRIBUTES and k != "exc_info":
116+
logged[k] = _encode_value(v)
117+
return " ".join(f"{k}={v}" for k, v in sorted(logged.items()))
118+
119+
120+
def _encode_value(value: Any) -> str:
121+
if value is None:
122+
return ""
123+
if isinstance(value, bool):
124+
return "true" if value else "false"
125+
if isinstance(value, numbers.Number):
126+
return str(value)
127+
return orjson.dumps(value).decode()
128+
129+
84130
def _json_formatter(datefmt: str) -> BaseJsonFormatter:
85131
fmt = OrjsonFormatter( # let's keep logging as fast as possible
86132
_LOGGED_ATTRIBUTES, datefmt=datefmt

datashare-python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "datashare-python"
3-
version = "0.7.3"
3+
version = "0.8.0"
44
description = "Manage Python tasks and local resources in Datashare"
55
authors = [
66
{ name = "Clément Doumouro", email = "cdoumouro@icij.org" },
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import logging
2+
import re
3+
import sys
4+
from logging import LogRecord
5+
6+
from datashare_python.logging_ import LogFmtFormatter
7+
8+
9+
def test_logfmt_formatter() -> None:
10+
# Given
11+
fmt = LogFmtFormatter()
12+
exc_info = sys.exc_info()
13+
record = LogRecord(
14+
name="some_logger",
15+
level=logging.DEBUG,
16+
pathname="/some/path",
17+
lineno=2,
18+
msg="some message with space in it",
19+
args=dict(),
20+
exc_info=exc_info,
21+
)
22+
# When
23+
logged = fmt.format(record)
24+
# Then
25+
expected_logged = (
26+
'exc_info=NoneType: None filename="path" funcName= '
27+
r'levelname="DEBUG" levelno=10 lineno=2 module="path" msecs=\d+.\d+'
28+
' msg="some message with space in it" name="some_logger" pathname="/some/path"'
29+
)
30+
expected_logged = re.compile(expected_logged)
31+
assert expected_logged.match(logged)

datashare-python/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)