Artikel

21. Logging

Allgemein

Logging ist kein Zusatzmodul, sondern Bestandteil der Betriebssicherheit. Das System muss später rekonstruieren können:

was erzeugt wurde
was geprüft wurde
was freigegeben wurde
was veröffentlicht wurde
was fehlgeschlagen ist
welcher Screenshot dazugehört
welcher OpenAI-Aufruf Kosten verursacht hat

Für das MVP reicht JSONL.

storage/logs/events.jsonl
storage/logs/errors.jsonl
storage/logs/openai_usage.jsonl
storage/logs/platform_responses.jsonl

JSONL bedeutet:

eine JSON-Zeile pro Ereignis
append-only
leicht per grep, jq oder Python auswertbar
kein Datenbankserver notwendig

21.1 Strukturierte Logs

Nicht sinnvoll:

Post veröffentlicht
Fehler beim Upload
OpenAI fertig

Sinnvoll:

{
  "timestamp": "2026-05-12T09:30:00+02:00",
  "level": "info",
  "event": "post_published",
  "platform": "linkedin",
  "reference_id": "draft_20260512_001",
  "payload": {
    "status": "published",
    "screenshot_path": "storage/screenshots/linkedin/draft_20260512_001_published.png"
  }
}

Pflichtfelder:

timestamp
level
event
reference_id
platform
payload

Log-Level:

debug
info
warning
error
critical

Event-Namen sollten stabil und maschinenlesbar sein:

post_draft_created
quality_gate_passed
quality_gate_failed
post_approved
post_rejected
publish_started
post_published
post_failed
post_blocked
interaction_candidate_created
comment_suggested
comment_rejected
openai_request_completed
platform_warning_detected
screenshot_saved

21.2 Aktionsprotokoll

Das Aktionsprotokoll speichert normale Systemereignisse.

Datei:

storage/logs/events.jsonl

Beispiele:

{
  "timestamp": "2026-05-12T09:00:00+02:00",
  "level": "info",
  "event": "post_draft_created",
  "platform": "linkedin",
  "reference_id": "draft_20260512_001",
  "payload": {
    "source_type": "manual_topic",
    "source_id": "topic_001",
    "post_type": "workshop",
    "prompt_name": "workshop_note",
    "prompt_version": "1.0.0",
    "model": "gpt-4.1-mini"
  }
}

{
  "timestamp": "2026-05-12T09:10:00+02:00",
  "level": "info",
  "event": "quality_gate_passed",
  "platform": "linkedin",
  "reference_id": "draft_20260512_001",
  "payload": {
    "checks": {
      "hard_reject": true,
      "character_limit": true,
      "link": true,
      "generic": true,
      "duplicate": true,
      "similarity": true,
      "tone": true,
      "sales_pressure": true
    }
  }
}

{
  "timestamp": "2026-05-12T09:30:00+02:00",
  "level": "info",
  "event": "post_published",
  "platform": "linkedin",
  "reference_id": "draft_20260512_001",
  "payload": {
    "screenshot_path": "storage/screenshots/linkedin/draft_20260512_001_published.png",
    "platform_post_url": null
  }
}

21.3 Fehlerprotokoll

Fehler gehören in eine eigene Datei.

Datei:

storage/logs/errors.jsonl

Beispiel:

{
  "timestamp": "2026-05-12T09:31:00+02:00",
  "level": "error",
  "event": "post_failed",
  "platform": "linkedin",
  "reference_id": "draft_20260512_001",
  "payload": {
    "error_type": "PlaywrightTimeoutError",
    "error_message": "Post button not found.",
    "screenshot_path": "storage/screenshots/linkedin/draft_20260512_001_failed.png"
  }
}

Kritische Fehler:

{
  "timestamp": "2026-05-12T09:31:00+02:00",
  "level": "critical",
  "event": "platform_warning_detected",
  "platform": "linkedin",
  "reference_id": "draft_20260512_001",
  "payload": {
    "reason": "security check detected",
    "action": "platform_blocked",
    "screenshot_path": "storage/screenshots/linkedin/draft_20260512_001_blocked.png"
  }
}

Fehler werden nicht nur geloggt. Sie müssen auch den Status des betroffenen Objekts verändern:

approved → failed
approved → blocked
candidate → failed
candidate → blocked

21.4 Screenshot-Archiv

Screenshots sind Belege.

Gespeichert wird bei:

Dry-Run
erfolgreicher Veröffentlichung
Fehler
Login erforderlich
CAPTCHA
Plattformwarnung
unklarem Status

Verzeichnisstruktur:

storage/screenshots/
├── linkedin/
│   ├── draft_20260512_001_dry_run.png
│   ├── draft_20260512_001_published.png
│   └── draft_20260512_002_failed.png
├── instagram/
└── facebook/

Dateinamenschema:

{reference_id}_{status}.png

Beispiele:

draft_20260512_001_dry_run.png
draft_20260512_001_published.png
draft_20260512_001_failed.png
draft_20260512_001_blocked.png
interaction_20260512_001_suggested.png

Screenshot-Log:

{
  "timestamp": "2026-05-12T09:30:00+02:00",
  "level": "info",
  "event": "screenshot_saved",
  "platform": "linkedin",
  "reference_id": "draft_20260512_001",
  "payload": {
    "status": "published",
    "screenshot_path": "storage/screenshots/linkedin/draft_20260512_001_published.png"
  }
}

21.5 API-Kostenprotokoll

OpenAI-Aufrufe müssen getrennt protokolliert werden.

Datei:

storage/logs/openai_usage.jsonl

Gespeichert wird:

Zeitpunkt
Response-ID
Modell
Prompt-Name
Prompt-Version
Reference-ID
Input-Tokens
Output-Tokens
Total-Tokens
Use-Case

Beispiel:

{
  "timestamp": "2026-05-12T09:00:00+02:00",
  "level": "info",
  "event": "openai_request_completed",
  "platform": "linkedin",
  "reference_id": "draft_20260512_001",
  "payload": {
    "response_id": "resp_123",
    "model": "gpt-4.1-mini",
    "use_case": "post_generation",
    "prompt_name": "workshop_note",
    "prompt_version": "1.0.0",
    "input_tokens": 420,
    "output_tokens": 180,
    "total_tokens": 600
  }
}

Ein einfacher Tagesreport kann daraus später berechnen:

OpenAI-Aufrufe pro Tag
Tokens pro Tag
Tokens pro Modell
Tokens pro Use-Case
Kostenabschätzung

Das Log speichert Tokens, nicht zwingend Euro-Beträge. Preise ändern sich; Kosten können beim Report anhand aktueller Preisparameter berechnet werden.

21.6 Plattformantworten speichern

Plattformantworten sind bei Playwright nicht immer API-Antworten. Meist sind es UI-Zustände.

Gespeichert werden sollte:

aktuelle URL
Seitentitel
Status
sichtbarer Hinweistext
Screenshot
Fehlertyp

Datei:

storage/logs/platform_responses.jsonl

Beispiel:

{
  "timestamp": "2026-05-12T09:30:00+02:00",
  "level": "info",
  "event": "platform_response",
  "platform": "linkedin",
  "reference_id": "draft_20260512_001",
  "payload": {
    "url": "https://www.linkedin.com/feed/",
    "title": "Feed | LinkedIn",
    "status": "after_publish_click",
    "screenshot_path": "storage/screenshots/linkedin/draft_20260512_001_published.png"
  }
}

Bei Fehler:

{
  "timestamp": "2026-05-12T09:31:00+02:00",
  "level": "warning",
  "event": "platform_response",
  "platform": "linkedin",
  "reference_id": "draft_20260512_002",
  "payload": {
    "url": "https://www.linkedin.com/checkpoint/challenge/",
    "title": "Security Verification | LinkedIn",
    "status": "blocked",
    "detected_fragment": "security verification",
    "screenshot_path": "storage/screenshots/linkedin/draft_20260512_002_blocked.png"
  }
}

Optional kann ein kurzer HTML-Auszug gespeichert werden. Nicht sinnvoll ist das vollständige Archivieren ganzer Social-Media-Seiten.

HTML-Auszug nur bei Fehlern
keine vollständigen Feeds archivieren
keine personenbezogenen Daten unnötig speichern

21.7 Beispiel: logger.py

Datei:

src/logger.py

import json
import traceback
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo


class LoggerError(Exception):
    pass


@dataclass(frozen=True)
class LogEvent:
    level: str
    event: str
    platform: str | None
    reference_id: str | None
    payload: dict[str, Any]


class JsonlLogger:
    def __init__(self, log_file: Path, timezone: str) -> None:
        self.log_file = log_file
        self.timezone = timezone

    def write(self, log_event: LogEvent) -> None:
        self.log_file.parent.mkdir(parents=True, exist_ok=True)

        payload = {
            "timestamp": datetime.now(ZoneInfo(self.timezone)).isoformat(),
            "level": log_event.level,
            "event": log_event.event,
            "platform": log_event.platform,
            "reference_id": log_event.reference_id,
            "payload": log_event.payload,
        }

        with self.log_file.open("a", encoding="utf-8") as file:
            file.write(json.dumps(payload, ensure_ascii=False))
            file.write("\n")


class ApplicationLogger:
    def __init__(self, root_dir: Path, timezone: str) -> None:
        self.root_dir = root_dir
        self.timezone = timezone

        self.event_logger = JsonlLogger(
            log_file=self.root_dir / "storage" / "logs" / "events.jsonl",
            timezone=self.timezone,
        )

        self.error_logger = JsonlLogger(
            log_file=self.root_dir / "storage" / "logs" / "errors.jsonl",
            timezone=self.timezone,
        )

        self.openai_usage_logger = JsonlLogger(
            log_file=self.root_dir / "storage" / "logs" / "openai_usage.jsonl",
            timezone=self.timezone,
        )

        self.platform_response_logger = JsonlLogger(
            log_file=self.root_dir / "storage" / "logs" / "platform_responses.jsonl",
            timezone=self.timezone,
        )

    def info(
        self,
        event: str,
        platform: str | None,
        reference_id: str | None,
        payload: dict[str, Any],
    ) -> None:
        self.event_logger.write(
            LogEvent(
                level="info",
                event=event,
                platform=platform,
                reference_id=reference_id,
                payload=payload,
            )
        )

    def warning(
        self,
        event: str,
        platform: str | None,
        reference_id: str | None,
        payload: dict[str, Any],
    ) -> None:
        self.event_logger.write(
            LogEvent(
                level="warning",
                event=event,
                platform=platform,
                reference_id=reference_id,
                payload=payload,
            )
        )

    def error(
        self,
        event: str,
        platform: str | None,
        reference_id: str | None,
        exception: Exception,
        payload: dict[str, Any],
    ) -> None:
        error_payload = dict(payload)
        error_payload["error_type"] = exception.__class__.__name__
        error_payload["error_message"] = str(exception)
        error_payload["traceback"] = traceback.format_exc()

        self.error_logger.write(
            LogEvent(
                level="error",
                event=event,
                platform=platform,
                reference_id=reference_id,
                payload=error_payload,
            )
        )

    def critical(
        self,
        event: str,
        platform: str | None,
        reference_id: str | None,
        exception: Exception,
        payload: dict[str, Any],
    ) -> None:
        error_payload = dict(payload)
        error_payload["error_type"] = exception.__class__.__name__
        error_payload["error_message"] = str(exception)
        error_payload["traceback"] = traceback.format_exc()

        self.error_logger.write(
            LogEvent(
                level="critical",
                event=event,
                platform=platform,
                reference_id=reference_id,
                payload=error_payload,
            )
        )

    def openai_usage(
        self,
        platform: str | None,
        reference_id: str | None,
        response_id: str,
        model: str,
        use_case: str,
        prompt_name: str,
        prompt_version: str,
        input_tokens: int,
        output_tokens: int,
        total_tokens: int,
    ) -> None:
        self.openai_usage_logger.write(
            LogEvent(
                level="info",
                event="openai_request_completed",
                platform=platform,
                reference_id=reference_id,
                payload={
                    "response_id": response_id,
                    "model": model,
                    "use_case": use_case,
                    "prompt_name": prompt_name,
                    "prompt_version": prompt_version,
                    "input_tokens": input_tokens,
                    "output_tokens": output_tokens,
                    "total_tokens": total_tokens,
                },
            )
        )

    def platform_response(
        self,
        platform: str,
        reference_id: str,
        status: str,
        url: str,
        title: str,
        screenshot_path: str | None,
        extra: dict[str, Any],
    ) -> None:
        payload: dict[str, Any] = {
            "status": status,
            "url": url,
            "title": title,
            "screenshot_path": screenshot_path,
        }

        for key, value in extra.items():
            payload[key] = value

        self.platform_response_logger.write(
            LogEvent(
                level="info",
                event="platform_response",
                platform=platform,
                reference_id=reference_id,
                payload=payload,
            )
        )


class ScreenshotArchiveLogger:
    def __init__(self, application_logger: ApplicationLogger) -> None:
        self.application_logger = application_logger

    def log_saved(
        self,
        platform: str,
        reference_id: str,
        status: str,
        screenshot_path: Path,
    ) -> None:
        self.application_logger.info(
            event="screenshot_saved",
            platform=platform,
            reference_id=reference_id,
            payload={
                "status": status,
                "screenshot_path": str(screenshot_path),
            },
        )

Beispielnutzung

from pathlib import Path

from src.logger import ApplicationLogger


def main() -> None:
    root_dir = Path(__file__).resolve().parent.parent

    logger = ApplicationLogger(
        root_dir=root_dir,
        timezone="Europe/Berlin",
    )

    logger.info(
        event="post_draft_created",
        platform="linkedin",
        reference_id="draft_20260512_001",
        payload={
            "source_type": "manual_topic",
            "source_id": "topic_001",
            "post_type": "workshop",
            "prompt_name": "workshop_note",
            "prompt_version": "1.0.0",
            "model": "gpt-4.1-mini",
        },
    )

    logger.openai_usage(
        platform="linkedin",
        reference_id="draft_20260512_001",
        response_id="resp_123",
        model="gpt-4.1-mini",
        use_case="post_generation",
        prompt_name="workshop_note",
        prompt_version="1.0.0",
        input_tokens=420,
        output_tokens=180,
        total_tokens=600,
    )


if __name__ == "__main__":
    main()

Logger im Publisher verwenden

Ausschnitt:

logger.info(
    event="publish_started",
    platform=request.platform,
    reference_id=request.id,
    payload={
        "post_type": request.post_type,
        "dry_run": request.dry_run,
    },
)

Bei Erfolg:

logger.info(
    event="post_published",
    platform=request.platform,
    reference_id=request.id,
    payload={
        "screenshot_path": str(screenshot_path),
        "platform_post_url": platform_post_url,
    },
)

Bei Fehler:

logger.error(
    event="post_failed",
    platform=request.platform,
    reference_id=request.id,
    exception=exception,
    payload={
        "screenshot_path": str(screenshot_path),
    },
)

Logger im OpenAI-Client verwenden

Ausschnitt:

logger.openai_usage(
    platform=platform,
    reference_id=reference_id,
    response_id=result.response_id,
    model=result.model,
    use_case="post_generation",
    prompt_name=prompt_name,
    prompt_version=prompt_version,
    input_tokens=result.input_tokens,
    output_tokens=result.output_tokens,
    total_tokens=result.total_tokens,
)

CLI-Auswertung mit jq

Letzte Ereignisse:

tail -n 20 storage/logs/events.jsonl | jq

Fehler anzeigen:

cat storage/logs/errors.jsonl | jq

OpenAI-Token summieren:

jq -s 'map(.payload.total_tokens) | add' storage/logs/openai_usage.jsonl

Veröffentlichte Beiträge anzeigen:

jq 'select(.event == "post_published")' storage/logs/events.jsonl

Fehler nach Plattform filtern:

jq 'select(.platform == "linkedin")' storage/logs/errors.jsonl

Ergebnis dieses Kapitels

Das Logging-System liefert jetzt:

strukturierte JSONL-Logs
Aktionsprotokoll
Fehlerprotokoll
Screenshot-Archiv
OpenAI-Tokenprotokoll
Plattformantworten
auswertbare Eventnamen
klare Referenz auf Beitrag oder Interaktion

Damit ist der Betrieb nachvollziehbar. Jede Veröffentlichung, jeder Fehler und jeder relevante API-Aufruf lässt sich später rekonstruieren.

0 Kommentare