Skip to content

Commit aa9483f

Browse files
configuration: add logging section & ad log_level to configuration_opts (#50)
* configuration: add logging section Allow to configure both the logging-levels and targets of various loggers by adding a new section `logging` to the configuration file. Each entry in that section configures one logger / channel. An example config is: ```yaml logging: - name root level: INFO - name: foomodule.barcollector: level: WARNING target: /path/to/my/collector/logfile.log ``` * configuration: add log_level to configuration_opts Add `CollectorBase.setLoggers(logger_names)` as a means for collectors derived from `CollectorBase` to easily configure logging-levels. The new method uses the `collector_opts.logging_level` value (if set in the configuration) to set the logging-level of any loggers given to it. --------- Co-authored-by: Christian Meißner <cme+github@meissner.sh>
1 parent 6b65812 commit aa9483f

File tree

6 files changed

+228
-0
lines changed

6 files changed

+228
-0
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ This project adheres to [Semantic Versioning](http://semver.org/) and [Keep a Ch
99

1010
### New
1111
* Fix \#47 - add environment variables for fundamental parameters
12+
* configuration: add logging section
13+
* configuration: add log_level to configuration_opts
1214

1315
### Changes
1416

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,29 @@ collectors:
109109
- netdev
110110
collector_opts:
111111
netdev:
112+
log_level: DEBUG
112113
whitelist:
113114
blacklist:
114115
- docker0
115116
- lo
117+
logging:
118+
- name: root
119+
level: INFO
120+
- name: foomodule.barcollector
121+
level: WARNING
122+
target: /path/to/my/collector/logfile.log
116123
```
117124
125+
The `collector_opts` can optionally contain a `log_level` entry which
126+
will configure the logging-level for that specific collector. Note that
127+
support for this must be implemented by each individual collector.
128+
129+
Logging can optionally be configured for any logger. The entries must
130+
specify the name of the logger and can optionally specify a
131+
logging-level (default: stay at whatever the default logging-level for
132+
that logger is) and/or can specify a file to write the log to (default:
133+
log to stderr).
134+
118135
### Start-up Configuration
119136

120137
You can define two fundamental Parameters on program start-up. The following table summarized you options:

p3.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@ collector_opts:
99
blacklist:
1010
- docker0
1111
- lo
12+
logging:
13+
- name: root
14+
level: INFO

p3exporter/__init__.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,35 @@
1313
from p3exporter.web import create_app
1414

1515

16+
def setup_logging(cfg: dict):
17+
"""Set up logging as configured.
18+
19+
The configuration may optionally contain an entry `logging`,
20+
if it does not or if that entry is not an array then does nothing.
21+
Each array element must be a dict that contains at least a key
22+
`name` that refers to the logger to configure. It may also contain
23+
the optional keys `level` and `target` that configure the
24+
logging-level and a file-target, respectively if present.
25+
26+
:param cfg: Configuration as read from config-file.
27+
:type cfg: dict
28+
"""
29+
if not isinstance(cfg.get('logging'), list):
30+
return
31+
for c in cfg['logging']:
32+
if not isinstance(c, dict):
33+
return
34+
if not isinstance(c.get('name'), str):
35+
return
36+
logger = logging.getLogger(c["name"])
37+
level = c.get('level')
38+
if level is not None:
39+
logger.setLevel(level)
40+
target = c.get('target')
41+
if target is not None:
42+
logger.addHandler(logging.FileHandler(target))
43+
44+
1645
def shutdown():
1746
"""Shutdown the app in a clean way."""
1847
logging.info('Shutting down, see you next time!')
@@ -42,6 +71,7 @@ def main():
4271
with open(args.config, 'r') as config_file:
4372
cfg = yaml.load(config_file, Loader=yaml.SafeLoader)
4473
collector_config = CollectorConfig(**cfg)
74+
setup_logging(cfg)
4575

4676
Collector(collector_config)
4777

p3exporter/collector/__init__.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,25 @@ def collector_name_from_class(self):
5858

5959
return '_'.join(class_name_parts)
6060

61+
def setLoggers(self, logger_names: list | str):
62+
"""Configure the provided logger(s) according to the CollectorConfig.
63+
64+
It is recommended to call this method from the constructor of any
65+
deriving class. Either bump the required p3exporter version or check
66+
dynamically if the method is supported.
67+
68+
:param logger_names: Name or names of loggers to configure.
69+
:type logger_names: list or str
70+
"""
71+
if not isinstance(logger_names, list):
72+
logger_names = [logger_names]
73+
if "log_level" not in self.opts:
74+
return
75+
level = self.opts["log_level"]
76+
for name in logger_names:
77+
logger = logging.getLogger(name)
78+
logger.setLevel(level)
79+
6180

6281
class Collector(object):
6382
"""Base class to load collectors.

tests/test_cases/logging_config.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
from p3exporter import setup_logging
2+
from p3exporter.collector import CollectorBase, CollectorConfig
3+
import logging
4+
import os.path
5+
import pytest
6+
7+
8+
loggers = ["", "foo", "bar"]
9+
files = ["file1.log", "file2.log"]
10+
11+
12+
def setup_function(fn):
13+
"""Start with a clean slate of default logging-levels and no handlers."""
14+
for name in loggers:
15+
logger = logging.getLogger(name)
16+
level = logging.WARNING if name == "" else logging.NOTSET
17+
logger.setLevel(level)
18+
for handler in logger.handlers:
19+
logger.removeHandler(handler)
20+
21+
22+
def teardown_function(fn):
23+
"""Remove any files we may have created."""
24+
for file in files:
25+
if os.path.exists(file):
26+
os.remove(file)
27+
28+
29+
data_logging_levels = [
30+
pytest.param(None,
31+
[logging.WARNING, logging.NOTSET, logging.NOTSET],
32+
[None, None, None],
33+
id="no logging-section at all"),
34+
pytest.param("Not an array",
35+
[logging.WARNING, logging.NOTSET, logging.NOTSET],
36+
[None, None, None],
37+
id="logging-section has wrong type"),
38+
pytest.param([{"level": "INFO"},
39+
{"target": "file1.log"},
40+
{"level": "DEBUG", "target": "file2.log"}],
41+
[logging.WARNING, logging.NOTSET, logging.NOTSET],
42+
[None, None, None],
43+
id="no names in otherwise valid entries"),
44+
pytest.param([{"name": "", "level": "INFO"},
45+
{"name": "foo", "level": "DEBUG"}],
46+
[logging.INFO, logging.DEBUG, logging.NOTSET],
47+
[None, None, None],
48+
id="levels only, using empty-string for root"),
49+
pytest.param([{"name": "root", "level": "ERROR"},
50+
{"name": "bar", "level": "CRITICAL"}],
51+
[logging.ERROR, logging.NOTSET, logging.CRITICAL],
52+
[None, None, None],
53+
id="levels only, using name of root"),
54+
pytest.param([{"name": "foo", "level": 10},
55+
{"name": "bar", "level": 20}],
56+
[logging.WARNING, logging.DEBUG, logging.INFO],
57+
[None, None, None],
58+
id="levels only, using integers for levels"),
59+
pytest.param([{"name": "root", "target": "file1.log"},
60+
{"name": "foo", "target": "file2.log"}],
61+
[logging.WARNING, logging.NOTSET, logging.NOTSET],
62+
["file1.log", "file2.log", None],
63+
id="targets only"),
64+
pytest.param([{"name": "foo", "level": "INFO", "target": "file1.log"}],
65+
[logging.WARNING, logging.INFO, logging.NOTSET],
66+
[None, "file1.log", None],
67+
id="both level and target"),
68+
]
69+
70+
71+
@pytest.mark.parametrize("cfg_logging,levels,targets", data_logging_levels)
72+
def test_logging_levels(cfg_logging, levels, targets):
73+
# pytest adds lots of extra handlers, so remember the starting state
74+
orig_handlers = []
75+
for name in loggers:
76+
logger = logging.getLogger(name)
77+
orig_handlers.append(logger.handlers.copy())
78+
79+
# GIVEN an input config-dictionary
80+
cfg = {
81+
"exporter_name": "Test only",
82+
"collectors": [],
83+
"collector_opts": {},
84+
}
85+
if cfg_logging is not None:
86+
cfg["logging"] = cfg_logging
87+
88+
# WHEN calling setup_logging()
89+
setup_logging(cfg)
90+
91+
# THEN the logging-levels should get changed to the expected
92+
for i, name in enumerate(loggers):
93+
logger = logging.getLogger(name)
94+
assert logger.level == levels[i]
95+
96+
# AND the expected file-handlers should get added
97+
for i, name in enumerate(loggers):
98+
logger = logging.getLogger(name)
99+
added_handlers = [h for h in logger.handlers
100+
if h not in orig_handlers[i]]
101+
if targets[i] is None:
102+
assert len(added_handlers) == 0
103+
else:
104+
assert len(added_handlers) == 1
105+
handler = added_handlers[0]
106+
assert isinstance(handler, logging.FileHandler)
107+
assert handler.baseFilename == os.path.abspath(targets[i])
108+
109+
110+
class FooCollector(CollectorBase):
111+
pass
112+
113+
114+
data_collectorbase_setloggers = [
115+
pytest.param(None,
116+
["foo", "bar"],
117+
[logging.WARNING, logging.NOTSET, logging.NOTSET],
118+
id="no log_level setting"),
119+
pytest.param("CRITICAL",
120+
"foo",
121+
[logging.WARNING, logging.CRITICAL, logging.NOTSET],
122+
id="single logger-name"),
123+
pytest.param("ERROR",
124+
["foo", "bar"],
125+
[logging.WARNING, logging.ERROR, logging.ERROR],
126+
id="list of loggers"),
127+
pytest.param(20,
128+
["", "foo"],
129+
[logging.INFO, logging.INFO, logging.NOTSET],
130+
id="numeric log_level"),
131+
]
132+
133+
134+
@pytest.mark.parametrize("cfg_log_level,logger_names,expected",
135+
data_collectorbase_setloggers)
136+
def test_collectorbase_setloggers(cfg_log_level, logger_names, expected):
137+
# GIVEN an input config-dictionary
138+
cfg = {
139+
"exporter_name": "Test only",
140+
"collectors": ["foo"],
141+
"collector_opts": {
142+
"foo": {}
143+
},
144+
}
145+
if cfg_log_level is not None:
146+
cfg["collector_opts"]["foo"]["log_level"] = cfg_log_level
147+
148+
# AND a collector-base using this config
149+
collector = FooCollector(CollectorConfig(**cfg))
150+
151+
# WHEN the setLoggers() method is called
152+
collector.setLoggers(logger_names)
153+
154+
# THEN the logging-levels should get changed to the expected
155+
for i, name in enumerate(loggers):
156+
logger = logging.getLogger(name)
157+
assert logger.level == expected[i]

0 commit comments

Comments
 (0)