Files
parsedmarc/parsedmarc/loki.py
T
768bf75480 Bastischubert/loki output (#926)
* feat: add grafana loki output

Push aggregate, failure and SMTP TLS reports as JSON log lines to the
Loki push API. Static labels and labels taken from report fields are
configurable, host defaults to the FQDN. The timestamp is either the
ingest time or the report begin time. Supports basic auth, bearer
tokens, X-Scope-OrgID and batching. Push errors are reported as output
errors so messages are not archived.

Signed-off-by: Sebastian Schubert <basti@schubert.digital>

* feat(dashboards): add grafana dashboard for the loki output

Signed-off-by: Sebastian Schubert <basti@schubert.digital>

* Address Copilot review on the Loki output

Responds to Copilot's review of #926:

- Treat any push status other than 200/204 as a failure. Loki returns
  block_ingestion_status_code (260 by default) when block_ingestion_until
  blocks ingestion, and the logs are discarded (Loki configuration
  reference); the old >= 300 check counted that as a successful save, so
  the CLI could archive mailbox messages whose reports were never stored.
- Read aggregate begin_date as UTC for timestamp = report_begin. Aggregate
  CSV rows carry the record's interval_begin, which the parser builds as
  naive UTC wall-clock time (to_utc=True); reading it as local time shifted
  entries by the host's UTC offset (+4h on America/New_York).
- Group the Published Policies dashboard dedup by org_name as well as
  report_id: report IDs are only unique per reporter (the PostgreSQL key
  is UNIQUE (org_name, report_id, ...)).
- Drop the legend sum from SMTP TLS Sessions Over Time: its [1d] lookback
  with the automatic step yields overlapping rolling totals, so the sum
  counted the same sessions repeatedly.

Adds regression tests for each, including a non-UTC timezone test against
a real sample report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address second Copilot review on the Loki output

Responds to Copilot's second review of #926 (previously-missed findings):

- Strip each [loki] label_fields entry. _str_to_list() only lstrips, so
  "report_type , domain" produced "report_type " and LokiClient refused to
  start with "Invalid Loki label name 'report_type '".
- Wipe Loki on RESEED=1 even when Elasticsearch is empty. The wipe sat
  inside the ES-has-docs guard, so after the ES container was recreated a
  reseed appended a second copy of the samples to Loki, which does not
  deduplicate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Address third Copilot review on the Loki output

Responds to Copilot's third review of #926 (previously-missed findings):

- Keep a base URL's query string when appending the push path. The path
  was string-concatenated, so https://gw.example.org?route=logs became
  ...?route=logs/loki/api/v1/push, hitting the root path with a corrupted
  query. The path is now set on the parsed httpx.URL.
- Push each report's entries in timestamp order. With timestamp =
  report_begin, a normalized multi-day aggregate report yields rows
  source-major (every day of one source, then day 1 of the next), all in
  one stream. Loki rejects entries older than the newest line minus half
  of max_chunk_age (1h by default) with too_far_behind (Loki configuration
  docs, "Accept out-of-order writes"), so later sources were partly
  rejected. The whole entry list is now sorted before batching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(loki): do not follow redirects and drop forensic alias

httpx re-sends a 301/302 POST as GET, so a redirected push could return 200 without storing anything, and the mailbox messages were archived. Redirects are no longer followed, so a 3xx raises LokiError.

Remove save_forensic_report_to_loki: the module is new, so no caller needs the legacy alias.

Signed-off-by: Sebastian Schubert <basti@schubert.digital>

* fix(dashboards): use the existing Loki datasource uid in the dev bootstrap

When a datasource named Loki already exists with another uid, the dashboard import still bound every panel to dmarc-loki. Read the uid from the lookup response instead.

Signed-off-by: Sebastian Schubert <basti@schubert.digital>

---------

Signed-off-by: Sebastian Schubert <basti@schubert.digital>
Co-authored-by: Sean Whalen <44679+seanthegeek@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:13:37 -04:00

213 lines
8.3 KiB
Python

# -*- coding: utf-8 -*-
from __future__ import annotations
import json
import re
import socket
import time
from collections.abc import Callable
from typing import Any
import httpx
from parsedmarc import (
parsed_aggregate_reports_to_csv_rows,
parsed_failure_reports_to_csv_rows,
parsed_smtp_tls_reports_to_csv_rows,
)
from parsedmarc.constants import USER_AGENT
from parsedmarc.log import logger
from parsedmarc.types import AggregateReport, FailureReport, SMTPTLSReport
from parsedmarc.utils import human_timestamp_to_unix_timestamp
PUSH_PATH = "/loki/api/v1/push"
TIMESTAMP_MODES = ("ingest", "report_begin")
DEFAULT_LABEL_FIELDS = ["report_type", "domain"]
_LABEL_NAME = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
# Per report type: the row field holding the domain the report is about,
# and the row field with its begin time (and whether that value is UTC
# without an offset; aggregate rows carry the record's interval_begin,
# which parsedmarc builds as naive UTC wall-clock time).
_REPORT_TYPES: dict[str, tuple[str, str, bool]] = {
"aggregate": ("domain", "begin_date", True),
"failure": ("reported_domain", "arrival_date_utc", True),
"smtp_tls": ("policy_domain", "begin_date", False),
}
class LokiError(RuntimeError):
"""Raised when pushing to Loki fails"""
class LokiClient(object):
"""A client for the Grafana Loki push API"""
def __init__(
self,
url: str,
*,
labels: dict[str, str] | None = None,
label_fields: list[str] | None = None,
timestamp: str = "ingest",
username: str | None = None,
password: str | None = None,
token: str | None = None,
tenant_id: str | None = None,
verify: bool = True,
timeout: float = 60,
batch_size: int = 1000,
):
"""
Initializes the LokiClient
Every report record becomes one JSON log line. Streams are labeled
with ``labels`` plus the record fields named in ``label_fields``;
keep those to fields with few distinct values.
Args:
url (str): Loki base URL (``/loki/api/v1/push`` is appended when
the URL has no path) or the full push URL
labels (dict): Static stream labels, merged over the defaults
``job=parsedmarc`` and ``host=<FQDN>``
label_fields (list): Record fields used as stream labels
(Default: ``report_type``, ``domain``)
timestamp (str): ``ingest`` (time of the push) or
``report_begin`` (begin of the reported period; Loki rejects
entries older than its ``reject_old_samples_max_age``)
username (str): Basic auth user
password (str): Basic auth password
token (str): Bearer token, instead of basic auth
tenant_id (str): Sent as ``X-Scope-OrgID``
verify (bool): Verify TLS certificates
timeout (float): Request timeout in seconds
batch_size (int): Maximum log lines per push request
"""
if timestamp not in TIMESTAMP_MODES:
raise ValueError(
f"Invalid Loki timestamp '{timestamp}'. "
f"Must be one of: {', '.join(TIMESTAMP_MODES)}"
)
if token and (username or password):
raise ValueError("Loki token and username/password are exclusive")
self.labels = {"job": "parsedmarc", "host": socket.getfqdn()}
self.labels.update(labels or {})
self.label_fields = list(
DEFAULT_LABEL_FIELDS if label_fields is None else label_fields
)
for name in list(self.labels) + self.label_fields:
if not _LABEL_NAME.match(name):
raise ValueError(f"Invalid Loki label name '{name}'")
parsed = httpx.URL(url)
# copy_with(path=...) keeps any query string, userinfo and port intact
self.url = (
str(parsed.copy_with(path=PUSH_PATH)) if parsed.path in ("", "/") else url
)
self.timestamp = timestamp
self.timeout = timeout
self.batch_size = max(1, int(batch_size))
headers = {"User-Agent": USER_AGENT, "Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
if tenant_id:
headers["X-Scope-OrgID"] = tenant_id
auth = httpx.BasicAuth(username or "", password or "") if username else None
# Redirects are not followed: httpx re-sends a 301/302 POST as a GET,
# which the target can answer 200 without storing anything.
self.session = httpx.Client(
headers=headers, auth=auth, verify=verify, follow_redirects=False
)
def save_aggregate_report_to_loki(
self, aggregate_reports: AggregateReport | list[AggregateReport]
):
self._save("aggregate", parsed_aggregate_reports_to_csv_rows, aggregate_reports)
def save_failure_report_to_loki(
self, failure_reports: FailureReport | list[FailureReport]
):
self._save("failure", parsed_failure_reports_to_csv_rows, failure_reports)
def save_smtp_tls_report_to_loki(
self, smtp_tls_reports: SMTPTLSReport | list[SMTPTLSReport]
):
self._save("smtp_tls", parsed_smtp_tls_reports_to_csv_rows, smtp_tls_reports)
def _save(self, report_type: str, to_rows: Callable, reports: Any):
rows = to_rows(reports)
if not rows:
return
logger.debug(f"Saving {len(rows)} {report_type} records to Loki")
domain_field, begin_field, begin_is_utc = _REPORT_TYPES[report_type]
now = time.time_ns()
entries = []
for i, row in enumerate(rows):
line = dict(row)
line["report_type"] = report_type
line.setdefault("domain", row.get(domain_field))
ts = now
if self.timestamp == "report_begin":
try:
begin = human_timestamp_to_unix_timestamp(
str(row[begin_field]), assume_utc=begin_is_utc
)
ts = begin * 10**9
except Exception:
logger.debug(f"Unparsable {begin_field}, using ingest time")
entries.append((self._stream_labels(line), ts + i, line))
# Loki only accepts out-of-order entries within half of max_chunk_age
# (so 1h by default) of the newest line in a stream, else it rejects
# them with too_far_behind. Aggregate rows come source-major then per
# day, so sort the whole list (not per push, which would still go
# backwards across batches).
entries.sort(key=lambda entry: entry[1])
for start in range(0, len(entries), self.batch_size):
self._push(entries[start : start + self.batch_size])
def _stream_labels(self, line: dict[str, Any]) -> tuple[tuple[str, str], ...]:
labels = dict(self.labels)
for field in self.label_fields:
value = line.get(field)
if value is not None and value != "":
labels[field] = str(value)
return tuple(labels.items())
def _push(self, entries: list):
streams: dict[tuple, list[list[str]]] = {}
for labels, ts, line in entries:
streams.setdefault(labels, []).append(
[str(ts), json.dumps(line, ensure_ascii=False, default=str)]
)
body = {
"streams": [
{"stream": dict(labels), "values": values}
for labels, values in streams.items()
]
}
try:
response = self.session.post(
self.url, content=json.dumps(body), timeout=self.timeout
)
except httpx.HTTPError as error_:
raise LokiError(f"Loki push to {self.url} failed: {error_}") from error_
# Loki answers 204 when it stored the push; 200 is also accepted.
# Other 2xx codes are not success: with block_ingestion_until set it
# returns a custom 260 and discards the logs
# (block_ingestion_status_code).
if response.status_code not in (200, 204):
raise LokiError(
f"Loki push to {self.url} failed with HTTP "
f"{response.status_code}: {response.text[:500]}"
)
def close(self):
"""Close the underlying HTTP session."""
self.session.close()