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.
@@ -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,2 @@
1
+ [keepup.plugins]
2
+ log_shipping = keepup_log_shipping:Log_shippingPlugin
@@ -0,0 +1,4 @@
1
+ keepup-admin>=0.4.0
2
+ requests>=2.33
3
+ urllib3>=2.8
4
+ httpx>=0.28
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+