logtail-python 0.3.4__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {logtail_python-0.3.4/logtail_python.egg-info → logtail_python-0.4.1}/PKG-INFO +8 -6
  2. {logtail_python-0.3.4 → logtail_python-0.4.1}/logtail/__init__.py +1 -1
  3. {logtail_python-0.3.4 → logtail_python-0.4.1}/logtail/flusher.py +32 -5
  4. {logtail_python-0.3.4 → logtail_python-0.4.1}/logtail/handler.py +28 -2
  5. logtail_python-0.4.1/logtail/rq.py +29 -0
  6. {logtail_python-0.3.4 → logtail_python-0.4.1/logtail_python.egg-info}/PKG-INFO +8 -6
  7. {logtail_python-0.3.4 → logtail_python-0.4.1}/logtail_python.egg-info/SOURCES.txt +3 -8
  8. {logtail_python-0.3.4 → logtail_python-0.4.1}/setup.py +6 -5
  9. {logtail_python-0.3.4 → logtail_python-0.4.1}/test-requirements.txt +1 -0
  10. {logtail_python-0.3.4 → logtail_python-0.4.1}/tests/test_flusher.py +76 -3
  11. {logtail_python-0.3.4 → logtail_python-0.4.1}/tests/test_frame.py +1 -5
  12. {logtail_python-0.3.4 → logtail_python-0.4.1}/tests/test_handler.py +102 -0
  13. logtail_python-0.4.1/tests/test_rq.py +81 -0
  14. logtail_python-0.3.4/tests/__pycache__/__init__.cpython-311.pyc +0 -0
  15. logtail_python-0.3.4/tests/__pycache__/test_flusher.cpython-311.pyc +0 -0
  16. logtail_python-0.3.4/tests/__pycache__/test_formatter.cpython-311.pyc +0 -0
  17. logtail_python-0.3.4/tests/__pycache__/test_frame.cpython-311.pyc +0 -0
  18. logtail_python-0.3.4/tests/__pycache__/test_handler.cpython-311.pyc +0 -0
  19. logtail_python-0.3.4/tests/__pycache__/test_helpers.cpython-311.pyc +0 -0
  20. logtail_python-0.3.4/tests/__pycache__/test_uploader.cpython-311.pyc +0 -0
  21. {logtail_python-0.3.4 → logtail_python-0.4.1}/LICENSE.md +0 -0
  22. {logtail_python-0.3.4 → logtail_python-0.4.1}/MANIFEST.in +0 -0
  23. {logtail_python-0.3.4 → logtail_python-0.4.1}/README.md +0 -0
  24. {logtail_python-0.3.4 → logtail_python-0.4.1}/logtail/compat.py +0 -0
  25. {logtail_python-0.3.4 → logtail_python-0.4.1}/logtail/formatter.py +0 -0
  26. {logtail_python-0.3.4 → logtail_python-0.4.1}/logtail/frame.py +0 -0
  27. {logtail_python-0.3.4 → logtail_python-0.4.1}/logtail/helpers.py +0 -0
  28. {logtail_python-0.3.4 → logtail_python-0.4.1}/logtail/uploader.py +0 -0
  29. {logtail_python-0.3.4 → logtail_python-0.4.1}/logtail_python.egg-info/dependency_links.txt +0 -0
  30. {logtail_python-0.3.4 → logtail_python-0.4.1}/logtail_python.egg-info/requires.txt +0 -0
  31. {logtail_python-0.3.4 → logtail_python-0.4.1}/logtail_python.egg-info/top_level.txt +0 -0
  32. {logtail_python-0.3.4 → logtail_python-0.4.1}/requirements.txt +0 -0
  33. {logtail_python-0.3.4 → logtail_python-0.4.1}/setup.cfg +0 -0
  34. {logtail_python-0.3.4 → logtail_python-0.4.1}/tests/__init__.py +0 -0
  35. {logtail_python-0.3.4 → logtail_python-0.4.1}/tests/test_formatter.py +0 -0
  36. {logtail_python-0.3.4 → logtail_python-0.4.1}/tests/test_helpers.py +0 -0
  37. {logtail_python-0.3.4 → logtail_python-0.4.1}/tests/test_uploader.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: logtail-python
3
- Version: 0.3.4
4
- Summary: Logtail.com client library
3
+ Version: 0.4.1
4
+ Summary: Better Stack client library
5
5
  Home-page: https://github.com/logtail/logtail-python
6
- Download-URL: https://github.com/logtail/logtail-python/tarball/0.3.4
6
+ Download-URL: https://github.com/logtail/logtail-python/tarball/0.4.1
7
7
  Author: Logtail
8
8
  Author-email: hello@logtail.com
9
9
  License: ISC
@@ -12,13 +12,14 @@ Classifier: Intended Audience :: Developers
12
12
  Classifier: License :: OSI Approved :: ISC License (ISCL)
13
13
  Classifier: Operating System :: OS Independent
14
14
  Classifier: Programming Language :: Python :: 3
15
- Classifier: Programming Language :: Python :: 3.7
16
- Classifier: Programming Language :: Python :: 3.8
17
- Classifier: Programming Language :: Python :: 3.9
18
15
  Classifier: Programming Language :: Python :: 3.10
19
16
  Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
20
  Classifier: Programming Language :: Python
21
21
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.10
22
23
  Description-Content-Type: text/markdown
23
24
  License-File: LICENSE.md
24
25
  Requires-Dist: requests>=2.18.4
@@ -34,6 +35,7 @@ Dynamic: keywords
34
35
  Dynamic: license
35
36
  Dynamic: license-file
36
37
  Dynamic: requires-dist
38
+ Dynamic: requires-python
37
39
  Dynamic: summary
38
40
 
39
41
  # [Better Stack](https://betterstack.com/logs) Python client
@@ -5,6 +5,6 @@ from .handler import LogtailHandler
5
5
  from .helpers import LogtailContext, DEFAULT_CONTEXT
6
6
  from .formatter import LogtailFormatter
7
7
 
8
- __version__ = '0.3.4'
8
+ __version__ = '0.4.1'
9
9
 
10
10
  context = DEFAULT_CONTEXT
@@ -9,6 +9,14 @@ from .compat import queue
9
9
  RETRY_SCHEDULE = (1, 10, 60) # seconds
10
10
 
11
11
 
12
+ class TransportFrame(dict):
13
+ """A record logged by a flush worker itself, i.e. urllib3 describing our own upload.
14
+
15
+ It rides along with the next batch of regular records but never triggers an upload
16
+ on its own, since that upload would log again and the queue would never drain.
17
+ """
18
+
19
+
12
20
  class FlushWorker(threading.Thread):
13
21
  def __init__(self, upload, pipe, buffer_capacity, flush_interval, check_interval):
14
22
  threading.Thread.__init__(self)
@@ -26,6 +34,16 @@ class FlushWorker(threading.Thread):
26
34
  while self.should_run:
27
35
  self.step()
28
36
 
37
+ def _is_parent_alive(self):
38
+ try:
39
+ return self.parent_thread.is_alive()
40
+ except RuntimeError:
41
+ # Starting with Python 3.14, calling is_alive() on an
42
+ # already-terminated thread raises a RuntimeError instead of
43
+ # returning False (see the parent thread being torn down during
44
+ # interpreter shutdown). Treat that as "no longer alive".
45
+ return False
46
+
29
47
  def step(self):
30
48
  last_flush = time.time()
31
49
  time_remaining = _initial_time_remaining(self.flush_interval)
@@ -34,7 +52,7 @@ class FlushWorker(threading.Thread):
34
52
 
35
53
  # If the parent thread has exited but there are still outstanding
36
54
  # events, attempt to send them before exiting.
37
- shutdown = not self.parent_thread.is_alive()
55
+ shutdown = not self._is_parent_alive()
38
56
 
39
57
  # Fill phase: take events out of the queue and group them for sending.
40
58
  # Takes up to `buffer_capacity` events out of the queue and groups them
@@ -54,14 +72,14 @@ class FlushWorker(threading.Thread):
54
72
  except queue.Empty:
55
73
  if shutdown or self._flushing:
56
74
  break
57
- shutdown = not self.parent_thread.is_alive()
75
+ shutdown = not self._is_parent_alive()
58
76
  time_remaining = _calculate_time_remaining(last_flush, self.flush_interval)
59
77
 
60
78
  # Send phase: takes the outstanding events (up to `buffer_capacity`
61
- # count) and sends them to the Logtail endpoint all at once. If the
79
+ # count) and sends them to the Better Stack endpoint all at once. If the
62
80
  # request fails in a way that can be retried, it is retried with an
63
81
  # exponential backoff in between attempts.
64
- if frame:
82
+ if frame and not all(isinstance(entry, TransportFrame) for entry in frame):
65
83
  response = None
66
84
  for delay in RETRY_SCHEDULE + (None, ):
67
85
  response = self.upload(frame)
@@ -70,7 +88,12 @@ class FlushWorker(threading.Thread):
70
88
  if delay is not None:
71
89
  time.sleep(delay)
72
90
 
73
- if response.status_code == 500 and getattr(response, "exception") != None:
91
+ # A network error surfaces as a Fake500 that carries an `exception`
92
+ # attribute; a genuine server HTTP 500 is a real `requests.Response`
93
+ # that has no such attribute. Use a default so the latter doesn't
94
+ # raise AttributeError here and kill the flush thread (dropping all
95
+ # subsequent logs for the life of the process).
96
+ if response.status_code == 500 and getattr(response, "exception", None) is not None:
74
97
  print('Failed to send logs to Better Stack after {} retries: {}'.format(len(RETRY_SCHEDULE), response.exception))
75
98
 
76
99
  self._clean = True
@@ -83,6 +106,10 @@ class FlushWorker(threading.Thread):
83
106
  time.sleep(self.check_interval)
84
107
  self._flushing = False
85
108
 
109
+ def in_flush_worker():
110
+ return isinstance(threading.current_thread(), FlushWorker)
111
+
112
+
86
113
  def _initial_time_remaining(flush_interval):
87
114
  return flush_interval
88
115
 
@@ -2,10 +2,12 @@
2
2
  from __future__ import print_function, unicode_literals
3
3
  import logging
4
4
  import json
5
+ import os
6
+ import weakref
5
7
 
6
8
  from .compat import queue
7
9
  from .helpers import DEFAULT_CONTEXT
8
- from .flusher import FlushWorker
10
+ from .flusher import FlushWorker, TransportFrame, in_flush_worker
9
11
  from .uploader import Uploader
10
12
  from .frame import create_frame
11
13
 
@@ -18,6 +20,8 @@ DEFAULT_DROP_EXTRA_EVENTS = True
18
20
  DEFAULT_INCLUDE_EXTRA_ATTRIBUTES = True
19
21
  DEFAULT_TIMEOUT = 30
20
22
 
23
+ _handlers = weakref.WeakSet()
24
+
21
25
 
22
26
  class LogtailHandler(logging.Handler):
23
27
  def __init__(self,
@@ -50,6 +54,7 @@ class LogtailHandler(logging.Handler):
50
54
  self.dropcount = 0
51
55
  # Do not initialize the flush thread yet because it causes issues on Render.
52
56
  self.flush_thread = None
57
+ _handlers.add(self)
53
58
 
54
59
  def ensure_flush_thread_alive(self):
55
60
  if self.flush_thread and self.flush_thread.is_alive():
@@ -71,8 +76,12 @@ class LogtailHandler(logging.Handler):
71
76
  message = self.format(record)
72
77
  frame = create_frame(record, message, self.context, include_extra_attributes=self.include_extra_attributes)
73
78
  serializable_frame = json.loads(json.dumps(frame, default=str))
79
+ transport = in_flush_worker()
80
+ if transport:
81
+ serializable_frame = TransportFrame(serializable_frame)
74
82
  try:
75
- self.pipe.put(serializable_frame, block=(not self.drop_extra_events))
83
+ # The flush worker must never block on its own queue, so its records are dropped when full.
84
+ self.pipe.put(serializable_frame, block=(not self.drop_extra_events) and not transport)
76
85
  except queue.Full:
77
86
  # Only raised when not blocking, which means that extra events
78
87
  # should be dropped.
@@ -84,3 +93,20 @@ class LogtailHandler(logging.Handler):
84
93
  def flush(self):
85
94
  if self.flush_thread and self.flush_thread.is_alive():
86
95
  self.flush_thread.flush()
96
+
97
+ def _reset_after_fork(self):
98
+ # A forked child inherits the parent's queue with whatever was still buffered in it,
99
+ # a flush thread object whose thread does not exist in the child, and an HTTP session
100
+ # whose socket it shares with the parent, so it starts over with fresh ones.
101
+ self.pipe = queue.Queue(maxsize=self.buffer_capacity)
102
+ self.flush_thread = None
103
+ self.uploader = Uploader(self.source_token, self.host, self.uploader.timeout)
104
+
105
+
106
+ def _reset_handlers_after_fork():
107
+ for handler in _handlers:
108
+ handler._reset_after_fork()
109
+
110
+
111
+ if hasattr(os, 'register_at_fork'):
112
+ os.register_at_fork(after_in_child=_reset_handlers_after_fork)
@@ -0,0 +1,29 @@
1
+ # coding: utf-8
2
+ from __future__ import print_function, unicode_literals
3
+ import logging
4
+
5
+ import rq
6
+
7
+ from .handler import LogtailHandler
8
+
9
+
10
+ class Worker(rq.Worker):
11
+ """An RQ worker that sends buffered log records after every job.
12
+
13
+ RQ runs each job in a forked work horse and ends it with os._exit(), which skips the
14
+ interpreter shutdown that would otherwise let LogtailHandler flush. Use it with
15
+ `rq worker --worker-class logtail.rq.Worker` or `RQ = {"WORKER_CLASS": "logtail.rq.Worker"}`
16
+ in django-rq.
17
+ """
18
+
19
+ def perform_job(self, job, queue):
20
+ try:
21
+ return super(Worker, self).perform_job(job, queue)
22
+ finally:
23
+ for handler in _logtail_handlers():
24
+ handler.flush()
25
+
26
+
27
+ def _logtail_handlers():
28
+ loggers = [logging.getLogger()] + [logger for logger in logging.Logger.manager.loggerDict.values() if isinstance(logger, logging.Logger)]
29
+ return {handler for logger in loggers for handler in logger.handlers if isinstance(handler, LogtailHandler)}
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: logtail-python
3
- Version: 0.3.4
4
- Summary: Logtail.com client library
3
+ Version: 0.4.1
4
+ Summary: Better Stack client library
5
5
  Home-page: https://github.com/logtail/logtail-python
6
- Download-URL: https://github.com/logtail/logtail-python/tarball/0.3.4
6
+ Download-URL: https://github.com/logtail/logtail-python/tarball/0.4.1
7
7
  Author: Logtail
8
8
  Author-email: hello@logtail.com
9
9
  License: ISC
@@ -12,13 +12,14 @@ Classifier: Intended Audience :: Developers
12
12
  Classifier: License :: OSI Approved :: ISC License (ISCL)
13
13
  Classifier: Operating System :: OS Independent
14
14
  Classifier: Programming Language :: Python :: 3
15
- Classifier: Programming Language :: Python :: 3.7
16
- Classifier: Programming Language :: Python :: 3.8
17
- Classifier: Programming Language :: Python :: 3.9
18
15
  Classifier: Programming Language :: Python :: 3.10
19
16
  Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
20
  Classifier: Programming Language :: Python
21
21
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Requires-Python: >=3.10
22
23
  Description-Content-Type: text/markdown
23
24
  License-File: LICENSE.md
24
25
  Requires-Dist: requests>=2.18.4
@@ -34,6 +35,7 @@ Dynamic: keywords
34
35
  Dynamic: license
35
36
  Dynamic: license-file
36
37
  Dynamic: requires-dist
38
+ Dynamic: requires-python
37
39
  Dynamic: summary
38
40
 
39
41
  # [Better Stack](https://betterstack.com/logs) Python client
@@ -12,6 +12,7 @@ logtail/formatter.py
12
12
  logtail/frame.py
13
13
  logtail/handler.py
14
14
  logtail/helpers.py
15
+ logtail/rq.py
15
16
  logtail/uploader.py
16
17
  logtail_python.egg-info/PKG-INFO
17
18
  logtail_python.egg-info/SOURCES.txt
@@ -24,11 +25,5 @@ tests/test_formatter.py
24
25
  tests/test_frame.py
25
26
  tests/test_handler.py
26
27
  tests/test_helpers.py
27
- tests/test_uploader.py
28
- tests/__pycache__/__init__.cpython-311.pyc
29
- tests/__pycache__/test_flusher.cpython-311.pyc
30
- tests/__pycache__/test_formatter.cpython-311.pyc
31
- tests/__pycache__/test_frame.cpython-311.pyc
32
- tests/__pycache__/test_handler.cpython-311.pyc
33
- tests/__pycache__/test_helpers.cpython-311.pyc
34
- tests/__pycache__/test_uploader.cpython-311.pyc
28
+ tests/test_rq.py
29
+ tests/test_uploader.py
@@ -3,7 +3,7 @@ import os
3
3
  from setuptools import setup
4
4
 
5
5
 
6
- VERSION = '0.3.4'
6
+ VERSION = '0.4.1'
7
7
  ROOT_DIR = os.path.dirname(__file__)
8
8
 
9
9
  REQUIREMENTS = [
@@ -20,13 +20,14 @@ setup(
20
20
  packages=['logtail'],
21
21
  include_package_data=True,
22
22
  license='ISC',
23
- description='Logtail.com client library',
23
+ description='Better Stack client library',
24
24
  long_description=long_description,
25
25
  long_description_content_type='text/markdown',
26
26
  url='https://github.com/logtail/logtail-python',
27
27
  download_url='https://github.com/logtail/logtail-python/tarball/%s' % (VERSION),
28
28
  keywords=['api', 'logtail', 'logging', 'client'],
29
29
  install_requires=REQUIREMENTS,
30
+ python_requires='>=3.10',
30
31
  author='Logtail',
31
32
  author_email='hello@logtail.com',
32
33
  classifiers=[
@@ -34,11 +35,11 @@ setup(
34
35
  'License :: OSI Approved :: ISC License (ISCL)',
35
36
  'Operating System :: OS Independent',
36
37
  'Programming Language :: Python :: 3',
37
- 'Programming Language :: Python :: 3.7',
38
- 'Programming Language :: Python :: 3.8',
39
- 'Programming Language :: Python :: 3.9',
40
38
  'Programming Language :: Python :: 3.10',
41
39
  'Programming Language :: Python :: 3.11',
40
+ 'Programming Language :: Python :: 3.12',
41
+ 'Programming Language :: Python :: 3.13',
42
+ 'Programming Language :: Python :: 3.14',
42
43
  'Programming Language :: Python',
43
44
  'Topic :: Software Development :: Libraries :: Python Modules',
44
45
  ],
@@ -2,3 +2,4 @@ coverage>=3.7.1
2
2
  httpretty>=0.9.4
3
3
  nose-py3
4
4
  mock>=1.0.1
5
+ rq
@@ -1,7 +1,7 @@
1
1
  # coding: utf-8
2
2
  from __future__ import print_function, unicode_literals
3
3
  import mock
4
- import sys
4
+ import requests
5
5
  import time
6
6
  import threading
7
7
  import unittest
@@ -124,6 +124,48 @@ class TestFlushWorker(unittest.TestCase):
124
124
  self.assertEqual(self.uploader_calls, len(RETRY_SCHEDULE) + 1)
125
125
  self.assertEqual(self.sleep_calls, len(RETRY_SCHEDULE))
126
126
 
127
+ @patch('logtail.flusher.time.sleep')
128
+ def test_step_does_not_crash_on_real_server_500(self, _mock_sleep):
129
+ # Regression: a genuine server HTTP 500 returns a real requests.Response,
130
+ # which (unlike the network-error Fake500) has no `.exception` attribute.
131
+ # `getattr(response, "exception")` without a default raised AttributeError
132
+ # here, which propagates out of step()/run() and kills the flush thread —
133
+ # every subsequent log is then queued but never sent. (Note: MagicMock
134
+ # would auto-vivify `.exception` and hide the bug, so use a real Response.)
135
+ first_frame = list(range(self.buffer_capacity))
136
+
137
+ def uploader(frame):
138
+ resp = requests.models.Response()
139
+ resp.status_code = 500
140
+ return resp
141
+
142
+ pipe, _, fw = self._setup_worker(uploader)
143
+ for log in first_frame:
144
+ pipe.put(log, block=False)
145
+
146
+ fw.step() # must not raise AttributeError
147
+
148
+ self.assertTrue(fw._clean)
149
+
150
+ @patch('logtail.flusher.time.sleep')
151
+ def test_step_still_logs_network_error_fake500(self, _mock_sleep):
152
+ # The network-error path (Fake500 carrying an exception) must still log.
153
+ from logtail.uploader import Fake500
154
+
155
+ first_frame = list(range(self.buffer_capacity))
156
+
157
+ def uploader(frame):
158
+ return Fake500(Exception('network is down'))
159
+
160
+ pipe, _, fw = self._setup_worker(uploader)
161
+ for log in first_frame:
162
+ pipe.put(log, block=False)
163
+
164
+ with patch('builtins.print') as mock_print:
165
+ fw.step()
166
+
167
+ mock_print.assert_called_once()
168
+
127
169
  def test_shutdown_condition_empties_queue_and_shuts_down(self):
128
170
  self.buffer_capacity = 10
129
171
  num_items = 5
@@ -146,8 +188,39 @@ class TestFlushWorker(unittest.TestCase):
146
188
  self.assertEqual(self.upload_calls, 1)
147
189
  self.assertFalse(fw.should_run)
148
190
 
149
- # test relies on overriding excepthook which is available from 3.8+
150
- @unittest.skipIf(sys.version_info < (3, 8), "Test skipped because overriding excepthook is only available on Python 3.8+")
191
+ def test_is_parent_alive_handles_runtime_error(self):
192
+ # Starting with Python 3.14, is_alive() raises RuntimeError when
193
+ # called on an already-terminated thread instead of returning False.
194
+ # The worker must treat that as the parent no longer being alive.
195
+ _, _, fw = self._setup_worker()
196
+ fw.parent_thread = mock.MagicMock()
197
+ fw.parent_thread.is_alive.side_effect = RuntimeError('cannot join current thread')
198
+
199
+ self.assertFalse(fw._is_parent_alive())
200
+
201
+ def test_shutdown_condition_when_parent_is_alive_raises(self):
202
+ self.buffer_capacity = 10
203
+ num_items = 5
204
+ first_frame = list(range(self.buffer_capacity))
205
+ self.assertLess(num_items, self.buffer_capacity)
206
+
207
+ self.upload_calls = 0
208
+ def uploader(frame):
209
+ self.upload_calls += 1
210
+ self.assertEqual(frame, first_frame[:num_items])
211
+ return mock.MagicMock(status_code=202)
212
+
213
+ pipe, _, fw = self._setup_worker(uploader)
214
+ fw.parent_thread = mock.MagicMock()
215
+ fw.parent_thread.is_alive.side_effect = RuntimeError('cannot join current thread')
216
+
217
+ for i in range(num_items):
218
+ pipe.put(first_frame[i], block=False)
219
+
220
+ fw.step()
221
+ self.assertEqual(self.upload_calls, 1)
222
+ self.assertFalse(fw.should_run)
223
+
151
224
  def test_shutdown_dont_raise_exception_in_thread(self):
152
225
  original_excepthook = threading.excepthook
153
226
  threading.excepthook = mock.Mock()
@@ -3,7 +3,6 @@
3
3
  from logtail.frame import create_frame
4
4
  from logtail.handler import LogtailHandler
5
5
  from logtail.helpers import LogtailContext
6
- from sys import version_info
7
6
  import datetime
8
7
  import unittest
9
8
  import logging
@@ -17,11 +16,8 @@ class TestLogtailLogEntry(unittest.TestCase):
17
16
  # ISO timestamp must end with timezone info
18
17
  self.assertTrue(frame['dt'].endswith("+00:00"))
19
18
 
20
- # These tests require Python >= 3.7
21
- if version_info.major == 2 or version_info.minor <= 6:
22
- return
23
19
  # Sent date matches log record date
24
- date_ref = datetime.datetime.utcfromtimestamp(log_record.created).replace(tzinfo=datetime.timezone.utc)
20
+ date_ref = datetime.datetime.fromtimestamp(log_record.created, datetime.timezone.utc)
25
21
  date_sent = datetime.datetime.fromisoformat(frame['dt'])
26
22
  self.assertEqual(date_ref, date_sent)
27
23
 
@@ -1,6 +1,7 @@
1
1
  # coding: utf-8
2
2
  from __future__ import print_function, unicode_literals
3
3
  import mock
4
+ import os
4
5
  import time
5
6
  import threading
6
7
  import unittest
@@ -277,6 +278,107 @@ class TestLogtailHandler(unittest.TestCase):
277
278
  self.assertEqual(log_entry['context']['data']['egg']['chicken']['egg'], "<omitted circular reference>")
278
279
  self.assertTrue(handler.pipe.empty())
279
280
 
281
+ @patch('logtail.handler.FlushWorker')
282
+ def test_forked_child_starts_with_a_fresh_queue_thread_and_session(self, MockWorker):
283
+ handler = LogtailHandler(source_token=self.source_token)
284
+ logger = logging.getLogger(__name__)
285
+ logger.handlers = []
286
+ logger.addHandler(handler)
287
+ logger.critical('queued in the parent')
288
+ parent_session = handler.uploader.session
289
+
290
+ pid = os.fork()
291
+ if pid == 0:
292
+ fresh = handler.pipe.empty() and handler.flush_thread is None and handler.uploader.session is not parent_session
293
+ os._exit(0 if fresh else 1)
294
+ _, status = os.waitpid(pid, 0)
295
+
296
+ self.assertEqual(os.waitstatus_to_exitcode(status), 0)
297
+ self.assertEqual(handler.pipe.get(block=False)['message'], 'queued in the parent')
298
+
299
+ def test_flush_returns_when_the_upload_itself_logs_to_the_handler(self):
300
+ logger = logging.getLogger(__name__)
301
+ logger.handlers = []
302
+ logger.setLevel(logging.DEBUG)
303
+ handler = LogtailHandler(source_token=self.source_token, flush_interval=0.01, check_interval=0.01)
304
+ uploads = []
305
+
306
+ def upload(frame):
307
+ uploads.append(frame)
308
+ # urllib3 logs every request it makes at DEBUG, and a handler on the
309
+ # root logger receives that record.
310
+ logger.debug('http://in.logs.betterstack.com:443 "POST / HTTP/1.1" 202 0')
311
+ return mock.MagicMock(status_code=202)
312
+
313
+ handler.uploader = upload
314
+ logger.addHandler(handler)
315
+ logger.info('hello')
316
+ self.addCleanup(self._stop_flush_worker, handler)
317
+
318
+ flushed = threading.Event()
319
+ threading.Thread(target=lambda: (handler.flush(), flushed.set()), daemon=True).start()
320
+
321
+ self.assertTrue(flushed.wait(2), 'flush() did not return')
322
+ self.assertEqual([f['message'] for frame in uploads for f in frame], ['hello'])
323
+
324
+ def test_records_logged_by_the_upload_ride_along_with_the_next_batch(self):
325
+ logger = logging.getLogger(__name__)
326
+ logger.handlers = []
327
+ logger.setLevel(logging.DEBUG)
328
+ # Only a full buffer sends a batch within the test.
329
+ handler = LogtailHandler(source_token=self.source_token, buffer_capacity=2, flush_interval=1000, check_interval=0.01)
330
+ uploads = []
331
+
332
+ def upload(frame):
333
+ logger.debug('POST / 202') # what urllib3 does, still inside the request
334
+ uploads.append([f['message'] for f in frame])
335
+ return mock.MagicMock(status_code=202)
336
+
337
+ handler.uploader = upload
338
+ logger.addHandler(handler)
339
+ self.addCleanup(self._stop_flush_worker, handler)
340
+
341
+ logger.info('hello')
342
+ logger.info('hello again')
343
+ self._wait_until(lambda: len(uploads) == 1)
344
+ logger.info('world')
345
+ self._wait_until(lambda: len(uploads) == 2)
346
+
347
+ self.assertEqual(uploads, [['hello', 'hello again'], ['POST / 202', 'world']])
348
+
349
+ def test_a_batch_of_only_the_uploads_own_records_is_dropped_not_sent(self):
350
+ logger = logging.getLogger(__name__)
351
+ logger.handlers = []
352
+ logger.setLevel(logging.DEBUG)
353
+ handler = LogtailHandler(source_token=self.source_token, flush_interval=0.01, check_interval=0.01)
354
+ uploads = []
355
+
356
+ def upload(frame):
357
+ logger.debug('POST / 202')
358
+ uploads.append([f['message'] for f in frame])
359
+ return mock.MagicMock(status_code=202)
360
+
361
+ handler.uploader = upload
362
+ logger.addHandler(handler)
363
+ self.addCleanup(self._stop_flush_worker, handler)
364
+
365
+ logger.info('hello')
366
+ self._wait_until(lambda: len(uploads) == 1)
367
+ time.sleep(0.1) # several flush intervals, enough for the loop to show if it existed
368
+
369
+ self.assertEqual(uploads, [['hello']])
370
+ self.assertTrue(handler.pipe.empty())
371
+
372
+ def _wait_until(self, condition):
373
+ deadline = time.time() + 2
374
+ while not condition() and time.time() < deadline:
375
+ time.sleep(0.01)
376
+ self.assertTrue(condition())
377
+
378
+ def _stop_flush_worker(self, handler):
379
+ handler.flush_thread.should_run = False
380
+ handler.flush_thread.join(1)
381
+
280
382
 
281
383
  class UnserializableObject(object):
282
384
  """ Because this is a custom class, it cannot be serialized into JSON. """
@@ -0,0 +1,81 @@
1
+ # coding: utf-8
2
+ from __future__ import print_function, unicode_literals
3
+ import json
4
+ import logging
5
+ import mock
6
+ import os
7
+ import unittest
8
+
9
+ from unittest.mock import patch
10
+
11
+ import rq
12
+
13
+ from logtail import LogtailHandler
14
+ from logtail.rq import Worker
15
+
16
+
17
+ class TestWorker(unittest.TestCase):
18
+ source_token = 'dummy_source_token'
19
+
20
+ def setUp(self):
21
+ self.logger = logging.getLogger(__name__)
22
+ self.logger.handlers = []
23
+ self.logger.setLevel(logging.DEBUG)
24
+ self.uploads = []
25
+ # Only an explicit flush can send anything within the test.
26
+ self.handler = LogtailHandler(source_token=self.source_token, flush_interval=1000, check_interval=0.01)
27
+ self.handler.uploader = self._upload
28
+ self.logger.addHandler(self.handler)
29
+ connection = mock.MagicMock()
30
+ connection.connection_pool.connection_kwargs = {}
31
+ self.worker = Worker(['default'], connection=connection, prepare_for_work=False)
32
+
33
+ def tearDown(self):
34
+ if self.handler.flush_thread:
35
+ self.handler.flush_thread.should_run = False
36
+ self.handler.flush_thread.join(1)
37
+
38
+ def _upload(self, frame):
39
+ self.uploads.append([f['message'] for f in frame])
40
+ return mock.MagicMock(status_code=202)
41
+
42
+ def test_is_an_rq_worker(self):
43
+ self.assertIsInstance(self.worker, rq.Worker)
44
+
45
+ @patch.object(rq.Worker, 'perform_job')
46
+ def test_flushes_logtail_handlers_after_the_job(self, perform_job):
47
+ perform_job.side_effect = lambda job, queue: self.logger.info('inside the job') or True
48
+
49
+ self.assertTrue(self.worker.perform_job(mock.sentinel.job, mock.sentinel.queue))
50
+
51
+ perform_job.assert_called_once_with(mock.sentinel.job, mock.sentinel.queue)
52
+ self.assertEqual(self.uploads, [['inside the job']])
53
+
54
+ @patch.object(rq.Worker, 'perform_job')
55
+ def test_flushes_logtail_handlers_when_the_job_raises(self, perform_job):
56
+ def fail(job, queue):
57
+ self.logger.error('the job blew up')
58
+ raise ValueError('boom')
59
+ perform_job.side_effect = fail
60
+
61
+ with self.assertRaises(ValueError):
62
+ self.worker.perform_job(mock.sentinel.job, mock.sentinel.queue)
63
+
64
+ self.assertEqual(self.uploads, [['the job blew up']])
65
+
66
+ @patch.object(rq.Worker, 'perform_job')
67
+ def test_records_logged_in_the_work_horse_are_sent_before_it_exits(self, perform_job):
68
+ # RQ forks a work horse per job and ends it with os._exit(), which skips
69
+ # the interpreter shutdown that would otherwise flush the handler.
70
+ perform_job.side_effect = lambda job, queue: self.logger.info('inside the work horse')
71
+ read_end, write_end = os.pipe()
72
+ self.handler.uploader = lambda frame: os.write(write_end, json.dumps([f['message'] for f in frame]).encode()) and mock.MagicMock(status_code=202)
73
+
74
+ pid = os.fork()
75
+ if pid == 0:
76
+ self.worker.main_work_horse(mock.MagicMock(), mock.MagicMock())
77
+ os.waitpid(pid, 0)
78
+ os.close(write_end)
79
+ os.set_blocking(read_end, False)
80
+
81
+ self.assertEqual(json.loads(os.read(read_end, 4096)), ['inside the work horse'])
File without changes
File without changes