Add CentralLogging stack, Covance/EDC sources, email import + IWRS scripts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
# CentralLogging — centrální logování (Loki + Grafana + FastAPI gateway)
|
||||
|
||||
**Verze:** 1.0 · **Datum:** 2026-06-08 · **Autor:** Vladimír Buzalka
|
||||
|
||||
Řešení pro sjednocení logů z rozházených skriptů projektu Janssen do jednoho
|
||||
centrálního místa na Unraidu, **bez zrušení stávajícího souborového logování**.
|
||||
Soubory a centrál běží paralelně; po ~měsíci ověřování se souborové logování
|
||||
vypne jedním přepínačem.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architektura
|
||||
|
||||
```
|
||||
┌────────────────┐ HTTP POST /log/batch ┌──────────────┐ push ┌────────┐
|
||||
│ Python skript │ ── Bearer token, JSON ──────▶ │ Log Gateway │ ───────▶ │ Loki │
|
||||
│ (central_ │ (na pozadí, v dávkách) │ (FastAPI) │ /push │ (90 d) │
|
||||
│ logging.py) │ └──────────────┘ └────┬───┘
|
||||
│ │ │
|
||||
│ + soubor .log │ (stávající RotatingFileHandler — zatím zůstává) │
|
||||
└────────────────┘ ┌─────▼────┐
|
||||
│ Grafana │
|
||||
Při výpadku gateway → lokální spool .ndjson, přehraje se po obnovení. │ (dashbrd)│
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
**Proč takhle:**
|
||||
- **Loki** — průmyslový standard pro centrální logy, levné úložiště (chunky na
|
||||
Unraid share), retence vynucená automaticky, vizualizace v Grafaně.
|
||||
- **FastAPI gateway** (jako tvůj `msgreceiver`) — skripty neznají interní detaily
|
||||
Loki ani DB hesla, jen jednoduchý JSON + sdílený token. Backend lze kdykoli
|
||||
vyměnit beze změny skriptů.
|
||||
- **Klient jen stdlib** (`urllib`) — žádné `pip install` do desítek skriptů.
|
||||
- **Neblokující + odolné** — emit jen vloží do fronty, odesílá vlákno na pozadí
|
||||
v dávkách; při výpadku spool soubor → žádný log se neztratí.
|
||||
|
||||
---
|
||||
|
||||
## 2. Adresářová struktura
|
||||
|
||||
```
|
||||
CentralLogging/
|
||||
├── README.md ← tento soubor
|
||||
├── docker/
|
||||
│ ├── docker-compose.yml Loki + Grafana + Gateway
|
||||
│ ├── loki-config.yml Loki, retence 90 dní
|
||||
│ └── grafana-datasource.yml auto-přidání Loki do Grafany
|
||||
├── gateway/
|
||||
│ ├── log_gateway_v1.0.py FastAPI brána
|
||||
│ ├── log_gateway_v1.0.md dokumentace brány
|
||||
│ ├── requirements.txt
|
||||
│ └── Dockerfile
|
||||
└── client/
|
||||
├── central_logging.py stabilní import shim
|
||||
├── central_logging_v1.0.py implementace (verze ve jméně)
|
||||
├── central_logging_v1.0.md dokumentace knihovny
|
||||
└── example_usage_v1.0.py ukázka integrace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Nasazení na Unraid
|
||||
|
||||
1. Zkopíruj adresář `CentralLogging/` na Unraid (např. do
|
||||
`/mnt/user/appdata/central-logging/src`).
|
||||
2. Uprav cesty volumes v `docker-compose.yml` (výchozí
|
||||
`/mnt/user/appdata/central-logging/{loki,grafana}`).
|
||||
3. Nastav tajemství (soubor `.env` vedle compose, NE do gitu):
|
||||
```env
|
||||
LOG_TOKEN=nejaky-dlouhy-nahodny-retezec
|
||||
GRAFANA_PASSWORD=silne-heslo
|
||||
```
|
||||
4. Spusť:
|
||||
```bash
|
||||
cd docker
|
||||
docker compose up -d
|
||||
docker compose ps
|
||||
```
|
||||
5. Kontrola:
|
||||
- Gateway health: `curl http://192.168.1.76:8770/health`
|
||||
→ očekávané `{"status":"ok","loki":"ready",...}`
|
||||
- Grafana: `http://192.168.1.76:3001` (admin / GRAFANA_PASSWORD)
|
||||
→ Explore → datasource Loki → dotaz `{app="..."}`
|
||||
|
||||
> Porty: Loki 3100 (interní), Grafana 3001 (3000 drží Gitea), Gateway 8770. Uprav, pokud kolidují
|
||||
> (msgreceiver běží na 8765).
|
||||
|
||||
---
|
||||
|
||||
## 4. Integrace do stávajících skriptů
|
||||
|
||||
Typický skript dnes obsahuje:
|
||||
```python
|
||||
import logging
|
||||
logging.basicConfig(filename=str(LOG_FILE), level=logging.ERROR,
|
||||
format="%(asctime)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S", encoding="utf-8")
|
||||
```
|
||||
|
||||
Nahradíš celý blok `basicConfig(...)` jedním voláním — **zbytek skriptu
|
||||
(`logging.error(...)`, `log.info(...)`) zůstává beze změny:**
|
||||
```python
|
||||
import sys
|
||||
sys.path.insert(0, r"U:\PythonProject\Janssen\CentralLogging\client")
|
||||
from central_logging import setup_logging
|
||||
|
||||
log = setup_logging("nazev_skriptu") # label aplikace v Loki
|
||||
```
|
||||
|
||||
Konfigurace přes ENV (nebo argumenty `setup_logging`):
|
||||
|
||||
| ENV | Default | Význam |
|
||||
|-------------------------|-----------------------------|---------------------------------------|
|
||||
| `CENTRAL_LOG_GATEWAY` | `http://192.168.1.76:8770` | URL brány |
|
||||
| `CENTRAL_LOG_TOKEN` | `change-this-shared-secret` | sdílené tajemství (musí sedět s bránou)|
|
||||
| `CENTRAL_LOG_ENV` | `prod` | label prostředí |
|
||||
| `CENTRAL_LOG_KEEP_FILE` | `1` | psát i do souboru (po měsíci → `0`) |
|
||||
| `CENTRAL_LOG_LEVEL` | `INFO` | min. úroveň |
|
||||
|
||||
---
|
||||
|
||||
## 5. Plán migrace (souběh → vypnutí souborů)
|
||||
|
||||
1. **Fáze A — souběh (cca 1 měsíc):** `keep_file=True` (default). Logy jdou do
|
||||
souboru i do Loki. Ověřuješ úplnost, ladíš dashboardy, labely, úrovně.
|
||||
2. **Fáze B — ověření:** porovnáš soubor vs. Loki na vybraných skriptech, že
|
||||
nic nechybí (spool funguje i při výpadcích).
|
||||
3. **Fáze C — vypnutí souborů:** nastav globálně `CENTRAL_LOG_KEEP_FILE=0`
|
||||
(nebo `setup_logging(..., keep_file=False)`). Skripty pak píší jen do
|
||||
centrálu. Staré `.log` soubory přesuň do `TRASH/` (nemazat — konvence).
|
||||
|
||||
---
|
||||
|
||||
## 6. Dotazy v Grafaně (LogQL)
|
||||
|
||||
```logql
|
||||
{app="parse_emails_graph"} # vše z jednoho skriptu
|
||||
{app="parse_emails_graph", level="ERROR"} # jen chyby
|
||||
{env="prod"} |= "bulk_write" # fulltext napříč skripty
|
||||
{app="edc_import"} | json | line > 100 # parsování JSON pole z těla
|
||||
sum by (app) (count_over_time({level="ERROR"}[1h])) # počet chyb / skript / h
|
||||
```
|
||||
|
||||
Labely (nízká kardinalita): `app`, `host`, `level`, `env`.
|
||||
Vše ostatní (`logger`, `func`, `line`, `exc`, `extra`) je v těle řádku jako
|
||||
JSON → v Grafaně dostupné přes `| json`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Bezpečnost a poznámky
|
||||
|
||||
- Token je sdílené tajemství; drž ho v `.env` / ENV, ne v gitu.
|
||||
- Gateway běží v interní docker síti; ven publikuje jen port 8770. Pokud má být
|
||||
dostupná i mimo LAN, dej před ni reverzní proxy s TLS.
|
||||
- Loki má `auth_enabled: false` (single-tenant, interní). Pro veřejné vystavení
|
||||
přidej autentizaci na proxy.
|
||||
- Spool soubory (`_log_spool/*.ndjson`) vznikají jen při výpadku brány a samy
|
||||
se po obnovení spojení vyprázdní.
|
||||
@@ -0,0 +1,31 @@
|
||||
# ============================================================================
|
||||
# central_logging.py — stabilní import shim
|
||||
# Verze: 1.0
|
||||
# Datum: 2026-06-08
|
||||
# Popis: Importovatelný název pro knihovnu centrálního logování.
|
||||
# Vlastní implementace je ve verzovaném souboru
|
||||
# central_logging_v1.0.py (konvence: verze ve jméně). Python však
|
||||
# neumí importovat název s tečkou, takže ho zde načteme přes
|
||||
# importlib a re-exportujeme veřejné API.
|
||||
#
|
||||
# Při vydání nové verze stačí přepnout VERSION_FILE níže.
|
||||
#
|
||||
# Použití ve skriptech:
|
||||
# from central_logging import setup_logging
|
||||
# log = setup_logging("muj_skript")
|
||||
# ============================================================================
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
VERSION_FILE = "central_logging_v1.0.py" # <- při upgrade přepni sem novou verzi
|
||||
|
||||
_path = Path(__file__).resolve().parent / VERSION_FILE
|
||||
_spec = importlib.util.spec_from_file_location("central_logging_impl", _path)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
# re-export veřejného API
|
||||
setup_logging = _mod.setup_logging
|
||||
CentralLogHandler = _mod.CentralLogHandler
|
||||
|
||||
__all__ = ["setup_logging", "CentralLogHandler"]
|
||||
@@ -0,0 +1,56 @@
|
||||
# central_logging — drop-in klient pro centrální logování
|
||||
|
||||
**Verze:** 1.0 · **Datum:** 2026-06-08
|
||||
**Soubory:** `central_logging.py` (import shim) + `central_logging_v1.0.py` (impl)
|
||||
|
||||
Knihovna, která ke stávajícímu souborovému logování přidá odesílání logů do
|
||||
Loki přes bránu `log_gateway`. Jen standardní knihovna — žádné `pip install`.
|
||||
|
||||
## Rychlý start
|
||||
|
||||
```python
|
||||
from central_logging import setup_logging
|
||||
log = setup_logging("nazev_skriptu")
|
||||
log.info("start")
|
||||
log.error("chyba: %s", err)
|
||||
```
|
||||
|
||||
## `setup_logging(...)` — parametry
|
||||
|
||||
| Argument | Default | Popis |
|
||||
|-------------|------------------------|---------------------------------------------|
|
||||
| `app_name` | (povinný) | label aplikace v Loki |
|
||||
| `log_file` | `<app_name>.log` | cesta souborového logu |
|
||||
| `keep_file` | ENV / `True` | psát i do souboru? po měsíci → `False` |
|
||||
| `level` | ENV / `INFO` | min. úroveň |
|
||||
| `gateway` | ENV / `192.168.1.76:8770` | URL brány |
|
||||
| `token` | ENV | sdílené tajemství |
|
||||
| `env` | ENV / `prod` | label prostředí |
|
||||
|
||||
## Jak to funguje
|
||||
|
||||
1. `emit()` jen vloží záznam do fronty (neblokuje skript).
|
||||
2. Vlákno na pozadí každé ~2 s pošle dávku (max 200 záznamů) na
|
||||
`POST /log/batch`.
|
||||
3. **Výpadek brány** → dávka se zapíše do `_log_spool/central_logging_spool_<app>.ndjson`
|
||||
a přehraje se při příštím úspěšném spojení. Žádný log se neztratí.
|
||||
4. `atexit` při ukončení skriptu dolije zbytek fronty.
|
||||
|
||||
Handler je psaný tak, aby **nikdy neshodil aplikaci** — všechny chyby logování
|
||||
se polykají.
|
||||
|
||||
## Import a konvence verzí
|
||||
|
||||
Konvence projektu = verze ve jméně (`central_logging_v1.0.py`). Python ale neumí
|
||||
import názvu s tečkou, proto je tu shim `central_logging.py`, který verzovaný
|
||||
soubor načte přes `importlib` a re-exportuje `setup_logging`. Při vydání v1.1
|
||||
stačí v shimu přepnout `VERSION_FILE` a starou verzi přesunout do `TRASH/`.
|
||||
|
||||
## Vypnutí souborů (fáze C migrace)
|
||||
|
||||
Globálně:
|
||||
```bash
|
||||
set CENTRAL_LOG_KEEP_FILE=0 # Windows
|
||||
export CENTRAL_LOG_KEEP_FILE=0 # Linux
|
||||
```
|
||||
nebo v kódu `setup_logging(..., keep_file=False)`.
|
||||
@@ -0,0 +1,320 @@
|
||||
# ============================================================================
|
||||
# central_logging_v1.0.py
|
||||
# Verze: 1.0
|
||||
# Datum: 2026-06-08
|
||||
# Autor: Vladimír Buzalka
|
||||
# Popis: Drop-in knihovna pro centrální logování do Grafana Loki přes
|
||||
# FastAPI bránu (log_gateway). Přidává se VEDLE stávajícího
|
||||
# souborového logování — jediným voláním setup_logging().
|
||||
#
|
||||
# Návrh (proč takhle):
|
||||
# - JEN standardní knihovna (urllib) — nevyžaduje pip install ve všech
|
||||
# skriptech projektu.
|
||||
# - Neblokující: emit() jen vloží záznam do fronty, odesílá vlákno na
|
||||
# pozadí v dávkách (batch). Skript se logováním nezdrží.
|
||||
# - Odolné proti výpadku: když je gateway nedostupná, dávka spadne do
|
||||
# lokálního spool souboru (.ndjson) a pošle se při příštím úspěchu.
|
||||
# => žádné logy se neztratí, i kdyby server byl chvíli dole.
|
||||
# - keep_file=True ponechá původní souborové logování. Po měsíci, až
|
||||
# bude centrál ověřený, stačí zavolat s keep_file=False (nebo nastavit
|
||||
# ENV CENTRAL_LOG_KEEP_FILE=0) a soubory se přestanou psát.
|
||||
#
|
||||
# Použití (minimum):
|
||||
# from central_logging_v1.0 import setup_logging
|
||||
# log = setup_logging("parse_emails_graph")
|
||||
# log.info("start")
|
||||
# log.error("něco selhalo: %s", err)
|
||||
#
|
||||
# Konfigurace přes ENV (s rozumnými defaulty):
|
||||
# CENTRAL_LOG_GATEWAY http://192.168.1.76:8770
|
||||
# CENTRAL_LOG_TOKEN sdílené tajemství (musí sedět s gateway)
|
||||
# CENTRAL_LOG_ENV prod | test | dev (default prod)
|
||||
# CENTRAL_LOG_KEEP_FILE 1 | 0 (default 1 = piš i soubory)
|
||||
# CENTRAL_LOG_LEVEL INFO | ERROR | ... (default INFO)
|
||||
# CENTRAL_LOG_SPOOL_DIR adresář pro spool (default vedle skriptu)
|
||||
# ============================================================================
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import atexit
|
||||
import socket
|
||||
import logging
|
||||
import threading
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from collections import deque
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Any, Deque, Dict, List, Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Výchozí konfigurace
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_GATEWAY = os.environ.get("CENTRAL_LOG_GATEWAY", "http://192.168.1.76:8770")
|
||||
DEFAULT_TOKEN = os.environ.get("CENTRAL_LOG_TOKEN", "change-this-shared-secret")
|
||||
DEFAULT_ENV = os.environ.get("CENTRAL_LOG_ENV", "prod")
|
||||
DEFAULT_LEVEL = os.environ.get("CENTRAL_LOG_LEVEL", "INFO").upper()
|
||||
|
||||
FLUSH_INTERVAL = 2.0 # s — jak často odeslat nasbíranou dávku
|
||||
BATCH_MAX = 200 # max záznamů v jedné dávce
|
||||
QUEUE_MAX = 50_000 # ochrana proti přetečení paměti
|
||||
HTTP_TIMEOUT = 5.0 # s — timeout odeslání do gateway
|
||||
SPOOL_REPLAY_MAX = 1000 # max záznamů přehraných ze spoolu na jeden cyklus
|
||||
|
||||
|
||||
class _GatewaySender:
|
||||
"""Vlákno na pozadí: sbírá záznamy z fronty a posílá je do gateway
|
||||
v dávkách. Při neúspěchu zapisuje do spool souboru a později přehraje."""
|
||||
|
||||
def __init__(self, app_name: str, gateway: str, token: str, env: str, spool_dir: Path):
|
||||
self.app_name = app_name
|
||||
self.host = socket.gethostname()
|
||||
self.gateway = gateway.rstrip("/")
|
||||
self.token = token
|
||||
self.env = env
|
||||
self.spool_file = spool_dir / f"central_logging_spool_{app_name}.ndjson"
|
||||
spool_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._queue: Deque[Dict[str, Any]] = deque(maxlen=QUEUE_MAX)
|
||||
self._lock = threading.Lock()
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(target=self._run, name=f"central-log-{app_name}", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
# -- veřejné --------------------------------------------------------
|
||||
def submit(self, record: Dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
self._queue.append(record)
|
||||
|
||||
def flush_and_stop(self, timeout: float = 5.0) -> None:
|
||||
self._stop.set()
|
||||
self._thread.join(timeout=timeout)
|
||||
# poslední pokus o odeslání toho, co zbylo
|
||||
self._drain_once(final=True)
|
||||
|
||||
# -- vnitřní --------------------------------------------------------
|
||||
def _run(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
time.sleep(FLUSH_INTERVAL)
|
||||
try:
|
||||
self._replay_spool()
|
||||
self._drain_once()
|
||||
except Exception: # noqa: BLE001 — logování se nikdy nesmí zhroutit
|
||||
pass
|
||||
|
||||
def _pop_batch(self) -> List[Dict[str, Any]]:
|
||||
batch: List[Dict[str, Any]] = []
|
||||
with self._lock:
|
||||
while self._queue and len(batch) < BATCH_MAX:
|
||||
batch.append(self._queue.popleft())
|
||||
return batch
|
||||
|
||||
def _drain_once(self, final: bool = False) -> None:
|
||||
while True:
|
||||
batch = self._pop_batch()
|
||||
if not batch:
|
||||
return
|
||||
ok = self._send(batch)
|
||||
if not ok:
|
||||
self._spool(batch)
|
||||
if final and not self._queue:
|
||||
return
|
||||
|
||||
def _send(self, records: List[Dict[str, Any]]) -> bool:
|
||||
payload = json.dumps({
|
||||
"app": self.app_name,
|
||||
"host": self.host,
|
||||
"env": self.env,
|
||||
"records": records,
|
||||
}, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{self.gateway}/log/batch",
|
||||
data=payload,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
|
||||
return 200 <= resp.status < 300
|
||||
except Exception: # noqa: BLE001 — síť/timeout/HTTP error -> spool
|
||||
return False
|
||||
|
||||
# -- spool (fallback při výpadku) -----------------------------------
|
||||
def _spool(self, records: List[Dict[str, Any]]) -> None:
|
||||
try:
|
||||
with open(self.spool_file, "a", encoding="utf-8") as f:
|
||||
for r in records:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def _replay_spool(self) -> None:
|
||||
if not self.spool_file.exists() or self.spool_file.stat().st_size == 0:
|
||||
return
|
||||
# načti dávku ze spoolu
|
||||
try:
|
||||
with open(self.spool_file, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
except Exception: # noqa: BLE001
|
||||
return
|
||||
if not lines:
|
||||
return
|
||||
|
||||
chunk = lines[:SPOOL_REPLAY_MAX]
|
||||
records = []
|
||||
for ln in chunk:
|
||||
ln = ln.strip()
|
||||
if ln:
|
||||
try:
|
||||
records.append(json.loads(ln))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if records and self._send(records):
|
||||
# úspěch -> odeber přehrané řádky ze spoolu
|
||||
remaining = lines[SPOOL_REPLAY_MAX:]
|
||||
try:
|
||||
if remaining:
|
||||
with open(self.spool_file, "w", encoding="utf-8") as f:
|
||||
f.writelines(remaining)
|
||||
else:
|
||||
self.spool_file.unlink(missing_ok=True)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
class CentralLogHandler(logging.Handler):
|
||||
"""logging.Handler, který předává záznamy senderu na pozadí."""
|
||||
|
||||
def __init__(self, sender: _GatewaySender):
|
||||
super().__init__()
|
||||
self._sender = sender
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
# msg = jen samotná zpráva; čas/úroveň/logger jdou do labelů a polí
|
||||
rec: Dict[str, Any] = {
|
||||
"ts": record.created,
|
||||
"level": record.levelname,
|
||||
"msg": record.getMessage(),
|
||||
"logger": record.name,
|
||||
"func": record.funcName,
|
||||
"line": record.lineno,
|
||||
}
|
||||
# POZOR: formatException je metoda Formatteru, ne Handleru —
|
||||
# proto použij vlastní Formatter, jinak by AttributeError shodil
|
||||
# celý záznam (a tracebacky by se ztrácely).
|
||||
if record.exc_info:
|
||||
rec["exc"] = logging.Formatter().formatException(record.exc_info)
|
||||
self._sender.submit(rec)
|
||||
except Exception: # noqa: BLE001 — handler nikdy nesmí shodit aplikaci
|
||||
pass
|
||||
|
||||
|
||||
def setup_logging(
|
||||
app_name: str,
|
||||
*,
|
||||
log_file: Optional[str] = None,
|
||||
keep_file: Optional[bool] = None,
|
||||
level: Optional[str] = None,
|
||||
gateway: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
env: Optional[str] = None,
|
||||
quiet_loggers: Optional[List[str]] = None,
|
||||
fmt: str = "%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||
datefmt: str = "%Y-%m-%d %H:%M:%S",
|
||||
spool_dir: Optional[str] = None,
|
||||
) -> logging.Logger:
|
||||
"""Nastaví root logger se dvěma cíli:
|
||||
1) souborový handler (RotatingFileHandler) — stávající chování,
|
||||
2) centrální handler do Loki přes gateway (na pozadí).
|
||||
|
||||
Args:
|
||||
app_name: label aplikace v Loki (např. "parse_emails_graph").
|
||||
log_file: cesta k log souboru. Default <app_name>.log vedle skriptu.
|
||||
keep_file: piš i do souboru? Default z ENV CENTRAL_LOG_KEEP_FILE (1).
|
||||
Po měsíci ověřování nastav False -> jen centrál.
|
||||
level: min. úroveň, default ENV CENTRAL_LOG_LEVEL nebo INFO.
|
||||
gateway/token/env: override ENV defaultů.
|
||||
|
||||
Returns:
|
||||
nakonfigurovaný root logger (lze i logging.getLogger()).
|
||||
"""
|
||||
# Konfiguraci čteme z os.environ AŽ TADY (call-time), ne při importu modulu.
|
||||
# Důvod: skripty často načítají vlastní .env (do os.environ) až po importu
|
||||
# této knihovny — kdybychom četli při importu, token/gateway bychom minuli.
|
||||
gw = gateway or os.environ.get("CENTRAL_LOG_GATEWAY", "http://192.168.1.76:8770")
|
||||
tok = token or os.environ.get("CENTRAL_LOG_TOKEN", "change-this-shared-secret")
|
||||
ev = env or os.environ.get("CENTRAL_LOG_ENV", "prod")
|
||||
lvl_name = (level or os.environ.get("CENTRAL_LOG_LEVEL", "INFO")).upper()
|
||||
lvl = getattr(logging, lvl_name, logging.INFO)
|
||||
|
||||
if keep_file is None:
|
||||
keep_file = os.environ.get("CENTRAL_LOG_KEEP_FILE", "1") not in ("0", "false", "False")
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(lvl)
|
||||
|
||||
# Ztiš upovídané knihovny (jinak root@INFO chytá jejich šum do centrálu).
|
||||
# Předej quiet_loggers=[] pro vypnutí, nebo vlastní seznam.
|
||||
_default_quiet = ["httpx", "httpcore", "urllib3", "anthropic", "openai",
|
||||
"PIL", "asyncio", "fdb", "fontTools", "pdfminer"]
|
||||
for _name in (_default_quiet if quiet_loggers is None else quiet_loggers):
|
||||
logging.getLogger(_name).setLevel(logging.WARNING)
|
||||
|
||||
# odstraň případné staré handlery (idempotentní setup)
|
||||
for h in list(root.handlers):
|
||||
root.removeHandler(h)
|
||||
|
||||
formatter = logging.Formatter(fmt=fmt, datefmt=datefmt)
|
||||
|
||||
# 1) Souborový handler (stávající způsob) -------------------------------
|
||||
if keep_file:
|
||||
if log_file is None:
|
||||
base = Path(sys.argv[0]).resolve().parent if sys.argv and sys.argv[0] else Path.cwd()
|
||||
log_file = str(base / f"{app_name}.log")
|
||||
fh = RotatingFileHandler(log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8")
|
||||
fh.setLevel(lvl)
|
||||
fh.setFormatter(formatter)
|
||||
root.addHandler(fh)
|
||||
|
||||
# 2) Centrální handler do Loki (na pozadí) ------------------------------
|
||||
spool_base = Path(spool_dir) if spool_dir else (
|
||||
Path(sys.argv[0]).resolve().parent if sys.argv and sys.argv[0] else Path.cwd()
|
||||
)
|
||||
sender = _GatewaySender(
|
||||
app_name=app_name,
|
||||
gateway=gw,
|
||||
token=tok,
|
||||
env=ev,
|
||||
spool_dir=spool_base / "_log_spool",
|
||||
)
|
||||
ch = CentralLogHandler(sender)
|
||||
ch.setLevel(lvl)
|
||||
ch.setFormatter(formatter)
|
||||
root.addHandler(ch)
|
||||
|
||||
# při ukončení skriptu dolij frontu
|
||||
atexit.register(sender.flush_and_stop)
|
||||
|
||||
root.info("central_logging v1.0 inicializováno (app=%s, keep_file=%s, gateway=%s)",
|
||||
app_name, keep_file, gw)
|
||||
return root
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# rychlý self-test
|
||||
log = setup_logging("central_logging_selftest", level="DEBUG")
|
||||
log.info("ahoj z self-testu")
|
||||
log.warning("varování %d", 42)
|
||||
try:
|
||||
1 / 0
|
||||
except ZeroDivisionError:
|
||||
log.exception("zachycená výjimka")
|
||||
print("Self-test odeslán. Zkontroluj Grafanu / spool soubor.")
|
||||
@@ -0,0 +1,40 @@
|
||||
# ============================================================================
|
||||
# example_usage_v1.0.py
|
||||
# Verze: 1.0
|
||||
# Datum: 2026-06-08
|
||||
# Popis: Ukázka, jak do stávajícího skriptu přidat centrální logování.
|
||||
#
|
||||
# PŘED (typický skript v projektu):
|
||||
# import logging
|
||||
# logging.basicConfig(
|
||||
# filename=str(LOG_FILE), level=logging.ERROR,
|
||||
# format="%(asctime)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S",
|
||||
# encoding="utf-8")
|
||||
# logging.error("něco selhalo: %s", e)
|
||||
#
|
||||
# PO (přidáme centrál, soubor zůstává):
|
||||
# celý blok basicConfig nahradíš jediným řádkem setup_logging(...)
|
||||
# — zbytek skriptu (logging.error / log.info) zůstává beze změny.
|
||||
# ============================================================================
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Knihovna leží v tomto adresáři. V praxi přidej CentralLogging/client do
|
||||
# sys.path (viz níže) nebo zkopíruj central_logging.py + central_logging_v1.0.py
|
||||
# vedle svého skriptu.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from central_logging import setup_logging # stabilní import shim
|
||||
|
||||
log = setup_logging("priklad_skript") # keep_file=True (default)
|
||||
|
||||
log.info("Skript odstartoval")
|
||||
log.warning("Pozor, %d nezpracovaných položek", 5)
|
||||
|
||||
try:
|
||||
raise ValueError("ukázková chyba")
|
||||
except ValueError:
|
||||
log.exception("Zachycená výjimka při zpracování")
|
||||
|
||||
log.info("Hotovo")
|
||||
print("Logy odeslány do souboru i do centrálu (Loki).")
|
||||
@@ -0,0 +1,171 @@
|
||||
{
|
||||
"annotations": { "list": [] },
|
||||
"editable": true,
|
||||
"graphTooltip": 1,
|
||||
"schemaVersion": 39,
|
||||
"tags": ["central-logging"],
|
||||
"title": "Central Logs — přehled",
|
||||
"uid": "central-logs",
|
||||
"time": { "from": "now-24h", "to": "now" },
|
||||
"refresh": "30s",
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "env",
|
||||
"label": "Prostředí",
|
||||
"type": "query",
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"query": "label_values(env)",
|
||||
"refresh": 2,
|
||||
"includeAll": true,
|
||||
"allValue": ".+",
|
||||
"multi": true,
|
||||
"current": { "text": "All", "value": "$__all" }
|
||||
},
|
||||
{
|
||||
"name": "app",
|
||||
"label": "Skript (app)",
|
||||
"type": "query",
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"query": "label_values({env=~\"$env\"}, app)",
|
||||
"refresh": 2,
|
||||
"includeAll": true,
|
||||
"allValue": ".+",
|
||||
"multi": true,
|
||||
"current": { "text": "All", "value": "$__all" }
|
||||
},
|
||||
{
|
||||
"name": "level",
|
||||
"label": "Úroveň",
|
||||
"type": "custom",
|
||||
"query": "DEBUG,INFO,WARNING,ERROR,CRITICAL",
|
||||
"includeAll": true,
|
||||
"allValue": ".+",
|
||||
"multi": true,
|
||||
"current": { "text": "All", "value": "$__all" }
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"label": "Hledat v textu",
|
||||
"type": "textbox",
|
||||
"current": { "text": "", "value": "" }
|
||||
}
|
||||
]
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"title": "Logů celkem (rozsah)",
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 0 },
|
||||
"options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "sum(count_over_time({app=~\"$app\", env=~\"$env\", level=~\"$level\"} |~ \"(?i)$search\" [$__range]))",
|
||||
"queryType": "instant"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "stat",
|
||||
"title": "Chyby (ERROR+CRITICAL, rozsah)",
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 0 },
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": { "mode": "absolute", "steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "red", "value": 1 }
|
||||
] }
|
||||
}
|
||||
},
|
||||
"options": { "colorMode": "background", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"] } },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "sum(count_over_time({app=~\"$app\", env=~\"$env\", level=~\"ERROR|CRITICAL\"} |~ \"(?i)$search\" [$__range]))",
|
||||
"queryType": "instant"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "stat",
|
||||
"title": "Aktivních skriptů",
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 0 },
|
||||
"options": { "colorMode": "value", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"] } },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "count(sum by (app) (count_over_time({env=~\"$env\"} [$__range])))",
|
||||
"queryType": "instant"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "timeseries",
|
||||
"title": "Tok logů podle úrovně",
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
|
||||
"fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 60, "stacking": { "mode": "normal" } } } },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "sum by (level) (count_over_time({app=~\"$app\", env=~\"$env\", level=~\"$level\"} |~ \"(?i)$search\" [$__interval]))",
|
||||
"legendFormat": "{{level}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "timeseries",
|
||||
"title": "Chyby podle skriptu",
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
|
||||
"fieldConfig": { "defaults": { "custom": { "drawStyle": "bars", "fillOpacity": 70, "stacking": { "mode": "normal" } } } },
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "sum by (app) (count_over_time({app=~\"$app\", env=~\"$env\", level=~\"ERROR|CRITICAL\"} |~ \"(?i)$search\" [$__interval]))",
|
||||
"legendFormat": "{{app}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "logs",
|
||||
"title": "Poslední logy",
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"gridPos": { "h": 14, "w": 24, "x": 0, "y": 12 },
|
||||
"options": {
|
||||
"showTime": true,
|
||||
"showLabels": false,
|
||||
"showCommonLabels": false,
|
||||
"wrapLogMessage": true,
|
||||
"prettifyLogMessage": true,
|
||||
"enableLogDetails": true,
|
||||
"dedupStrategy": "none",
|
||||
"sortOrder": "Descending"
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "loki" },
|
||||
"expr": "{app=~\"$app\", env=~\"$env\", level=~\"$level\"} |~ \"(?i)$search\" | json",
|
||||
"maxLines": 1000
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# ============================================================================
|
||||
# CentralLogging — docker-compose (Loki + Grafana + Log Gateway)
|
||||
# Verze: 1.0
|
||||
# Datum: 2026-06-08
|
||||
# Popis: Stack pro centrální logování na Unraidu.
|
||||
# - loki : úložiště logů (HTTP push API), retence 90 dní
|
||||
# - grafana : prohlížení a dashboardy nad Loki
|
||||
# - gateway : FastAPI brána, kam posílají logy klientské skripty
|
||||
#
|
||||
# Spuštění na Unraidu:
|
||||
# docker compose -f docker-compose.yml up -d
|
||||
#
|
||||
# Porty (uprav dle potřeby):
|
||||
# 3100 Loki HTTP (interní, klienti na něj nesahají přímo)
|
||||
# 3000 Grafana web UI
|
||||
# 8770 Log Gateway (sem posílají skripty)
|
||||
# ============================================================================
|
||||
services:
|
||||
|
||||
loki:
|
||||
image: grafana/loki:3.1.1
|
||||
container_name: central-loki
|
||||
restart: unless-stopped
|
||||
command: -config.file=/etc/loki/loki-config.yml
|
||||
ports:
|
||||
- "3100:3100"
|
||||
volumes:
|
||||
- ./loki-config.yml:/etc/loki/loki-config.yml:ro
|
||||
# Persistentní data na Unraid share (desítky TB) — uprav cestu:
|
||||
- /mnt/user/appdata/central-logging/loki:/loki
|
||||
networks:
|
||||
- logging
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:11.2.0
|
||||
container_name: central-grafana
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3001:3000" # 3000 na Unraidu drží Gitea -> Grafana na 3001
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-changeme}
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
volumes:
|
||||
- /mnt/user/appdata/central-logging/grafana:/var/lib/grafana
|
||||
- ./grafana-datasource.yml:/etc/grafana/provisioning/datasources/loki.yml:ro
|
||||
- ./grafana-dashboards.yml:/etc/grafana/provisioning/dashboards/central.yml:ro
|
||||
- ./dashboards:/etc/grafana/provisioning/dashboards/json:ro
|
||||
depends_on:
|
||||
- loki
|
||||
networks:
|
||||
- logging
|
||||
|
||||
gateway:
|
||||
build:
|
||||
context: ../gateway
|
||||
image: central-log-gateway:1.0
|
||||
container_name: central-log-gateway
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8770:8770"
|
||||
environment:
|
||||
- LOKI_URL=http://loki:3100
|
||||
- LOG_TOKEN=${LOG_TOKEN:-change-this-shared-secret}
|
||||
- GATEWAY_ENV=prod
|
||||
depends_on:
|
||||
- loki
|
||||
networks:
|
||||
- logging
|
||||
|
||||
networks:
|
||||
logging:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,20 @@
|
||||
# ============================================================================
|
||||
# CentralLogging — Grafana dashboard provisioning
|
||||
# Verze: 1.0
|
||||
# Datum: 2026-06-08
|
||||
# Popis: Automaticky načte dashboardy z /etc/grafana/provisioning/dashboards/json
|
||||
# (mountováno z ./dashboards). Soubor .json = jeden dashboard.
|
||||
# ============================================================================
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: CentralLogging
|
||||
orgId: 1
|
||||
folder: CentralLogging
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 30
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /etc/grafana/provisioning/dashboards/json
|
||||
foldersFromFilesStructure: false
|
||||
@@ -0,0 +1,17 @@
|
||||
# ============================================================================
|
||||
# CentralLogging — Grafana datasource provisioning
|
||||
# Verze: 1.0
|
||||
# Datum: 2026-06-08
|
||||
# Popis: Automaticky přidá Loki jako datasource při startu Grafany.
|
||||
# ============================================================================
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Loki
|
||||
type: loki
|
||||
uid: loki # pevné uid, na které odkazuje dashboard
|
||||
access: proxy
|
||||
url: http://loki:3100
|
||||
isDefault: true
|
||||
jsonData:
|
||||
maxLines: 5000
|
||||
@@ -0,0 +1,62 @@
|
||||
# ============================================================================
|
||||
# CentralLogging — Loki konfigurace
|
||||
# Verze: 1.0
|
||||
# Datum: 2026-06-08
|
||||
# Popis: Single-binary Loki, filesystem storage, retence 90 dní.
|
||||
# Retenci vynucuje compactor (delete_request + retention_period).
|
||||
# ============================================================================
|
||||
auth_enabled: false
|
||||
|
||||
server:
|
||||
http_listen_port: 3100
|
||||
grpc_listen_port: 9096
|
||||
log_level: info
|
||||
|
||||
common:
|
||||
instance_addr: 127.0.0.1
|
||||
path_prefix: /loki
|
||||
storage:
|
||||
filesystem:
|
||||
chunks_directory: /loki/chunks
|
||||
rules_directory: /loki/rules
|
||||
replication_factor: 1
|
||||
ring:
|
||||
kvstore:
|
||||
store: inmemory
|
||||
|
||||
schema_config:
|
||||
configs:
|
||||
- from: 2024-01-01
|
||||
store: tsdb
|
||||
object_store: filesystem
|
||||
schema: v13
|
||||
index:
|
||||
prefix: index_
|
||||
period: 24h
|
||||
|
||||
storage_config:
|
||||
tsdb_shipper:
|
||||
active_index_directory: /loki/tsdb-index
|
||||
cache_location: /loki/tsdb-cache
|
||||
filesystem:
|
||||
directory: /loki/chunks
|
||||
|
||||
limits_config:
|
||||
retention_period: 2160h # 90 dní
|
||||
reject_old_samples: true
|
||||
reject_old_samples_max_age: 168h # 7 dní (logy starší se odmítnou)
|
||||
max_label_names_per_series: 15
|
||||
ingestion_rate_mb: 16
|
||||
ingestion_burst_size_mb: 32
|
||||
allow_structured_metadata: true
|
||||
|
||||
compactor:
|
||||
working_directory: /loki/compactor
|
||||
compaction_interval: 10m
|
||||
retention_enabled: true # zapne mazání dle retention_period
|
||||
retention_delete_delay: 2h
|
||||
delete_request_store: filesystem
|
||||
|
||||
# Analytika do Grafana Labs vypnuta
|
||||
analytics:
|
||||
reporting_enabled: false
|
||||
@@ -0,0 +1,20 @@
|
||||
# ============================================================================
|
||||
# CentralLogging — Log Gateway image
|
||||
# Verze: 1.0
|
||||
# Datum: 2026-06-08
|
||||
# ============================================================================
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY log_gateway_v1.0.py .
|
||||
COPY main.py .
|
||||
|
||||
EXPOSE 8770
|
||||
|
||||
# main.py je shim, který přes importlib načte verzovaný log_gateway_v1.0.py
|
||||
# (uvicorn neumí import názvu s tečkou).
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8770"]
|
||||
@@ -0,0 +1,57 @@
|
||||
# log_gateway — FastAPI brána pro centrální logování
|
||||
|
||||
**Verze:** 1.0 · **Datum:** 2026-06-08 · **Soubor:** `log_gateway_v1.0.py`
|
||||
|
||||
FastAPI služba, která přijímá logy od klientských skriptů a přeposílá je do
|
||||
Grafana Loki přes jeho HTTP push API.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Metoda | Cesta | Popis |
|
||||
|--------|---------------|--------------------------------------------------|
|
||||
| GET | `/health` | liveness + dostupnost Loki (`/ready`) |
|
||||
| POST | `/log/batch` | dávka záznamů (klient používá toto) |
|
||||
| POST | `/log` | jeden záznam (query: `app_name`, `host`, `env`) |
|
||||
|
||||
Autorizace: hlavička `Authorization: Bearer <LOG_TOKEN>`.
|
||||
|
||||
## Payload `/log/batch`
|
||||
|
||||
```json
|
||||
{
|
||||
"app": "parse_emails_graph",
|
||||
"host": "PC-VB",
|
||||
"env": "prod",
|
||||
"records": [
|
||||
{"ts": 1780921433.81, "level": "ERROR", "msg": "bulk_write: ...",
|
||||
"logger": "root", "func": "save_batch", "line": 542, "exc": "Traceback ..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Brána seskupí záznamy podle `(app, host, level, env)` do Loki streamů, převede
|
||||
`ts` na nanosekundy, tělo řádku uloží jako JSON (kvůli `| json` v Grafaně) a
|
||||
pošle na `POST {LOKI_URL}/loki/api/v1/push`.
|
||||
|
||||
## ENV
|
||||
|
||||
| ENV | Default | Popis |
|
||||
|--------------|------------------------|------------------------------------|
|
||||
| `LOKI_URL` | `http://loki:3100` | adresa Loki (uvnitř docker sítě) |
|
||||
| `LOG_TOKEN` | `change-this-...` | sdílené tajemství (= klientův token)|
|
||||
| `GATEWAY_ENV`| `prod` | výchozí label env, když klient neuvede|
|
||||
|
||||
## Lokální běh (mimo docker)
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
LOKI_URL=http://192.168.1.76:3100 LOG_TOKEN=tajne \
|
||||
uvicorn log_gateway_v1.0:app --host 0.0.0.0 --port 8770
|
||||
```
|
||||
|
||||
## Návrhové poznámky
|
||||
|
||||
- Labely držíme nízkokardinální (`ALLOWED_LABELS`). Nikdy nedávat do labelů
|
||||
např. ID zprávy / pacienta — explodovala by kardinalita sérií v Loki.
|
||||
- Při chybě Loki vrací brána 502; klient si záznam uloží do spoolu a zkusí
|
||||
znovu → data se neztratí.
|
||||
@@ -0,0 +1,162 @@
|
||||
# ============================================================================
|
||||
# log_gateway_v1.0.py
|
||||
# Verze: 1.0
|
||||
# Datum: 2026-06-08
|
||||
# Autor: Vladimír Buzalka
|
||||
# Popis: FastAPI brána pro centrální logování. Přijímá jednoduchý JSON
|
||||
# od klientských skriptů (knihovna central_logging) a přeposílá
|
||||
# do Grafana Loki přes jeho HTTP push API.
|
||||
#
|
||||
# Proč brána a ne push přímo do Loki:
|
||||
# - klient nezná interní detaily Loki (ns timestampy, streamy)
|
||||
# - jeden sdílený token (Bearer) místo přístupu do DB/Loki
|
||||
# - lze kdykoli vyměnit backend bez zásahu do skriptů
|
||||
# - centrální místo pro normalizaci labelů a rate-limit
|
||||
#
|
||||
# Endpoints:
|
||||
# GET /health — liveness + dostupnost Loki
|
||||
# POST /log — jeden log záznam
|
||||
# POST /log/batch — dávka záznamů (preferováno klientem)
|
||||
#
|
||||
# Autorizace: hlavička Authorization: Bearer <LOG_TOKEN>
|
||||
#
|
||||
# ENV:
|
||||
# LOKI_URL výchozí http://loki:3100
|
||||
# LOG_TOKEN sdílené tajemství (musí sedět s klientem)
|
||||
# GATEWAY_ENV label env, výchozí "prod"
|
||||
# ============================================================================
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
LOKI_URL = os.environ.get("LOKI_URL", "http://loki:3100").rstrip("/")
|
||||
LOG_TOKEN = os.environ.get("LOG_TOKEN", "change-this-shared-secret")
|
||||
DEFAULT_ENV = os.environ.get("GATEWAY_ENV", "prod")
|
||||
|
||||
# Loki: labely držíme s nízkou kardinalitou (jinak exploze sérií).
|
||||
# Vše ostatní (logger, func, line, exc) jde do těla log řádku jako JSON.
|
||||
ALLOWED_LABELS = ("app", "host", "level", "env")
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
|
||||
log = logging.getLogger("log_gateway")
|
||||
|
||||
app = FastAPI(title="Central Log Gateway", version="1.0")
|
||||
|
||||
|
||||
class LogRecord(BaseModel):
|
||||
ts: Optional[float] = Field(None, description="Unix čas v sekundách (float). Když chybí, doplní brána.")
|
||||
level: str = "INFO"
|
||||
msg: str = ""
|
||||
logger: Optional[str] = None
|
||||
func: Optional[str] = None
|
||||
line: Optional[int] = None
|
||||
exc: Optional[str] = None
|
||||
extra: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class BatchPayload(BaseModel):
|
||||
app: str
|
||||
host: str = "unknown"
|
||||
env: Optional[str] = None
|
||||
records: List[LogRecord]
|
||||
|
||||
|
||||
def _check_token(authorization: Optional[str]) -> None:
|
||||
expected = f"Bearer {LOG_TOKEN}"
|
||||
if not authorization or authorization != expected:
|
||||
raise HTTPException(status_code=401, detail="Neplatný nebo chybějící token.")
|
||||
|
||||
|
||||
def _to_loki_streams(payload: BatchPayload) -> Dict[str, Any]:
|
||||
"""Seskupí záznamy podle (app, host, level, env) do Loki streamů.
|
||||
Hodnota každé položky = [unix_nano_str, json_řádek]."""
|
||||
env = payload.env or DEFAULT_ENV
|
||||
streams: Dict[tuple, List[List[str]]] = {}
|
||||
|
||||
for r in payload.records:
|
||||
ts = r.ts if r.ts is not None else time.time()
|
||||
ts_nano = str(int(ts * 1_000_000_000))
|
||||
|
||||
line_obj: Dict[str, Any] = {"msg": r.msg}
|
||||
if r.logger:
|
||||
line_obj["logger"] = r.logger
|
||||
if r.func:
|
||||
line_obj["func"] = r.func
|
||||
if r.line is not None:
|
||||
line_obj["line"] = r.line
|
||||
if r.exc:
|
||||
line_obj["exc"] = r.exc
|
||||
if r.extra:
|
||||
line_obj["extra"] = r.extra
|
||||
|
||||
key = (payload.app, payload.host, (r.level or "INFO").upper(), env)
|
||||
streams.setdefault(key, []).append([ts_nano, json.dumps(line_obj, ensure_ascii=False)])
|
||||
|
||||
out_streams = []
|
||||
for (app_name, host, level, env_v), values in streams.items():
|
||||
# Loki vyžaduje values seřazené vzestupně dle času v rámci streamu
|
||||
values.sort(key=lambda v: v[0])
|
||||
out_streams.append({
|
||||
"stream": {"app": app_name, "host": host, "level": level, "env": env_v},
|
||||
"values": values,
|
||||
})
|
||||
return {"streams": out_streams}
|
||||
|
||||
|
||||
async def _push_to_loki(body: Dict[str, Any]) -> None:
|
||||
url = f"{LOKI_URL}/loki/api/v1/push"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(url, json=body, headers={"Content-Type": "application/json"})
|
||||
if resp.status_code >= 300:
|
||||
log.error("Loki push selhal %s: %s", resp.status_code, resp.text[:500])
|
||||
raise HTTPException(status_code=502, detail=f"Loki push selhal: {resp.status_code}")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> Dict[str, Any]:
|
||||
loki_ok = False
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
r = await client.get(f"{LOKI_URL}/ready")
|
||||
loki_ok = r.status_code == 200
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("Loki nedostupný: %s", e)
|
||||
return {"status": "ok", "loki": "ready" if loki_ok else "unavailable", "version": "1.0"}
|
||||
|
||||
|
||||
@app.post("/log/batch")
|
||||
async def log_batch(payload: BatchPayload, authorization: Optional[str] = Header(None)) -> Dict[str, Any]:
|
||||
_check_token(authorization)
|
||||
if not payload.records:
|
||||
return {"accepted": 0}
|
||||
body = _to_loki_streams(payload)
|
||||
await _push_to_loki(body)
|
||||
return {"accepted": len(payload.records), "streams": len(body["streams"])}
|
||||
|
||||
|
||||
@app.post("/log")
|
||||
async def log_one(
|
||||
record: LogRecord,
|
||||
app_name: str,
|
||||
host: str = "unknown",
|
||||
env: Optional[str] = None,
|
||||
authorization: Optional[str] = Header(None),
|
||||
) -> Dict[str, Any]:
|
||||
_check_token(authorization)
|
||||
payload = BatchPayload(app=app_name, host=host, env=env, records=[record])
|
||||
body = _to_loki_streams(payload)
|
||||
await _push_to_loki(body)
|
||||
return {"accepted": 1}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8770)
|
||||
@@ -0,0 +1,25 @@
|
||||
# ============================================================================
|
||||
# main.py — ASGI import shim pro uvicorn
|
||||
# Verze: 1.0
|
||||
# Datum: 2026-06-08
|
||||
# Popis: Uvicorn neumí naimportovat modul s tečkou v názvu
|
||||
# (log_gateway_v1.0 -> čte .0 jako submodul). Tento shim načte
|
||||
# verzovaný soubor přes importlib a vystaví `app` pro uvicorn.
|
||||
# Spouštění: uvicorn main:app
|
||||
# Při upgrade přepni VERSION_FILE.
|
||||
# ============================================================================
|
||||
import sys
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
VERSION_FILE = "log_gateway_v1.0.py"
|
||||
|
||||
_path = Path(__file__).resolve().parent / VERSION_FILE
|
||||
_spec = importlib.util.spec_from_file_location("log_gateway_impl", _path)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
# Registrace do sys.modules je nutná, aby pydantic uměl dohledat forward-ref
|
||||
# typy (Optional, ...) při `from __future__ import annotations`.
|
||||
sys.modules[_spec.name] = _mod
|
||||
_spec.loader.exec_module(_mod)
|
||||
|
||||
app = _mod.app
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
httpx==0.27.2
|
||||
pydantic==2.9.2
|
||||
Reference in New Issue
Block a user