# ============================================================================ # 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 # # 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)