fix(apache): Sekunden-Kollision behoben (log_time + converter) — Node lieferte gar nichts mehr
Echte x22-Logs: 2000 Zeilen -> 286 Kollisionen (duplicate sample). Telegraf uebernimmt den Log-Zeitstempel; bei mehreren Treffern pro Sekunde kollidiert der +1ms-Aufschlag mit echten Zeilen. Prometheus weist den Batch ab, Telegraf retryt endlos -> FIFO steht -> Node liefert GAR NICHTS mehr. Fix: eigenes Grok-Muster mit log_time als Feld (Telegraf nutzt eigene ns-Uhr) + processors.converter, damit resp_code/verb Tags bleiben. Gemessen: 2000/2000 Samples, 0 Kollisionen. Ausserdem: Einheiten-Waechter im Archiver (ms statt s -> Abbruch statt nutzloser Block).
This commit is contained in:
1 parent
5a213eb580
commit
5483a4fea8
7 files changed
+626
-7
No files matched your search
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
apache-log-archive.py — Alte Apache-Logs parsen und als Prometheus-Archiv ablegen.
|
||||
|
||||
ABLAUF
|
||||
1. Logs einlesen (auch rotierte .gz), Access- UND Error-Logs.
|
||||
2. Pro Stunde/Domain/Status zu Metriken aggregieren.
|
||||
3. OpenMetrics-Dateien schreiben (Prometheus-Textformat mit Zeitstempeln).
|
||||
4. `promtool tsdb create-blocks-from openmetrics` erzeugt daraus TSDB-Blöcke.
|
||||
5. Ergebnis als tar.gz bereitstellen — zur Uebergabe an den zentralen Server.
|
||||
|
||||
WARUM AGGREGIERT UND NICHT PRO REQUEST
|
||||
Prometheus ist eine TSDB, kein Logstore. Eine Serie pro Request waere bei
|
||||
Millionen Zeilen sofort toedlich (Kardinalitaet). Aggregiert auf
|
||||
Stunde/Domain/Status bleiben es wenige tausend Serien — und beantwortet genau
|
||||
die Fragen, fuer die man alte Logs anschaut: Wie viele Requests, welche
|
||||
Fehlerquote, welcher Traffic, zu welcher Zeit.
|
||||
|
||||
Rohzeilen gehoeren in ein Logarchiv (Loki/tar.gz), nicht in die TSDB. Dieses
|
||||
Skript produziert beides: Metriken fuer Prometheus UND ein tar.gz der Rohlogs.
|
||||
|
||||
ERZEUGTE METRIKEN (Name : Labels)
|
||||
apache_archive_requests_total : domain, method, status, status_class
|
||||
apache_archive_response_bytes_total : domain
|
||||
apache_archive_unique_clients : domain (geschaetzt, pro Stunde)
|
||||
apache_archive_errors_total : domain, level
|
||||
apache_archive_log_lines_total : domain, kind (access|error|unparsed)
|
||||
|
||||
Alle tragen zusaetzlich label "archived=1" und "instance=<node>", damit sie
|
||||
im Dashboard von Live-Daten unterscheidbar sind.
|
||||
|
||||
AUFRUF (auf dem Quellhost, als root)
|
||||
python3 apache-log-archive.py --node x22.idx.wf-keyhelp
|
||||
python3 apache-log-archive.py --node x22 --out /root/apache-archive
|
||||
|
||||
Danach die .tar.gz auf den imac bringen und dort einspielen (siehe README).
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import gzip
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
PATTERNS = [
|
||||
"/var/log/apache2/*access*.log", "/var/log/apache2/*access*.log.*",
|
||||
"/var/log/apache2/*error*.log", "/var/log/apache2/*error*.log.*",
|
||||
"/home/users/*/logs/*/access.log", "/home/users/*/logs/*/access.log.*",
|
||||
"/home/users/*/logs/*/error.log", "/home/users/*/logs/*/error.log.*",
|
||||
"/var/www/vhosts/*/logs/access_log*", "/var/www/vhosts/*/logs/error_log*",
|
||||
]
|
||||
|
||||
RE_LINE = re.compile(
|
||||
r'^(?P<ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] "(?P<req>[^"]*)" (?P<status>\d{3}|-) (?P<bytes>\d+|-)'
|
||||
)
|
||||
RE_ERR = re.compile(r'^\[(?P<ts>[^\]]+)\] \[(?P<mod>[^\]:]+):(?P<lvl>\w+)\]')
|
||||
|
||||
TS_FORMATS = ["%d/%b/%Y:%H:%M:%S %z", "%a %b %d %H:%M:%S.%f %Y", "%a %b %d %H:%M:%S %Y"]
|
||||
|
||||
# Metriknamen, die das Skript erzeugt — auch fuer den Import-Loader relevant.
|
||||
METRICS = [
|
||||
"apache_archive_requests_total",
|
||||
"apache_archive_response_bytes_total",
|
||||
"apache_archive_unique_clients",
|
||||
"apache_archive_errors_total",
|
||||
"apache_archive_log_lines_total",
|
||||
]
|
||||
|
||||
|
||||
def parse_ts(raw):
|
||||
for fmt in TS_FORMATS:
|
||||
try:
|
||||
dt = datetime.strptime(raw.strip(), fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.astimezone()
|
||||
return dt.astimezone(timezone.utc)
|
||||
return None
|
||||
|
||||
|
||||
def status_class(code):
|
||||
try:
|
||||
c = int(code)
|
||||
except (TypeError, ValueError):
|
||||
return "unknown"
|
||||
return f"{c // 100}xx"
|
||||
|
||||
|
||||
def esc(v):
|
||||
"""Label-Wert fuer OpenMetrics escapen (Backslash, Anfuehrungszeichen, LF)."""
|
||||
return str(v).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
||||
|
||||
|
||||
def collect(patterns):
|
||||
"""Alle Logdateien finden und parsen. Liefert Zaehler und Rohdateiliste."""
|
||||
files = sorted({f for pat in patterns for f in glob.glob(pat)})
|
||||
# requests[(hour, domain, method, status)] = count
|
||||
requests = defaultdict(int)
|
||||
bytes_by_domain = defaultdict(int)
|
||||
bytes_by_hour = defaultdict(int) # (hour, domain) -> Bytes
|
||||
clients = defaultdict(set) # (hour, domain) -> {ip}
|
||||
errors = defaultdict(int) # (hour, domain, level) -> count
|
||||
lines = defaultdict(int) # (domain, kind) -> count
|
||||
|
||||
for path in files:
|
||||
domain = os.path.basename(os.path.dirname(path)) or os.path.basename(path)
|
||||
try:
|
||||
fh = (gzip.open(path, "rt", errors="replace") if path.endswith(".gz")
|
||||
else open(path, "r", errors="replace"))
|
||||
except OSError:
|
||||
continue
|
||||
with fh:
|
||||
for line in fh:
|
||||
m = RE_LINE.match(line)
|
||||
if m:
|
||||
lines[(domain, "access")] += 1
|
||||
ts = parse_ts(m.group("ts"))
|
||||
req = m.group("req") or ""
|
||||
method = req.split(" ", 1)[0] if " " in req else "UNKNOWN"
|
||||
status = m.group("status") or "unknown"
|
||||
hour = ts.strftime("%Y-%m-%dT%H:00:00Z") if ts else "unknown"
|
||||
requests[(hour, domain, method, status)] += 1
|
||||
try:
|
||||
nb = int(m.group("bytes") or 0)
|
||||
bytes_by_domain[domain] += nb
|
||||
if ts:
|
||||
bytes_by_hour[(hour, domain)] += nb
|
||||
except ValueError:
|
||||
pass
|
||||
if ts:
|
||||
clients[(hour, domain)].add(m.group("ip"))
|
||||
continue
|
||||
m = RE_ERR.match(line)
|
||||
if m:
|
||||
lines[(domain, "error")] += 1
|
||||
ts = parse_ts(m.group("ts"))
|
||||
hour = ts.strftime("%Y-%m-%dT%H:00:00Z") if ts else "unknown"
|
||||
errors[(hour, domain, m.group("lvl").lower())] += 1
|
||||
continue
|
||||
lines[(domain, "unparsed")] += 1
|
||||
return files, requests, bytes_by_domain, bytes_by_hour, clients, errors, lines
|
||||
|
||||
|
||||
def write_openmetrics(path, node, requests, bytes_by_domain, bytes_by_hour, clients, errors, lines):
|
||||
"""OpenMetrics-Textdatei schreiben. Zeitstempel in SEKUNDEN.
|
||||
|
||||
ACHTUNG: OpenMetrics will Sekunden (float). Nicht Millisekunden —
|
||||
Prometheus multipliziert beim Import mit 1000 und legt die Bloecke
|
||||
sonst ins Jahr 58.662, wo sie verworfen werden.
|
||||
"""
|
||||
base_labels = f'archived="1",instance="{esc(node)}"'
|
||||
n = 0
|
||||
_max_ts = 0 # groesster geschriebener Zeitstempel (Einheiten-Waechter)
|
||||
with open(path, "w") as fh:
|
||||
# EINHEITEN — hier lag ein realer Fehler:
|
||||
# OpenMetrics-Zeitstempel sind SEKUNDEN (float). Prometheus wandelt beim
|
||||
# Import ms = s * 1000. Wer hier Millisekunden schreibt, bekommt Bloecke
|
||||
# im Jahr 58.662 — sie werden als "too far into the future" verworfen
|
||||
# und die Abfrage liefert stillschweigend nichts. Kein * 1000.
|
||||
fh.write("# TYPE apache_archive_requests_total counter\n")
|
||||
fh.write("# TYPE apache_archive_response_bytes_total counter\n")
|
||||
fh.write("# TYPE apache_archive_unique_clients gauge\n")
|
||||
fh.write("# TYPE apache_archive_errors_total counter\n")
|
||||
fh.write("# TYPE apache_archive_log_lines_total counter\n")
|
||||
|
||||
for (hour, domain, method, status), cnt in sorted(requests.items()):
|
||||
if hour == "unknown":
|
||||
continue
|
||||
ms = int(datetime.strptime(hour, "%Y-%m-%dT%H:00:00Z")
|
||||
.replace(tzinfo=timezone.utc).timestamp())
|
||||
labels = (f'{base_labels},domain="{esc(domain)}",method="{esc(method)}",'
|
||||
f'status="{esc(status)}",status_class="{esc(status_class(status))}"')
|
||||
fh.write(f"apache_archive_requests_total{{{labels}}} {cnt} {ms}\n")
|
||||
_max_ts = max(_max_ts, ms)
|
||||
n += 1
|
||||
|
||||
# Bytes pro Stunde: das ergibt die Traffic-Zeitreihe im Archiv.
|
||||
for (hour, domain), total in sorted(bytes_by_hour.items()):
|
||||
if hour == "unknown":
|
||||
continue
|
||||
ms = int(datetime.strptime(hour, "%Y-%m-%dT%H:00:00Z")
|
||||
.replace(tzinfo=timezone.utc).timestamp())
|
||||
labels = f'{base_labels},domain="{esc(domain)}"'
|
||||
fh.write(f"apache_archive_response_bytes_total{{{labels}}} {total} {ms}\n")
|
||||
_max_ts = max(_max_ts, ms)
|
||||
n += 1
|
||||
|
||||
for (hour, domain), ips in sorted(clients.items()):
|
||||
try:
|
||||
ms = int(datetime.strptime(hour, "%Y-%m-%dT%H:00:00Z")
|
||||
.replace(tzinfo=timezone.utc).timestamp())
|
||||
except ValueError:
|
||||
continue
|
||||
labels = f'{base_labels},domain="{esc(domain)}"'
|
||||
fh.write(f"apache_archive_unique_clients{{{labels}}} {len(ips)} {ms}\n")
|
||||
_max_ts = max(_max_ts, ms)
|
||||
n += 1
|
||||
|
||||
for (hour, domain, level), cnt in sorted(errors.items()):
|
||||
if hour == "unknown":
|
||||
continue
|
||||
ms = int(datetime.strptime(hour, "%Y-%m-%dT%H:00:00Z")
|
||||
.replace(tzinfo=timezone.utc).timestamp())
|
||||
labels = f'{base_labels},domain="{esc(domain)}",level="{esc(level)}"'
|
||||
fh.write(f"apache_archive_errors_total{{{labels}}} {cnt} {ms}\n")
|
||||
_max_ts = max(_max_ts, ms)
|
||||
n += 1
|
||||
|
||||
# Zeilenzahlen sind Zustandswerte ohne eigenen Zeitbezug. Sie werden auf
|
||||
# die LETZTE Log-Stunde gestempelt, nicht auf "jetzt" — sonst haengt ein
|
||||
# Archiv-Eintrag am Abruftag und die Historie wirkt verschoben.
|
||||
_hours = [h for (h, _, _, _) in requests if h != "unknown"]
|
||||
if _hours:
|
||||
stamp_s = int(datetime.strptime(max(_hours), "%Y-%m-%dT%H:00:00Z")
|
||||
.replace(tzinfo=timezone.utc).timestamp())
|
||||
else:
|
||||
stamp_s = int(datetime.now(timezone.utc).timestamp())
|
||||
for (domain, kind), cnt in sorted(lines.items()):
|
||||
labels = f'{base_labels},domain="{esc(domain)}",kind="{esc(kind)}"'
|
||||
fh.write(f"apache_archive_log_lines_total{{{labels}}} {cnt} {stamp_s}\n")
|
||||
_max_ts = max(_max_ts, stamp_s)
|
||||
n += 1
|
||||
|
||||
# EINHEITEN-WAECHTER: Der groesste Zeitstempel muss ein plausibles
|
||||
# Sekunden-Datum ergeben. Faengt 1000x-Fehler (ms statt s) sofort ab,
|
||||
# statt sie still in einen nutzlosen Block zu schreiben.
|
||||
if _max_ts > 4102444800: # nach dem Jahr 2100 -> kein Sekundenwert
|
||||
raise SystemExit(
|
||||
f"FEHLER: Zeitstempel {_max_ts} liegt nach dem Jahr 2100.\n"
|
||||
f" OpenMetrics erwartet SEKUNDEN, nicht Millisekunden.\n"
|
||||
f" Pruefe die *1000-Multiplikationen in diesem Skript.\n"
|
||||
f" Ohne Korrektur entstehen Bloecke, die Prometheus verwirft.")
|
||||
|
||||
# PFLICHT: OpenMetrics verlangt "# EOF" als letzte Zeile. Ohne das
|
||||
# lehnt promtool ab: "data does not end with # EOF".
|
||||
fh.write("# EOF\n")
|
||||
return n
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Apache-Logs als Prometheus-Archiv aufbereiten")
|
||||
ap.add_argument("--node", required=True, help="Node-Name fuer das instance-Label")
|
||||
ap.add_argument("--out", default="/root/apache-archive", help="Ausgabeverzeichnis")
|
||||
ap.add_argument("--include-raw", action="store_true",
|
||||
help="Rohlogs mit ins tar.gz packen (empfohlen)")
|
||||
args = ap.parse_args()
|
||||
|
||||
os.makedirs(args.out, exist_ok=True)
|
||||
print(f"[1/5] Logs einlesen …")
|
||||
files, requests, bytes_by_domain, bytes_by_hour, clients, errors, lines = collect(PATTERNS)
|
||||
if not files:
|
||||
print("Keine Logdateien gefunden. Muster:", ", ".join(PATTERNS[:4]), file=sys.stderr)
|
||||
return 1
|
||||
total_lines = sum(lines.values())
|
||||
print(f" {len(files)} Dateien, {total_lines:,} Zeilen")
|
||||
print(f" {sum(requests.values()):,} Zugriffe, {sum(errors.values()):,} Fehlereintraege")
|
||||
|
||||
print(f"[2/5] OpenMetrics schreiben …")
|
||||
om = os.path.join(args.out, "apache-archive.openmetrics")
|
||||
n = write_openmetrics(om, args.node, requests, bytes_by_domain, bytes_by_hour,
|
||||
clients, errors, lines)
|
||||
size = os.path.getsize(om)
|
||||
print(f" {n:,} Datenpunkte, {size/1024:.1f} KiB")
|
||||
|
||||
# promtool suchen — liegt beim prometheus-Container bzw. als Binary vor.
|
||||
promtool = None
|
||||
for cand in ("promtool", "/usr/local/bin/promtool", "/usr/bin/promtool"):
|
||||
try:
|
||||
subprocess.run([cand, "--version"], capture_output=True, check=True)
|
||||
promtool = cand
|
||||
break
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
continue
|
||||
|
||||
if promtool is None:
|
||||
print(f"[3/5] promtool nicht gefunden — openmetrics-Datei bleibt liegen.")
|
||||
print(f" Der zentrale Server kann sie direkt verarbeiten:")
|
||||
print(f" promtool tsdb create-blocks-from openmetrics {om} <blockdir>")
|
||||
blocks_dir = None
|
||||
else:
|
||||
print(f"[3/5] TSDB-Bloecke erzeugen (promtool) …")
|
||||
blocks_dir = os.path.join(args.out, "blocks")
|
||||
os.makedirs(blocks_dir, exist_ok=True)
|
||||
r = subprocess.run([promtool, "tsdb", "create-blocks-from", "openmetrics",
|
||||
om, blocks_dir], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
print(" promtool-Fehler:", r.stderr.strip()[:400], file=sys.stderr)
|
||||
blocks_dir = None
|
||||
else:
|
||||
print(" " + (r.stdout.strip() or "Bloecke erzeugt"))
|
||||
|
||||
print(f"[4/5] Archiv packen …")
|
||||
tar_path = os.path.join(args.out, f"apache-archive-{args.node}.tar.gz")
|
||||
with tarfile.open(tar_path, "w:gz") as tf:
|
||||
tf.add(om, arcname="openmetrics/apache-archive.openmetrics")
|
||||
if blocks_dir:
|
||||
tf.add(blocks_dir, arcname="blocks")
|
||||
if args.include_raw:
|
||||
# Rohlogs mitsichern: Prometheus hat nur die Aggregate — fuer die
|
||||
# Fehlersuche im Einzelfall braucht es die Zeilen.
|
||||
for f in files:
|
||||
try:
|
||||
tf.add(f, arcname="raw/" + f.lstrip("/"))
|
||||
except (OSError, tarfile.TarError):
|
||||
pass
|
||||
print(f" {tar_path} ({os.path.getsize(tar_path)/1024/1024:.1f} MiB)")
|
||||
|
||||
print(f"[5/5] Fertig.")
|
||||
print()
|
||||
print(f" Uebergabe an den zentralen Server:")
|
||||
print(f" scp {tar_path} mm@192.168.188.177:/tmp/")
|
||||
print(f" Dort einspielen:")
|
||||
print(f" tar -xzf /tmp/{os.path.basename(tar_path)} -C /data/stacks/netbird-observability/stacks/central-imac/data/prometheus/")
|
||||
print(f" # Bloecke nach <prometheus>/ — Prometheus liest sie beim naechsten Start")
|
||||
print(f" docker restart observability-prometheus")
|
||||
print()
|
||||
print(f" Hinweis: instance=\"{args.node}\", archived=\"1\" — im Dashboard von")
|
||||
print(f" Live-Daten unterscheidbar (Filter archived!=\"1\" blendet das Archiv aus).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
apache-log-survey.py — Bestandsaufnahme der Apache-Logs auf einem KeyHelp-Host.
|
||||
|
||||
MESSEN, NICHT RATEN: Bevor Logs archiviert werden, muss der Umfang bekannt sein.
|
||||
Kardinalitaet ist der begrenzende Faktor in Prometheus — pro Request eine Serie
|
||||
wuerde die TSDB sprengen. Die Aggregation richtet sich nach diesen Zahlen.
|
||||
|
||||
Aufruf (als root, damit alle User-Homes lesbar sind):
|
||||
python3 apache-log-survey.py
|
||||
|
||||
Ausgabe: Dateien, Zeilen, Zeitraum, Status-Verteilung, Top-Domains.
|
||||
Schreibt nichts, aendert nichts.
|
||||
"""
|
||||
import gzip
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Zugriffs- und Fehlerlogs, inkl. rotierter Varianten (.1, .2.gz).
|
||||
PATTERNS = [
|
||||
"/var/log/apache2/*access*.log", "/var/log/apache2/*access*.log.*",
|
||||
"/var/log/apache2/*error*.log", "/var/log/apache2/*error*.log.*",
|
||||
"/home/users/*/logs/*/access.log", "/home/users/*/logs/*/access.log.*",
|
||||
"/home/users/*/logs/*/error.log", "/home/users/*/logs/*/error.log.*",
|
||||
"/var/www/vhosts/*/logs/access_log*", "/var/www/vhosts/*/logs/error_log*",
|
||||
]
|
||||
|
||||
# Combined / Common: ip - - [14/Sep/2026:00:47:01 +0200] "GET /x HTTP/1.1" 200 559
|
||||
RE_LINE = re.compile(
|
||||
r'^(?P<ip>\S+) \S+ \S+ \[(?P<ts>[^\]]+)\] "(?P<req>[^"]*)" (?P<status>\d{3}|-) (?P<bytes>\d+|-)'
|
||||
)
|
||||
# Apache-Fehlerlog: [Mon Sep 14 00:47:01.123456 2026] [core:error] [pid 1] msg
|
||||
RE_ERR = re.compile(r'^\[(?P<ts>[^\]]+)\] \[(?P<mod>[^\]:]+):(?P<lvl>\w+)\]')
|
||||
|
||||
TS_FORMATS = ["%d/%b/%Y:%H:%M:%S %z", "%a %b %d %H:%M:%S.%f %Y", "%a %b %d %H:%M:%S %Y"]
|
||||
|
||||
|
||||
def open_maybe_gz(path):
|
||||
try:
|
||||
if path.endswith(".gz"):
|
||||
return gzip.open(path, "rt", errors="replace")
|
||||
return open(path, "r", errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_ts(raw):
|
||||
"""Zeitstempel parsen und IMMER auf zeitzonenbewusst (UTC) normalisieren.
|
||||
|
||||
Access-Logs haben eine Zeitzone ([14/Sep/2026:00:47:01 +0200]), Error-Logs
|
||||
nicht. Ohne Normalisierung sind die Werte nicht vergleichbar — Python wirft
|
||||
dann 'can't compare offset-naive and offset-aware datetimes'. Lokale Zeiten
|
||||
werden als Systemzeitzone interpretiert (das ist auch ihre Bedeutung).
|
||||
"""
|
||||
for fmt in TS_FORMATS:
|
||||
try:
|
||||
dt = datetime.strptime(raw.strip(), fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.astimezone() # lokale Zeit -> mit Offset versehen
|
||||
return dt.astimezone(timezone.utc)
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
files = sorted({f for pat in PATTERNS for f in glob.glob(pat)})
|
||||
if not files:
|
||||
print("Keine Apache-Logs gefunden. Laeuft Apache? (apache2ctl -v)")
|
||||
return 1
|
||||
|
||||
total_lines = 0
|
||||
total_parsed = 0
|
||||
total_err = 0
|
||||
bytes_on_disk = 0
|
||||
first_ts = None
|
||||
last_ts = None
|
||||
by_status = Counter()
|
||||
by_domain = Counter()
|
||||
by_method = Counter()
|
||||
err_levels = Counter()
|
||||
# Stunden-Buckets: hier entscheidet sich die Kardinalitaet der Archiv-Metriken.
|
||||
hour_domain = set()
|
||||
day_domain = set()
|
||||
per_file = []
|
||||
|
||||
for path in files:
|
||||
fh = open_maybe_gz(path)
|
||||
if fh is None:
|
||||
continue
|
||||
try:
|
||||
bytes_on_disk += os.path.getsize(path)
|
||||
except OSError:
|
||||
pass
|
||||
domain = os.path.basename(os.path.dirname(path)) or os.path.basename(path)
|
||||
n_lines = n_ok = n_err = 0
|
||||
f_first = f_last = None
|
||||
with fh:
|
||||
for line in fh:
|
||||
n_lines += 1
|
||||
m = RE_LINE.match(line)
|
||||
if m:
|
||||
n_ok += 1
|
||||
by_status[m.group("status")] += 1
|
||||
by_domain[domain] += 1
|
||||
req = m.group("req") or ""
|
||||
if " " in req:
|
||||
by_method[req.split(" ", 1)[0]] += 1
|
||||
ts = parse_ts(m.group("ts"))
|
||||
if ts is not None:
|
||||
if first_ts is None or ts < first_ts:
|
||||
first_ts = ts
|
||||
if last_ts is None or ts > last_ts:
|
||||
last_ts = ts
|
||||
if f_first is None or ts < f_first:
|
||||
f_first = ts
|
||||
if f_last is None or ts > f_last:
|
||||
f_last = ts
|
||||
hour_domain.add((ts.strftime("%Y-%m-%dT%H"), domain))
|
||||
day_domain.add((ts.strftime("%Y-%m-%d"), domain))
|
||||
continue
|
||||
m = RE_ERR.match(line)
|
||||
if m:
|
||||
n_err += 1
|
||||
err_levels[m.group("lvl").lower()] += 1
|
||||
ts = parse_ts(m.group("ts"))
|
||||
if ts is not None:
|
||||
if first_ts is None or ts < first_ts:
|
||||
first_ts = ts
|
||||
if last_ts is None or ts > last_ts:
|
||||
last_ts = ts
|
||||
total_lines += n_lines
|
||||
total_parsed += n_ok
|
||||
total_err += n_err
|
||||
per_file.append((path, n_lines, n_ok, n_err, f_first, f_last))
|
||||
|
||||
def human(n):
|
||||
for u in ("B", "KB", "MB", "GB"):
|
||||
if n < 1024:
|
||||
return f"{n:.1f} {u}"
|
||||
n /= 1024
|
||||
return f"{n:.1f} TB"
|
||||
|
||||
print("=" * 72)
|
||||
print("APACHE-LOG BESTANDSAUFNAHME")
|
||||
print("=" * 72)
|
||||
print(f"Dateien: {len(files)} ({human(bytes_on_disk)} auf Platte)")
|
||||
print(f"Zeilen total: {total_lines:,}")
|
||||
print(f" Zugriffe: {total_parsed:,}")
|
||||
print(f" Fehlerlog: {total_err:,}")
|
||||
print(f" Unparsbar: {total_lines - total_parsed - total_err:,}")
|
||||
if first_ts and last_ts:
|
||||
span = last_ts - first_ts
|
||||
print(f"Zeitraum: {first_ts:%Y-%m-%d %H:%M} .. {last_ts:%Y-%m-%d %H:%M} ({span.days} Tage)")
|
||||
print()
|
||||
print("--- Status-Verteilung (Top 10) ---")
|
||||
for code, n in by_status.most_common(10):
|
||||
print(f" {code} {n:>12,}")
|
||||
print()
|
||||
print("--- Methoden ---")
|
||||
for meth, n in by_method.most_common(8):
|
||||
print(f" {meth:<8} {n:>12,}")
|
||||
print()
|
||||
print("--- Top 15 Domains ---")
|
||||
for dom, n in by_domain.most_common(15):
|
||||
print(f" {n:>12,} {dom}")
|
||||
print(f" ({len(by_domain)} Domains insgesamt)")
|
||||
if err_levels:
|
||||
print()
|
||||
print("--- Fehler-Level ---")
|
||||
for lvl, n in err_levels.most_common():
|
||||
print(f" {lvl:<10} {n:>10,}")
|
||||
print()
|
||||
print("=" * 72)
|
||||
print("KARDINALITAET der Archiv-Metriken (das entscheidet die Granularitaet)")
|
||||
print("=" * 72)
|
||||
print(f" pro Stunde + Domain: {len(hour_domain):,} Serien")
|
||||
print(f" pro Tag + Domain: {len(day_domain):,} Serien")
|
||||
print(f" pro Tag + Domain + Status: ~{len(day_domain) * max(1, len(by_status)):,} Serien")
|
||||
print()
|
||||
print("Faustregel: unter ~100k Serien ist unkritisch, darueber wird die TSDB traege.")
|
||||
print()
|
||||
print("--- Groesste Dateien ---")
|
||||
for path, n_lines, n_ok, n_err, _, _ in sorted(per_file, key=lambda x: -x[1])[:10]:
|
||||
print(f" {n_lines:>10,} Zeilen {path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user