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,99 @@
|
|
|
1
|
+
"""KinesisSink — put event records to a Kinesis Data Stream (arch §8, §9.1, SPEC-010).
|
|
2
|
+
|
|
3
|
+
Extends the durable-buffer path ``SQSSink`` established: a queue/stream absorbs spikes and outages
|
|
4
|
+
while a separate consumer (out of scope) drains it into ELK. ``boto3`` is the optional ``aws`` extra,
|
|
5
|
+
imported lazily inside the sink (never at module top) so importing this module needs no ``boto3``
|
|
6
|
+
unless a sink is built without an injected client. Each incoming batch is re-chunked to Kinesis's
|
|
7
|
+
``put_records`` limits (≤ 500 records **and** ≤ 5 MB); partial failures are retried within a bound.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from log_forge.sinks._chunk import chunk_items
|
|
17
|
+
|
|
18
|
+
__all__ = ["KinesisSink"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class KinesisSink:
|
|
22
|
+
"""A :class:`~log_forge.sinks.base.Sink` that writes events to a Kinesis Data Stream."""
|
|
23
|
+
|
|
24
|
+
MAX_RECORDS = 500 # put_records hard limit: records per request
|
|
25
|
+
MAX_REQUEST_BYTES = 5 * 1024 * 1024 # 5 MB per put_records request
|
|
26
|
+
MAX_RECORD_BYTES = 1024 * 1024 # 1 MB per record (Data)
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
stream_name: str,
|
|
31
|
+
*,
|
|
32
|
+
client: Any = None,
|
|
33
|
+
partition_key_field: str = "trace_id",
|
|
34
|
+
max_retries: int = 3,
|
|
35
|
+
) -> None:
|
|
36
|
+
if client is None:
|
|
37
|
+
import boto3 # type: ignore[import-not-found] # optional 'aws' extra
|
|
38
|
+
|
|
39
|
+
client = boto3.client("kinesis")
|
|
40
|
+
self.stream_name = stream_name
|
|
41
|
+
self.client = client
|
|
42
|
+
self.partition_key_field = partition_key_field
|
|
43
|
+
self.max_retries = max_retries
|
|
44
|
+
self.failed = 0
|
|
45
|
+
self.dropped_oversized = 0
|
|
46
|
+
|
|
47
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
48
|
+
"""Re-chunk to put_records limits and send each chunk, retrying failures (FR-003)."""
|
|
49
|
+
records = self._records(batch)
|
|
50
|
+
for chunk in chunk_items(
|
|
51
|
+
records,
|
|
52
|
+
max_count=self.MAX_RECORDS,
|
|
53
|
+
max_bytes=self.MAX_REQUEST_BYTES,
|
|
54
|
+
size_of=lambda record: len(record["Data"]),
|
|
55
|
+
):
|
|
56
|
+
self._send(chunk)
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
"""No-op: the sink buffers nothing internally (FR-001)."""
|
|
60
|
+
|
|
61
|
+
# -- internals ----------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def _records(self, batch: list[dict[str, object]]) -> list[dict[str, Any]]:
|
|
64
|
+
"""Build put_records entries, dropping any single record too large to ever fit (FR-011)."""
|
|
65
|
+
records: list[dict[str, Any]] = []
|
|
66
|
+
for event in batch:
|
|
67
|
+
data = json.dumps(event).encode("utf-8")
|
|
68
|
+
if len(data) > self.MAX_RECORD_BYTES:
|
|
69
|
+
self.dropped_oversized += 1
|
|
70
|
+
sys.stderr.write(
|
|
71
|
+
f"log-forge: KinesisSink dropped an event of {len(data)} bytes exceeding "
|
|
72
|
+
f"the {self.MAX_RECORD_BYTES}-byte per-record limit\n"
|
|
73
|
+
)
|
|
74
|
+
continue
|
|
75
|
+
key = str(event.get(self.partition_key_field) or "log-forge")[:256]
|
|
76
|
+
records.append({"Data": data, "PartitionKey": key})
|
|
77
|
+
return records
|
|
78
|
+
|
|
79
|
+
def _send(self, records: list[dict[str, Any]]) -> None:
|
|
80
|
+
"""Send one chunk, retrying only the records the response flags as failed (FR-003)."""
|
|
81
|
+
for attempt in range(self.max_retries + 1):
|
|
82
|
+
response = self.client.put_records(StreamName=self.stream_name, Records=records)
|
|
83
|
+
if not response.get("FailedRecordCount"):
|
|
84
|
+
return
|
|
85
|
+
results = response.get("Records", [])
|
|
86
|
+
records = [
|
|
87
|
+
record
|
|
88
|
+
for record, result in zip(records, results)
|
|
89
|
+
if result.get("ErrorCode")
|
|
90
|
+
]
|
|
91
|
+
if not records:
|
|
92
|
+
return
|
|
93
|
+
if attempt >= self.max_retries:
|
|
94
|
+
self.failed += len(records)
|
|
95
|
+
sys.stderr.write(
|
|
96
|
+
f"log-forge: {len(records)} Kinesis record(s) still failing after "
|
|
97
|
+
f"{self.max_retries + 1} attempts; abandoned\n"
|
|
98
|
+
)
|
|
99
|
+
return
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""LoggingSink — bridge events into the stdlib ``logging`` framework (arch §8, SPEC-007).
|
|
2
|
+
|
|
3
|
+
Rather than reimplement rotating files, syslog, or the dozens of third-party log handlers that
|
|
4
|
+
already exist, this sink turns each built event dict into a ``logging.LogRecord`` and dispatches it
|
|
5
|
+
through a configurable logger — handing users their entire existing ``logging`` setup (handlers,
|
|
6
|
+
``logging.config``, third-party handlers) for free with no new dependency. The module is named
|
|
7
|
+
``logging_sink`` (not ``logging``) so it never shadows the stdlib module. Like every sink it
|
|
8
|
+
receives *already-built* event dicts and knows nothing about spans or context.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
__all__ = ["LoggingSink"]
|
|
16
|
+
|
|
17
|
+
# Level name -> stdlib numeric level; compared case-insensitively (arch §6 level names).
|
|
18
|
+
_LEVELS = {
|
|
19
|
+
"DEBUG": logging.DEBUG,
|
|
20
|
+
"INFO": logging.INFO,
|
|
21
|
+
"WARNING": logging.WARNING,
|
|
22
|
+
"ERROR": logging.ERROR,
|
|
23
|
+
"CRITICAL": logging.CRITICAL,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Attributes a LogRecord already owns; a user field must never overwrite one of these. Computed
|
|
27
|
+
# from a sample record plus the two names ``logging`` itself reserves against ``extra``.
|
|
28
|
+
_RESERVED = set(logging.LogRecord("", 0, "", 0, "", (), None).__dict__) | {"message", "asctime"}
|
|
29
|
+
|
|
30
|
+
# Identity keys copied verbatim onto the record as flat attributes (all known non-reserved).
|
|
31
|
+
_IDENTITY = (
|
|
32
|
+
"trace_id",
|
|
33
|
+
"span_id",
|
|
34
|
+
"parent_span_id",
|
|
35
|
+
"log_id",
|
|
36
|
+
"function",
|
|
37
|
+
"service",
|
|
38
|
+
"version",
|
|
39
|
+
"env",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LoggingSink:
|
|
44
|
+
"""A :class:`~log_forge.sinks.base.Sink` that dispatches each event as a ``logging.LogRecord``.
|
|
45
|
+
|
|
46
|
+
The sink only *emits* into the user's logging pipeline; it never configures loggers, handlers,
|
|
47
|
+
or formatters, and never tears the framework down. ``default_level`` (a level name) is used for
|
|
48
|
+
events whose ``level`` is unknown or missing.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
logger: logging.Logger | None = None,
|
|
54
|
+
*,
|
|
55
|
+
default_level: str = "INFO",
|
|
56
|
+
) -> None:
|
|
57
|
+
self._logger = logger if logger is not None else logging.getLogger("log_forge")
|
|
58
|
+
self._default_level = _LEVELS.get(default_level.upper(), logging.INFO)
|
|
59
|
+
|
|
60
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
61
|
+
"""Dispatch one LogRecord per event, in batch order, through the target logger (FR-001)."""
|
|
62
|
+
for event in batch:
|
|
63
|
+
self._logger.handle(self._to_record(event))
|
|
64
|
+
|
|
65
|
+
def close(self) -> None:
|
|
66
|
+
"""No-op — the sink does not own the user's logging configuration (FR-005)."""
|
|
67
|
+
|
|
68
|
+
# -- internals ----------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
def _level_of(self, event: dict[str, object]) -> int:
|
|
71
|
+
"""Map the event's textual ``level`` to a numeric level, case-insensitively (FR-002)."""
|
|
72
|
+
level = event.get("level")
|
|
73
|
+
if isinstance(level, str):
|
|
74
|
+
return _LEVELS.get(level.upper(), self._default_level)
|
|
75
|
+
return self._default_level
|
|
76
|
+
|
|
77
|
+
def _to_record(self, event: dict[str, object]) -> logging.LogRecord:
|
|
78
|
+
"""Build one record: verbatim message, mapped level, structured attrs (FR-002..FR-004)."""
|
|
79
|
+
record = logging.LogRecord(
|
|
80
|
+
name=self._logger.name,
|
|
81
|
+
level=self._level_of(event),
|
|
82
|
+
pathname="",
|
|
83
|
+
lineno=0,
|
|
84
|
+
msg=event.get("message", ""),
|
|
85
|
+
args=(), # no %-args, so a literal '%' in the message is never interpolated (FR-004)
|
|
86
|
+
exc_info=None,
|
|
87
|
+
)
|
|
88
|
+
self._attach(record, event)
|
|
89
|
+
return record
|
|
90
|
+
|
|
91
|
+
def _attach(self, record: logging.LogRecord, event: dict[str, object]) -> None:
|
|
92
|
+
"""Attach identity keys + structured fields without clobbering reserved attrs (FR-003)."""
|
|
93
|
+
for key in _IDENTITY:
|
|
94
|
+
if key in event:
|
|
95
|
+
setattr(record, key, event[key])
|
|
96
|
+
fields = event.get("fields")
|
|
97
|
+
if isinstance(fields, dict):
|
|
98
|
+
# Nested payload is lossless even when a flat key collides and is skipped below.
|
|
99
|
+
setattr(record, "fields", fields)
|
|
100
|
+
for key, value in fields.items():
|
|
101
|
+
# Skip reserved LogRecord attrs, identity keys, and "fields": the sink owns
|
|
102
|
+
# record.fields (the nested payload); a field named "fields" must not overwrite it.
|
|
103
|
+
if key not in _RESERVED and key not in _IDENTITY and key != "fields":
|
|
104
|
+
setattr(record, key, value)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""LogstashSink — JSON lines to Logstash over HTTP or a raw TCP/UDP socket (arch §8, SPEC-009).
|
|
2
|
+
|
|
3
|
+
Two mutually-exclusive modes chosen at construction:
|
|
4
|
+
|
|
5
|
+
* ``LogstashSink(url=...)`` — send the batch as JSON lines over HTTP (reusing the ``HTTPSink`` core).
|
|
6
|
+
* ``LogstashSink(host=..., port=...)`` — send one ``json.dumps(event) + "\\n"`` per event over a raw
|
|
7
|
+
TCP or UDP socket (reusing :class:`~log_forge.sinks._socket.SocketTransport`).
|
|
8
|
+
|
|
9
|
+
Either backend handles its own bounded retry; ``close()`` releases whichever it holds.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
from log_forge.sinks._socket import SocketTransport
|
|
17
|
+
from log_forge.sinks.http import HTTPSink
|
|
18
|
+
|
|
19
|
+
__all__ = ["LogstashSink"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LogstashSink:
|
|
23
|
+
"""A :class:`~log_forge.sinks.base.Sink` that ships JSON lines to Logstash (FR-005)."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
url: str | None = None,
|
|
28
|
+
*,
|
|
29
|
+
host: str | None = None,
|
|
30
|
+
port: int | None = None,
|
|
31
|
+
transport: str = "tcp",
|
|
32
|
+
timeout: float = 5.0,
|
|
33
|
+
max_retries: int = 3,
|
|
34
|
+
**http_kwargs: object,
|
|
35
|
+
) -> None:
|
|
36
|
+
if url is not None:
|
|
37
|
+
self._http: HTTPSink | None = HTTPSink(
|
|
38
|
+
url, body_format="ndjson", timeout=timeout, max_retries=max_retries,
|
|
39
|
+
**http_kwargs, # type: ignore[arg-type]
|
|
40
|
+
)
|
|
41
|
+
self._socket: SocketTransport | None = None
|
|
42
|
+
elif host is not None and port is not None:
|
|
43
|
+
self._http = None
|
|
44
|
+
self._socket = SocketTransport(
|
|
45
|
+
host, port, transport=transport, timeout=timeout, max_retries=max_retries
|
|
46
|
+
)
|
|
47
|
+
else:
|
|
48
|
+
raise ValueError("LogstashSink requires either url= (HTTP) or host= + port= (socket)")
|
|
49
|
+
|
|
50
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
51
|
+
"""Send the batch over the configured backend (FR-005)."""
|
|
52
|
+
if not batch:
|
|
53
|
+
return
|
|
54
|
+
if self._http is not None:
|
|
55
|
+
self._http.emit(batch)
|
|
56
|
+
else:
|
|
57
|
+
assert self._socket is not None
|
|
58
|
+
frames = [(json.dumps(event) + "\n").encode("utf-8") for event in batch]
|
|
59
|
+
self._socket.send_all(frames)
|
|
60
|
+
|
|
61
|
+
def close(self) -> None:
|
|
62
|
+
"""Close whichever backend is held (FR-005)."""
|
|
63
|
+
if self._http is not None:
|
|
64
|
+
self._http.close()
|
|
65
|
+
elif self._socket is not None:
|
|
66
|
+
self._socket.close()
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def failed(self) -> int:
|
|
70
|
+
"""Requests/messages abandoned past the retry bound, from the active backend."""
|
|
71
|
+
return self._http.failed if self._http is not None else (
|
|
72
|
+
self._socket.failed if self._socket is not None else 0
|
|
73
|
+
)
|
log_forge/sinks/loki.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""LokiSink — push events to Grafana Loki (arch §8, SPEC-009).
|
|
2
|
+
|
|
3
|
+
A specialization of :class:`~log_forge.sinks.http.HTTPSink` that builds Loki's push payload: events
|
|
4
|
+
are grouped into ``streams`` by a configurable set of label keys (e.g. ``service``/``env``/
|
|
5
|
+
``level``), each carrying ``values`` of ``[<nanosecond_timestamp_str>, <log_line>]``. Timestamps are
|
|
6
|
+
derived by parsing the event's ISO-8601 ``timestamp`` (falling back to emit-time now).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
from log_forge.sinks._time import epoch_nanos
|
|
14
|
+
from log_forge.sinks.http import HTTPSink
|
|
15
|
+
|
|
16
|
+
__all__ = ["LokiSink"]
|
|
17
|
+
|
|
18
|
+
_PUSH_PATH = "/loki/api/v1/push"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class LokiSink(HTTPSink):
|
|
22
|
+
"""POST a Loki push payload (streams + labels + nanosecond values) (FR-004)."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, url: str, *, labels: tuple[str, ...] = ("service", "env", "level"),
|
|
25
|
+
**http_kwargs: object) -> None:
|
|
26
|
+
self._labels = labels
|
|
27
|
+
super().__init__(
|
|
28
|
+
url.rstrip("/") + _PUSH_PATH, body_format="json_array", **http_kwargs # type: ignore[arg-type]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
32
|
+
"""Group events into labelled streams and POST the push payload (FR-004)."""
|
|
33
|
+
if not batch:
|
|
34
|
+
return
|
|
35
|
+
# key = sorted label tuple -> (label dict, list of [ns_ts, line])
|
|
36
|
+
streams: dict[tuple[tuple[str, str], ...], tuple[dict[str, str], list[list[str]]]] = {}
|
|
37
|
+
for event in batch:
|
|
38
|
+
labels = {key: str(event[key]) for key in self._labels if key in event}
|
|
39
|
+
stream_key = tuple(sorted(labels.items()))
|
|
40
|
+
value = [str(epoch_nanos(event.get("timestamp"))), json.dumps(event)]
|
|
41
|
+
streams.setdefault(stream_key, (labels, []))[1].append(value)
|
|
42
|
+
payload = {
|
|
43
|
+
"streams": [
|
|
44
|
+
{"stream": labels, "values": values} for labels, values in streams.values()
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
body = json.dumps(payload).encode("utf-8")
|
|
48
|
+
self._send(body, content_type="application/json")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""MongoDBSink — insert events into a MongoDB collection (arch §8, SPEC-011).
|
|
2
|
+
|
|
3
|
+
Near-zero impedance: events are already dicts, so ``insert_many`` takes them as-is. ``pymongo`` is
|
|
4
|
+
the optional ``mongo`` extra, imported lazily inside the sink (never at module top). Inserts are
|
|
5
|
+
unordered (``ordered=False``) so one bad document does not abort the rest of the batch; a
|
|
6
|
+
``BulkWriteError`` is caught, its failed-insert count recorded, and the successfully-inserted
|
|
7
|
+
documents retained. Write-only — querying is the downstream tool's job.
|
|
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__ = ["MongoDBSink"]
|
|
18
|
+
|
|
19
|
+
_BACKOFF_BASE = 0.1
|
|
20
|
+
_MAX_DOC_BYTES = 16 * 1024 * 1024 # MongoDB's hard 16 MB per-document limit
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MongoDBSink:
|
|
24
|
+
"""A :class:`~log_forge.sinks.base.Sink` that inserts events into a MongoDB collection.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
failed: Documents the server rejected (bulk write errors) or a whole batch abandoned past
|
|
28
|
+
the retry bound.
|
|
29
|
+
dropped_oversized: Documents dropped for exceeding the 16 MB limit.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
client: Any = None,
|
|
36
|
+
uri: str | None = None,
|
|
37
|
+
database: str,
|
|
38
|
+
collection: str,
|
|
39
|
+
max_retries: int = 3,
|
|
40
|
+
) -> None:
|
|
41
|
+
self._owns_client = client is None
|
|
42
|
+
if client is None:
|
|
43
|
+
from pymongo import MongoClient # type: ignore[import-not-found] # 'mongo' extra
|
|
44
|
+
|
|
45
|
+
client = MongoClient(uri)
|
|
46
|
+
self._client = client
|
|
47
|
+
self._collection = client[database][collection]
|
|
48
|
+
self.max_retries = max_retries
|
|
49
|
+
self.failed = 0
|
|
50
|
+
self.dropped_oversized = 0
|
|
51
|
+
self._closed = False
|
|
52
|
+
|
|
53
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
54
|
+
"""Insert every event unordered; count bulk failures, retry connection errors (FR-003)."""
|
|
55
|
+
documents = self._documents(batch)
|
|
56
|
+
if not documents:
|
|
57
|
+
return
|
|
58
|
+
for attempt in range(self.max_retries + 1):
|
|
59
|
+
try:
|
|
60
|
+
self._collection.insert_many(documents, ordered=False)
|
|
61
|
+
return
|
|
62
|
+
except Exception as err: # isolation boundary: never crash the worker (FR-006)
|
|
63
|
+
details = getattr(err, "details", None)
|
|
64
|
+
if isinstance(details, dict) and "writeErrors" in details:
|
|
65
|
+
# BulkWriteError: unordered insert stored what it could; count the rejects and
|
|
66
|
+
# do not retry (successes are already in, a retry would duplicate/re-error).
|
|
67
|
+
rejects = len(details["writeErrors"])
|
|
68
|
+
self.failed += rejects
|
|
69
|
+
sys.stderr.write(
|
|
70
|
+
f"log-forge: MongoDBSink had {rejects} document(s) rejected by a bulk "
|
|
71
|
+
f"write; the rest were inserted\n"
|
|
72
|
+
)
|
|
73
|
+
return
|
|
74
|
+
if attempt < self.max_retries:
|
|
75
|
+
time.sleep(_BACKOFF_BASE * (2**attempt))
|
|
76
|
+
continue
|
|
77
|
+
self.failed += len(documents)
|
|
78
|
+
sys.stderr.write(
|
|
79
|
+
f"log-forge: MongoDBSink abandoned {len(documents)} document(s) after "
|
|
80
|
+
f"{self.max_retries + 1} attempts ({err!r})\n"
|
|
81
|
+
)
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
"""Close the client only if the sink owns it; idempotent (FR-005)."""
|
|
86
|
+
if self._closed:
|
|
87
|
+
return
|
|
88
|
+
if self._owns_client:
|
|
89
|
+
self._client.close()
|
|
90
|
+
self._closed = True
|
|
91
|
+
|
|
92
|
+
# -- internals ----------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def _documents(self, batch: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
95
|
+
"""Copy each event (so ``_id`` insertion doesn't mutate the caller's dicts), drop oversized."""
|
|
96
|
+
documents: list[dict[str, object]] = []
|
|
97
|
+
for event in batch:
|
|
98
|
+
if len(json.dumps(event).encode("utf-8")) > _MAX_DOC_BYTES:
|
|
99
|
+
self.dropped_oversized += 1
|
|
100
|
+
sys.stderr.write(
|
|
101
|
+
"log-forge: MongoDBSink dropped a document exceeding the 16 MB limit\n"
|
|
102
|
+
)
|
|
103
|
+
continue
|
|
104
|
+
documents.append(dict(event))
|
|
105
|
+
return documents
|
log_forge/sinks/multi.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""MultiSink — fan one batch out to several sinks (arch §8, SPEC-006 FR-002).
|
|
2
|
+
|
|
3
|
+
One ``configure(sink=...)`` can echo to stdout *and* ship to SQS by wrapping both in a
|
|
4
|
+
``MultiSink``. Children receive the batch sequentially in construction order; a single child
|
|
5
|
+
whose ``emit`` (or ``close``) raises is isolated — the failure is counted on ``failed`` and
|
|
6
|
+
logged to stderr, and its siblings still run — so one broken destination never fails the whole
|
|
7
|
+
fan-out or the worker's retry. This mirrors the worker's own survive-a-sink-failure isolation
|
|
8
|
+
boundary (arch §9; best-practices §7 sanctions the broad catch here).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from log_forge.sinks.base import Sink
|
|
16
|
+
|
|
17
|
+
__all__ = ["MultiSink"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MultiSink:
|
|
21
|
+
"""A :class:`~log_forge.sinks.base.Sink` that forwards each batch to every child sink.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
failed: Count of child ``emit``/``close`` calls that raised and were isolated.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, *sinks: Sink) -> None:
|
|
28
|
+
self._sinks = sinks
|
|
29
|
+
self.failed = 0
|
|
30
|
+
|
|
31
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
32
|
+
"""Forward ``batch`` to every child in construction order, isolating failures (FR-002)."""
|
|
33
|
+
for sink in self._sinks:
|
|
34
|
+
try:
|
|
35
|
+
sink.emit(batch)
|
|
36
|
+
except Exception as err: # isolation boundary: one child must not fail the rest
|
|
37
|
+
self.failed += 1
|
|
38
|
+
sys.stderr.write(
|
|
39
|
+
f"log-forge: MultiSink child {type(sink).__name__}.emit "
|
|
40
|
+
f"failed and was skipped: {err!r}\n"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def close(self) -> None:
|
|
44
|
+
"""Close every child, isolating a failing child so the rest still close (FR-002)."""
|
|
45
|
+
for sink in self._sinks:
|
|
46
|
+
try:
|
|
47
|
+
sink.close()
|
|
48
|
+
except Exception as err: # close-all: an earlier failure must not skip the rest
|
|
49
|
+
self.failed += 1
|
|
50
|
+
sys.stderr.write(
|
|
51
|
+
f"log-forge: MultiSink child {type(sink).__name__}.close "
|
|
52
|
+
f"failed and was skipped: {err!r}\n"
|
|
53
|
+
)
|
log_forge/sinks/nats.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""NATSSink — publish events to a NATS subject, optionally via JetStream (arch §8, §9.1, SPEC-010).
|
|
2
|
+
|
|
3
|
+
A durable-buffer sink on ``nats-py`` (the optional ``nats`` extra, imported lazily). ``nats-py`` is
|
|
4
|
+
async, so the sink owns a private event loop and drives each publish to completion from the
|
|
5
|
+
synchronous ``emit`` (which therefore returns only after the batch is handed off). With
|
|
6
|
+
``jetstream=True`` it publishes through JetStream for durable acknowledgement. ``close()`` drains and
|
|
7
|
+
closes the connection.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
__all__ = ["NATSSink"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NATSSink:
|
|
21
|
+
"""A :class:`~log_forge.sinks.base.Sink` that publishes events to a NATS subject."""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
subject: str,
|
|
26
|
+
*,
|
|
27
|
+
client: Any = None,
|
|
28
|
+
jetstream: bool = False,
|
|
29
|
+
servers: str | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._subject = subject
|
|
32
|
+
self._jetstream = jetstream
|
|
33
|
+
self._loop = asyncio.new_event_loop()
|
|
34
|
+
self.failed = 0
|
|
35
|
+
if client is None:
|
|
36
|
+
import nats # type: ignore[import-not-found] # optional 'nats' extra
|
|
37
|
+
|
|
38
|
+
client = self._loop.run_until_complete(
|
|
39
|
+
nats.connect(servers or "nats://localhost:4222")
|
|
40
|
+
)
|
|
41
|
+
self._client = client
|
|
42
|
+
|
|
43
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
44
|
+
"""Drive the async publishes to completion on the managed loop (FR-007)."""
|
|
45
|
+
if not batch:
|
|
46
|
+
return
|
|
47
|
+
self._loop.run_until_complete(self._publish_all(batch))
|
|
48
|
+
|
|
49
|
+
def close(self) -> None:
|
|
50
|
+
"""Drain/flush and close the connection, then close the managed loop (FR-007)."""
|
|
51
|
+
if self._loop.is_closed():
|
|
52
|
+
return
|
|
53
|
+
try:
|
|
54
|
+
self._loop.run_until_complete(self._drain())
|
|
55
|
+
finally:
|
|
56
|
+
self._loop.close()
|
|
57
|
+
|
|
58
|
+
# -- internals ----------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
async def _publish_all(self, batch: list[dict[str, object]]) -> None:
|
|
61
|
+
target = self._client.jetstream() if self._jetstream else self._client
|
|
62
|
+
for event in batch:
|
|
63
|
+
try:
|
|
64
|
+
await target.publish(self._subject, json.dumps(event).encode("utf-8"))
|
|
65
|
+
except Exception as err: # isolation boundary: never crash the worker (FR-011)
|
|
66
|
+
self.failed += 1
|
|
67
|
+
sys.stderr.write(f"log-forge: NATSSink publish failed: {err!r}\n")
|
|
68
|
+
|
|
69
|
+
async def _drain(self) -> None:
|
|
70
|
+
drain = getattr(self._client, "drain", None)
|
|
71
|
+
if drain is not None:
|
|
72
|
+
await drain()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""NewRelicSink — ship to the New Relic Log API (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 Log API endpoint with an ``Api-Key`` header.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from log_forge.sinks.http import HTTPSink, merge_headers
|
|
10
|
+
|
|
11
|
+
__all__ = ["NewRelicSink"]
|
|
12
|
+
|
|
13
|
+
# region -> Log API host.
|
|
14
|
+
_HOSTS = {"US": "log-api.newrelic.com", "EU": "log-api.eu.newrelic.com"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NewRelicSink(HTTPSink):
|
|
18
|
+
"""POST events to the New Relic Log API (FR-009)."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, api_key: str, *, region: str = "US", **http_kwargs: object) -> None:
|
|
21
|
+
region = region.upper()
|
|
22
|
+
if region not in _HOSTS:
|
|
23
|
+
raise ValueError(f"invalid region {region!r}; expected one of {sorted(_HOSTS)}")
|
|
24
|
+
headers = merge_headers({"Api-Key": api_key}, http_kwargs)
|
|
25
|
+
super().__init__(
|
|
26
|
+
f"https://{_HOSTS[region]}/log/v1",
|
|
27
|
+
headers=headers, body_format="json_array", **http_kwargs, # type: ignore[arg-type]
|
|
28
|
+
)
|