Catalyst / admin/Syanpse-Vanguard 14.8 GB / 57.8 GB 40.0 GB free
Help Sign in

admin / Syanpse-Vanguard

public
Code Issues Pull requests Pipelines Packages Security Insights Wiki Settings
Syanpse-Vanguard / synapse-vanguard-v3 / sv / pipeline / normalizer.py 4433 B · main
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
"""Normalizer worker.

Consumes raw NDJSON batches from the broker consumer group, parses each line
into a normalized EventModel, and bulk-writes to storage. At-least-once: a
message is only acked after its batch is durably written. Idempotent replay is
provided by the agent's monotonic seq (dedup can be layered later)."""
from __future__ import annotations

import argparse
import asyncio
import json
import logging
import os
import signal

from sv.bootstrap import shutdown, startup
from sv.config import settings
from sv.models.event import EventModel
from sv.pipeline.parsers import normalize

log = logging.getLogger("sv.normalizer")


def default_consumer() -> str:
    return f"{os.uname().nodename if hasattr(os, 'uname') else 'win'}-{os.getpid()}"


async def run_loop(engine, broker, stop: asyncio.Event, consumer: str) -> None:
    """Consume-parse-write-ack loop over a SHARED engine/broker. Used both by the
    standalone worker and by the consolidated single-process entrypoint (sv.main),
    where it runs as a background task alongside the API on one DuckDB connection."""
    log.info("normalizer loop up (consumer=%s)", consumer)
    while not stop.is_set():
        msgs = await broker.read_group(
            consumer, count=settings.normalizer_batch,
            block_ms=settings.normalizer_block_ms,
        )
        if not msgs:
            continue

        events: list[EventModel] = []
        for m in msgs:
            events.extend(_parse_lines(m.payload))

        if events:
            # Apply keyword alert rules BEFORE storing so matched events are
            # persisted with their Alert severity + name. Returns (event,
            # webhook_id) pairs for rules that should fire a webhook on match.
            alert_targets = []
            try:
                from sv import alerts
                alert_targets = await alerts.apply_rules(engine, events)
            except Exception as exc:  # noqa: BLE001 — alerts must never break ingest
                log.warning("alert-rule matching error: %s", exc)

            written = await engine.write_batch(events)
            log.debug("wrote %d events from %d messages", written, len(msgs))
            # Fire outbound webhooks for qualifying events (fire-and-forget so
            # slow/failing receivers never stall the pipeline).
            asyncio.create_task(_safe_dispatch(engine, events, alert_targets))

        for m in msgs:                           # ack only after durable write
            await broker.ack(m.msg_id)
    log.info("normalizer loop stopped")


async def _safe_dispatch(engine, events, alert_targets=None) -> None:
    try:
        from sv import webhooks
        await webhooks.dispatch(engine, events, direct=alert_targets)
    except Exception as exc:  # noqa: BLE001 — webhooks must never break ingest
        log.warning("webhook dispatch error: %s", exc)


async def _run(concurrency: int) -> None:
    """Standalone worker entrypoint (owns its own engine/broker). Only safe as a
    separate process when the storage backend is a shared server (ClickHouse) —
    NOT with embedded DuckDB, which is single-writer. See sv.main for the
    consolidated DuckDB deployment."""
    engine, broker = await startup(with_broker=True)
    stop = asyncio.Event()

    def _handle(*_):
        stop.set()

    try:
        signal.signal(signal.SIGINT, _handle)
        signal.signal(signal.SIGTERM, _handle)
    except (ValueError, AttributeError):
        pass  # not on main thread / unsupported platform

    try:
        await run_loop(engine, broker, stop, default_consumer())
    finally:
        await shutdown(engine, broker)


def _parse_lines(payload: bytes) -> list[EventModel]:
    out: list[EventModel] = []
    for line in payload.split(b"\n"):
        line = line.strip()
        if not line:
            continue
        try:
            raw = json.loads(line)
            out.append(normalize(raw))
        except Exception as exc:                 # tolerant: drop the bad line, keep the batch
            log.warning("dropping malformed event: %s", exc)
    return out


def main() -> None:
    ap = argparse.ArgumentParser(description="Synapse-Vanguard normalizer worker")
    ap.add_argument("--concurrency", type=int, default=4)
    args = ap.parse_args()
    logging.basicConfig(level=settings.log_level)
    asyncio.run(_run(args.concurrency))


if __name__ == "__main__":
    main()