Artikel

22. Monitoring

Allgemein

Monitoring fasst den laufenden Betrieb zusammen. Es beantwortet nicht die Frage, ob Social Media strategisch funktioniert. Es beantwortet zuerst nur:

Was hat das System heute getan?
Was ist fehlgeschlagen?
Welche Beiträge wurden veröffentlicht?
Welche Interaktionen wurden vorgeschlagen?
Welche OpenAI-Kosten sind entstanden?
Welche Daten müssen manuell ergänzt werden?

Für das MVP reicht ein dateibasierter Report aus JSONL-Logs und Queue-Dateien.

storage/logs/events.jsonl
storage/logs/errors.jsonl
storage/logs/openai_usage.jsonl
data/post_drafts.json
data/interaction_candidates.json

22.1 Tagesreport

Der Tagesreport enthält:

Datum
veröffentlichte Beiträge
fehlgeschlagene Beiträge
blockierte Plattformzustände
erzeugte Entwürfe
freigegebene Beiträge
Kommentarvorschläge
OpenAI-Token
offene Review-Queue

Beispielausgabe:

TAGESREPORT 2026-05-12

Beiträge:
- veröffentlicht: 1
- fehlgeschlagen: 0
- blockiert: 0
- offen in Review: 3

Interaktionen:
- Kandidaten: 4
- Vorschläge: 2
- freigegeben: 0
- ausgeführt: 0

OpenAI:
- Requests: 6
- Input-Tokens: 2.410
- Output-Tokens: 980
- Total-Tokens: 3.390

Fehler:
- keine

Der Tagesreport sollte automatisch erzeugt werden, aber nicht automatisch veröffentlicht werden.

22.2 Wochenreport

Der Wochenreport zeigt Trends.

Beiträge pro Plattform
Fehler pro Plattform
häufige Beitragstypen
genutzte Themen
Interaktionsvorschläge
Tokenverbrauch
manuell ergänzte Reichweitenwerte

Beispiel:

WOCHENREPORT 2026-W20

LinkedIn:
- Beiträge: 3
- Fehler: 0
- Interaktionsvorschläge: 4

Instagram:
- Beiträge: 2
- Fehler: 1
- häufigster Fehler: image upload timeout

Facebook:
- Beiträge: 2
- Fehler: 0

OpenAI:
- Requests: 31
- Total-Tokens: 18.240

Für Entscheidungen reicht der Wochenreport meist aus. Tagesreports sind operativ, Wochenreports strategisch brauchbarer.

22.3 Veröffentlichte Beiträge

Veröffentlichte Beiträge kommen aus:

data/post_drafts.json

oder später aus SQLite.

Gefiltert wird nach:

status == published
published_at im Zeitraum

Beispielobjekt:

{
  "id": "draft_20260512_001",
  "platform": "linkedin",
  "post_type": "workshop",
  "status": "published",
  "text": "Gemeinfreie Texte sind frei verfügbar, aber nicht automatisch verlagsfertig.",
  "published_at": "2026-05-12T09:30:00+02:00",
  "screenshot_path": "storage/screenshots/linkedin/draft_20260512_001_published.png"
}

Auswertung:

ID
Plattform
Typ
Veröffentlichungszeit
Screenshot
Textauszug

22.4 Fehlgeschlagene Beiträge

Fehlschläge kommen aus:

data/post_drafts.json
storage/logs/errors.jsonl

Relevant:

status == failed
status == blocked
failed_at
blocked_at
error_message
screenshot_path

Beispielausgabe:

Fehlgeschlagene Beiträge:

draft_20260512_002
Plattform: instagram
Status: failed
Fehler: Caption textbox not found.
Screenshot: storage/screenshots/instagram/draft_20260512_002_failed.png

blocked ist schwerer als failed.

failed: technischer Fehler
blocked: Login, CAPTCHA, Sicherheitsprüfung oder Plattformwarnung

Bei blocked sollte die Plattform nicht weiter automatisch bearbeitet werden.

22.5 Interaktionen

Interaktionen werden ausgelesen aus:

data/interaction_candidates.json

Statusgruppen:

candidate
analyzed
suggested
approved
executed
rejected
failed
blocked

Tagesreport:

neue Kandidaten
neue Kommentarvorschläge
freigegebene Vorschläge
ausgeführte Interaktionen
blockierte Interaktionen

Beispiel:

Interaktionen 2026-05-12:

suggested:
- interaction_20260512_001 / linkedin / external_comment
- interaction_20260512_002 / linkedin / comment_reply

rejected:
- interaction_20260512_003 / blocked topic: politics

Für das MVP werden Kommentare nur vorgeschlagen, nicht automatisch veröffentlicht.

22.6 API-Kosten

Das System protokolliert Tokens, nicht feste Euro-Beträge.

Quelle:

storage/logs/openai_usage.jsonl

Beispiel:

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

Auswertung:

Requests
Input-Tokens
Output-Tokens
Total-Tokens
nach Modell
nach Use-Case
nach Plattform

Kostenabschätzung über Konfiguration:

cost_estimates:
  gpt-4.1-mini:
    input_per_1m_tokens: 0.40
    output_per_1m_tokens: 1.60

Berechnung:

input_cost = input_tokens / 1_000_000 * input_price
output_cost = output_tokens / 1_000_000 * output_price

Die Preise müssen bei Bedarf manuell aktualisiert werden.

22.7 Reichweiten-Metriken manuell erfassen

Nicht alle Plattformmetriken sind zuverlässig oder einfach automatisiert zugänglich. Für das MVP werden Reichweitenwerte manuell ergänzt.

Datei:

data/manual_metrics.json

Beispiel:

[
  {
    "post_id": "draft_20260512_001",
    "platform": "linkedin",
    "recorded_at": "2026-05-13T09:00:00+02:00",
    "impressions": 420,
    "likes": 7,
    "comments": 1,
    "shares": 0,
    "clicks": 3
  }
]

Minimalwerte:

Impressionen
Likes
Kommentare
Shares
Klicks

Für den Verlag sind wichtiger als Likes:

Klicks
Kommentare mit Substanz
Newsletter-Anmeldungen
Shop-Besuche
Blog-Besuche

Social-Media-Metriken sind nur Rohsignale. Sie ersetzen keine Verkaufs- oder Traffic-Auswertung.

22.8 Beispiel: report.py

Datei:

src/report.py

import argparse
import json
from dataclasses import dataclass
from datetime import date
from datetime import datetime
from pathlib import Path
from typing import Any


class ReportError(Exception):
    pass


@dataclass(frozen=True)
class ReportPeriod:
    start_date: date
    end_date: date


class JsonFileReader:
    def load_list(self, file_path: Path) -> list[dict[str, Any]]:
        if file_path.exists() is False:
            return []

        with file_path.open("r", encoding="utf-8") as file:
            payload = json.load(file)

        if isinstance(payload, list) is False:
            raise ReportError("JSON file must contain a list: " + str(file_path))

        result: list[dict[str, Any]] = []

        for item in payload:
            if isinstance(item, dict) is False:
                raise ReportError("JSON list contains non-object item: " + str(file_path))

            result.append(item)

        return result


class JsonlFileReader:
    def load_events(self, file_path: Path) -> list[dict[str, Any]]:
        if file_path.exists() is False:
            return []

        events: list[dict[str, Any]] = []

        with file_path.open("r", encoding="utf-8") as file:
            for line in file:
                cleaned_line = line.strip()

                if cleaned_line == "":
                    continue

                payload = json.loads(cleaned_line)

                if isinstance(payload, dict) is False:
                    raise ReportError("JSONL line must contain an object: " + str(file_path))

                events.append(payload)

        return events


class DateFilter:
    def filter_by_field(
        self,
        items: list[dict[str, Any]],
        field_name: str,
        period: ReportPeriod,
    ) -> list[dict[str, Any]]:
        result: list[dict[str, Any]] = []

        for item in items:
            value = item.get(field_name)

            if isinstance(value, str) is False:
                continue

            item_date = self._parse_date(value)

            if item_date < period.start_date:
                continue

            if item_date > period.end_date:
                continue

            result.append(item)

        return result

    def filter_events(
        self,
        events: list[dict[str, Any]],
        period: ReportPeriod,
    ) -> list[dict[str, Any]]:
        result: list[dict[str, Any]] = []

        for event in events:
            timestamp = event.get("timestamp")

            if isinstance(timestamp, str) is False:
                continue

            event_date = self._parse_date(timestamp)

            if event_date < period.start_date:
                continue

            if event_date > period.end_date:
                continue

            result.append(event)

        return result

    def _parse_date(self, value: str) -> date:
        normalized_value = value.replace("Z", "+00:00")
        parsed = datetime.fromisoformat(normalized_value)

        return parsed.date()


class PostReportAnalyzer:
    def count_by_status(
        self,
        posts: list[dict[str, Any]],
        status: str,
    ) -> int:
        count = 0

        for post in posts:
            if post.get("status") == status:
                count += 1

        return count

    def count_by_platform(
        self,
        posts: list[dict[str, Any]],
        status: str,
    ) -> dict[str, int]:
        result: dict[str, int] = {}

        for post in posts:
            if post.get("status") != status:
                continue

            platform = post.get("platform")

            if isinstance(platform, str) is False:
                continue

            current_count = result.get(platform)

            if current_count is None:
                result[platform] = 1
            else:
                result[platform] = current_count + 1

        return result

    def get_failed_posts(
        self,
        posts: list[dict[str, Any]],
    ) -> list[dict[str, Any]]:
        result: list[dict[str, Any]] = []

        for post in posts:
            status = post.get("status")

            if status == "failed" or status == "blocked":
                result.append(post)

        return result


class InteractionReportAnalyzer:
    def count_by_status(
        self,
        interactions: list[dict[str, Any]],
    ) -> dict[str, int]:
        result: dict[str, int] = {}

        for interaction in interactions:
            status = interaction.get("status")

            if isinstance(status, str) is False:
                continue

            current_count = result.get(status)

            if current_count is None:
                result[status] = 1
            else:
                result[status] = current_count + 1

        return result


class OpenAiUsageAnalyzer:
    def summarize(self, events: list[dict[str, Any]]) -> dict[str, Any]:
        request_count = 0
        input_tokens = 0
        output_tokens = 0
        total_tokens = 0
        by_model: dict[str, int] = {}
        by_use_case: dict[str, int] = {}

        for event in events:
            if event.get("event") != "openai_request_completed":
                continue

            payload = event.get("payload")

            if isinstance(payload, dict) is False:
                continue

            request_count += 1
            input_tokens += self._read_int(payload, "input_tokens")
            output_tokens += self._read_int(payload, "output_tokens")
            total_tokens += self._read_int(payload, "total_tokens")

            model = payload.get("model")

            if isinstance(model, str) is True:
                current_model_count = by_model.get(model)

                if current_model_count is None:
                    by_model[model] = 1
                else:
                    by_model[model] = current_model_count + 1

            use_case = payload.get("use_case")

            if isinstance(use_case, str) is True:
                current_use_case_count = by_use_case.get(use_case)

                if current_use_case_count is None:
                    by_use_case[use_case] = 1
                else:
                    by_use_case[use_case] = current_use_case_count + 1

        return {
            "request_count": request_count,
            "input_tokens": input_tokens,
            "output_tokens": output_tokens,
            "total_tokens": total_tokens,
            "by_model": by_model,
            "by_use_case": by_use_case,
        }

    def _read_int(self, payload: dict[str, Any], key: str) -> int:
        value = payload.get(key)

        if isinstance(value, int) is False:
            return 0

        return value


class ManualMetricsAnalyzer:
    def summarize(self, metrics: list[dict[str, Any]]) -> dict[str, int]:
        impressions = 0
        likes = 0
        comments = 0
        shares = 0
        clicks = 0

        for metric in metrics:
            impressions += self._read_int(metric, "impressions")
            likes += self._read_int(metric, "likes")
            comments += self._read_int(metric, "comments")
            shares += self._read_int(metric, "shares")
            clicks += self._read_int(metric, "clicks")

        return {
            "impressions": impressions,
            "likes": likes,
            "comments": comments,
            "shares": shares,
            "clicks": clicks,
        }

    def _read_int(self, payload: dict[str, Any], key: str) -> int:
        value = payload.get(key)

        if isinstance(value, int) is False:
            return 0

        return value


class ReportPrinter:
    def print_report(
        self,
        title: str,
        period: ReportPeriod,
        posts: list[dict[str, Any]],
        failed_posts: list[dict[str, Any]],
        posts_by_platform: dict[str, int],
        interaction_counts: dict[str, int],
        openai_summary: dict[str, Any],
        manual_metrics_summary: dict[str, int],
    ) -> None:
        print(title + " " + period.start_date.isoformat() + " bis " + period.end_date.isoformat())
        print("")

        print("Beiträge:")
        print("- veröffentlicht: " + str(self._count_status(posts, "published")))
        print("- fehlgeschlagen: " + str(self._count_status(posts, "failed")))
        print("- blockiert: " + str(self._count_status(posts, "blocked")))
        print("- offen: " + str(self._count_status(posts, "draft")))
        print("")

        print("Beiträge nach Plattform:")
        for platform, count in posts_by_platform.items():
            print("- " + platform + ": " + str(count))
        print("")

        print("Fehlgeschlagene Beiträge:")
        if len(failed_posts) == 0:
            print("- keine")
        else:
            for post in failed_posts:
                print("- " + str(post.get("id")) + " / " + str(post.get("platform")) + " / " + str(post.get("error_message")))
        print("")

        print("Interaktionen:")
        for status, count in interaction_counts.items():
            print("- " + status + ": " + str(count))
        print("")

        print("OpenAI:")
        print("- Requests: " + str(openai_summary["request_count"]))
        print("- Input-Tokens: " + str(openai_summary["input_tokens"]))
        print("- Output-Tokens: " + str(openai_summary["output_tokens"]))
        print("- Total-Tokens: " + str(openai_summary["total_tokens"]))
        print("")

        print("Manuelle Reichweitenwerte:")
        print("- Impressionen: " + str(manual_metrics_summary["impressions"]))
        print("- Likes: " + str(manual_metrics_summary["likes"]))
        print("- Kommentare: " + str(manual_metrics_summary["comments"]))
        print("- Shares: " + str(manual_metrics_summary["shares"]))
        print("- Klicks: " + str(manual_metrics_summary["clicks"]))

    def _count_status(self, posts: list[dict[str, Any]], status: str) -> int:
        count = 0

        for post in posts:
            if post.get("status") == status:
                count += 1

        return count


class ReportService:
    def __init__(self, root_dir: Path) -> None:
        self.root_dir = root_dir
        self.json_reader = JsonFileReader()
        self.jsonl_reader = JsonlFileReader()
        self.date_filter = DateFilter()
        self.post_analyzer = PostReportAnalyzer()
        self.interaction_analyzer = InteractionReportAnalyzer()
        self.openai_analyzer = OpenAiUsageAnalyzer()
        self.manual_metrics_analyzer = ManualMetricsAnalyzer()
        self.printer = ReportPrinter()

    def daily(self, report_date: date) -> None:
        period = ReportPeriod(
            start_date=report_date,
            end_date=report_date,
        )

        self._print(period, "TAGESREPORT")

    def weekly(self, start_date: date, end_date: date) -> None:
        period = ReportPeriod(
            start_date=start_date,
            end_date=end_date,
        )

        self._print(period, "WOCHENREPORT")

    def _print(self, period: ReportPeriod, title: str) -> None:
        posts = self.json_reader.load_list(self.root_dir / "data" / "post_drafts.json")
        interactions = self.json_reader.load_list(self.root_dir / "data" / "interaction_candidates.json")
        manual_metrics = self.json_reader.load_list(self.root_dir / "data" / "manual_metrics.json")
        openai_events = self.jsonl_reader.load_events(self.root_dir / "storage" / "logs" / "openai_usage.jsonl")

        period_posts = self._filter_posts_for_period(posts, period)
        period_interactions = self.date_filter.filter_by_field(interactions, "created_at", period)
        period_manual_metrics = self.date_filter.filter_by_field(manual_metrics, "recorded_at", period)
        period_openai_events = self.date_filter.filter_events(openai_events, period)

        failed_posts = self.post_analyzer.get_failed_posts(period_posts)
        posts_by_platform = self.post_analyzer.count_by_platform(period_posts, "published")
        interaction_counts = self.interaction_analyzer.count_by_status(period_interactions)
        openai_summary = self.openai_analyzer.summarize(period_openai_events)
        manual_metrics_summary = self.manual_metrics_analyzer.summarize(period_manual_metrics)

        self.printer.print_report(
            title=title,
            period=period,
            posts=period_posts,
            failed_posts=failed_posts,
            posts_by_platform=posts_by_platform,
            interaction_counts=interaction_counts,
            openai_summary=openai_summary,
            manual_metrics_summary=manual_metrics_summary,
        )

    def _filter_posts_for_period(
        self,
        posts: list[dict[str, Any]],
        period: ReportPeriod,
    ) -> list[dict[str, Any]]:
        result: list[dict[str, Any]] = []

        for post in posts:
            candidate_fields = [
                "published_at",
                "failed_at",
                "blocked_at",
                "created_at",
            ]

            for field_name in candidate_fields:
                value = post.get(field_name)

                if isinstance(value, str) is False:
                    continue

                item_date = datetime.fromisoformat(value.replace("Z", "+00:00")).date()

                if item_date < period.start_date:
                    continue

                if item_date > period.end_date:
                    continue

                result.append(post)
                break

        return result


def parse_date(value: str) -> date:
    return date.fromisoformat(value)


def main() -> None:
    parser = argparse.ArgumentParser()
    subparsers = parser.add_subparsers(dest="command", required=True)

    daily_parser = subparsers.add_parser("daily")
    daily_parser.add_argument("--date", required=True)

    weekly_parser = subparsers.add_parser("weekly")
    weekly_parser.add_argument("--start", required=True)
    weekly_parser.add_argument("--end", required=True)

    args = parser.parse_args()

    root_dir = Path(__file__).resolve().parent.parent
    service = ReportService(root_dir)

    if args.command == "daily":
        service.daily(parse_date(args.date))
        return

    if args.command == "weekly":
        service.weekly(
            start_date=parse_date(args.start),
            end_date=parse_date(args.end),
        )
        return

    raise ReportError("Unsupported command: " + str(args.command))


if __name__ == "__main__":
    main()

Beispielaufrufe

Tagesreport:

python -m src.report daily --date 2026-05-12

Wochenreport:

python -m src.report weekly --start 2026-05-11 --end 2026-05-17

Per Cron:

0 18 * * 1-5 cd /opt/social-publisher && .venv/bin/python -m src.report daily --date $(date +\%F) >> storage/logs/reports.log 2>&1

Wochenreport montags für die Vorwoche:

0 8 * * 1 cd /opt/social-publisher && .venv/bin/python -m src.report weekly --start $(date -d 'last monday -7 days' +\%F) --end $(date -d 'last sunday' +\%F) >> storage/logs/reports.log 2>&1

Ergebnis dieses Kapitels

Das Monitoring liefert jetzt:

Tagesreport
Wochenreport
Liste veröffentlichter Beiträge
Liste fehlgeschlagener Beiträge
Interaktionsstatus
OpenAI-Tokenauswertung
manuelle Reichweitenmetriken
CLI-Ausgabe für operative Kontrolle

Damit ist sichtbar, ob die Automatisierung stabil läuft, wo sie scheitert und welche Inhalte tatsächlich veröffentlicht wurden.

0 Kommentare