Skip to content

Commit e66fa77

Browse files
feat: Add InfluxLoggingHandler to use the InfluxClient in python native logging (#405)
1 parent 62a0ba1 commit e66fa77

File tree

8 files changed

+308
-21
lines changed

8 files changed

+308
-21
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
### Features
44
1. [#412](https://github.com/influxdata/influxdb-client-python/pull/412): `DeleteApi` uses default value from `InfluxDBClient.org` if an `org` parameter is not specified
5+
2. [#405](https://github.com/influxdata/influxdb-client-python/pull/405): Add `InfluxLoggingHandler`. A handler to use the client in native python logging.
56

67
### CI
78
1. [#411](https://github.com/influxdata/influxdb-client-python/pull/411): Use new Codecov uploader for reporting code coverage
@@ -45,6 +46,7 @@ This release introduces a support for new version of InfluxDB OSS API definition
4546
1. [#408](https://github.com/influxdata/influxdb-client-python/pull/408): Improve error message when the client cannot find organization by name
4647
1. [#407](https://github.com/influxdata/influxdb-client-python/pull/407): Use `pandas.concat()` instead of deprecated `DataFrame.append()` [DataFrame]
4748

49+
4850
## 1.25.0 [2022-01-20]
4951

5052
### Features

examples/README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- [import_data_set_sync_batching.py](import_data_set_sync_batching.py) - How to use [RxPY](https://rxpy.readthedocs.io/en/latest/) to prepare batches for synchronous write into InfluxDB
1010
- [write_api_callbacks.py](write_api_callbacks.py) - How to handle batch events
1111
- [write_structured_data.py](write_structured_data.py) - How to write structured data - [NamedTuple](https://docs.python.org/3/library/collections.html#collections.namedtuple), [Data Classes](https://docs.python.org/3/library/dataclasses.html) - (_requires Python v3.8+_)
12+
- [logging_handler.py](logging_handler.py) - How to set up a python native logging handler that writes to InfluxDB
1213

1314
## Queries
1415
- [query.py](query.py) - How to query data into `FluxTable`s, `Stream` and `CSV`
@@ -28,4 +29,4 @@
2829
- [influxdb_18_example.py](influxdb_18_example.py) - How to connect to InfluxDB 1.8
2930
- [nanosecond_precision.py](nanosecond_precision.py) - How to use nanoseconds precision
3031
- [invocable_scripts.py](invocable_scripts.py) - How to use Invocable scripts Cloud API to create custom endpoints that query data
31-
32+

examples/logging_handler.py

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""
2+
Show the usage of influx with python native logging.
3+
4+
This is useful if you
5+
* want to log to influx and a local file.
6+
* want to set up influx logging in a project without specifying it in submodules
7+
"""
8+
import datetime
9+
import logging
10+
import time
11+
12+
from influxdb_client import InfluxLoggingHandler, WritePrecision, Point
13+
from influxdb_client.client.write_api import SYNCHRONOUS
14+
15+
DATA_LOGGER_NAME = '…'
16+
17+
18+
def setup_logger():
19+
"""
20+
Set up data logger with the influx logging handler.
21+
22+
This can happen in your core module.
23+
"""
24+
influx_logging_handler = InfluxLoggingHandler(
25+
url="http://localhost:8086", token="my-token", org="my-org", bucket="my-bucket",
26+
client_args={'timeout': 30_000}, # optional configuration of the client
27+
write_api_args={'write_options': SYNCHRONOUS}) # optional configuration of the write api
28+
influx_logging_handler.setLevel(logging.DEBUG)
29+
30+
data_logger = logging.getLogger(DATA_LOGGER_NAME)
31+
data_logger.setLevel(logging.DEBUG)
32+
data_logger.addHandler(influx_logging_handler)
33+
# feel free to add other handlers here.
34+
# if you find yourself writing filters e.g. to only log points to influx, think about adding a PR :)
35+
36+
37+
def use_logger():
38+
"""Use the logger. This can happen in any submodule."""
39+
# `data_logger` will have the influx_logging_handler attached if setup_logger was called somewhere.
40+
data_logger = logging.getLogger(DATA_LOGGER_NAME)
41+
# write a line yourself
42+
data_logger.debug(f"my-measurement,host=host1 temperature=25.3 {int(time.time() * 1e9)}")
43+
# or make use of the influxdb helpers like Point
44+
data_logger.debug(
45+
Point('my-measurement')
46+
.tag('host', 'host1')
47+
.field('temperature', 25.3)
48+
.time(datetime.datetime.utcnow(), WritePrecision.MS)
49+
)
50+
51+
52+
if __name__ == "__main__":
53+
setup_logger()
54+
use_logger()

influxdb_client/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,7 @@
383383
from influxdb_client.client.users_api import UsersApi
384384
from influxdb_client.client.write_api import WriteApi, WriteOptions
385385
from influxdb_client.client.influxdb_client import InfluxDBClient
386+
from influxdb_client.client.loggingHandler import InfluxLoggingHandler
386387
from influxdb_client.client.write.point import Point
387388

388389
from influxdb_client.version import CLIENT_VERSION
+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Use the influxdb_client with python native logging."""
2+
import logging
3+
4+
from influxdb_client import InfluxDBClient
5+
6+
7+
class InfluxLoggingHandler(logging.Handler):
8+
"""
9+
InfluxLoggingHandler instances dispatch logging events to influx.
10+
11+
There is no need to set a Formatter.
12+
The raw input will be passed on to the influx write api.
13+
"""
14+
15+
DEFAULT_LOG_RECORD_KEYS = list(logging.makeLogRecord({}).__dict__.keys()) + ['message']
16+
17+
def __init__(self, *, url, token, org, bucket, client_args=None, write_api_args=None):
18+
"""
19+
Initialize defaults.
20+
21+
The arguments `client_args` and `write_api_args` can be dicts of kwargs.
22+
They are passed on to the InfluxDBClient and write_api calls respectively.
23+
"""
24+
super().__init__()
25+
26+
self.bucket = bucket
27+
28+
client_args = {} if client_args is None else client_args
29+
self.client = InfluxDBClient(url=url, token=token, org=org, **client_args)
30+
31+
write_api_args = {} if write_api_args is None else write_api_args
32+
self.write_api = self.client.write_api(**write_api_args)
33+
34+
def __del__(self):
35+
"""Make sure all resources are closed."""
36+
self.close()
37+
38+
def close(self) -> None:
39+
"""Close the write_api, client and logger."""
40+
self.write_api.close()
41+
self.client.close()
42+
super().close()
43+
44+
def emit(self, record: logging.LogRecord) -> None:
45+
"""Emit a record via the influxDB WriteApi."""
46+
try:
47+
message = self.format(record)
48+
extra = self._get_extra_values(record)
49+
return self.write_api.write(record=message, **extra)
50+
except (KeyboardInterrupt, SystemExit):
51+
raise
52+
except (Exception,):
53+
self.handleError(record)
54+
55+
def _get_extra_values(self, record: logging.LogRecord) -> dict:
56+
"""
57+
Extract all items from the record that were injected via extra.
58+
59+
Example: `logging.debug(msg, extra={key: value, ...})`.
60+
"""
61+
extra = {'bucket': self.bucket}
62+
extra.update({key: value for key, value in record.__dict__.items()
63+
if key not in self.DEFAULT_LOG_RECORD_KEYS})
64+
return extra

influxdb_client/client/write/point.py

+9-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Point data structure to represent LineProtocol."""
22

3-
43
import math
54
from builtins import int
65
from datetime import datetime, timedelta
@@ -146,7 +145,6 @@ def __init__(self, measurement_name):
146145
self._name = measurement_name
147146
self._time = None
148147
self._write_precision = DEFAULT_WRITE_PRECISION
149-
pass
150148

151149
def time(self, time, write_precision=DEFAULT_WRITE_PRECISION):
152150
"""
@@ -195,6 +193,15 @@ def write_precision(self):
195193
"""Get precision."""
196194
return self._write_precision
197195

196+
@classmethod
197+
def set_str_rep(cls, rep_function):
198+
"""Set the string representation for all Points."""
199+
cls.__str___rep = rep_function
200+
201+
def __str__(self):
202+
"""Create string representation of this Point."""
203+
return self.to_line_protocol()
204+
198205

199206
def _append_tags(tags):
200207
_return = []

tests/test_loggingHandler.py

+149
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import logging
2+
import unittest
3+
import unittest.mock
4+
5+
import urllib3
6+
7+
from influxdb_client import InfluxLoggingHandler, InfluxDBClient, WriteApi, WritePrecision, Point
8+
9+
10+
class LoggingBaseTestCase(unittest.TestCase):
11+
fake_line_record = "tag,field=value 123456"
12+
URL_TOKEN_ORG = {
13+
'url': 'http://example.com',
14+
'token': 'my-token',
15+
'org': 'my-org',
16+
}
17+
BUCKET = 'my-bucket'
18+
19+
def setUp(self) -> None:
20+
self.mock_InfluxDBClient = unittest.mock.patch("influxdb_client.client.loggingHandler.InfluxDBClient").start()
21+
self.mock_client = unittest.mock.MagicMock(spec=InfluxDBClient)
22+
self.mock_write_api = unittest.mock.MagicMock(spec=WriteApi)
23+
self.mock_client.write_api.return_value = self.mock_write_api
24+
self.mock_InfluxDBClient.return_value = self.mock_client
25+
26+
def gen_handler_and_logger(self):
27+
self.handler = InfluxLoggingHandler(**self.URL_TOKEN_ORG, bucket=self.BUCKET)
28+
self.handler.setLevel(logging.DEBUG)
29+
30+
self.logger = logging.getLogger("test-logger")
31+
self.logger.setLevel(logging.DEBUG)
32+
33+
def tearDown(self) -> None:
34+
unittest.mock.patch.stopall()
35+
36+
37+
class TestHandlerCreation(LoggingBaseTestCase):
38+
def test_can_create_handler(self):
39+
self.handler = InfluxLoggingHandler(**self.URL_TOKEN_ORG, bucket=self.BUCKET)
40+
self.mock_InfluxDBClient.assert_called_once_with(**self.URL_TOKEN_ORG)
41+
self.assertEqual(self.BUCKET, self.handler.bucket)
42+
self.mock_client.write_api.assert_called_once_with()
43+
44+
def test_can_create_handler_with_optional_args_for_client(self):
45+
self.handler = InfluxLoggingHandler(**self.URL_TOKEN_ORG, bucket=self.BUCKET,
46+
client_args={'arg2': 2.90, 'optArg': 'whot'})
47+
self.mock_InfluxDBClient.assert_called_once_with(**self.URL_TOKEN_ORG, arg2=2.90, optArg="whot")
48+
self.mock_client.write_api.assert_called_once_with()
49+
50+
def test_can_create_handler_with_args_for_write_api(self):
51+
self.handler = InfluxLoggingHandler(**self.URL_TOKEN_ORG, bucket=self.BUCKET,
52+
client_args={'arg2': 2.90, 'optArg': 'whot'},
53+
write_api_args={'foo': 'bar'})
54+
self.mock_InfluxDBClient.assert_called_once_with(**self.URL_TOKEN_ORG, arg2=2.90, optArg="whot")
55+
self.mock_client.write_api.assert_called_once_with(foo='bar')
56+
57+
58+
class CreatedHandlerTestCase(LoggingBaseTestCase):
59+
def setUp(self) -> None:
60+
super().setUp()
61+
self.gen_handler_and_logger()
62+
63+
64+
class LoggingSetupAndTearDown(CreatedHandlerTestCase):
65+
def test_is_handler(self):
66+
self.assertIsInstance(self.handler, logging.Handler)
67+
68+
def test_set_up_client(self):
69+
self.mock_InfluxDBClient.assert_called_once()
70+
71+
def test_closes_connections_on_close(self):
72+
self.handler.close()
73+
self.mock_write_api.close.assert_called_once()
74+
self.mock_client.close.assert_called_once()
75+
76+
def test_handler_can_be_attached_to_logger(self):
77+
self.logger.addHandler(self.handler)
78+
self.assertTrue(self.logger.hasHandlers())
79+
self.assertTrue(self.handler in self.logger.handlers)
80+
81+
82+
class LoggingWithAttachedHandler(CreatedHandlerTestCase):
83+
84+
def setUp(self) -> None:
85+
super().setUp()
86+
self.logger.addHandler(self.handler)
87+
88+
89+
class LoggingHandlerTest(LoggingWithAttachedHandler):
90+
91+
def test_can_log_str(self):
92+
self.logger.debug(self.fake_line_record)
93+
self.mock_write_api.write.assert_called_once_with(bucket="my-bucket", record=self.fake_line_record)
94+
95+
def test_can_log_points(self):
96+
point = Point("measurement").field("field_name", "field_value").time(333, WritePrecision.NS)
97+
self.logger.debug(point)
98+
self.mock_write_api.write.assert_called_once_with(bucket="my-bucket", record=str(point))
99+
100+
def test_catches_urllib_exceptions(self):
101+
self.mock_write_api.write.side_effect = urllib3.exceptions.HTTPError()
102+
try:
103+
with unittest.mock.patch("logging.sys.stderr") as _:
104+
# Handler writes logging errors to stderr. Don't display it in the test output.
105+
self.logger.debug(self.fake_line_record)
106+
finally:
107+
self.mock_write_api.write.side_effect = None
108+
109+
def test_raises_on_exit(self):
110+
try:
111+
self.mock_write_api.write.side_effect = KeyboardInterrupt()
112+
self.assertRaises(KeyboardInterrupt, self.logger.debug, self.fake_line_record)
113+
self.mock_write_api.write.side_effect = SystemExit()
114+
self.assertRaises(SystemExit, self.logger.debug, self.fake_line_record)
115+
finally:
116+
self.mock_write_api.write.side_effect = None
117+
118+
def test_can_set_bucket(self):
119+
self.handler.bucket = "new-bucket"
120+
self.logger.debug(self.fake_line_record)
121+
self.mock_write_api.write.assert_called_once_with(bucket="new-bucket", record=self.fake_line_record)
122+
123+
def test_can_pass_bucket_on_log(self):
124+
self.logger.debug(self.fake_line_record, extra={'bucket': "other-bucket"})
125+
self.mock_write_api.write.assert_called_once_with(bucket="other-bucket", record=self.fake_line_record)
126+
127+
def test_can_pass_optional_params_on_log(self):
128+
self.logger.debug(self.fake_line_record, extra={'org': "other-org", 'write_precision': WritePrecision.S,
129+
"arg3": 3, "arg2": "two"})
130+
self.mock_write_api.write.assert_called_once_with(bucket="my-bucket", org='other-org',
131+
record=self.fake_line_record,
132+
write_precision=WritePrecision.S,
133+
arg3=3, arg2="two")
134+
135+
def test_formatter(self):
136+
class MyFormatter(logging.Formatter):
137+
def format(self, record: logging.LogRecord) -> str:
138+
time_ns = int(record.created * 1e9) * 0 + 123
139+
return f"{record.name},level={record.levelname} message=\"{record.msg}\" {time_ns}"
140+
141+
self.handler.setFormatter(MyFormatter())
142+
msg = "a debug message"
143+
self.logger.debug(msg)
144+
expected_record = f"test-logger,level=DEBUG message=\"{msg}\" 123"
145+
self.mock_write_api.write.assert_called_once_with(bucket="my-bucket", record=expected_record)
146+
147+
148+
if __name__ == "__main__":
149+
unittest.main()

0 commit comments

Comments
 (0)