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
log_forge/sinks/file.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""FileSink + RotatingFileSink — durable local NDJSON on disk (arch §8, SPEC-008).
|
|
2
|
+
|
|
3
|
+
Not every deployment ships to a cloud queue; local dev, debugging, air-gapped hosts, and simple
|
|
4
|
+
archival just want events on the local disk. These are the zero-dependency file sinks: one appends
|
|
5
|
+
NDJSON to a single file; the other bounds on-disk growth by rotating on a size and/or time trigger
|
|
6
|
+
while retaining a fixed number of numbered backups. Like every sink they receive *already-built*
|
|
7
|
+
event dicts and know nothing about spans or context (that dumbness is what makes sinks swappable).
|
|
8
|
+
|
|
9
|
+
Synchronous stdlib writes only — no async/mmap/O_DIRECT — and a single-process, single-worker-thread
|
|
10
|
+
writer is assumed (arch §9); cross-process coordination is out of scope (SPEC-008).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
from typing import TextIO
|
|
19
|
+
|
|
20
|
+
__all__ = ["FileSink", "RotatingFileSink"]
|
|
21
|
+
|
|
22
|
+
# Time-trigger unit codes -> seconds, mirroring stdlib TimedRotatingFileHandler's vocabulary
|
|
23
|
+
# (subset). Matched case-insensitively; the rollover interval is ``interval * _WHEN_SECONDS[when]``.
|
|
24
|
+
_WHEN_SECONDS = {
|
|
25
|
+
"S": 1,
|
|
26
|
+
"M": 60,
|
|
27
|
+
"H": 60 * 60,
|
|
28
|
+
"D": 60 * 60 * 24,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FileSink:
|
|
33
|
+
"""A :class:`~log_forge.sinks.base.Sink` that appends events as NDJSON to one file.
|
|
34
|
+
|
|
35
|
+
The file is opened once in append text mode at construction, so a missing parent directory
|
|
36
|
+
surfaces immediately (at ``configure`` time) rather than on the first flush. The file is created
|
|
37
|
+
if absent and appended to — never truncated — if it already exists.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, path: str, *, encoding: str = "utf-8") -> None:
|
|
41
|
+
self._path = path
|
|
42
|
+
self._encoding = encoding
|
|
43
|
+
self._stream: TextIO = open(path, "a", encoding=encoding)
|
|
44
|
+
self._closed = False
|
|
45
|
+
|
|
46
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
47
|
+
"""Write every event as one ``json.dumps`` line terminated by ``\\n``, then flush (FR-001)."""
|
|
48
|
+
for event in batch:
|
|
49
|
+
self._stream.write(json.dumps(event) + "\n")
|
|
50
|
+
self._stream.flush()
|
|
51
|
+
|
|
52
|
+
def close(self) -> None:
|
|
53
|
+
"""Flush and close the file handle; a second call is a no-op (FR-001)."""
|
|
54
|
+
if self._closed:
|
|
55
|
+
return
|
|
56
|
+
self._stream.flush()
|
|
57
|
+
self._stream.close()
|
|
58
|
+
self._closed = True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RotatingFileSink:
|
|
62
|
+
"""A :class:`~log_forge.sinks.base.Sink` that rotates the active NDJSON file to bound its growth.
|
|
63
|
+
|
|
64
|
+
Two independent triggers, either or both enabled:
|
|
65
|
+
|
|
66
|
+
* **size** — with ``max_bytes > 0``, rotate *before* the write that would push the active file
|
|
67
|
+
past ``max_bytes`` (so it never grows unbounded).
|
|
68
|
+
* **time** — with a ``when`` unit code (``"S"``/``"M"``/``"H"``/``"D"``) and ``interval``, rotate
|
|
69
|
+
on the first emit after ``interval`` units have elapsed since the last rotation.
|
|
70
|
+
|
|
71
|
+
Rotation renames the active file through numbered backups (``path.1`` … ``path.N``), prunes any
|
|
72
|
+
backup beyond ``backup_count``, and opens a fresh active file. ``backup_count=0`` keeps no
|
|
73
|
+
backups (the active file is simply truncated/replaced). No event is lost across a rotation: the
|
|
74
|
+
rotate happens *before* the pending event is written, and the event lands in the fresh file.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
path: str,
|
|
80
|
+
*,
|
|
81
|
+
max_bytes: int = 0,
|
|
82
|
+
backup_count: int = 0,
|
|
83
|
+
when: str | None = None,
|
|
84
|
+
interval: int = 1,
|
|
85
|
+
) -> None:
|
|
86
|
+
self._path = path
|
|
87
|
+
self._encoding = "utf-8"
|
|
88
|
+
self._max_bytes = max_bytes
|
|
89
|
+
self._backup_count = backup_count
|
|
90
|
+
self._interval_seconds = self._rollover_seconds(when, interval)
|
|
91
|
+
self._stream: TextIO = open(path, "a", encoding=self._encoding)
|
|
92
|
+
# Byte size of the active file, tracked explicitly so ``max_bytes`` is measured in bytes even
|
|
93
|
+
# through a text-mode stream. Seeded from any pre-existing file we appended to.
|
|
94
|
+
self._size = os.path.getsize(path) if os.path.exists(path) else 0
|
|
95
|
+
self._next_rollover = self._schedule_next()
|
|
96
|
+
self._closed = False
|
|
97
|
+
|
|
98
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
99
|
+
"""Append each event, rotating first whenever a size/time trigger fires (FR-002)."""
|
|
100
|
+
for event in batch:
|
|
101
|
+
line = json.dumps(event) + "\n"
|
|
102
|
+
data = len(line.encode(self._encoding)) # byte cost, for size accounting
|
|
103
|
+
if self._should_rotate(data):
|
|
104
|
+
self._rotate()
|
|
105
|
+
self._stream.write(line)
|
|
106
|
+
self._size += data
|
|
107
|
+
self._stream.flush()
|
|
108
|
+
|
|
109
|
+
def close(self) -> None:
|
|
110
|
+
"""Flush and close the active handle; a second call is a no-op (FR-002)."""
|
|
111
|
+
if self._closed:
|
|
112
|
+
return
|
|
113
|
+
self._stream.flush()
|
|
114
|
+
self._stream.close()
|
|
115
|
+
self._closed = True
|
|
116
|
+
|
|
117
|
+
# -- internals ----------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _rollover_seconds(when: str | None, interval: int) -> float | None:
|
|
121
|
+
"""Translate a ``when`` unit code + ``interval`` into a rollover period in seconds.
|
|
122
|
+
|
|
123
|
+
Returns ``None`` (no time trigger) when ``when`` is ``None``; raises ``ValueError`` on an
|
|
124
|
+
unrecognized unit code so a typo fails loudly at construction, not silently at runtime.
|
|
125
|
+
"""
|
|
126
|
+
if when is None:
|
|
127
|
+
return None
|
|
128
|
+
try:
|
|
129
|
+
unit = _WHEN_SECONDS[when.upper()]
|
|
130
|
+
except KeyError:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"invalid 'when' unit {when!r}; expected one of {sorted(_WHEN_SECONDS)}"
|
|
133
|
+
) from None
|
|
134
|
+
return unit * interval
|
|
135
|
+
|
|
136
|
+
def _schedule_next(self) -> float | None:
|
|
137
|
+
"""Absolute monotonic-wallclock time of the next time-based rotation (or ``None``)."""
|
|
138
|
+
if self._interval_seconds is None:
|
|
139
|
+
return None
|
|
140
|
+
return time.time() + self._interval_seconds
|
|
141
|
+
|
|
142
|
+
def _should_rotate(self, incoming: int) -> bool:
|
|
143
|
+
"""Decide whether to rotate before writing ``incoming`` bytes (size and/or time)."""
|
|
144
|
+
if (
|
|
145
|
+
self._max_bytes > 0
|
|
146
|
+
and self._size > 0
|
|
147
|
+
and self._size + incoming > self._max_bytes
|
|
148
|
+
):
|
|
149
|
+
return True
|
|
150
|
+
if self._next_rollover is not None and time.time() >= self._next_rollover:
|
|
151
|
+
return True
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
def _rotate(self) -> None:
|
|
155
|
+
"""Close the active file, shift/prune numbered backups, and open a fresh active file."""
|
|
156
|
+
self._stream.close()
|
|
157
|
+
if self._backup_count > 0:
|
|
158
|
+
# Shift path.(N-1) -> path.N downward, dropping anything past backup_count, then move
|
|
159
|
+
# the active file to path.1. Highest-numbered backup is the oldest.
|
|
160
|
+
for i in range(self._backup_count - 1, 0, -1):
|
|
161
|
+
src = f"{self._path}.{i}"
|
|
162
|
+
dst = f"{self._path}.{i + 1}"
|
|
163
|
+
if os.path.exists(src):
|
|
164
|
+
if os.path.exists(dst):
|
|
165
|
+
os.remove(dst)
|
|
166
|
+
os.replace(src, dst)
|
|
167
|
+
first = f"{self._path}.1"
|
|
168
|
+
if os.path.exists(first):
|
|
169
|
+
os.remove(first)
|
|
170
|
+
if os.path.exists(self._path):
|
|
171
|
+
os.replace(self._path, first)
|
|
172
|
+
elif os.path.exists(self._path):
|
|
173
|
+
# No backups retained: drop the active file so reopening starts empty.
|
|
174
|
+
os.remove(self._path)
|
|
175
|
+
self._stream = open(self._path, "a", encoding=self._encoding)
|
|
176
|
+
self._size = 0
|
|
177
|
+
self._next_rollover = self._schedule_next()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""FilteringSink — drop events by predicate and/or minimum level (arch §8, SPEC-006 FR-003).
|
|
2
|
+
|
|
3
|
+
A static, emit-time filter in front of an inner sink: forward only the events that satisfy a
|
|
4
|
+
``predicate`` and/or meet a ``min_level``. This is *not* the reserved tail-sampling
|
|
5
|
+
``should_send`` seam (arch §10) — that stays deferred and owns rate policy at span-decision
|
|
6
|
+
time; this only reshapes an already-built batch on its way to a sink.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
from log_forge.sinks.base import Sink
|
|
14
|
+
|
|
15
|
+
__all__ = ["FilteringSink"]
|
|
16
|
+
|
|
17
|
+
# Standard severity ranks; higher is more severe. Level names are the arch §6 set.
|
|
18
|
+
_LEVELS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FilteringSink:
|
|
22
|
+
"""A :class:`~log_forge.sinks.base.Sink` that forwards only events passing a filter.
|
|
23
|
+
|
|
24
|
+
Wraps ``inner`` and forwards the subset of each batch that satisfies ``predicate`` and/or
|
|
25
|
+
meets ``min_level`` (both, when both are given). An event whose ``level`` is unknown or
|
|
26
|
+
missing is forwarded fail-open rather than dropped; when nothing passes, ``inner.emit`` is
|
|
27
|
+
not called. Level comparison is case-insensitive.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ValueError: if ``min_level`` is not one of the five standard names (case-insensitive).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
inner: Sink,
|
|
36
|
+
*,
|
|
37
|
+
predicate: Callable[[dict[str, object]], bool] | None = None,
|
|
38
|
+
min_level: str | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._inner = inner
|
|
41
|
+
self._predicate = predicate
|
|
42
|
+
self._min_rank: int | None = None
|
|
43
|
+
if min_level is not None:
|
|
44
|
+
rank = _LEVELS.get(min_level.upper())
|
|
45
|
+
if rank is None:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"unknown min_level: {min_level!r} (expected one of {list(_LEVELS)})"
|
|
48
|
+
)
|
|
49
|
+
self._min_rank = rank
|
|
50
|
+
|
|
51
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
52
|
+
"""Forward the events that clear both the predicate and min_level, in order (FR-003)."""
|
|
53
|
+
kept = [event for event in batch if self._passes(event)]
|
|
54
|
+
if kept:
|
|
55
|
+
self._inner.emit(kept)
|
|
56
|
+
|
|
57
|
+
def _passes(self, event: dict[str, object]) -> bool:
|
|
58
|
+
"""Whether ``event`` clears the predicate and the minimum level (fail-open on level)."""
|
|
59
|
+
if self._predicate is not None and not self._predicate(event):
|
|
60
|
+
return False
|
|
61
|
+
if self._min_rank is not None:
|
|
62
|
+
level = event.get("level")
|
|
63
|
+
rank = _LEVELS.get(level.upper()) if isinstance(level, str) else None
|
|
64
|
+
# A known level below the floor is dropped; an unknown/missing level fails open.
|
|
65
|
+
if rank is not None and rank < self._min_rank:
|
|
66
|
+
return False
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
def close(self) -> None:
|
|
70
|
+
"""Close the inner sink (FR-003)."""
|
|
71
|
+
self._inner.close()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""FirehoseSink — put event records to a Kinesis Data Firehose delivery stream (arch §8, SPEC-010).
|
|
2
|
+
|
|
3
|
+
The same durable-buffer shape as :class:`~log_forge.sinks.kinesis.KinesisSink`, using Firehose's
|
|
4
|
+
``put_record_batch`` (≤ 500 records **and** ≤ 4 MB per request). ``boto3`` is the optional ``aws``
|
|
5
|
+
extra, imported lazily. Partial failures (the response ``RequestResponses``/``FailedPutCount``) are
|
|
6
|
+
retried within a bounded count.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from log_forge.sinks._chunk import chunk_items
|
|
16
|
+
|
|
17
|
+
__all__ = ["FirehoseSink"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FirehoseSink:
|
|
21
|
+
"""A :class:`~log_forge.sinks.base.Sink` that writes events to a Firehose delivery stream."""
|
|
22
|
+
|
|
23
|
+
MAX_RECORDS = 500 # put_record_batch hard limit: records per request
|
|
24
|
+
MAX_REQUEST_BYTES = 4 * 1024 * 1024 # 4 MB per put_record_batch request
|
|
25
|
+
MAX_RECORD_BYTES = 1024 * 1024 # 1 MB per record
|
|
26
|
+
|
|
27
|
+
def __init__(self, delivery_stream: str, *, client: Any = None, max_retries: int = 3) -> None:
|
|
28
|
+
if client is None:
|
|
29
|
+
import boto3 # type: ignore[import-not-found] # optional 'aws' extra
|
|
30
|
+
|
|
31
|
+
client = boto3.client("firehose")
|
|
32
|
+
self.delivery_stream = delivery_stream
|
|
33
|
+
self.client = client
|
|
34
|
+
self.max_retries = max_retries
|
|
35
|
+
self.failed = 0
|
|
36
|
+
self.dropped_oversized = 0
|
|
37
|
+
|
|
38
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
39
|
+
"""Re-chunk to put_record_batch limits and send each chunk, retrying failures (FR-004)."""
|
|
40
|
+
records = self._records(batch)
|
|
41
|
+
for chunk in chunk_items(
|
|
42
|
+
records,
|
|
43
|
+
max_count=self.MAX_RECORDS,
|
|
44
|
+
max_bytes=self.MAX_REQUEST_BYTES,
|
|
45
|
+
size_of=lambda record: len(record["Data"]),
|
|
46
|
+
):
|
|
47
|
+
self._send(chunk)
|
|
48
|
+
|
|
49
|
+
def close(self) -> None:
|
|
50
|
+
"""No-op: the sink buffers nothing internally (FR-001)."""
|
|
51
|
+
|
|
52
|
+
# -- internals ----------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def _records(self, batch: list[dict[str, object]]) -> list[dict[str, Any]]:
|
|
55
|
+
"""Build put_record_batch entries, dropping any single record too large (FR-011)."""
|
|
56
|
+
records: list[dict[str, Any]] = []
|
|
57
|
+
for event in batch:
|
|
58
|
+
data = json.dumps(event).encode("utf-8")
|
|
59
|
+
if len(data) > self.MAX_RECORD_BYTES:
|
|
60
|
+
self.dropped_oversized += 1
|
|
61
|
+
sys.stderr.write(
|
|
62
|
+
f"log-forge: FirehoseSink dropped an event of {len(data)} bytes exceeding "
|
|
63
|
+
f"the {self.MAX_RECORD_BYTES}-byte per-record limit\n"
|
|
64
|
+
)
|
|
65
|
+
continue
|
|
66
|
+
records.append({"Data": data})
|
|
67
|
+
return records
|
|
68
|
+
|
|
69
|
+
def _send(self, records: list[dict[str, Any]]) -> None:
|
|
70
|
+
"""Send one chunk, retrying only the entries the response flags as failed (FR-004)."""
|
|
71
|
+
for attempt in range(self.max_retries + 1):
|
|
72
|
+
response = self.client.put_record_batch(
|
|
73
|
+
DeliveryStreamName=self.delivery_stream, Records=records
|
|
74
|
+
)
|
|
75
|
+
if not response.get("FailedPutCount"):
|
|
76
|
+
return
|
|
77
|
+
results = response.get("RequestResponses", [])
|
|
78
|
+
records = [
|
|
79
|
+
record
|
|
80
|
+
for record, result in zip(records, results)
|
|
81
|
+
if result.get("ErrorCode")
|
|
82
|
+
]
|
|
83
|
+
if not records:
|
|
84
|
+
return
|
|
85
|
+
if attempt >= self.max_retries:
|
|
86
|
+
self.failed += len(records)
|
|
87
|
+
sys.stderr.write(
|
|
88
|
+
f"log-forge: {len(records)} Firehose record(s) still failing after "
|
|
89
|
+
f"{self.max_retries + 1} attempts; abandoned\n"
|
|
90
|
+
)
|
|
91
|
+
return
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""HoneycombSink — ship to Honeycomb's batch events API (arch §8, SPEC-009).
|
|
2
|
+
|
|
3
|
+
An :class:`~log_forge.sinks.http.HTTPSink` specialization: POSTs to ``/1/batch/<dataset>`` with an
|
|
4
|
+
``X-Honeycomb-Team`` header, using Honeycomb's ``[{"data": <event>}, ...]`` batch shape.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from log_forge.sinks.http import HTTPSink, merge_headers
|
|
12
|
+
|
|
13
|
+
__all__ = ["HoneycombSink"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HoneycombSink(HTTPSink):
|
|
17
|
+
"""POST events to Honeycomb's batch API for a dataset (FR-010)."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
api_key: str,
|
|
22
|
+
dataset: str,
|
|
23
|
+
*,
|
|
24
|
+
url: str = "https://api.honeycomb.io",
|
|
25
|
+
**http_kwargs: object,
|
|
26
|
+
) -> None:
|
|
27
|
+
headers = merge_headers({"X-Honeycomb-Team": api_key}, http_kwargs)
|
|
28
|
+
super().__init__(
|
|
29
|
+
f"{url.rstrip('/')}/1/batch/{dataset}",
|
|
30
|
+
headers=headers, body_format="json_array", **http_kwargs, # type: ignore[arg-type]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
34
|
+
"""POST the batch as Honeycomb's ``[{"data": event}, ...]`` shape (FR-010)."""
|
|
35
|
+
if not batch:
|
|
36
|
+
return
|
|
37
|
+
body = json.dumps([{"data": event} for event in batch]).encode("utf-8")
|
|
38
|
+
self._send(body, content_type="application/json")
|
log_forge/sinks/http.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""HTTPSink — POST batches over the stdlib ``urllib`` (arch §8, §9.1, SPEC-009).
|
|
2
|
+
|
|
3
|
+
The dependency-free base every other HTTP platform sink here builds on: it serializes a batch as
|
|
4
|
+
NDJSON or a JSON array, applies headers/auth/optional gzip, POSTs it, and retries ``429``/``5xx``
|
|
5
|
+
with bounded exponential backoff (honoring ``Retry-After``) before counting and logging an abandoned
|
|
6
|
+
request. Like every sink it receives *already-built* event dicts and knows nothing about spans.
|
|
7
|
+
|
|
8
|
+
These are **terminal, direct-ship** sinks: per arch §9.1 they couple application delivery to the
|
|
9
|
+
destination's availability, so they are best-effort — for durability put a queue (SQS/SPEC-010) in
|
|
10
|
+
front. A test can inject a fake ``opener`` (a ``urlopen``-shaped callable) to assert on the request
|
|
11
|
+
without any network access.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import gzip as _gzip
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.request
|
|
22
|
+
from base64 import b64encode
|
|
23
|
+
from typing import Any, Callable
|
|
24
|
+
|
|
25
|
+
__all__ = ["HTTPSink", "merge_headers"]
|
|
26
|
+
|
|
27
|
+
_BACKOFF_BASE = 0.1 # seconds; delay for retry attempt n is _BACKOFF_BASE * 2**n
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def merge_headers(base: dict[str, str], http_kwargs: dict[str, object]) -> dict[str, str]:
|
|
31
|
+
"""Merge a platform sink's own headers with any caller-supplied ``headers`` (caller wins).
|
|
32
|
+
|
|
33
|
+
Pops ``headers`` out of ``http_kwargs`` (mutating it) so the remaining kwargs can be forwarded
|
|
34
|
+
to :class:`HTTPSink` without a duplicate ``headers`` argument. Shared by the SaaS sinks that set
|
|
35
|
+
a fixed auth header (Datadog/Splunk/New Relic/Honeycomb).
|
|
36
|
+
"""
|
|
37
|
+
caller = http_kwargs.pop("headers", None)
|
|
38
|
+
if isinstance(caller, dict):
|
|
39
|
+
base.update(caller)
|
|
40
|
+
return base
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HTTPSink:
|
|
44
|
+
"""A :class:`~log_forge.sinks.base.Sink` that POSTs each batch to an HTTP endpoint.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
failed: Requests abandoned past the retry bound.
|
|
48
|
+
dropped_oversized: Events dropped for exceeding a destination's hard size limit (used by
|
|
49
|
+
subclasses that enforce one; the generic core imposes no universal limit).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
url: str,
|
|
55
|
+
*,
|
|
56
|
+
method: str = "POST",
|
|
57
|
+
headers: dict[str, str] | None = None,
|
|
58
|
+
auth: str | tuple[str, str] | None = None,
|
|
59
|
+
body_format: str = "ndjson",
|
|
60
|
+
timeout: float = 5.0,
|
|
61
|
+
gzip: bool = False,
|
|
62
|
+
max_retries: int = 3,
|
|
63
|
+
opener: Callable[..., Any] | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.url = url
|
|
66
|
+
self.method = method
|
|
67
|
+
self._headers = dict(headers) if headers else {}
|
|
68
|
+
self._auth = auth
|
|
69
|
+
self.body_format = body_format
|
|
70
|
+
self.timeout = timeout
|
|
71
|
+
self.gzip = gzip
|
|
72
|
+
self.max_retries = max_retries
|
|
73
|
+
self._opener = opener if opener is not None else urllib.request.urlopen
|
|
74
|
+
self.failed = 0
|
|
75
|
+
self.dropped_oversized = 0
|
|
76
|
+
|
|
77
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
78
|
+
"""Serialize ``batch`` per ``body_format`` and POST it (FR-001)."""
|
|
79
|
+
if not batch:
|
|
80
|
+
return
|
|
81
|
+
body, content_type = self._encode(batch)
|
|
82
|
+
self._send(body, content_type=content_type)
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
"""No-op — ``urllib`` opens a fresh connection per request; idempotent (FR-012)."""
|
|
86
|
+
|
|
87
|
+
# -- body building ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def _encode(self, batch: list[dict[str, object]]) -> tuple[bytes, str]:
|
|
90
|
+
"""Return ``(body_bytes, default_content_type)`` for the configured ``body_format``."""
|
|
91
|
+
if self.body_format == "json_array":
|
|
92
|
+
return json.dumps(batch).encode("utf-8"), "application/json"
|
|
93
|
+
text = "".join(json.dumps(event) + "\n" for event in batch)
|
|
94
|
+
return text.encode("utf-8"), "application/x-ndjson"
|
|
95
|
+
|
|
96
|
+
# -- transport ----------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def _send(
|
|
99
|
+
self,
|
|
100
|
+
body: bytes,
|
|
101
|
+
*,
|
|
102
|
+
content_type: str,
|
|
103
|
+
extra_headers: dict[str, str] | None = None,
|
|
104
|
+
) -> bytes | None:
|
|
105
|
+
"""POST ``body`` with bounded retry; return the response bytes, or ``None`` if abandoned.
|
|
106
|
+
|
|
107
|
+
Subclasses that must inspect the response (e.g. Elasticsearch ``_bulk`` ``items``) use the
|
|
108
|
+
returned bytes; a ``None`` return means the request was retried to exhaustion and counted.
|
|
109
|
+
"""
|
|
110
|
+
headers, data = self._prepare(body, content_type, extra_headers)
|
|
111
|
+
for attempt in range(self.max_retries + 1):
|
|
112
|
+
request = urllib.request.Request(
|
|
113
|
+
self.url, data=data, method=self.method, headers=headers
|
|
114
|
+
)
|
|
115
|
+
try:
|
|
116
|
+
status, payload, retry_after = self._attempt(request)
|
|
117
|
+
except (urllib.error.URLError, OSError) as err:
|
|
118
|
+
if attempt < self.max_retries:
|
|
119
|
+
self._sleep_backoff(attempt, None)
|
|
120
|
+
continue
|
|
121
|
+
self._abandon(f"connection error: {err!r}")
|
|
122
|
+
return None
|
|
123
|
+
if 200 <= status < 300:
|
|
124
|
+
return payload
|
|
125
|
+
if (status == 429 or 500 <= status < 600) and attempt < self.max_retries:
|
|
126
|
+
self._sleep_backoff(attempt, retry_after)
|
|
127
|
+
continue
|
|
128
|
+
self._abandon(f"HTTP {status}")
|
|
129
|
+
return None
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
def _prepare(
|
|
133
|
+
self,
|
|
134
|
+
body: bytes,
|
|
135
|
+
content_type: str,
|
|
136
|
+
extra_headers: dict[str, str] | None,
|
|
137
|
+
) -> tuple[dict[str, str], bytes]:
|
|
138
|
+
"""Build the final header map + (optionally gzipped) body. Caller headers win (FR-002)."""
|
|
139
|
+
headers: dict[str, str] = {"Content-Type": content_type}
|
|
140
|
+
if extra_headers:
|
|
141
|
+
headers.update(extra_headers)
|
|
142
|
+
headers.update(self._headers) # caller-provided headers override defaults (FR-002)
|
|
143
|
+
data = body
|
|
144
|
+
if self.gzip:
|
|
145
|
+
data = _gzip.compress(body)
|
|
146
|
+
headers["Content-Encoding"] = "gzip"
|
|
147
|
+
self._apply_auth(headers)
|
|
148
|
+
return headers, data
|
|
149
|
+
|
|
150
|
+
def _apply_auth(self, headers: dict[str, str]) -> None:
|
|
151
|
+
"""Apply bearer-token (str) or basic-auth (user, pass) credentials, if not already set."""
|
|
152
|
+
if self._auth is None or "Authorization" in headers:
|
|
153
|
+
return
|
|
154
|
+
if isinstance(self._auth, str):
|
|
155
|
+
headers["Authorization"] = f"Bearer {self._auth}"
|
|
156
|
+
else:
|
|
157
|
+
user, password = self._auth
|
|
158
|
+
token = b64encode(f"{user}:{password}".encode()).decode("ascii")
|
|
159
|
+
headers["Authorization"] = f"Basic {token}"
|
|
160
|
+
|
|
161
|
+
def _attempt(self, request: urllib.request.Request) -> tuple[int, bytes, float | None]:
|
|
162
|
+
"""Perform one request. Returns ``(status, payload, retry_after)``.
|
|
163
|
+
|
|
164
|
+
A ``4xx``/``5xx`` arrives from ``urlopen`` as an ``HTTPError``, which *is* a readable
|
|
165
|
+
response object, so it is unified here rather than raised. Connection-level failures
|
|
166
|
+
(``URLError``/``OSError``) propagate for the retry loop to handle.
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
response = self._opener(request, timeout=self.timeout)
|
|
170
|
+
except urllib.error.HTTPError as err:
|
|
171
|
+
response = err
|
|
172
|
+
status = getattr(response, "status", None)
|
|
173
|
+
if status is None:
|
|
174
|
+
status = response.getcode()
|
|
175
|
+
payload = response.read()
|
|
176
|
+
return int(status), payload, _parse_retry_after(getattr(response, "headers", None))
|
|
177
|
+
|
|
178
|
+
def _sleep_backoff(self, attempt: int, retry_after: float | None) -> None:
|
|
179
|
+
"""Sleep before the next attempt: ``Retry-After`` if the server gave one, else backoff."""
|
|
180
|
+
delay = retry_after if retry_after is not None else _BACKOFF_BASE * (2**attempt)
|
|
181
|
+
time.sleep(delay)
|
|
182
|
+
|
|
183
|
+
def _abandon(self, reason: str) -> None:
|
|
184
|
+
"""Count and log a request abandoned past the retry bound (FR-012)."""
|
|
185
|
+
self.failed += 1
|
|
186
|
+
sys.stderr.write(
|
|
187
|
+
f"log-forge: {type(self).__name__} abandoned a request after "
|
|
188
|
+
f"{self.max_retries + 1} attempt(s) ({reason})\n"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _parse_retry_after(headers: Any) -> float | None:
|
|
193
|
+
"""Parse a ``Retry-After`` delay in seconds; ``None`` if absent or an HTTP-date we don't parse."""
|
|
194
|
+
if headers is None:
|
|
195
|
+
return None
|
|
196
|
+
value = headers.get("Retry-After")
|
|
197
|
+
if value is None:
|
|
198
|
+
return None
|
|
199
|
+
try:
|
|
200
|
+
return float(value)
|
|
201
|
+
except (TypeError, ValueError):
|
|
202
|
+
return None # HTTP-date form: fall back to exponential backoff
|
log_forge/sinks/kafka.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""KafkaSink — produce events to a Kafka topic (arch §8, §9.1, SPEC-010).
|
|
2
|
+
|
|
3
|
+
A durable-buffer sink built on ``confluent-kafka`` (the optional ``kafka`` extra, imported lazily).
|
|
4
|
+
``produce()`` enqueues locally into the producer's internal batch and returns without blocking; the
|
|
5
|
+
delivery result arrives asynchronously on a callback serviced by ``poll()``/``flush()``. ``close()``
|
|
6
|
+
flushes so buffered messages are sent before exit. Delivery errors are counted and logged, never
|
|
7
|
+
raised out of ``emit``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
__all__ = ["KafkaSink"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class KafkaSink:
|
|
20
|
+
"""A :class:`~log_forge.sinks.base.Sink` that produces events to a Kafka topic.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
failed: Messages whose delivery callback reported an error.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
topic: str,
|
|
29
|
+
*,
|
|
30
|
+
producer: Any = None,
|
|
31
|
+
bootstrap_servers: str | None = None,
|
|
32
|
+
key_field: str = "trace_id",
|
|
33
|
+
) -> None:
|
|
34
|
+
if producer is None:
|
|
35
|
+
if bootstrap_servers is None:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"KafkaSink requires bootstrap_servers when no producer is injected"
|
|
38
|
+
)
|
|
39
|
+
from confluent_kafka import Producer # type: ignore[import-not-found] # 'kafka' extra
|
|
40
|
+
|
|
41
|
+
producer = Producer({"bootstrap.servers": bootstrap_servers})
|
|
42
|
+
self.topic = topic
|
|
43
|
+
self.producer = producer
|
|
44
|
+
self.key_field = key_field
|
|
45
|
+
self.failed = 0
|
|
46
|
+
|
|
47
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
48
|
+
"""Produce one message per event; serve delivery callbacks without blocking (FR-002)."""
|
|
49
|
+
for event in batch:
|
|
50
|
+
body = json.dumps(event).encode("utf-8")
|
|
51
|
+
key = self._key(event)
|
|
52
|
+
self.producer.produce(self.topic, value=body, key=key, callback=self._on_delivery)
|
|
53
|
+
self.producer.poll(0) # serve queued delivery callbacks, non-blocking
|
|
54
|
+
|
|
55
|
+
def close(self) -> None:
|
|
56
|
+
"""Flush the producer so buffered messages are delivered before exit (FR-002)."""
|
|
57
|
+
self.producer.flush()
|
|
58
|
+
|
|
59
|
+
# -- internals ----------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def _key(self, event: dict[str, object]) -> bytes | None:
|
|
62
|
+
if not self.key_field:
|
|
63
|
+
return None
|
|
64
|
+
value = event.get(self.key_field)
|
|
65
|
+
return str(value).encode("utf-8") if value is not None else None
|
|
66
|
+
|
|
67
|
+
def _on_delivery(self, err: object, msg: object) -> None:
|
|
68
|
+
"""confluent-kafka delivery callback: count and log failures (FR-002)."""
|
|
69
|
+
if err is not None:
|
|
70
|
+
self.failed += 1
|
|
71
|
+
sys.stderr.write(f"log-forge: KafkaSink delivery failed: {err!r}\n")
|