Skip to content

Commit 51e2c2f

Browse files
authored
LogtailHandler: implement logging.Handler.flush (#26)
1 parent 95ab74b commit 51e2c2f

File tree

5 files changed

+53
-25
lines changed

5 files changed

+53
-25
lines changed

logtail/flusher.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,17 @@
1010

1111

1212
class FlushWorker(threading.Thread):
13-
def __init__(self, upload, pipe, buffer_capacity, flush_interval):
13+
def __init__(self, upload, pipe, buffer_capacity, flush_interval, check_interval):
1414
threading.Thread.__init__(self)
1515
self.parent_thread = threading.current_thread()
1616
self.upload = upload
1717
self.pipe = pipe
1818
self.buffer_capacity = buffer_capacity
1919
self.flush_interval = flush_interval
20+
self.check_interval = check_interval
2021
self.should_run = True
22+
self._flushing = False
23+
self._clean = True
2124

2225
def run(self):
2326
while self.should_run:
@@ -27,6 +30,7 @@ def step(self):
2730
last_flush = time.time()
2831
time_remaining = _initial_time_remaining(self.flush_interval)
2932
frame = []
33+
self._clean = True
3034

3135
# If the parent thread has exited but there are still outstanding
3236
# events, attempt to send them before exiting.
@@ -38,16 +42,17 @@ def step(self):
3842
# `flush_interval` seconds have passed without sending any events.
3943
while len(frame) < self.buffer_capacity and time_remaining > 0:
4044
try:
41-
# Blocks for up to 1.0 seconds for each item to prevent
45+
# Blocks for up to `check_interval` seconds for each item to prevent
4246
# spinning and burning CPU unnecessarily. Could block for the
4347
# entire amount of `time_remaining` but then in the case that
4448
# the parent thread has exited, that entire amount of time
4549
# would be waited before this child worker thread exits.
46-
entry = self.pipe.get(block=(not shutdown), timeout=1.0)
50+
entry = self.pipe.get(block=(not shutdown), timeout=self.check_interval)
51+
self._clean = False
4752
frame.append(entry)
4853
self.pipe.task_done()
4954
except queue.Empty:
50-
if shutdown:
55+
if shutdown or self._flushing:
5156
break
5257
shutdown = not self.parent_thread.is_alive()
5358
time_remaining = _calculate_time_remaining(last_flush, self.flush_interval)
@@ -68,9 +73,15 @@ def step(self):
6873
if response.status_code == 500 and getattr(response, "exception") != None:
6974
print('Failed to send logs to Better Stack after {} retries: {}'.format(len(RETRY_SCHEDULE), response.exception))
7075

76+
self._clean = True
7177
if shutdown and self.pipe.empty():
7278
self.should_run = False
7379

80+
def flush(self):
81+
self._flushing = True
82+
while not self._clean or not self.pipe.empty():
83+
time.sleep(self.check_interval)
84+
self._flushing = False
7485

7586
def _initial_time_remaining(flush_interval):
7687
return flush_interval

logtail/handler.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
DEFAULT_HOST = 'https://in.logs.betterstack.com'
1313
DEFAULT_BUFFER_CAPACITY = 1000
1414
DEFAULT_FLUSH_INTERVAL = 1
15+
DEFAULT_CHECK_INTERVAL = 0.1
1516
DEFAULT_RAISE_EXCEPTIONS = False
1617
DEFAULT_DROP_EXTRA_EVENTS = True
1718
DEFAULT_INCLUDE_EXTRA_ATTRIBUTES = True
@@ -23,6 +24,7 @@ def __init__(self,
2324
host=DEFAULT_HOST,
2425
buffer_capacity=DEFAULT_BUFFER_CAPACITY,
2526
flush_interval=DEFAULT_FLUSH_INTERVAL,
27+
check_interval=DEFAULT_CHECK_INTERVAL,
2628
raise_exceptions=DEFAULT_RAISE_EXCEPTIONS,
2729
drop_extra_events=DEFAULT_DROP_EXTRA_EVENTS,
2830
include_extra_attributes=DEFAULT_INCLUDE_EXTRA_ATTRIBUTES,
@@ -38,6 +40,7 @@ def __init__(self,
3840
self.include_extra_attributes = include_extra_attributes
3941
self.buffer_capacity = buffer_capacity
4042
self.flush_interval = flush_interval
43+
self.check_interval = check_interval
4144
self.raise_exceptions = raise_exceptions
4245
self.dropcount = 0
4346
# Do not initialize the flush thread yet because it causes issues on Render.
@@ -51,7 +54,8 @@ def ensure_flush_thread_alive(self):
5154
self.uploader,
5255
self.pipe,
5356
self.buffer_capacity,
54-
self.flush_interval
57+
self.flush_interval,
58+
self.check_interval,
5559
)
5660
self.flush_thread.start()
5761

@@ -71,3 +75,7 @@ def emit(self, record):
7175
except Exception as e:
7276
if self.raise_exceptions:
7377
raise e
78+
79+
def flush(self):
80+
if self.flush_thread and self.flush_thread.is_alive():
81+
self.flush_thread.flush()

tests/test_flusher.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import threading
77
import unittest
88

9+
from unittest.mock import patch
10+
911
from logtail.compat import queue
1012
from logtail.flusher import RETRY_SCHEDULE
1113
from logtail.flusher import FlushWorker
@@ -17,11 +19,12 @@ class TestFlushWorker(unittest.TestCase):
1719
source_token = 'dummy_source_token'
1820
buffer_capacity = 5
1921
flush_interval = 2
22+
check_interval = 0.01
2023

2124
def _setup_worker(self, uploader=None):
2225
pipe = queue.Queue(maxsize=self.buffer_capacity)
2326
uploader = uploader or Uploader(self.source_token, self.host)
24-
fw = FlushWorker(uploader, pipe, self.buffer_capacity, self.flush_interval)
27+
fw = FlushWorker(uploader, pipe, self.buffer_capacity, self.flush_interval, self.check_interval)
2528
return pipe, uploader, fw
2629

2730
def test_is_thread(self):
@@ -50,7 +53,7 @@ def uploader(frame):
5053

5154
self.assertEqual(self.calls, 1)
5255

53-
@mock.patch('logtail.flusher._calculate_time_remaining')
56+
@patch('logtail.flusher._calculate_time_remaining')
5457
def test_flushes_after_interval(self, calculate_time_remaining):
5558
self.buffer_capacity = 10
5659
num_items = 2
@@ -82,8 +85,8 @@ def timeout(last_flush, interval):
8285
self.assertEqual(self.upload_calls, 1)
8386
self.assertEqual(self.timeout_calls, 2)
8487

85-
@mock.patch('logtail.flusher._calculate_time_remaining')
86-
@mock.patch('logtail.flusher._initial_time_remaining')
88+
@patch('logtail.flusher._calculate_time_remaining')
89+
@patch('logtail.flusher._initial_time_remaining')
8790
def test_does_nothing_without_any_items(self, initial_time_remaining, calculate_time_remaining):
8891
calculate_time_remaining.side_effect = lambda a,b: 0.0
8992
initial_time_remaining.side_effect = lambda a: 0.0001
@@ -95,7 +98,7 @@ def test_does_nothing_without_any_items(self, initial_time_remaining, calculate_
9598
fw.step()
9699
self.assertFalse(uploader.called)
97100

98-
@mock.patch('logtail.flusher.time.sleep')
101+
@patch('logtail.flusher.time.sleep')
99102
def test_retries_according_to_schedule(self, mock_sleep):
100103
first_frame = list(range(self.buffer_capacity))
101104

tests/test_handler.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,22 @@
66
import unittest
77
import logging
88

9-
from logtail import LogtailHandler, context
9+
from unittest.mock import patch
1010

11+
from logtail import LogtailHandler, context
12+
from logtail.handler import FlushWorker
1113

1214
class TestLogtailHandler(unittest.TestCase):
1315
source_token = 'dummy_source_token'
1416
host = 'dummy_host'
1517

16-
@mock.patch('logtail.handler.FlushWorker')
18+
@patch('logtail.handler.FlushWorker')
1719
def test_handler_creates_uploader_from_args(self, MockWorker):
1820
handler = LogtailHandler(source_token=self.source_token, host=self.host)
1921
self.assertEqual(handler.uploader.source_token, self.source_token)
2022
self.assertEqual(handler.uploader.host, self.host)
2123

22-
@mock.patch('logtail.handler.FlushWorker')
24+
@patch('logtail.handler.FlushWorker')
2325
def test_handler_creates_pipe_from_args(self, MockWorker):
2426
buffer_capacity = 9
2527
flush_interval = 1
@@ -30,11 +32,12 @@ def test_handler_creates_pipe_from_args(self, MockWorker):
3032
)
3133
self.assertTrue(handler.pipe.empty())
3234

33-
@mock.patch('logtail.handler.FlushWorker')
35+
@patch('logtail.handler.FlushWorker')
3436
def test_handler_creates_and_starts_worker_from_args_after_first_log(self, MockWorker):
3537
buffer_capacity = 9
3638
flush_interval = 9
37-
handler = LogtailHandler(source_token=self.source_token, buffer_capacity=buffer_capacity, flush_interval=flush_interval)
39+
check_interval = 4
40+
handler = LogtailHandler(source_token=self.source_token, buffer_capacity=buffer_capacity, flush_interval=flush_interval, check_interval=check_interval)
3841

3942
self.assertFalse(MockWorker.called)
4043

@@ -47,11 +50,12 @@ def test_handler_creates_and_starts_worker_from_args_after_first_log(self, MockW
4750
handler.uploader,
4851
handler.pipe,
4952
buffer_capacity,
50-
flush_interval
53+
flush_interval,
54+
check_interval,
5155
)
5256
self.assertEqual(handler.flush_thread.start.call_count, 1)
5357

54-
@mock.patch('logtail.handler.FlushWorker')
58+
@patch('logtail.handler.FlushWorker')
5559
def test_emit_starts_thread_if_not_alive(self, MockWorker):
5660
handler = LogtailHandler(source_token=self.source_token)
5761

@@ -67,7 +71,7 @@ def test_emit_starts_thread_if_not_alive(self, MockWorker):
6771

6872
self.assertEqual(handler.flush_thread.start.call_count, 2)
6973

70-
@mock.patch('logtail.handler.FlushWorker')
74+
@patch('logtail.handler.FlushWorker')
7175
def test_emit_drops_records_if_configured(self, MockWorker):
7276
buffer_capacity = 1
7377
handler = LogtailHandler(
@@ -87,7 +91,7 @@ def test_emit_drops_records_if_configured(self, MockWorker):
8791
self.assertTrue(handler.pipe.empty())
8892
self.assertEqual(handler.dropcount, 1)
8993

90-
@mock.patch('logtail.handler.FlushWorker')
94+
@patch('logtail.handler.FlushWorker')
9195
def test_emit_does_not_drop_records_if_configured(self, MockWorker):
9296
buffer_capacity = 1
9397
handler = LogtailHandler(
@@ -118,7 +122,7 @@ def consumer(q):
118122

119123
self.assertEqual(handler.dropcount, 0)
120124

121-
@mock.patch('logtail.handler.FlushWorker')
125+
@patch('logtail.handler.FlushWorker')
122126
def test_error_suppression(self, MockWorker):
123127
buffer_capacity = 1
124128
handler = LogtailHandler(
@@ -139,7 +143,7 @@ def test_error_suppression(self, MockWorker):
139143
handler.raise_exceptions = False
140144
logger.critical('hello')
141145

142-
@mock.patch('logtail.handler.FlushWorker')
146+
@patch('logtail.handler.FlushWorker')
143147
def test_can_send_unserializable_extra_data(self, MockWorker):
144148
buffer_capacity = 1
145149
handler = LogtailHandler(
@@ -158,7 +162,7 @@ def test_can_send_unserializable_extra_data(self, MockWorker):
158162
self.assertRegex(log_entry['data']['unserializable'], r'^<tests\.test_handler\.UnserializableObject object at 0x[0-f]+>$')
159163
self.assertTrue(handler.pipe.empty())
160164

161-
@mock.patch('logtail.handler.FlushWorker')
165+
@patch('logtail.handler.FlushWorker')
162166
def test_can_send_unserializable_context(self, MockWorker):
163167
buffer_capacity = 1
164168
handler = LogtailHandler(
@@ -178,7 +182,7 @@ def test_can_send_unserializable_context(self, MockWorker):
178182
self.assertRegex(log_entry['context']['data']['unserializable'], r'^<tests\.test_handler\.UnserializableObject object at 0x[0-f]+>$')
179183
self.assertTrue(handler.pipe.empty())
180184

181-
@mock.patch('logtail.handler.FlushWorker')
185+
@patch('logtail.handler.FlushWorker')
182186
def test_can_send_circular_dependency_in_extra_data(self, MockWorker):
183187
buffer_capacity = 1
184188
handler = LogtailHandler(
@@ -200,7 +204,7 @@ def test_can_send_circular_dependency_in_extra_data(self, MockWorker):
200204
self.assertTrue(handler.pipe.empty())
201205

202206

203-
@mock.patch('logtail.handler.FlushWorker')
207+
@patch('logtail.handler.FlushWorker')
204208
def test_can_send_circular_dependency_in_context(self, MockWorker):
205209
buffer_capacity = 1
206210
handler = LogtailHandler(

tests/test_uploader.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import mock
55
import unittest
66

7+
from unittest.mock import patch
8+
79
from logtail.uploader import Uploader
810

911

@@ -12,7 +14,7 @@ class TestUploader(unittest.TestCase):
1214
source_token = 'dummy_source_token'
1315
frame = [1, 2, 3]
1416

15-
@mock.patch('logtail.uploader.requests.Session.post')
17+
@patch('logtail.uploader.requests.Session.post')
1618
def test_call(self, post):
1719
def mock_post(endpoint, data=None, headers=None):
1820
# Check that the data is sent to ther correct endpoint

0 commit comments

Comments
 (0)