logtail-python 0.4.0__py2.py3-none-any.whl → 0.4.2__py2.py3-none-any.whl

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.
logtail/__init__.py CHANGED
@@ -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.4.0'
8
+ __version__ = '0.4.2'
9
9
 
10
10
  context = DEFAULT_CONTEXT
logtail/flusher.py CHANGED
@@ -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)
@@ -71,7 +79,7 @@ class FlushWorker(threading.Thread):
71
79
  # count) and sends them to the Better Stack endpoint all at once. If the
72
80
  # request fails in a way that can be retried, it is retried with an
73
81
  # exponential backoff in between attempts.
74
- if frame:
82
+ if frame and not all(isinstance(entry, TransportFrame) for entry in frame):
75
83
  response = None
76
84
  for delay in RETRY_SCHEDULE + (None, ):
77
85
  response = self.upload(frame)
@@ -80,18 +88,46 @@ class FlushWorker(threading.Thread):
80
88
  if delay is not None:
81
89
  time.sleep(delay)
82
90
 
83
- 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:
84
97
  print('Failed to send logs to Better Stack after {} retries: {}'.format(len(RETRY_SCHEDULE), response.exception))
85
98
 
86
99
  self._clean = True
87
100
  if shutdown and self.pipe.empty():
88
101
  self.should_run = False
89
102
 
90
- def flush(self):
103
+ def flush(self, timeout=None):
104
+ """Block until the worker has drained the queue.
105
+
106
+ If ``timeout`` is given (in seconds), return after at most that much
107
+ wall time has elapsed even if the queue is still non-empty. Returns
108
+ ``True`` if the queue was drained, ``False`` if the timeout fired.
109
+
110
+ The bound matters when the caller holds the global logging lock, as
111
+ ``logging.config.dictConfig()`` does while it flushes and closes the
112
+ handlers it replaces: if this worker is inside an upload whose urllib3
113
+ ``debug()`` call needs that same lock, an unbounded wait deadlocks the
114
+ process. It also keeps a slow or unreachable endpoint from stalling a
115
+ shutdown forever.
116
+ """
91
117
  self._flushing = True
92
- while not self._clean or not self.pipe.empty():
93
- time.sleep(self.check_interval)
94
- self._flushing = False
118
+ try:
119
+ deadline = None if timeout is None else time.time() + timeout
120
+ while not self._clean or not self.pipe.empty():
121
+ if deadline is not None and time.time() >= deadline:
122
+ return False
123
+ time.sleep(self.check_interval)
124
+ return True
125
+ finally:
126
+ self._flushing = False
127
+
128
+ def in_flush_worker():
129
+ return isinstance(threading.current_thread(), FlushWorker)
130
+
95
131
 
96
132
  def _initial_time_remaining(flush_interval):
97
133
  return flush_interval
logtail/handler.py CHANGED
@@ -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
 
@@ -17,6 +19,10 @@ DEFAULT_RAISE_EXCEPTIONS = False
17
19
  DEFAULT_DROP_EXTRA_EVENTS = True
18
20
  DEFAULT_INCLUDE_EXTRA_ATTRIBUTES = True
19
21
  DEFAULT_TIMEOUT = 30
22
+ DEFAULT_FLUSH_TIMEOUT = 30
23
+
24
+
25
+ _handlers = weakref.WeakSet()
20
26
 
21
27
 
22
28
  class LogtailHandler(logging.Handler):
@@ -31,6 +37,7 @@ class LogtailHandler(logging.Handler):
31
37
  include_extra_attributes=DEFAULT_INCLUDE_EXTRA_ATTRIBUTES,
32
38
  context=DEFAULT_CONTEXT,
33
39
  timeout=DEFAULT_TIMEOUT,
40
+ flush_timeout=DEFAULT_FLUSH_TIMEOUT,
34
41
  level=logging.NOTSET):
35
42
  super(LogtailHandler, self).__init__(level=level)
36
43
  self.source_token = source_token
@@ -47,9 +54,11 @@ class LogtailHandler(logging.Handler):
47
54
  self.flush_interval = flush_interval
48
55
  self.check_interval = check_interval
49
56
  self.raise_exceptions = raise_exceptions
57
+ self.flush_timeout = flush_timeout
50
58
  self.dropcount = 0
51
59
  # Do not initialize the flush thread yet because it causes issues on Render.
52
60
  self.flush_thread = None
61
+ _handlers.add(self)
53
62
 
54
63
  def ensure_flush_thread_alive(self):
55
64
  if self.flush_thread and self.flush_thread.is_alive():
@@ -71,8 +80,12 @@ class LogtailHandler(logging.Handler):
71
80
  message = self.format(record)
72
81
  frame = create_frame(record, message, self.context, include_extra_attributes=self.include_extra_attributes)
73
82
  serializable_frame = json.loads(json.dumps(frame, default=str))
83
+ transport = in_flush_worker()
84
+ if transport:
85
+ serializable_frame = TransportFrame(serializable_frame)
74
86
  try:
75
- self.pipe.put(serializable_frame, block=(not self.drop_extra_events))
87
+ # The flush worker must never block on its own queue, so its records are dropped when full.
88
+ self.pipe.put(serializable_frame, block=(not self.drop_extra_events) and not transport)
76
89
  except queue.Full:
77
90
  # Only raised when not blocking, which means that extra events
78
91
  # should be dropped.
@@ -83,4 +96,22 @@ class LogtailHandler(logging.Handler):
83
96
 
84
97
  def flush(self):
85
98
  if self.flush_thread and self.flush_thread.is_alive():
86
- self.flush_thread.flush()
99
+ if not self.flush_thread.flush(timeout=self.flush_timeout):
100
+ print('Gave up waiting for Better Stack uploads after {}s, logs are still buffered'.format(self.flush_timeout))
101
+
102
+ def _reset_after_fork(self):
103
+ # A forked child inherits the parent's queue with whatever was still buffered in it,
104
+ # a flush thread object whose thread does not exist in the child, and an HTTP session
105
+ # whose socket it shares with the parent, so it starts over with fresh ones.
106
+ self.pipe = queue.Queue(maxsize=self.buffer_capacity)
107
+ self.flush_thread = None
108
+ self.uploader.reset()
109
+
110
+
111
+ def _reset_handlers_after_fork():
112
+ for handler in _handlers:
113
+ handler._reset_after_fork()
114
+
115
+
116
+ if hasattr(os, 'register_at_fork'):
117
+ os.register_at_fork(after_in_child=_reset_handlers_after_fork)
logtail/rq.py ADDED
@@ -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)}
logtail/uploader.py CHANGED
@@ -1,7 +1,9 @@
1
1
  # coding: utf-8
2
2
  from __future__ import print_function, unicode_literals
3
+ import os
3
4
  import msgpack
4
5
  import requests
6
+ import requests.utils
5
7
 
6
8
  class Fake500(object):
7
9
  def __init__(self, exception):
@@ -13,11 +15,17 @@ class Uploader(object):
13
15
  self.source_token = source_token
14
16
  self.host = host
15
17
  self.timeout = timeout
16
- self.session = requests.Session()
18
+ # requests would otherwise ask the environment, and on macOS SystemConfiguration, for
19
+ # proxy settings on every request. Resolving them once here keeps the flush thread out
20
+ # of CoreFoundation, which refuses to run in a child forked from a multithreaded
21
+ # process, such as an RQ work horse. The CA bundle variables follow the same rule.
22
+ self.proxies = requests.utils.get_environ_proxies(host)
23
+ self.verify = os.environ.get('REQUESTS_CA_BUNDLE') or os.environ.get('CURL_CA_BUNDLE') or True
17
24
  self.headers = {
18
25
  'Authorization': 'Bearer %s' % source_token,
19
26
  'Content-Type': 'application/msgpack',
20
27
  }
28
+ self.session = self._new_session()
21
29
 
22
30
  def __call__(self, frame):
23
31
  data = msgpack.packb(frame, use_bin_type=True)
@@ -25,3 +33,15 @@ class Uploader(object):
25
33
  return self.session.post(self.host, data=data, headers=self.headers, timeout=self.timeout)
26
34
  except requests.RequestException as e:
27
35
  return Fake500(e)
36
+
37
+ def reset(self):
38
+ # A forked child shares the parent's pooled socket, so it needs a session of its own,
39
+ # built from the settings resolved above rather than looked up again.
40
+ self.session = self._new_session()
41
+
42
+ def _new_session(self):
43
+ session = requests.Session()
44
+ session.trust_env = False
45
+ session.proxies = self.proxies
46
+ session.verify = self.verify
47
+ return session
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: logtail-python
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
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.4.0
6
+ Download-URL: https://github.com/logtail/logtail-python/tarball/0.4.2
7
7
  Author: Logtail
8
8
  Author-email: hello@logtail.com
9
9
  License: ISC
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Programming Language :: Python :: 3.15
20
21
  Classifier: Programming Language :: Python
21
22
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
23
  Requires-Python: >=3.10
@@ -0,0 +1,14 @@
1
+ logtail/__init__.py,sha256=4glP1HM13UrMvg1eNN_C4MspQPuKyztVnktGcqsFTuA,252
2
+ logtail/compat.py,sha256=KbZmcmDpj_vbMotp16pADPwJ1FkEHGDNjKbOfCy-c10,141
3
+ logtail/flusher.py,sha256=bPSCvKNniXBZWYY5AVZ0w6lFkxswe739Ooa3ZAj608s,5987
4
+ logtail/formatter.py,sha256=nk4W1G95P19ijylPBJ8D9FlO_qbh3tfbv7xzCSfpDN8,968
5
+ logtail/frame.py,sha256=p7vVYcajZvjH4FJDt4ADLfYljVbQXKTGKk3kFec5JUs,3335
6
+ logtail/handler.py,sha256=Dg4XKhe1bJITz4XBK9JymmNa0cTV6bCcobHeFExlFws,4495
7
+ logtail/helpers.py,sha256=ezDTLsTBAtCtIh_XptSYOiBrZFa-E9wo2SWTnjKgOOU,1156
8
+ logtail/rq.py,sha256=SEjVwjp8lRLab2F0StdssBQ0-vFK1CekEgYwkSICBeo,1023
9
+ logtail/uploader.py,sha256=5oeNxmnyogq1w9MoK2YkxQGxQy_bJ-yS7RqWzltQrKY,1873
10
+ logtail_python-0.4.2.dist-info/licenses/LICENSE.md,sha256=aCBMqxfm7QNyFR9JiJ2GSMvhAURXN2E0yTcYNQloqI0,737
11
+ logtail_python-0.4.2.dist-info/METADATA,sha256=km7pAQqucdR1TGjol0bHQyw5qqeqowG-CTFEvafH44Y,2480
12
+ logtail_python-0.4.2.dist-info/WHEEL,sha256=4YBfCYNH4wlLpv3pzq1hbEuIlXA4WJabKLFurZ7eTL0,109
13
+ logtail_python-0.4.2.dist-info/top_level.txt,sha256=QZIbQogN1yFBgjiJ-8NlYArVCL00gBoYbEUT_Ew8l-Q,8
14
+ logtail_python-0.4.2.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py2-none-any
5
5
  Tag: py3-none-any
@@ -1,13 +0,0 @@
1
- logtail/__init__.py,sha256=lz_ouCpEkpsK8Lq9fCQ7Sqj8xbowot6JzNZGFeEfOrI,252
2
- logtail/compat.py,sha256=KbZmcmDpj_vbMotp16pADPwJ1FkEHGDNjKbOfCy-c10,141
3
- logtail/flusher.py,sha256=KKb4IEuPC_XGKaNBrbMEiwc-X4MKsYURPjY7PqR3v4k,4176
4
- logtail/formatter.py,sha256=nk4W1G95P19ijylPBJ8D9FlO_qbh3tfbv7xzCSfpDN8,968
5
- logtail/frame.py,sha256=p7vVYcajZvjH4FJDt4ADLfYljVbQXKTGKk3kFec5JUs,3335
6
- logtail/handler.py,sha256=9PNJPSc2S3W5uQADKE4iDFH16A7BZW90D-m7aR66Mo4,3188
7
- logtail/helpers.py,sha256=ezDTLsTBAtCtIh_XptSYOiBrZFa-E9wo2SWTnjKgOOU,1156
8
- logtail/uploader.py,sha256=Ilhh8wuAGlz1YJcQpzXtFqwD-2RYmuamKv3dFNoTWUg,868
9
- logtail_python-0.4.0.dist-info/licenses/LICENSE.md,sha256=aCBMqxfm7QNyFR9JiJ2GSMvhAURXN2E0yTcYNQloqI0,737
10
- logtail_python-0.4.0.dist-info/METADATA,sha256=Bwsctru3-I0ZkrvKuukF6j1jDB7P1nz5OYyBmBsw79M,2429
11
- logtail_python-0.4.0.dist-info/WHEEL,sha256=TdQ5LtNwLuxTCjgxN51AgdU5w-KkB9ttmLbzjTH02pg,109
12
- logtail_python-0.4.0.dist-info/top_level.txt,sha256=QZIbQogN1yFBgjiJ-8NlYArVCL00gBoYbEUT_Ew8l-Q,8
13
- logtail_python-0.4.0.dist-info/RECORD,,