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,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
+ )
@@ -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
@@ -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
+ )
@@ -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
+ )