keepup-log-shipping 0.5.0__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.
- keepup_log_shipping-0.5.0/PKG-INFO +21 -0
- keepup_log_shipping-0.5.0/README.md +10 -0
- keepup_log_shipping-0.5.0/keepup_log_shipping/__init__.py +32 -0
- keepup_log_shipping-0.5.0/keepup_log_shipping/log_shipping.py +689 -0
- keepup_log_shipping-0.5.0/keepup_log_shipping/plugin.py +111 -0
- keepup_log_shipping-0.5.0/keepup_log_shipping.egg-info/PKG-INFO +21 -0
- keepup_log_shipping-0.5.0/keepup_log_shipping.egg-info/SOURCES.txt +11 -0
- keepup_log_shipping-0.5.0/keepup_log_shipping.egg-info/dependency_links.txt +1 -0
- keepup_log_shipping-0.5.0/keepup_log_shipping.egg-info/entry_points.txt +2 -0
- keepup_log_shipping-0.5.0/keepup_log_shipping.egg-info/requires.txt +4 -0
- keepup_log_shipping-0.5.0/keepup_log_shipping.egg-info/top_level.txt +1 -0
- keepup_log_shipping-0.5.0/pyproject.toml +24 -0
- keepup_log_shipping-0.5.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keepup-log-shipping
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: The application's own log, queued and shipped to a collector.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: keepup-admin>=0.4.0
|
|
8
|
+
Requires-Dist: requests>=2.33
|
|
9
|
+
Requires-Dist: urllib3>=2.8
|
|
10
|
+
Requires-Dist: httpx>=0.28
|
|
11
|
+
|
|
12
|
+
# keepup-log-shipping
|
|
13
|
+
|
|
14
|
+
The application's own log, queued and shipped to a collector.
|
|
15
|
+
|
|
16
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
17
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
18
|
+
this distribution carries one part of it. It is installed on its own
|
|
19
|
+
(`pip install keepup-log-shipping`) or together with the base (`pip install "keepup-admin[panel]"`),
|
|
20
|
+
and it declares itself to the kernel through its
|
|
21
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# keepup-log-shipping
|
|
2
|
+
|
|
3
|
+
The application's own log, queued and shipped to a collector.
|
|
4
|
+
|
|
5
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
6
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
7
|
+
this distribution carries one part of it. It is installed on its own
|
|
8
|
+
(`pip install keepup-log-shipping`) or together with the base (`pip install "keepup-admin[panel]"`),
|
|
9
|
+
and it declares itself to the kernel through its
|
|
10
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Shipping this application's own log to a collector (keepup-118).
|
|
2
|
+
|
|
3
|
+
This distribution provides the `log_shipping` capability and declares one
|
|
4
|
+
plugin, `log_shipping`, in the `keepup.plugins` group. It depends on
|
|
5
|
+
`keepup-admin` for the constructor and on `requests`, which is what carries the
|
|
6
|
+
batches; the framework's own logging -- the console, the rotating file, the
|
|
7
|
+
redaction helper -- stays in the kernel, because every deployment has a log and
|
|
8
|
+
not every one has a collector.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.5.0"
|
|
12
|
+
|
|
13
|
+
__all__ = ["Log_shippingPlugin", "SERVICE_LOG_SHIPPING", "create_logger_token",
|
|
14
|
+
"init_remote_logging", "setup_remote_logging"]
|
|
15
|
+
|
|
16
|
+
#: Both are answered on access and never on import: the kernel imports this
|
|
17
|
+
#: package to read the plugin's descriptor, and pulling `requests` in for that
|
|
18
|
+
#: would make every deployment pay for a collector it may not have (keepup-118).
|
|
19
|
+
_FROM_SHIPPING = {"create_logger_token", "init_remote_logging", "setup_remote_logging"}
|
|
20
|
+
_FROM_PLUGIN = {"Log_shippingPlugin", "SERVICE_LOG_SHIPPING"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def __getattr__(name):
|
|
24
|
+
if name in _FROM_PLUGIN:
|
|
25
|
+
from keepup_log_shipping import plugin
|
|
26
|
+
|
|
27
|
+
return getattr(plugin, name)
|
|
28
|
+
if name in _FROM_SHIPPING:
|
|
29
|
+
from keepup_log_shipping import log_shipping
|
|
30
|
+
|
|
31
|
+
return getattr(log_shipping, name)
|
|
32
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
"""Shipping application logs to an external collector.
|
|
2
|
+
|
|
3
|
+
Logs are shipped in batches from a background thread when the application
|
|
4
|
+
names a collector. Shipping must never be able to hold up a request or bring
|
|
5
|
+
the process down: every failure here is swallowed, and repeated failures put
|
|
6
|
+
the collector aside for a while rather than retrying on the hot path.
|
|
7
|
+
|
|
8
|
+
Apart from local logging (`keepup/logging_setup.py`, keepup-24): the two share
|
|
9
|
+
only the word "log". The collector address, its token and the project name
|
|
10
|
+
logs are filed under belong to the application; it supplies them through
|
|
11
|
+
``keepup.logging_setup.configure()``, which hands them on here, or through
|
|
12
|
+
this module's ``configure()``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import atexit
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import queue
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from typing import Any, Dict, List
|
|
24
|
+
|
|
25
|
+
import requests
|
|
26
|
+
|
|
27
|
+
from keepup.instance import get_instance_id
|
|
28
|
+
|
|
29
|
+
#: What an application may import from this module. Everything else is
|
|
30
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
31
|
+
__all__ = [
|
|
32
|
+
"AsyncRemoteLogHandler",
|
|
33
|
+
"RemoteLogHandler",
|
|
34
|
+
"RemoteLoggerWrapper",
|
|
35
|
+
"configure",
|
|
36
|
+
"create_logger_token",
|
|
37
|
+
"init_remote_logging",
|
|
38
|
+
"setup_remote_logging",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
#: Set by the application through configure(); the framework has no project of
|
|
44
|
+
#: its own and no collector to ship to.
|
|
45
|
+
PROJECT_NAME = "keepup"
|
|
46
|
+
REMOTE_LOG_URL = None
|
|
47
|
+
#: The credential the collector expects. It identifies the application to a
|
|
48
|
+
#: service the framework knows nothing about, so it arrives from the
|
|
49
|
+
#: application; the package carries no token of its own. Without it nothing is
|
|
50
|
+
#: shipped -- see flush().
|
|
51
|
+
REMOTE_LOG_TOKEN = None
|
|
52
|
+
REMOTE_FLUSH_INTERVAL = 600
|
|
53
|
+
REMOTE_BATCH_SIZE = 100_000
|
|
54
|
+
#: How many records wait for the collector at most. While it is away, or
|
|
55
|
+
#: before the application has given a token, the oldest are dropped rather
|
|
56
|
+
#: than held for ever (keepup-68): a queue without a bound grew for as long as
|
|
57
|
+
#: the collector stayed down, in the memory of every replica.
|
|
58
|
+
REMOTE_MAX_QUEUED = 50_000
|
|
59
|
+
#: (connect, read) seconds for registering with the collector.
|
|
60
|
+
REGISTRATION_TIMEOUT = (5, 15)
|
|
61
|
+
|
|
62
|
+
_UNSET = object()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def configure(project_name=_UNSET, remote_url=_UNSET, flush_interval=_UNSET,
|
|
66
|
+
batch_size=_UNSET, remote_token=_UNSET, max_queued=_UNSET):
|
|
67
|
+
"""Supply the collector's values before the handlers are built."""
|
|
68
|
+
global PROJECT_NAME, REMOTE_LOG_URL, REMOTE_LOG_TOKEN
|
|
69
|
+
global REMOTE_FLUSH_INTERVAL, REMOTE_BATCH_SIZE, REMOTE_MAX_QUEUED
|
|
70
|
+
if max_queued is not _UNSET and max_queued is not None:
|
|
71
|
+
REMOTE_MAX_QUEUED = max_queued
|
|
72
|
+
if project_name is not _UNSET and project_name is not None:
|
|
73
|
+
PROJECT_NAME = project_name
|
|
74
|
+
if remote_url is not _UNSET:
|
|
75
|
+
REMOTE_LOG_URL = remote_url
|
|
76
|
+
if remote_token is not _UNSET:
|
|
77
|
+
REMOTE_LOG_TOKEN = remote_token
|
|
78
|
+
if flush_interval is not _UNSET and flush_interval is not None:
|
|
79
|
+
REMOTE_FLUSH_INTERVAL = flush_interval
|
|
80
|
+
if batch_size is not _UNSET and batch_size is not None:
|
|
81
|
+
REMOTE_BATCH_SIZE = batch_size
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RemoteLoggerWrapper:
|
|
87
|
+
"""Buffers log records and ships them to a remote collector."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, remote_url: str = None, flush_interval: int = 120, batch_size: int = 100_000_000):
|
|
90
|
+
# No default address. A package that ships to an address of its own
|
|
91
|
+
# turns "nobody configured a collector" into "shipping quietly fails
|
|
92
|
+
# against a port nothing listens on", which looks the same from the
|
|
93
|
+
# outside and reads as working.
|
|
94
|
+
self.remote_url = remote_url or REMOTE_LOG_URL
|
|
95
|
+
self.flush_interval = flush_interval
|
|
96
|
+
self.batch_size = batch_size
|
|
97
|
+
self.max_queued = REMOTE_MAX_QUEUED
|
|
98
|
+
self.log_queue = queue.Queue(maxsize=self.max_queued)
|
|
99
|
+
#: Records dropped since the last delivery, reported with the next one.
|
|
100
|
+
self.dropped = 0
|
|
101
|
+
self.buffer = []
|
|
102
|
+
self.lock = threading.Lock()
|
|
103
|
+
#: Wakes the one shipping thread early. A full batch used to start a
|
|
104
|
+
#: thread of its own per record -- and without a token nothing was ever
|
|
105
|
+
#: sent, so every record past the batch size started one.
|
|
106
|
+
self._wake = threading.Event()
|
|
107
|
+
self.running = True
|
|
108
|
+
self.flush_thread = None
|
|
109
|
+
self.project = self._get_project_name()
|
|
110
|
+
self.version = self._get_version()
|
|
111
|
+
self.instance = get_instance_id()
|
|
112
|
+
self.token = self._get_token()
|
|
113
|
+
|
|
114
|
+
self.remote_available = True
|
|
115
|
+
self.consecutive_failures = 0
|
|
116
|
+
self.max_consecutive_failures = 3
|
|
117
|
+
self.backoff_until = 0
|
|
118
|
+
|
|
119
|
+
if not self.remote_url:
|
|
120
|
+
self.running = False
|
|
121
|
+
logger.info("Remote log shipping is off: the application named no collector.")
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
self._start_flush_thread()
|
|
125
|
+
atexit.register(self.force_flush)
|
|
126
|
+
logger.info(f"RemoteLoggerWrapper initialized. Flush interval: {flush_interval}s, Batch size: {batch_size}")
|
|
127
|
+
|
|
128
|
+
def _get_project_name(self) -> str:
|
|
129
|
+
"""Return the project name."""
|
|
130
|
+
try:
|
|
131
|
+
with open('config/modules.json', 'r', encoding='utf-8') as f:
|
|
132
|
+
config = json.load(f)
|
|
133
|
+
return config.get('project_name', PROJECT_NAME)
|
|
134
|
+
except:
|
|
135
|
+
return PROJECT_NAME
|
|
136
|
+
|
|
137
|
+
def _get_version(self) -> str:
|
|
138
|
+
"""Return the application version."""
|
|
139
|
+
try:
|
|
140
|
+
with open('static/version.json', 'r', encoding='utf-8') as f:
|
|
141
|
+
version_data = json.load(f)
|
|
142
|
+
return version_data.get('version', '1.0.0')
|
|
143
|
+
except:
|
|
144
|
+
return '1.0.0'
|
|
145
|
+
|
|
146
|
+
def _get_token(self) -> str:
|
|
147
|
+
"""Return the collector credential the application configured.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The token, or None when the application named none -- in which
|
|
151
|
+
case nothing is shipped.
|
|
152
|
+
"""
|
|
153
|
+
return REMOTE_LOG_TOKEN
|
|
154
|
+
|
|
155
|
+
def _start_flush_thread(self):
|
|
156
|
+
"""Start the background shipping thread."""
|
|
157
|
+
self.flush_thread = threading.Thread(target=self._flush_worker, daemon=True)
|
|
158
|
+
self.flush_thread.start()
|
|
159
|
+
|
|
160
|
+
def _flush_worker(self):
|
|
161
|
+
"""Background task shipping logs periodically, or early on a full batch."""
|
|
162
|
+
while self.running:
|
|
163
|
+
self._wake.wait(self.flush_interval)
|
|
164
|
+
self._wake.clear()
|
|
165
|
+
try:
|
|
166
|
+
self.flush()
|
|
167
|
+
except Exception as e:
|
|
168
|
+
print(f"Error in flush worker: {str(e)}")
|
|
169
|
+
|
|
170
|
+
def emit(self, record: logging.LogRecord):
|
|
171
|
+
"""Queue a log record; the oldest is dropped when the queue is full."""
|
|
172
|
+
if not self.token:
|
|
173
|
+
# Nothing would ever be sent (flush() refuses without one), so
|
|
174
|
+
# nothing is kept.
|
|
175
|
+
return
|
|
176
|
+
try:
|
|
177
|
+
log_entry = self._format_record(record)
|
|
178
|
+
with self.lock:
|
|
179
|
+
try:
|
|
180
|
+
self.log_queue.put_nowait(log_entry)
|
|
181
|
+
except queue.Full:
|
|
182
|
+
try:
|
|
183
|
+
self.log_queue.get_nowait()
|
|
184
|
+
self.dropped += 1
|
|
185
|
+
self.log_queue.put_nowait(log_entry)
|
|
186
|
+
except (queue.Empty, queue.Full):
|
|
187
|
+
self.dropped += 1
|
|
188
|
+
|
|
189
|
+
if self.log_queue.qsize() >= self.batch_size:
|
|
190
|
+
self._wake.set()
|
|
191
|
+
|
|
192
|
+
except Exception as e:
|
|
193
|
+
print(f"Error in RemoteLoggerWrapper.emit: {str(e)}")
|
|
194
|
+
|
|
195
|
+
def _format_record(self, record: logging.LogRecord) -> Dict[str, Any]:
|
|
196
|
+
"""Format a log record."""
|
|
197
|
+
return {
|
|
198
|
+
"date": datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S.%f')[:-3],
|
|
199
|
+
"level": record.levelname,
|
|
200
|
+
"message": record.getMessage(),
|
|
201
|
+
"class": record.name
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
def flush(self):
|
|
205
|
+
"""Ship the buffered logs to the collector."""
|
|
206
|
+
if not self.remote_url:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
if not self.remote_available and time.time() < self.backoff_until:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
if not self.token:
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
with self.lock:
|
|
216
|
+
logs_to_send = []
|
|
217
|
+
batch_count = 0
|
|
218
|
+
while not self.log_queue.empty() and batch_count < self.batch_size:
|
|
219
|
+
try:
|
|
220
|
+
log = self.log_queue.get_nowait()
|
|
221
|
+
logs_to_send.append(log)
|
|
222
|
+
batch_count += 1
|
|
223
|
+
except queue.Empty:
|
|
224
|
+
break
|
|
225
|
+
except Exception:
|
|
226
|
+
break
|
|
227
|
+
|
|
228
|
+
if not logs_to_send:
|
|
229
|
+
return
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
payload = {
|
|
233
|
+
"project": self.project,
|
|
234
|
+
"version": self.version,
|
|
235
|
+
"instance": self.instance,
|
|
236
|
+
"logs": logs_to_send
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
response = requests.post(
|
|
240
|
+
self.remote_url,
|
|
241
|
+
json=payload,
|
|
242
|
+
headers={
|
|
243
|
+
"Authorization": f"Bearer {self.token}",
|
|
244
|
+
"Content-Type": "application/json"
|
|
245
|
+
},
|
|
246
|
+
timeout=(2, 5)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if response.status_code == 200:
|
|
250
|
+
self.consecutive_failures = 0
|
|
251
|
+
self.remote_available = True
|
|
252
|
+
logger.debug(f"Successfully sent {len(logs_to_send)} logs to remote server")
|
|
253
|
+
with self.lock:
|
|
254
|
+
dropped, self.dropped = self.dropped, 0
|
|
255
|
+
if dropped:
|
|
256
|
+
logger.warning(f"{dropped} log records were dropped while they "
|
|
257
|
+
f"could not be shipped")
|
|
258
|
+
else:
|
|
259
|
+
self._handle_failure(f"HTTP {response.status_code}")
|
|
260
|
+
self._return_logs_to_queue(logs_to_send)
|
|
261
|
+
|
|
262
|
+
except requests.exceptions.Timeout:
|
|
263
|
+
self._handle_failure("timeout")
|
|
264
|
+
logger.warning(f"Timeout sending logs to {self.remote_url}")
|
|
265
|
+
self._return_logs_to_queue(logs_to_send)
|
|
266
|
+
|
|
267
|
+
except requests.exceptions.ConnectionError as e:
|
|
268
|
+
self._handle_failure("connection error")
|
|
269
|
+
logger.warning(f"Connection error sending logs: {str(e)}")
|
|
270
|
+
self._return_logs_to_queue(logs_to_send)
|
|
271
|
+
|
|
272
|
+
except requests.exceptions.RequestException as e:
|
|
273
|
+
self._handle_failure("request error")
|
|
274
|
+
logger.warning(f"Request error sending logs: {str(e)}")
|
|
275
|
+
self._return_logs_to_queue(logs_to_send)
|
|
276
|
+
|
|
277
|
+
except Exception as e:
|
|
278
|
+
self._handle_failure("unexpected error")
|
|
279
|
+
logger.warning(f"Unexpected error while sending logs: {str(e)}")
|
|
280
|
+
self._return_logs_to_queue(logs_to_send)
|
|
281
|
+
|
|
282
|
+
def _handle_failure(self, error_type: str):
|
|
283
|
+
"""Handle a failed delivery."""
|
|
284
|
+
self.consecutive_failures += 1
|
|
285
|
+
|
|
286
|
+
if self.consecutive_failures >= self.max_consecutive_failures:
|
|
287
|
+
self.remote_available = False
|
|
288
|
+
# Exponential backoff: 30s, 1m, 2m, 4m, 8m, ...
|
|
289
|
+
backoff_time = min(30 * (2 ** (self.consecutive_failures - self.max_consecutive_failures)), 480)
|
|
290
|
+
self.backoff_until = time.time() + backoff_time
|
|
291
|
+
logger.warning(f"Remote logging disabled for {backoff_time}s due to {self.consecutive_failures} failures")
|
|
292
|
+
|
|
293
|
+
def _return_logs_to_queue(self, logs: List[Dict]):
|
|
294
|
+
"""Put logs back on the queue after a failed delivery.
|
|
295
|
+
|
|
296
|
+
Returned to the front, so ordering is preserved; what does not fit
|
|
297
|
+
under the bound is the oldest, and it is dropped.
|
|
298
|
+
"""
|
|
299
|
+
try:
|
|
300
|
+
with self.lock:
|
|
301
|
+
waiting = list(logs)
|
|
302
|
+
while True:
|
|
303
|
+
try:
|
|
304
|
+
waiting.append(self.log_queue.get_nowait())
|
|
305
|
+
except queue.Empty:
|
|
306
|
+
break
|
|
307
|
+
overflow = max(0, len(waiting) - self.max_queued)
|
|
308
|
+
self.dropped += overflow
|
|
309
|
+
refilled = queue.Queue(maxsize=self.max_queued)
|
|
310
|
+
for log in waiting[overflow:]:
|
|
311
|
+
refilled.put_nowait(log)
|
|
312
|
+
self.log_queue = refilled
|
|
313
|
+
except Exception as e:
|
|
314
|
+
print(f"Error returning logs to queue: {str(e)}")
|
|
315
|
+
|
|
316
|
+
def force_flush(self):
|
|
317
|
+
"""Ship every buffered log, called at shutdown."""
|
|
318
|
+
logger.info("Force flushing remote logs before shutdown...")
|
|
319
|
+
self.running = False
|
|
320
|
+
|
|
321
|
+
# Short timeouts: shutdown must not hang on an unreachable collector.
|
|
322
|
+
flush_attempts = 0
|
|
323
|
+
max_attempts = 3
|
|
324
|
+
|
|
325
|
+
while not self.log_queue.empty() and flush_attempts < max_attempts:
|
|
326
|
+
try:
|
|
327
|
+
self.flush()
|
|
328
|
+
flush_attempts += 1
|
|
329
|
+
time.sleep(1)
|
|
330
|
+
except:
|
|
331
|
+
break
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class AsyncRemoteLogHandler(logging.Handler):
|
|
335
|
+
"""Asynchronous log handler for use inside FastAPI."""
|
|
336
|
+
|
|
337
|
+
def __init__(self, remote_url: str = None, flush_interval: int = 120, batch_size: int = 100):
|
|
338
|
+
super().__init__()
|
|
339
|
+
# See RemoteLoggerWrapper.__init__ for why there is no default address.
|
|
340
|
+
self.remote_url = remote_url or REMOTE_LOG_URL
|
|
341
|
+
self.flush_interval = flush_interval
|
|
342
|
+
self.batch_size = batch_size
|
|
343
|
+
self.buffer = []
|
|
344
|
+
self.lock = asyncio.Lock()
|
|
345
|
+
self.project = self._get_project_name()
|
|
346
|
+
self.version = self._get_version()
|
|
347
|
+
self.instance = get_instance_id()
|
|
348
|
+
self.token = self._get_token()
|
|
349
|
+
self.flush_task = None
|
|
350
|
+
self.running = True
|
|
351
|
+
|
|
352
|
+
self.remote_available = True
|
|
353
|
+
self.consecutive_failures = 0
|
|
354
|
+
self.max_consecutive_failures = 3
|
|
355
|
+
self.backoff_until = 0
|
|
356
|
+
self.max_buffer_size = 10000 # maximum buffer size
|
|
357
|
+
self._sending = False
|
|
358
|
+
|
|
359
|
+
def _get_project_name(self) -> str:
|
|
360
|
+
try:
|
|
361
|
+
with open('config/modules.json', 'r', encoding='utf-8') as f:
|
|
362
|
+
config = json.load(f)
|
|
363
|
+
return config.get('project_name', PROJECT_NAME)
|
|
364
|
+
except:
|
|
365
|
+
return PROJECT_NAME
|
|
366
|
+
|
|
367
|
+
def _get_version(self) -> str:
|
|
368
|
+
try:
|
|
369
|
+
with open('static/version.json', 'r', encoding='utf-8') as f:
|
|
370
|
+
version_data = json.load(f)
|
|
371
|
+
return version_data.get('version', '1.0.0')
|
|
372
|
+
except:
|
|
373
|
+
return '1.0.0'
|
|
374
|
+
|
|
375
|
+
def _get_token(self) -> str:
|
|
376
|
+
return REMOTE_LOG_TOKEN
|
|
377
|
+
|
|
378
|
+
async def start(self):
|
|
379
|
+
"""Start the background task."""
|
|
380
|
+
self.flush_task = asyncio.create_task(self._flush_worker())
|
|
381
|
+
|
|
382
|
+
async def stop(self):
|
|
383
|
+
"""Stop the background task."""
|
|
384
|
+
self.running = False
|
|
385
|
+
if self.flush_task:
|
|
386
|
+
self.flush_task.cancel()
|
|
387
|
+
try:
|
|
388
|
+
await self.flush_task
|
|
389
|
+
except asyncio.CancelledError:
|
|
390
|
+
pass
|
|
391
|
+
await self.flush()
|
|
392
|
+
|
|
393
|
+
async def _flush_worker(self):
|
|
394
|
+
"""Background task shipping logs periodically."""
|
|
395
|
+
while self.running:
|
|
396
|
+
try:
|
|
397
|
+
await asyncio.sleep(self.flush_interval)
|
|
398
|
+
await self.flush()
|
|
399
|
+
except asyncio.CancelledError:
|
|
400
|
+
break
|
|
401
|
+
except Exception as e:
|
|
402
|
+
# Logged, but the loop keeps running.
|
|
403
|
+
print(f"Error in flush worker: {str(e)}")
|
|
404
|
+
|
|
405
|
+
def emit(self, record):
|
|
406
|
+
"""Add a record to the buffer."""
|
|
407
|
+
if not self.token:
|
|
408
|
+
return
|
|
409
|
+
try:
|
|
410
|
+
log_entry = {
|
|
411
|
+
"date": datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S.%f')[:-3],
|
|
412
|
+
"level": record.levelname,
|
|
413
|
+
"message": record.getMessage(),
|
|
414
|
+
"class": record.name
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
# Scheduled without awaiting: emit() must never block the caller.
|
|
418
|
+
asyncio.create_task(self._safe_add_to_buffer(log_entry))
|
|
419
|
+
|
|
420
|
+
except Exception as e:
|
|
421
|
+
# Logging must never bring the application down.
|
|
422
|
+
print(f"Error in AsyncRemoteLogHandler.emit: {str(e)}")
|
|
423
|
+
|
|
424
|
+
async def _safe_add_to_buffer(self, log_entry: Dict[str, Any]):
|
|
425
|
+
"""Add a record to the buffer, keeping the buffer bounded."""
|
|
426
|
+
try:
|
|
427
|
+
async with self.lock:
|
|
428
|
+
if len(self.buffer) >= self.max_buffer_size:
|
|
429
|
+
# A full buffer drops its oldest records rather than growing without bound.
|
|
430
|
+
self.buffer = self.buffer[-(self.max_buffer_size - 1):]
|
|
431
|
+
|
|
432
|
+
self.buffer.append(log_entry)
|
|
433
|
+
|
|
434
|
+
# One send at a time: a full buffer used to start a send per
|
|
435
|
+
# record while the previous one was still on its way.
|
|
436
|
+
if len(self.buffer) >= self.batch_size and not self._sending:
|
|
437
|
+
self._sending = True
|
|
438
|
+
asyncio.create_task(self._safe_send_buffer())
|
|
439
|
+
|
|
440
|
+
except Exception as e:
|
|
441
|
+
print(f"Error adding to buffer: {str(e)}")
|
|
442
|
+
|
|
443
|
+
async def _safe_send_buffer(self):
|
|
444
|
+
"""Ship the buffer, swallowing any error."""
|
|
445
|
+
try:
|
|
446
|
+
await self._send_buffer()
|
|
447
|
+
except Exception as e:
|
|
448
|
+
print(f"Error in safe send buffer: {str(e)}")
|
|
449
|
+
finally:
|
|
450
|
+
self._sending = False
|
|
451
|
+
|
|
452
|
+
async def _send_buffer(self):
|
|
453
|
+
"""Ship the buffer to the collector."""
|
|
454
|
+
if not self.remote_available and time.time() < self.backoff_until:
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
async with self.lock:
|
|
458
|
+
if not self.token or not self.buffer:
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
logs_to_send = self.buffer.copy()
|
|
462
|
+
self.buffer.clear()
|
|
463
|
+
|
|
464
|
+
try:
|
|
465
|
+
import httpx
|
|
466
|
+
|
|
467
|
+
# Short timeouts: log shipping must not stall request handling.
|
|
468
|
+
async with httpx.AsyncClient(
|
|
469
|
+
timeout=httpx.Timeout(3.0, connect=2.0),
|
|
470
|
+
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10)
|
|
471
|
+
) as client:
|
|
472
|
+
|
|
473
|
+
payload = {
|
|
474
|
+
"project": self.project,
|
|
475
|
+
"version": self.version,
|
|
476
|
+
"instance": self.instance,
|
|
477
|
+
"logs": logs_to_send
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
response = await client.post(
|
|
481
|
+
self.remote_url,
|
|
482
|
+
json=payload,
|
|
483
|
+
headers={"Authorization": f"Bearer {self.token}"}
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
if response.status_code == 200:
|
|
487
|
+
self.consecutive_failures = 0
|
|
488
|
+
self.remote_available = True
|
|
489
|
+
logger.debug(f"Successfully sent {len(logs_to_send)} logs")
|
|
490
|
+
else:
|
|
491
|
+
self._handle_failure(f"HTTP {response.status_code}")
|
|
492
|
+
await self._safe_return_to_buffer(logs_to_send)
|
|
493
|
+
|
|
494
|
+
except httpx.TimeoutException:
|
|
495
|
+
self._handle_failure("timeout")
|
|
496
|
+
logger.warning(f"Timeout sending logs to {self.remote_url}")
|
|
497
|
+
await self._safe_return_to_buffer(logs_to_send)
|
|
498
|
+
|
|
499
|
+
except httpx.ConnectError as e:
|
|
500
|
+
self._handle_failure("connection error")
|
|
501
|
+
logger.warning(f"Connection error sending logs: {str(e)}")
|
|
502
|
+
await self._safe_return_to_buffer(logs_to_send)
|
|
503
|
+
|
|
504
|
+
except httpx.HTTPError as e:
|
|
505
|
+
self._handle_failure("http error")
|
|
506
|
+
logger.warning(f"HTTP error sending logs: {str(e)}")
|
|
507
|
+
await self._safe_return_to_buffer(logs_to_send)
|
|
508
|
+
|
|
509
|
+
except Exception as e:
|
|
510
|
+
self._handle_failure("unexpected error")
|
|
511
|
+
logger.warning(f"Unexpected error sending logs: {str(e)}")
|
|
512
|
+
# On an unexpected error the logs are dropped rather than requeued, which
|
|
513
|
+
|
|
514
|
+
def _handle_failure(self, error_type: str):
|
|
515
|
+
"""Handle a failed delivery."""
|
|
516
|
+
self.consecutive_failures += 1
|
|
517
|
+
|
|
518
|
+
if self.consecutive_failures >= self.max_consecutive_failures:
|
|
519
|
+
self.remote_available = False
|
|
520
|
+
# Exponential backoff
|
|
521
|
+
backoff_time = min(30 * (2 ** (self.consecutive_failures - self.max_consecutive_failures)), 480)
|
|
522
|
+
self.backoff_until = time.time() + backoff_time
|
|
523
|
+
logger.warning(f"Remote logging disabled for {backoff_time}s due to failures")
|
|
524
|
+
|
|
525
|
+
async def _safe_return_to_buffer(self, logs: List[Dict]):
|
|
526
|
+
"""Put logs back into the buffer, keeping it bounded."""
|
|
527
|
+
try:
|
|
528
|
+
async with self.lock:
|
|
529
|
+
# Returned to the front so ordering is preserved.
|
|
530
|
+
self.buffer = logs + self.buffer
|
|
531
|
+
if len(self.buffer) > self.max_buffer_size:
|
|
532
|
+
self.buffer = self.buffer[:self.max_buffer_size]
|
|
533
|
+
except Exception as e:
|
|
534
|
+
print(f"Error returning logs to buffer: {str(e)}")
|
|
535
|
+
|
|
536
|
+
async def flush(self):
|
|
537
|
+
"""Ship every buffered log."""
|
|
538
|
+
try:
|
|
539
|
+
await self._send_buffer()
|
|
540
|
+
except Exception as e:
|
|
541
|
+
logger.error(f"Error in flush: {str(e)}")
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def setup_remote_logging(remote_url: str = None, flush_interval: int = 120, batch_size: int = 100):
|
|
545
|
+
"""Configure remote logging so that it can never block the application."""
|
|
546
|
+
try:
|
|
547
|
+
remote_handler = RemoteLogHandler(remote_url, flush_interval, batch_size)
|
|
548
|
+
|
|
549
|
+
root_logger = logging.getLogger()
|
|
550
|
+
root_logger.addHandler(remote_handler)
|
|
551
|
+
|
|
552
|
+
logger.info(f"Remote logging configured. URL: {remote_url or 'default'}")
|
|
553
|
+
return remote_handler
|
|
554
|
+
|
|
555
|
+
except Exception as e:
|
|
556
|
+
logger.error(f"Failed to setup remote logging (non-critical): {str(e)}")
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def init_remote_logging(remote_url=None, flush_interval=None, batch_size=None):
|
|
561
|
+
"""Initialise remote logging, tolerating any failure.
|
|
562
|
+
|
|
563
|
+
Without a collector address -- neither passed here nor given to
|
|
564
|
+
``configure()`` -- there is nothing to ship to, and the function does
|
|
565
|
+
nothing rather than inventing a destination.
|
|
566
|
+
"""
|
|
567
|
+
try:
|
|
568
|
+
remote_url = remote_url or REMOTE_LOG_URL
|
|
569
|
+
if not remote_url:
|
|
570
|
+
return None
|
|
571
|
+
flush_interval = flush_interval or REMOTE_FLUSH_INTERVAL
|
|
572
|
+
batch_size = batch_size or REMOTE_BATCH_SIZE
|
|
573
|
+
|
|
574
|
+
remote_handler = RemoteLogHandler(remote_url, flush_interval, batch_size)
|
|
575
|
+
logging.getLogger().addHandler(remote_handler)
|
|
576
|
+
|
|
577
|
+
for name in ['uvicorn', 'uvicorn.error', 'fastapi']:
|
|
578
|
+
try:
|
|
579
|
+
logger = logging.getLogger(name)
|
|
580
|
+
if logger and not any(isinstance(h, RemoteLogHandler) for h in logger.handlers):
|
|
581
|
+
logger.addHandler(remote_handler)
|
|
582
|
+
except:
|
|
583
|
+
pass
|
|
584
|
+
|
|
585
|
+
logger.info(f"Remote logging initialized. URL: {remote_url}")
|
|
586
|
+
return remote_handler
|
|
587
|
+
|
|
588
|
+
except Exception as e:
|
|
589
|
+
print(f"WARNING: Remote logging initialization failed (non-critical): {str(e)}")
|
|
590
|
+
return None
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
class RemoteLogHandler(logging.Handler):
|
|
594
|
+
"""Log handler shipping records to a remote collector."""
|
|
595
|
+
|
|
596
|
+
def __init__(self, remote_url: str = None, flush_interval: int = 1200, batch_size: int = 100_000):
|
|
597
|
+
super().__init__()
|
|
598
|
+
self.wrapper = RemoteLoggerWrapper(remote_url, flush_interval, batch_size)
|
|
599
|
+
self.setLevel(logging.INFO)
|
|
600
|
+
|
|
601
|
+
def emit(self, record):
|
|
602
|
+
self.wrapper.emit(record)
|
|
603
|
+
|
|
604
|
+
def close(self):
|
|
605
|
+
self.wrapper.force_flush()
|
|
606
|
+
super().close()
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
#: The path the shipping address ends with, and what registration lives under
|
|
610
|
+
#: instead. Both are the collector's own layout, so one address is enough for
|
|
611
|
+
#: the two calls and the application is not asked for a second one.
|
|
612
|
+
_SHIPPING_PATH = "/logs"
|
|
613
|
+
_REGISTRATION_PATH = "/admin/applications/register"
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _registration_url():
|
|
617
|
+
"""Derive the registration endpoint from the configured shipping address.
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
The registration address, or None when the application named no
|
|
621
|
+
collector.
|
|
622
|
+
"""
|
|
623
|
+
if not REMOTE_LOG_URL:
|
|
624
|
+
return None
|
|
625
|
+
if REMOTE_LOG_URL.endswith(_SHIPPING_PATH):
|
|
626
|
+
return REMOTE_LOG_URL[: -len(_SHIPPING_PATH)] + _REGISTRATION_PATH
|
|
627
|
+
return REMOTE_LOG_URL.rstrip("/") + _REGISTRATION_PATH
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def create_logger_token(admin_token: str, collector_url: str = None,
|
|
631
|
+
app_name: str = None, description: str = None,
|
|
632
|
+
contact_email: str = None):
|
|
633
|
+
"""Register this application with the log collector and keep its token.
|
|
634
|
+
|
|
635
|
+
The collector is a service of the deployment, and the name and description
|
|
636
|
+
it files the application under belong to the application. The framework
|
|
637
|
+
supplies none of the three: it has no collector, no product name and
|
|
638
|
+
nothing to say about itself.
|
|
639
|
+
|
|
640
|
+
Args:
|
|
641
|
+
admin_token: the collector's administrator token.
|
|
642
|
+
collector_url: address of the collector's registration endpoint. When
|
|
643
|
+
omitted, the address the application gave ``configure()`` is used.
|
|
644
|
+
app_name: the name to register under; defaults to the configured
|
|
645
|
+
project name.
|
|
646
|
+
description: what to file the application as.
|
|
647
|
+
contact_email: contact address for the registration.
|
|
648
|
+
|
|
649
|
+
Returns:
|
|
650
|
+
The issued API token, or None when no address is known, the collector
|
|
651
|
+
refused or did not answer. The token is the caller's to keep -- in the
|
|
652
|
+
environment, handed back through ``configure(remote_token=...)``. It is
|
|
653
|
+
written nowhere by the framework: it used to go in the clear to
|
|
654
|
+
``config/logger_token.json``, a file with default permissions that git
|
|
655
|
+
did not ignore (keepup-75).
|
|
656
|
+
"""
|
|
657
|
+
collector_url = collector_url or _registration_url()
|
|
658
|
+
if not collector_url:
|
|
659
|
+
logger.error("Cannot register with the log collector: no address was given.")
|
|
660
|
+
return None
|
|
661
|
+
|
|
662
|
+
try:
|
|
663
|
+
response = requests.post(
|
|
664
|
+
collector_url,
|
|
665
|
+
json={
|
|
666
|
+
"name": app_name or PROJECT_NAME,
|
|
667
|
+
"description": description,
|
|
668
|
+
"contact_email": contact_email
|
|
669
|
+
},
|
|
670
|
+
headers={
|
|
671
|
+
"Authorization": f"Bearer {admin_token}",
|
|
672
|
+
"Content-Type": "application/json"
|
|
673
|
+
},
|
|
674
|
+
# Bounded: without it a collector that accepts the connection and
|
|
675
|
+
# never answers held the caller for ever.
|
|
676
|
+
timeout=REGISTRATION_TIMEOUT,
|
|
677
|
+
)
|
|
678
|
+
except requests.exceptions.RequestException as error:
|
|
679
|
+
logger.error(f"Could not register with the log collector: {error}")
|
|
680
|
+
return None
|
|
681
|
+
|
|
682
|
+
if response.status_code == 200:
|
|
683
|
+
token_data = response.json()
|
|
684
|
+
logger.info(f"Registered with the log collector as application "
|
|
685
|
+
f"{token_data.get('id')}; keep the returned token in the environment")
|
|
686
|
+
return token_data['api_token']
|
|
687
|
+
else:
|
|
688
|
+
logger.error(f"Failed to create logger token: HTTP {response.status_code}")
|
|
689
|
+
return None
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""The application's log, sent to a collector that wants it (keepup-118).
|
|
2
|
+
|
|
3
|
+
`log_shipping.py` lived in the kernel and was switched on by four fields of
|
|
4
|
+
`KeepupSettings`, so every deployment carried the queue, the retries and the
|
|
5
|
+
backoff of a collector most of them do not have; `logging_setup.py` kept the
|
|
6
|
+
values aside for it and handed them on.
|
|
7
|
+
|
|
8
|
+
The capability owns the shipping now, and the kernel keeps the local log:
|
|
9
|
+
|
|
10
|
+
* its settings are its own -- the address, the credential, the flush interval
|
|
11
|
+
and the batch size are the plugin's `config` (the four settings fields are
|
|
12
|
+
still read, with a warning, so a deployment that named them keeps shipping
|
|
13
|
+
until keepup-122 removes them);
|
|
14
|
+
* it contributes its handler through the `logging` contribution point, and the
|
|
15
|
+
application attaches what the running plugins contribute -- a deployment that
|
|
16
|
+
never installed this distribution attaches nothing and ships nowhere;
|
|
17
|
+
* without an address there is no handler at all: an unconfigured collector is
|
|
18
|
+
not a queue that fills up.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from typing import Any, Dict, List
|
|
23
|
+
|
|
24
|
+
from keepup.kernel.descriptor import KIND_OPTIONAL, PluginDescriptor
|
|
25
|
+
from keepup.plugins.base import BasePlugin
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
__all__ = ["Log_shippingPlugin", "SERVICE_LOG_SHIPPING"]
|
|
30
|
+
|
|
31
|
+
#: What a plugin asks for to ship a batch of its own, if it ever needs to.
|
|
32
|
+
SERVICE_LOG_SHIPPING = "log_shipping"
|
|
33
|
+
|
|
34
|
+
#: The settings fields the kernel used to keep for this capability. Read once,
|
|
35
|
+
#: with a warning, until keepup-122 takes them away.
|
|
36
|
+
_DEPRECATED_FIELDS = ("remote_log_url", "remote_log_token",
|
|
37
|
+
"remote_log_flush_interval", "remote_log_batch_size")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Log_shippingPlugin(BasePlugin):
|
|
41
|
+
"""The application's log, queued and shipped to the collector it names."""
|
|
42
|
+
|
|
43
|
+
descriptor = PluginDescriptor(
|
|
44
|
+
id="log_shipping",
|
|
45
|
+
name="Log shipping",
|
|
46
|
+
version="0.5.0",
|
|
47
|
+
distribution="keepup-log-shipping",
|
|
48
|
+
kind=KIND_OPTIONAL,
|
|
49
|
+
priority=90,
|
|
50
|
+
provides=(f"{SERVICE_LOG_SHIPPING}>=1",),
|
|
51
|
+
contributions=("logging",),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def __init__(self, config=None):
|
|
55
|
+
super().__init__("log_shipping", "Log shipping", config)
|
|
56
|
+
self.handler = None
|
|
57
|
+
|
|
58
|
+
def _setting(self, name: str) -> Any:
|
|
59
|
+
"""One value: the plugin's own config first, the old kernel field second."""
|
|
60
|
+
if name in self.config:
|
|
61
|
+
return self.config[name]
|
|
62
|
+
settings = getattr(self.plugin_manager, "settings", None)
|
|
63
|
+
value = getattr(settings, name, None)
|
|
64
|
+
if value is not None:
|
|
65
|
+
import warnings
|
|
66
|
+
|
|
67
|
+
warnings.warn(
|
|
68
|
+
f"KeepupSettings.{name} moved to the log_shipping plugin's own setting "
|
|
69
|
+
f"({name}) and goes away in 0.6.0",
|
|
70
|
+
DeprecationWarning, stacklevel=3)
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
async def initialize(self):
|
|
74
|
+
"""Build the handler when the deployment named a collector, and no earlier.
|
|
75
|
+
|
|
76
|
+
Imported here and not at the top of the module: the kernel imports this
|
|
77
|
+
module to read the descriptor, and `requests` is only needed by a
|
|
78
|
+
deployment that actually ships somewhere (keepup-118).
|
|
79
|
+
"""
|
|
80
|
+
url = self._setting("remote_log_url")
|
|
81
|
+
if not url:
|
|
82
|
+
logger.info("No collector named: this deployment ships its log nowhere")
|
|
83
|
+
return True
|
|
84
|
+
from keepup_log_shipping.log_shipping import init_remote_logging
|
|
85
|
+
|
|
86
|
+
self.handler = init_remote_logging(
|
|
87
|
+
url,
|
|
88
|
+
self._setting("remote_log_flush_interval") or 120,
|
|
89
|
+
self._setting("remote_log_batch_size") or 100_000)
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
async def cleanup(self):
|
|
93
|
+
"""Ship what the queue still holds, and stop shipping."""
|
|
94
|
+
handler, self.handler = self.handler, None
|
|
95
|
+
if handler is None:
|
|
96
|
+
return
|
|
97
|
+
try:
|
|
98
|
+
handler.wrapper.force_flush()
|
|
99
|
+
except Exception as error: # noqa: BLE001 - shutdown must not raise
|
|
100
|
+
logger.warning("Could not flush the shipped log: %s", error)
|
|
101
|
+
|
|
102
|
+
def get_api_routes(self) -> List[Dict[str, Any]]:
|
|
103
|
+
"""No routes: the capability ships what the application logs, and answers none."""
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
def get_log_handlers(self) -> List[Any]:
|
|
107
|
+
"""What the application attaches to the root logger, if there is a collector."""
|
|
108
|
+
return [self.handler] if self.handler is not None else []
|
|
109
|
+
|
|
110
|
+
def get_handlers(self) -> Dict[str, Any]:
|
|
111
|
+
return {"handler": self.handler}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keepup-log-shipping
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: The application's own log, queued and shipped to a collector.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: keepup-admin>=0.4.0
|
|
8
|
+
Requires-Dist: requests>=2.33
|
|
9
|
+
Requires-Dist: urllib3>=2.8
|
|
10
|
+
Requires-Dist: httpx>=0.28
|
|
11
|
+
|
|
12
|
+
# keepup-log-shipping
|
|
13
|
+
|
|
14
|
+
The application's own log, queued and shipped to a collector.
|
|
15
|
+
|
|
16
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
17
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
18
|
+
this distribution carries one part of it. It is installed on its own
|
|
19
|
+
(`pip install keepup-log-shipping`) or together with the base (`pip install "keepup-admin[panel]"`),
|
|
20
|
+
and it declares itself to the kernel through its
|
|
21
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
keepup_log_shipping/__init__.py
|
|
4
|
+
keepup_log_shipping/log_shipping.py
|
|
5
|
+
keepup_log_shipping/plugin.py
|
|
6
|
+
keepup_log_shipping.egg-info/PKG-INFO
|
|
7
|
+
keepup_log_shipping.egg-info/SOURCES.txt
|
|
8
|
+
keepup_log_shipping.egg-info/dependency_links.txt
|
|
9
|
+
keepup_log_shipping.egg-info/entry_points.txt
|
|
10
|
+
keepup_log_shipping.egg-info/requires.txt
|
|
11
|
+
keepup_log_shipping.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
keepup_log_shipping
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "keepup-log-shipping"
|
|
7
|
+
version = "0.5.0"
|
|
8
|
+
description = "The application's own log, queued and shipped to a collector."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"keepup-admin>=0.4.0",
|
|
13
|
+
"requests>=2.33",
|
|
14
|
+
# requests' own transport, with the floor that excludes its advisories
|
|
15
|
+
# (keepup-69); it travels with the distribution that asks for requests.
|
|
16
|
+
"urllib3>=2.8",
|
|
17
|
+
"httpx>=0.28",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.entry-points."keepup.plugins"]
|
|
21
|
+
log_shipping = "keepup_log_shipping:Log_shippingPlugin"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools]
|
|
24
|
+
packages = ["keepup_log_shipping"]
|