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:
2026-06-08 16:06:21 +02:00
parent af787d9f02
commit 5545f05eee
173 changed files with 21334 additions and 1 deletions
+158
View File
@@ -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í.
+31
View File
@@ -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
}
]
}
]
}
+73
View File
@@ -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
+62
View File
@@ -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
+20
View File
@@ -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í.
+162
View File
@@ -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)
+25
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
httpx==0.27.2
pydantic==2.9.2