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.
Files changed (51) hide show
  1. log_forge/__init__.py +45 -0
  2. log_forge/api.py +89 -0
  3. log_forge/config.py +79 -0
  4. log_forge/console.py +30 -0
  5. log_forge/context.py +62 -0
  6. log_forge/decorator.py +157 -0
  7. log_forge/ids.py +29 -0
  8. log_forge/model.py +106 -0
  9. log_forge/py.typed +0 -0
  10. log_forge/sinks/__init__.py +0 -0
  11. log_forge/sinks/_chunk.py +58 -0
  12. log_forge/sinks/_socket.py +105 -0
  13. log_forge/sinks/_time.py +28 -0
  14. log_forge/sinks/base.py +22 -0
  15. log_forge/sinks/callback.py +42 -0
  16. log_forge/sinks/clickhouse.py +111 -0
  17. log_forge/sinks/datadog.py +53 -0
  18. log_forge/sinks/elasticsearch.py +68 -0
  19. log_forge/sinks/eventhubs.py +110 -0
  20. log_forge/sinks/file.py +177 -0
  21. log_forge/sinks/filtering.py +71 -0
  22. log_forge/sinks/firehose.py +91 -0
  23. log_forge/sinks/honeycomb.py +38 -0
  24. log_forge/sinks/http.py +202 -0
  25. log_forge/sinks/kafka.py +71 -0
  26. log_forge/sinks/kinesis.py +99 -0
  27. log_forge/sinks/logging_sink.py +104 -0
  28. log_forge/sinks/logstash.py +73 -0
  29. log_forge/sinks/loki.py +48 -0
  30. log_forge/sinks/mongodb.py +105 -0
  31. log_forge/sinks/multi.py +53 -0
  32. log_forge/sinks/nats.py +72 -0
  33. log_forge/sinks/newrelic.py +28 -0
  34. log_forge/sinks/postgres.py +101 -0
  35. log_forge/sinks/pubsub.py +45 -0
  36. log_forge/sinks/rabbitmq.py +129 -0
  37. log_forge/sinks/redis.py +90 -0
  38. log_forge/sinks/sentry.py +136 -0
  39. log_forge/sinks/sns.py +82 -0
  40. log_forge/sinks/splunk.py +51 -0
  41. log_forge/sinks/sqlite.py +100 -0
  42. log_forge/sinks/sqs.py +107 -0
  43. log_forge/sinks/stdout.py +31 -0
  44. log_forge/sinks/syslog.py +88 -0
  45. log_forge/sinks/transform.py +46 -0
  46. log_forge/sinks/util.py +68 -0
  47. log_forge/worker.py +153 -0
  48. log_foundry-0.0.2.dev1.dist-info/METADATA +537 -0
  49. log_foundry-0.0.2.dev1.dist-info/RECORD +51 -0
  50. log_foundry-0.0.2.dev1.dist-info/WHEEL +4 -0
  51. log_foundry-0.0.2.dev1.dist-info/licenses/LICENSE +21 -0
@@ -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")
@@ -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
@@ -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")