Skip to content

Commit ec94e3e

Browse files
Implement and standardize logging configuration via config file (frequenz-floss#1095)
* Add LoggerConfig and LoggingConfig to standardize logging configuration. * Create LoggingConfigUpdater to handle runtime config updates. * Support individual log level settings for each module.
2 parents 1986665 + ce73140 commit ec94e3e

File tree

5 files changed

+356
-1
lines changed

5 files changed

+356
-1
lines changed

RELEASE_NOTES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
## New Features
2121

2222
- The `ConfigManagingActor` can now take multiple configuration files as input, allowing to override default configurations with custom configurations.
23+
- Implement and standardize logging configuration with the following changes:
24+
* Add LoggerConfig and LoggingConfig to standardize logging configuration.
25+
* Create LoggingConfigUpdater to handle runtime config updates.
26+
* Support individual log level settings for each module.
2327

2428
## Bug Fixes
2529

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ dependencies = [
3535
"networkx >= 2.8, < 4",
3636
"numpy >= 1.26.4, < 2",
3737
"typing_extensions >= 4.6.1, < 5",
38+
"marshmallow >= 3.19.0, < 4",
39+
"marshmallow_dataclass >= 8.7.1, < 9",
3840
]
3941
dynamic = ["version"]
4042

src/frequenz/sdk/config/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,11 @@
44
"""Read and update config variables."""
55

66
from ._config_managing import ConfigManagingActor
7+
from ._logging_config_updater import LoggerConfig, LoggingConfig, LoggingConfigUpdater
78

8-
__all__ = ["ConfigManagingActor"]
9+
__all__ = [
10+
"ConfigManagingActor",
11+
"LoggingConfig",
12+
"LoggerConfig",
13+
"LoggingConfigUpdater",
14+
]
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Read and update logging severity from config."""
5+
6+
import logging
7+
from collections.abc import Mapping
8+
from dataclasses import field
9+
from typing import Annotated, Any, Self, cast
10+
11+
import marshmallow
12+
import marshmallow.validate
13+
from frequenz.channels import Receiver
14+
from marshmallow import RAISE
15+
from marshmallow_dataclass import class_schema, dataclass
16+
17+
from frequenz.sdk.actor import Actor
18+
19+
_logger = logging.getLogger(__name__)
20+
21+
LogLevel = Annotated[
22+
str,
23+
marshmallow.fields.String(
24+
validate=marshmallow.validate.OneOf(choices=logging.getLevelNamesMapping())
25+
),
26+
]
27+
28+
29+
@dataclass
30+
class LoggerConfig:
31+
"""A configuration for a logger."""
32+
33+
level: LogLevel = field(
34+
default="NOTSET",
35+
metadata={
36+
"metadata": {
37+
"description": "Log level for the logger. Uses standard logging levels."
38+
},
39+
"required": False,
40+
},
41+
)
42+
"""The log level for the logger."""
43+
44+
45+
@dataclass
46+
class LoggingConfig:
47+
"""A configuration for the logging system."""
48+
49+
root_logger: LoggerConfig = field(
50+
default_factory=LoggerConfig,
51+
metadata={
52+
"metadata": {
53+
"description": "Default default configuration for all loggers.",
54+
},
55+
"required": False,
56+
},
57+
)
58+
"""The default log level."""
59+
60+
loggers: dict[str, LoggerConfig] = field(
61+
default_factory=dict,
62+
metadata={
63+
"metadata": {
64+
"description": "Configuration for a logger (the key is the logger name)."
65+
},
66+
"required": False,
67+
},
68+
)
69+
"""The list of loggers configurations."""
70+
71+
@classmethod
72+
def load(cls, configs: Mapping[str, Any]) -> Self: # noqa: DOC502
73+
"""Load and validate configs from a dictionary.
74+
75+
Args:
76+
configs: The configuration to validate.
77+
78+
Returns:
79+
The configuration if they are valid.
80+
81+
Raises:
82+
ValidationError: if the configuration are invalid.
83+
"""
84+
schema = class_schema(cls)()
85+
return cast(Self, schema.load(configs, unknown=RAISE))
86+
87+
88+
class LoggingConfigUpdater(Actor):
89+
"""Actor that listens for logging configuration changes and sets them.
90+
91+
Example:
92+
`config.toml` file:
93+
```toml
94+
[logging.root_logger]
95+
level = "INFO"
96+
97+
[logging.loggers."frequenz.sdk.actor.power_distributing"]
98+
level = "DEBUG"
99+
100+
[logging.loggers."frequenz.channels"]
101+
level = "DEBUG"
102+
```
103+
104+
```python
105+
import asyncio
106+
from collections.abc import Mapping
107+
from typing import Any
108+
109+
from frequenz.channels import Broadcast
110+
from frequenz.sdk.config import LoggingConfigUpdater, ConfigManager
111+
from frequenz.sdk.actor import run as run_actors
112+
113+
async def run() -> None:
114+
config_channel = Broadcast[Mapping[str, Any]](name="config", resend_latest=True)
115+
actors = [
116+
ConfigManager(config_paths=["config.toml"], output=config_channel.new_sender()),
117+
LoggingConfigUpdater(
118+
config_recv=config_channel.new_receiver(limit=1)).map(
119+
lambda app_config: app_config.get("logging", {}
120+
)
121+
),
122+
]
123+
await run_actors(*actors)
124+
125+
asyncio.run(run())
126+
```
127+
128+
Now whenever the `config.toml` file is updated, the logging configuration
129+
will be updated as well.
130+
"""
131+
132+
def __init__(
133+
self,
134+
config_recv: Receiver[Mapping[str, Any]],
135+
log_format: str = "%(asctime)s %(levelname)-8s %(name)s:%(lineno)s: %(message)s",
136+
log_datefmt: str = "%Y-%m-%dT%H:%M:%S%z",
137+
):
138+
"""Initialize this instance.
139+
140+
Args:
141+
config_recv: The receiver to listen for configuration changes.
142+
log_format: Use the specified format string in logs.
143+
log_datefmt: Use the specified date/time format in logs.
144+
"""
145+
super().__init__()
146+
self._config_recv = config_recv
147+
self._format = log_format
148+
self._datefmt = log_datefmt
149+
150+
# Setup default configuration.
151+
# This ensures logging is configured even if actor fails to start or
152+
# if the configuration cannot be loaded.
153+
self._current_config: LoggingConfig = LoggingConfig()
154+
self._update_logging(self._current_config)
155+
156+
async def _run(self) -> None:
157+
"""Listen for configuration changes and update logging."""
158+
async for message in self._config_recv:
159+
try:
160+
new_config = LoggingConfig.load(message)
161+
except marshmallow.ValidationError:
162+
_logger.exception(
163+
"Invalid logging configuration received. Skipping config update"
164+
)
165+
continue
166+
167+
if new_config != self._current_config:
168+
self._update_logging(new_config)
169+
170+
def _update_logging(self, config: LoggingConfig) -> None:
171+
"""Configure the logging level."""
172+
# If the logger is not in the new config, set it to NOTSET
173+
loggers_to_unset = self._current_config.loggers.keys() - config.loggers.keys()
174+
for logger_id in loggers_to_unset:
175+
_logger.debug("Unsetting log level for logger '%s'", logger_id)
176+
logging.getLogger(logger_id).setLevel(logging.NOTSET)
177+
178+
self._current_config = config
179+
logging.basicConfig(
180+
format=self._format,
181+
level=self._current_config.root_logger.level,
182+
datefmt=self._datefmt,
183+
)
184+
185+
# For each logger in the new config, set the log level
186+
for logger_id, logger_config in self._current_config.loggers.items():
187+
_logger.debug(
188+
"Setting log level for logger '%s' to '%s'",
189+
logger_id,
190+
logger_config.level,
191+
)
192+
logging.getLogger(logger_id).setLevel(logger_config.level)
193+
194+
_logger.info("Logging config changed to: %s", self._current_config)
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Tests for logging config updater."""
5+
6+
import asyncio
7+
import logging
8+
from collections.abc import Mapping
9+
from typing import Any
10+
11+
import pytest
12+
from frequenz.channels import Broadcast
13+
from marshmallow import ValidationError
14+
from pytest_mock import MockerFixture
15+
16+
from frequenz.sdk.config import LoggerConfig, LoggingConfig, LoggingConfigUpdater
17+
18+
19+
def test_logging_config() -> None:
20+
"""Test if logging config is correctly loaded."""
21+
config_raw = {
22+
"root_logger": {"level": "DEBUG"},
23+
"loggers": {
24+
"frequenz.sdk.actor": {"level": "INFO"},
25+
"frequenz.sdk.timeseries": {"level": "ERROR"},
26+
},
27+
}
28+
config = LoggingConfig(
29+
root_logger=LoggerConfig(level="DEBUG"),
30+
loggers={
31+
"frequenz.sdk.actor": LoggerConfig(level="INFO"),
32+
"frequenz.sdk.timeseries": LoggerConfig(level="ERROR"),
33+
},
34+
)
35+
36+
assert LoggingConfig.load(config_raw) == config
37+
38+
config_raw = {}
39+
config = LoggingConfig()
40+
assert LoggingConfig.load(config_raw) == config
41+
42+
config_raw = {"root_logger": {"level": "UNKNOWN"}}
43+
with pytest.raises(ValidationError):
44+
LoggingConfig.load(config_raw)
45+
46+
config_raw = {"unknown": {"frequenz.sdk.actor": {"level": "DEBUG"}}}
47+
with pytest.raises(ValidationError):
48+
LoggingConfig.load(config_raw)
49+
50+
51+
@pytest.fixture
52+
def cleanup_logs() -> Any:
53+
"""Reset logging to default.
54+
55+
Python doesn't cleanup logging configuration after tests, so we need to do it manually.
56+
"""
57+
yield
58+
59+
logging.getLogger("frequenz.sdk.actor").setLevel(logging.NOTSET)
60+
logging.getLogger("frequenz.sdk.timeseries").setLevel(logging.NOTSET)
61+
62+
63+
async def test_logging_config_updater_actor(
64+
mocker: MockerFixture,
65+
cleanup_logs: Any,
66+
) -> None:
67+
"""Test if logging is configured and updated correctly."""
68+
# Mock method that sets root level logging.
69+
# Python doesn't cleanup logging configuration after tests.
70+
# Overriding logging.basicConfig would mess up other tests, so we mock it.
71+
# This is just for extra safety because changing root logging level in unit tests
72+
# is not working anyway - python ignores it.
73+
mocker.patch("frequenz.sdk.config._logging_config_updater.logging.basicConfig")
74+
75+
config_channel = Broadcast[Mapping[str, Any]](name="config")
76+
config_sender = config_channel.new_sender()
77+
async with LoggingConfigUpdater(
78+
config_recv=config_channel.new_receiver().map(
79+
lambda app_config: app_config.get("logging", {})
80+
)
81+
) as actor:
82+
assert logging.getLogger("frequenz.sdk.actor").level == logging.NOTSET
83+
assert logging.getLogger("frequenz.sdk.timeseries").level == logging.NOTSET
84+
85+
update_logging_spy = mocker.spy(actor, "_update_logging")
86+
87+
# Send first config
88+
await config_sender.send(
89+
{
90+
"logging": {
91+
"root_logger": {"level": "ERROR"},
92+
"loggers": {
93+
"frequenz.sdk.actor": {"level": "DEBUG"},
94+
"frequenz.sdk.timeseries": {"level": "ERROR"},
95+
},
96+
}
97+
}
98+
)
99+
await asyncio.sleep(0.01)
100+
update_logging_spy.assert_called_once_with(
101+
LoggingConfig(
102+
root_logger=LoggerConfig(level="ERROR"),
103+
loggers={
104+
"frequenz.sdk.actor": LoggerConfig(level="DEBUG"),
105+
"frequenz.sdk.timeseries": LoggerConfig(level="ERROR"),
106+
},
107+
)
108+
)
109+
assert logging.getLogger("frequenz.sdk.actor").level == logging.DEBUG
110+
assert logging.getLogger("frequenz.sdk.timeseries").level == logging.ERROR
111+
update_logging_spy.reset_mock()
112+
113+
# Update config
114+
await config_sender.send(
115+
{
116+
"logging": {
117+
"root_logger": {"level": "WARNING"},
118+
"loggers": {
119+
"frequenz.sdk.actor": {"level": "INFO"},
120+
},
121+
}
122+
}
123+
)
124+
await asyncio.sleep(0.01)
125+
expected_config = LoggingConfig(
126+
root_logger=LoggerConfig(level="WARNING"),
127+
loggers={
128+
"frequenz.sdk.actor": LoggerConfig(level="INFO"),
129+
},
130+
)
131+
update_logging_spy.assert_called_once_with(expected_config)
132+
assert logging.getLogger("frequenz.sdk.actor").level == logging.INFO
133+
assert logging.getLogger("frequenz.sdk.timeseries").level == logging.NOTSET
134+
update_logging_spy.reset_mock()
135+
136+
# Send invalid config to make sure actor doesn't crash and doesn't setup invalid config.
137+
await config_sender.send({"logging": {"root_logger": {"level": "UNKNOWN"}}})
138+
await asyncio.sleep(0.01)
139+
update_logging_spy.assert_not_called()
140+
assert actor._current_config == expected_config
141+
update_logging_spy.reset_mock()
142+
143+
# Send empty config to reset logging to default
144+
await config_sender.send({"other": {"var1": 1}})
145+
await asyncio.sleep(0.01)
146+
update_logging_spy.assert_called_once_with(LoggingConfig())
147+
assert logging.getLogger("frequenz.sdk.actor").level == logging.NOTSET
148+
assert logging.getLogger("frequenz.sdk.timeseries").level == logging.NOTSET
149+
update_logging_spy.reset_mock()

0 commit comments

Comments
 (0)