log-foundry 0.0.2.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- log_forge/__init__.py +45 -0
- log_forge/api.py +89 -0
- log_forge/config.py +79 -0
- log_forge/console.py +30 -0
- log_forge/context.py +62 -0
- log_forge/decorator.py +157 -0
- log_forge/ids.py +29 -0
- log_forge/model.py +106 -0
- log_forge/py.typed +0 -0
- log_forge/sinks/__init__.py +0 -0
- log_forge/sinks/_chunk.py +58 -0
- log_forge/sinks/_socket.py +105 -0
- log_forge/sinks/_time.py +28 -0
- log_forge/sinks/base.py +22 -0
- log_forge/sinks/callback.py +42 -0
- log_forge/sinks/clickhouse.py +111 -0
- log_forge/sinks/datadog.py +53 -0
- log_forge/sinks/elasticsearch.py +68 -0
- log_forge/sinks/eventhubs.py +110 -0
- log_forge/sinks/file.py +177 -0
- log_forge/sinks/filtering.py +71 -0
- log_forge/sinks/firehose.py +91 -0
- log_forge/sinks/honeycomb.py +38 -0
- log_forge/sinks/http.py +202 -0
- log_forge/sinks/kafka.py +71 -0
- log_forge/sinks/kinesis.py +99 -0
- log_forge/sinks/logging_sink.py +104 -0
- log_forge/sinks/logstash.py +73 -0
- log_forge/sinks/loki.py +48 -0
- log_forge/sinks/mongodb.py +105 -0
- log_forge/sinks/multi.py +53 -0
- log_forge/sinks/nats.py +72 -0
- log_forge/sinks/newrelic.py +28 -0
- log_forge/sinks/postgres.py +101 -0
- log_forge/sinks/pubsub.py +45 -0
- log_forge/sinks/rabbitmq.py +129 -0
- log_forge/sinks/redis.py +90 -0
- log_forge/sinks/sentry.py +136 -0
- log_forge/sinks/sns.py +82 -0
- log_forge/sinks/splunk.py +51 -0
- log_forge/sinks/sqlite.py +100 -0
- log_forge/sinks/sqs.py +107 -0
- log_forge/sinks/stdout.py +31 -0
- log_forge/sinks/syslog.py +88 -0
- log_forge/sinks/transform.py +46 -0
- log_forge/sinks/util.py +68 -0
- log_forge/worker.py +153 -0
- log_foundry-0.0.2.dev1.dist-info/METADATA +537 -0
- log_foundry-0.0.2.dev1.dist-info/RECORD +51 -0
- log_foundry-0.0.2.dev1.dist-info/WHEEL +4 -0
- log_foundry-0.0.2.dev1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Shared raw-socket transport for LogstashSink (socket mode) and SyslogSink (SPEC-009).
|
|
2
|
+
|
|
3
|
+
Sends pre-framed byte messages over TCP or UDP with bounded reconnect retry. For TCP a single
|
|
4
|
+
connection is opened and reused (reconnecting on error); for UDP each message is an independent
|
|
5
|
+
datagram. The transport is framing-agnostic — callers hand it the exact bytes to put on the wire
|
|
6
|
+
(newline-terminated JSON for Logstash, RFC 5424 / octet-counted frames for Syslog).
|
|
7
|
+
|
|
8
|
+
Socket creation goes through the module-level ``_make_tcp`` / ``_make_udp`` seams so tests can
|
|
9
|
+
substitute a fake socket without any network access.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import socket
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
__all__ = ["SocketTransport"]
|
|
19
|
+
|
|
20
|
+
_BACKOFF_BASE = 0.1 # seconds; delay for retry attempt n is _BACKOFF_BASE * 2**n
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _make_tcp(host: str, port: int, timeout: float) -> socket.socket:
|
|
24
|
+
"""Open a connected TCP socket (indirection seam for tests)."""
|
|
25
|
+
return socket.create_connection((host, port), timeout=timeout)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _make_udp() -> socket.socket:
|
|
29
|
+
"""Open an unconnected UDP socket (indirection seam for tests)."""
|
|
30
|
+
return socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SocketTransport:
|
|
34
|
+
"""Send pre-framed messages over a TCP or UDP socket, reconnecting on error within a bound.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
failed: Messages abandoned past the reconnect-retry bound.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
host: str,
|
|
43
|
+
port: int,
|
|
44
|
+
*,
|
|
45
|
+
transport: str = "tcp",
|
|
46
|
+
timeout: float = 5.0,
|
|
47
|
+
max_retries: int = 3,
|
|
48
|
+
) -> None:
|
|
49
|
+
if transport not in ("tcp", "udp"):
|
|
50
|
+
raise ValueError(f"invalid transport {transport!r}; expected 'tcp' or 'udp'")
|
|
51
|
+
self._host = host
|
|
52
|
+
self._port = port
|
|
53
|
+
self._transport = transport
|
|
54
|
+
self._timeout = timeout
|
|
55
|
+
self._max_retries = max_retries
|
|
56
|
+
self._sock: socket.socket | None = None
|
|
57
|
+
self.failed = 0
|
|
58
|
+
|
|
59
|
+
def send_all(self, messages: list[bytes]) -> None:
|
|
60
|
+
"""Send each pre-framed message, reconnecting on error (FR-005, FR-006)."""
|
|
61
|
+
for message in messages:
|
|
62
|
+
self._send_one(message)
|
|
63
|
+
|
|
64
|
+
def close(self) -> None:
|
|
65
|
+
"""Close the held socket, if any; idempotent (FR-005, FR-012)."""
|
|
66
|
+
self._reset()
|
|
67
|
+
|
|
68
|
+
# -- internals ----------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def _send_one(self, message: bytes) -> None:
|
|
71
|
+
for attempt in range(self._max_retries + 1):
|
|
72
|
+
try:
|
|
73
|
+
if self._transport == "udp":
|
|
74
|
+
self._socket().sendto(message, (self._host, self._port))
|
|
75
|
+
else:
|
|
76
|
+
self._socket().sendall(message)
|
|
77
|
+
return
|
|
78
|
+
except OSError as err:
|
|
79
|
+
self._reset() # force a fresh connection on the next attempt
|
|
80
|
+
if attempt < self._max_retries:
|
|
81
|
+
time.sleep(_BACKOFF_BASE * (2**attempt))
|
|
82
|
+
continue
|
|
83
|
+
self.failed += 1
|
|
84
|
+
sys.stderr.write(
|
|
85
|
+
f"log-forge: SocketTransport abandoned a message after "
|
|
86
|
+
f"{self._max_retries + 1} attempt(s) ({err!r})\n"
|
|
87
|
+
)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
def _socket(self) -> socket.socket:
|
|
91
|
+
if self._sock is None:
|
|
92
|
+
self._sock = (
|
|
93
|
+
_make_udp() if self._transport == "udp" else _make_tcp(
|
|
94
|
+
self._host, self._port, self._timeout
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return self._sock
|
|
98
|
+
|
|
99
|
+
def _reset(self) -> None:
|
|
100
|
+
if self._sock is not None:
|
|
101
|
+
try:
|
|
102
|
+
self._sock.close()
|
|
103
|
+
except OSError:
|
|
104
|
+
pass
|
|
105
|
+
self._sock = None
|
log_forge/sinks/_time.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""ISO-8601 → epoch helpers for sinks that need numeric time (SPEC-009).
|
|
2
|
+
|
|
3
|
+
A SPEC-001 ``LogEvent``'s ``timestamp`` is an ISO-8601 *string* (``2026-07-11T00:00:00.123Z``), not
|
|
4
|
+
an epoch. Sinks like Loki (nanoseconds) and Splunk HEC (epoch seconds) derive numeric time by
|
|
5
|
+
parsing that string, falling back to emit-time ``now`` when it is absent or unparseable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
|
|
13
|
+
__all__ = ["epoch_seconds", "epoch_nanos"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def epoch_seconds(timestamp: object) -> float:
|
|
17
|
+
"""Epoch seconds from a SPEC-001 ISO-8601 string; emit-time ``now`` if absent/unparseable."""
|
|
18
|
+
if isinstance(timestamp, str):
|
|
19
|
+
try:
|
|
20
|
+
return datetime.fromisoformat(timestamp).timestamp()
|
|
21
|
+
except ValueError:
|
|
22
|
+
pass
|
|
23
|
+
return time.time()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def epoch_nanos(timestamp: object) -> int:
|
|
27
|
+
"""Epoch nanoseconds (as required by Loki), derived from :func:`epoch_seconds`."""
|
|
28
|
+
return int(epoch_seconds(timestamp) * 1_000_000_000)
|
log_forge/sinks/base.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The sink interface (arch §8).
|
|
2
|
+
|
|
3
|
+
A sink is the swappable output transport. It receives *already-built, batched* event
|
|
4
|
+
dicts from the worker and knows nothing about spans or context — that dumbness is what
|
|
5
|
+
makes sinks trivially interchangeable (StdoutSink, SQSSink, …). Only the Protocol lives
|
|
6
|
+
here; concrete sinks arrive in later phases.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Protocol, runtime_checkable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class Sink(Protocol):
|
|
16
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
17
|
+
"""Ship a batch of serialized event dicts."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
def close(self) -> None:
|
|
21
|
+
"""Flush and release any resources."""
|
|
22
|
+
...
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""CallbackSink — turn a plain callable into a Sink (arch §8, SPEC-006 FR-001).
|
|
2
|
+
|
|
3
|
+
The ultimate escape hatch: point log-forge at any destination by handing it a function,
|
|
4
|
+
without writing a ``Sink`` subclass. Like every sink it receives *already-built* event dicts
|
|
5
|
+
and knows nothing about spans or context — that dumbness is what makes sinks swappable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
__all__ = ["CallbackSink"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CallbackSink:
|
|
16
|
+
"""A :class:`~log_forge.sinks.base.Sink` that delegates each batch to a callable.
|
|
17
|
+
|
|
18
|
+
Attributes are internal; the observable contract is that ``emit`` hands the batch to
|
|
19
|
+
``fn`` unchanged and ``close`` invokes ``on_close`` once when one was supplied.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
fn: Callable[[list[dict[str, object]]], None],
|
|
25
|
+
*,
|
|
26
|
+
on_close: Callable[[], None] | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._fn = fn
|
|
29
|
+
self._on_close = on_close
|
|
30
|
+
|
|
31
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
32
|
+
"""Hand ``batch`` to the callable, unchanged and exactly once (FR-001).
|
|
33
|
+
|
|
34
|
+
Any exception the callable raises propagates out — the worker's retry/backoff path
|
|
35
|
+
handles it; this sink never swallows it.
|
|
36
|
+
"""
|
|
37
|
+
self._fn(batch)
|
|
38
|
+
|
|
39
|
+
def close(self) -> None:
|
|
40
|
+
"""Call ``on_close`` once if one was supplied, otherwise a no-op (FR-001)."""
|
|
41
|
+
if self._on_close is not None:
|
|
42
|
+
self._on_close()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""ClickHouseSink — batch-insert events into a ClickHouse table (arch §8, SPEC-011).
|
|
2
|
+
|
|
3
|
+
ClickHouse is the columnar, observability-scale favorite. ``clickhouse-connect`` is the optional
|
|
4
|
+
``clickhouse`` extra, imported lazily. Each event maps to a row of extracted typed columns plus the
|
|
5
|
+
full event as a ``String`` column, inserted in a single ``insert`` call per chunk. An optional
|
|
6
|
+
idempotent ``MergeTree`` ``create_table`` convenience is off by default. Write-only.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from log_forge.sinks._chunk import chunk_list, valid_identifier
|
|
17
|
+
|
|
18
|
+
__all__ = ["ClickHouseSink"]
|
|
19
|
+
|
|
20
|
+
_BACKOFF_BASE = 0.1
|
|
21
|
+
|
|
22
|
+
# Extracted columns (typed in the MergeTree schema); ``event`` (full JSON String) is stored too.
|
|
23
|
+
_COLUMNS = (
|
|
24
|
+
"timestamp", "level", "trace_id", "span_id", "function", "service", "duration_ms", "status"
|
|
25
|
+
)
|
|
26
|
+
_COLUMN_NAMES = [*_COLUMNS, "event"]
|
|
27
|
+
|
|
28
|
+
# Per-column DDL types for the optional create_table convenience (all nullable to tolerate missing
|
|
29
|
+
# keys — e.g. span-start events have no duration_ms/status).
|
|
30
|
+
_COLUMN_TYPES = {
|
|
31
|
+
"timestamp": "Nullable(String)",
|
|
32
|
+
"level": "Nullable(String)",
|
|
33
|
+
"trace_id": "Nullable(String)",
|
|
34
|
+
"span_id": "Nullable(String)",
|
|
35
|
+
"function": "Nullable(String)",
|
|
36
|
+
"service": "Nullable(String)",
|
|
37
|
+
"duration_ms": "Nullable(Float64)",
|
|
38
|
+
"status": "Nullable(String)",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ClickHouseSink:
|
|
43
|
+
"""A :class:`~log_forge.sinks.base.Sink` that batch-inserts events into a ClickHouse table."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
table: str,
|
|
48
|
+
*,
|
|
49
|
+
client: Any = None,
|
|
50
|
+
dsn: str | None = None,
|
|
51
|
+
create_table: bool = False,
|
|
52
|
+
chunk_size: int = 1000,
|
|
53
|
+
max_retries: int = 3,
|
|
54
|
+
) -> None:
|
|
55
|
+
self._table = valid_identifier(table)
|
|
56
|
+
self._chunk_size = chunk_size
|
|
57
|
+
self.max_retries = max_retries
|
|
58
|
+
self.failed = 0
|
|
59
|
+
self._closed = False
|
|
60
|
+
self._owns_client = client is None
|
|
61
|
+
if client is None:
|
|
62
|
+
import clickhouse_connect # type: ignore[import-not-found] # 'clickhouse' extra
|
|
63
|
+
|
|
64
|
+
client = clickhouse_connect.get_client(dsn=dsn)
|
|
65
|
+
self.client = client
|
|
66
|
+
if create_table:
|
|
67
|
+
self._ensure_schema()
|
|
68
|
+
|
|
69
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
70
|
+
"""Insert each chunk as one columnar ``insert`` call, retrying on failure (FR-002)."""
|
|
71
|
+
if not batch:
|
|
72
|
+
return
|
|
73
|
+
for chunk in chunk_list(batch, self._chunk_size):
|
|
74
|
+
rows = [self._row(event) for event in chunk]
|
|
75
|
+
self._insert(rows)
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
"""Close the client only if the sink owns it; idempotent (FR-005)."""
|
|
79
|
+
if self._closed:
|
|
80
|
+
return
|
|
81
|
+
if self._owns_client:
|
|
82
|
+
self.client.close()
|
|
83
|
+
self._closed = True
|
|
84
|
+
|
|
85
|
+
# -- internals ----------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _row(self, event: dict[str, object]) -> list[object]:
|
|
88
|
+
return [*(event.get(col) for col in _COLUMNS), json.dumps(event)]
|
|
89
|
+
|
|
90
|
+
def _insert(self, rows: list[list[object]]) -> None:
|
|
91
|
+
for attempt in range(self.max_retries + 1):
|
|
92
|
+
try:
|
|
93
|
+
self.client.insert(self._table, data=rows, column_names=_COLUMN_NAMES)
|
|
94
|
+
return
|
|
95
|
+
except Exception as err: # isolation boundary: never crash the worker (FR-006)
|
|
96
|
+
if attempt < self.max_retries:
|
|
97
|
+
time.sleep(_BACKOFF_BASE * (2**attempt))
|
|
98
|
+
continue
|
|
99
|
+
self.failed += len(rows)
|
|
100
|
+
sys.stderr.write(
|
|
101
|
+
f"log-forge: ClickHouseSink abandoned {len(rows)} row(s) after "
|
|
102
|
+
f"{self.max_retries + 1} attempts ({err!r})\n"
|
|
103
|
+
)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
def _ensure_schema(self) -> None:
|
|
107
|
+
columns = ", ".join(f"{col} {_COLUMN_TYPES[col]}" for col in _COLUMNS)
|
|
108
|
+
self.client.command(
|
|
109
|
+
f"CREATE TABLE IF NOT EXISTS {self._table} "
|
|
110
|
+
f"({columns}, event String) ENGINE = MergeTree ORDER BY tuple()"
|
|
111
|
+
)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""DatadogSink — ship to Datadog's logs intake (arch §8, SPEC-009).
|
|
2
|
+
|
|
3
|
+
An :class:`~log_forge.sinks.http.HTTPSink` specialization: POSTs the batch as a JSON array to the
|
|
4
|
+
region-specific logs intake with a ``DD-API-KEY`` header, enriching each entry with ``ddsource``,
|
|
5
|
+
``service``, and ``ddtags`` from configuration / the event.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from log_forge.sinks.http import HTTPSink, merge_headers
|
|
13
|
+
|
|
14
|
+
__all__ = ["DatadogSink"]
|
|
15
|
+
|
|
16
|
+
_DDSOURCE = "log-forge"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DatadogSink(HTTPSink):
|
|
20
|
+
"""POST events to Datadog's logs intake (FR-007)."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
api_key: str,
|
|
25
|
+
*,
|
|
26
|
+
site: str = "datadoghq.com",
|
|
27
|
+
service: str | None = None,
|
|
28
|
+
ddtags: str | None = None,
|
|
29
|
+
**http_kwargs: object,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._service = service
|
|
32
|
+
self._ddtags = ddtags
|
|
33
|
+
headers = merge_headers({"DD-API-KEY": api_key}, http_kwargs)
|
|
34
|
+
super().__init__(
|
|
35
|
+
f"https://http-intake.logs.{site}/api/v2/logs",
|
|
36
|
+
headers=headers, body_format="json_array", **http_kwargs, # type: ignore[arg-type]
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
40
|
+
"""POST each event (enriched) as one JSON array (FR-007)."""
|
|
41
|
+
if not batch:
|
|
42
|
+
return
|
|
43
|
+
body = json.dumps([self._entry(event) for event in batch]).encode("utf-8")
|
|
44
|
+
self._send(body, content_type="application/json")
|
|
45
|
+
|
|
46
|
+
def _entry(self, event: dict[str, object]) -> dict[str, object]:
|
|
47
|
+
entry = dict(event)
|
|
48
|
+
entry["ddsource"] = _DDSOURCE
|
|
49
|
+
if self._service is not None:
|
|
50
|
+
entry["service"] = self._service
|
|
51
|
+
if self._ddtags is not None:
|
|
52
|
+
entry["ddtags"] = self._ddtags
|
|
53
|
+
return entry
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""ElasticsearchSink / OpenSearchSink — index events via the ``_bulk`` API (arch §8, SPEC-009).
|
|
2
|
+
|
|
3
|
+
A thin specialization of :class:`~log_forge.sinks.http.HTTPSink`: each event becomes an action line
|
|
4
|
+
(``{"index": {"_index": target}}``) followed by its source line, POSTed to ``_bulk`` as newline-
|
|
5
|
+
delimited JSON. The bulk response is inspected per item so a partial failure is counted and logged
|
|
6
|
+
without discarding the successfully-indexed items. OpenSearch speaks the same bulk protocol, so
|
|
7
|
+
``OpenSearchSink`` is a straight reuse (endpoint/auth differ only by configuration).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from log_forge.sinks.http import HTTPSink
|
|
16
|
+
|
|
17
|
+
__all__ = ["ElasticsearchSink", "OpenSearchSink"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ElasticsearchSink(HTTPSink):
|
|
21
|
+
"""POST events to an Elasticsearch ``_bulk`` endpoint, parsing per-item errors (FR-003).
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
item_errors: Count of bulk items the server reported as failed (distinct from ``failed``,
|
|
25
|
+
which counts whole requests abandoned past the retry bound).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, url: str, *, index: str, auth: str | tuple[str, str] | None = None,
|
|
29
|
+
**http_kwargs: object) -> None:
|
|
30
|
+
self._index = index
|
|
31
|
+
super().__init__(
|
|
32
|
+
url.rstrip("/") + "/_bulk", auth=auth, body_format="ndjson", **http_kwargs # type: ignore[arg-type]
|
|
33
|
+
)
|
|
34
|
+
self.item_errors = 0
|
|
35
|
+
|
|
36
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
37
|
+
"""Build the ``_bulk`` NDJSON payload, POST it, and parse the response items (FR-003)."""
|
|
38
|
+
if not batch:
|
|
39
|
+
return
|
|
40
|
+
lines: list[str] = []
|
|
41
|
+
for event in batch:
|
|
42
|
+
lines.append(json.dumps({"index": {"_index": self._index}}))
|
|
43
|
+
lines.append(json.dumps(event))
|
|
44
|
+
body = ("\n".join(lines) + "\n").encode("utf-8") # bulk must be newline-terminated
|
|
45
|
+
payload = self._send(body, content_type="application/x-ndjson")
|
|
46
|
+
if payload is not None:
|
|
47
|
+
self._parse_bulk_response(payload)
|
|
48
|
+
|
|
49
|
+
def _parse_bulk_response(self, payload: bytes) -> None:
|
|
50
|
+
"""Count items the bulk response flagged as errors; a partial failure keeps the rest."""
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads(payload)
|
|
53
|
+
except (ValueError, TypeError):
|
|
54
|
+
return
|
|
55
|
+
if not isinstance(data, dict) or not data.get("errors"):
|
|
56
|
+
return
|
|
57
|
+
errors = 0
|
|
58
|
+
for item in data.get("items", []):
|
|
59
|
+
result: object = next(iter(item.values()), {}) if isinstance(item, dict) else {}
|
|
60
|
+
if isinstance(result, dict) and result.get("error"):
|
|
61
|
+
errors += 1
|
|
62
|
+
if errors:
|
|
63
|
+
self.item_errors += errors
|
|
64
|
+
sys.stderr.write(f"log-forge: ElasticsearchSink saw {errors} failed bulk item(s)\n")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class OpenSearchSink(ElasticsearchSink):
|
|
68
|
+
"""OpenSearch reuses the Elasticsearch ``_bulk`` protocol verbatim (FR-003)."""
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""AzureEventHubsSink — send events to an Azure Event Hub (arch §8, §9.1, SPEC-010).
|
|
2
|
+
|
|
3
|
+
A durable-buffer sink on ``azure-eventhub`` (the optional ``azure-eventhubs`` extra, imported
|
|
4
|
+
lazily). Events are packed into one or more ``EventDataBatch`` objects respecting the 1 MB per-batch
|
|
5
|
+
limit — the SDK signals a full batch by raising ``ValueError`` from ``batch.add`` — and each full
|
|
6
|
+
batch is sent. A single event too large for an even-empty batch is dropped with a counted warning;
|
|
7
|
+
send errors are retried within a bound. ``close()`` closes the producer.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
__all__ = ["AzureEventHubsSink"]
|
|
18
|
+
|
|
19
|
+
_BACKOFF_BASE = 0.1
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AzureEventHubsSink:
|
|
23
|
+
"""A :class:`~log_forge.sinks.base.Sink` that sends events to an Azure Event Hub."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
producer: Any = None,
|
|
29
|
+
connection_str: str | None = None,
|
|
30
|
+
eventhub: str | None = None,
|
|
31
|
+
max_retries: int = 3,
|
|
32
|
+
) -> None:
|
|
33
|
+
if producer is None:
|
|
34
|
+
if connection_str is None:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
"AzureEventHubsSink requires connection_str when no producer is injected"
|
|
37
|
+
)
|
|
38
|
+
from azure.eventhub import ( # type: ignore[import-not-found] # 'azure-eventhubs'
|
|
39
|
+
EventHubProducerClient,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
producer = EventHubProducerClient.from_connection_string(
|
|
43
|
+
connection_str, eventhub_name=eventhub
|
|
44
|
+
)
|
|
45
|
+
self.producer = producer
|
|
46
|
+
self.max_retries = max_retries
|
|
47
|
+
self.failed = 0
|
|
48
|
+
self.dropped_oversized = 0
|
|
49
|
+
|
|
50
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
51
|
+
"""Pack events into ≤ 1 MB EventDataBatches and send each; drop oversized events (FR-009)."""
|
|
52
|
+
if not batch:
|
|
53
|
+
return
|
|
54
|
+
event_data_cls = _event_data_cls()
|
|
55
|
+
current = self.producer.create_batch()
|
|
56
|
+
for event in batch:
|
|
57
|
+
data = event_data_cls(json.dumps(event).encode("utf-8"))
|
|
58
|
+
if _try_add(current, data):
|
|
59
|
+
continue
|
|
60
|
+
# current batch is full: send it and start a fresh one for this event.
|
|
61
|
+
self._send(current)
|
|
62
|
+
current = self.producer.create_batch()
|
|
63
|
+
if not _try_add(current, data):
|
|
64
|
+
self.dropped_oversized += 1
|
|
65
|
+
sys.stderr.write(
|
|
66
|
+
"log-forge: AzureEventHubsSink dropped an event too large for an "
|
|
67
|
+
"empty 1 MB batch\n"
|
|
68
|
+
)
|
|
69
|
+
self._send(current)
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
"""Close the producer (FR-009)."""
|
|
73
|
+
self.producer.close()
|
|
74
|
+
|
|
75
|
+
# -- internals ----------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def _send(self, event_batch: Any) -> None:
|
|
78
|
+
"""Send one EventDataBatch (skipping an empty one), retrying failures (FR-009, FR-011)."""
|
|
79
|
+
if len(event_batch) == 0:
|
|
80
|
+
return
|
|
81
|
+
for attempt in range(self.max_retries + 1):
|
|
82
|
+
try:
|
|
83
|
+
self.producer.send_batch(event_batch)
|
|
84
|
+
return
|
|
85
|
+
except Exception as err: # isolation boundary: never crash the worker (FR-011)
|
|
86
|
+
if attempt < self.max_retries:
|
|
87
|
+
time.sleep(_BACKOFF_BASE * (2**attempt))
|
|
88
|
+
continue
|
|
89
|
+
self.failed += len(event_batch)
|
|
90
|
+
sys.stderr.write(
|
|
91
|
+
f"log-forge: AzureEventHubsSink abandoned a batch of {len(event_batch)} "
|
|
92
|
+
f"event(s) after {self.max_retries + 1} attempts ({err!r})\n"
|
|
93
|
+
)
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _try_add(event_batch: Any, data: Any) -> bool:
|
|
98
|
+
"""Add ``data`` to the batch; ``False`` when the batch signals it is full (``ValueError``)."""
|
|
99
|
+
try:
|
|
100
|
+
event_batch.add(data)
|
|
101
|
+
return True
|
|
102
|
+
except ValueError:
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _event_data_cls() -> Any:
|
|
107
|
+
"""Return ``azure.eventhub.EventData`` (indirection seam for tests)."""
|
|
108
|
+
from azure.eventhub import EventData # 'azure-eventhubs' extra (type-ignored in __init__)
|
|
109
|
+
|
|
110
|
+
return EventData
|