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,101 @@
1
+ """PostgresSink — insert events into a Postgres table with a JSONB column (arch §8, SPEC-011).
2
+
3
+ Each event is stored as a ``JSONB`` ``event`` column plus a few extracted columns for indexing.
4
+ ``psycopg`` (v3) is the optional ``postgres`` extra, imported lazily. The whole batch inserts in a
5
+ single transaction (chunked into driver-friendly statements); on failure the transaction is rolled
6
+ back and retried within a bounded count. Write-only. An optional idempotent ``create_table``
7
+ convenience is off by default — the user owns their schema and indexes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sys
14
+ import time
15
+ from typing import Any
16
+
17
+ from log_forge.sinks._chunk import chunk_list, valid_identifier
18
+
19
+ __all__ = ["PostgresSink"]
20
+
21
+ _BACKOFF_BASE = 0.1
22
+
23
+ # Columns extracted from each event for indexing; ``event`` (full JSONB) is stored alongside.
24
+ _COLUMNS = ("timestamp", "level", "trace_id", "span_id", "function", "service")
25
+
26
+
27
+ class PostgresSink:
28
+ """A :class:`~log_forge.sinks.base.Sink` that batch-inserts events into a Postgres table."""
29
+
30
+ def __init__(
31
+ self,
32
+ table: str,
33
+ *,
34
+ connection: Any = None,
35
+ dsn: str | None = None,
36
+ create_table: bool = False,
37
+ chunk_size: int = 1000,
38
+ max_retries: int = 3,
39
+ ) -> None:
40
+ self._table = valid_identifier(table)
41
+ self._chunk_size = chunk_size
42
+ self.max_retries = max_retries
43
+ self.failed = 0
44
+ self._closed = False
45
+ self._owns_connection = connection is None
46
+ if connection is None:
47
+ import psycopg # type: ignore[import-not-found] # optional 'postgres' extra
48
+
49
+ connection = psycopg.connect(dsn)
50
+ self._conn = connection
51
+ columns = ", ".join((*_COLUMNS, "event"))
52
+ placeholders = ", ".join(["%s"] * len(_COLUMNS) + ["%s::jsonb"])
53
+ self._insert_sql = f"INSERT INTO {self._table} ({columns}) VALUES ({placeholders})"
54
+ if create_table:
55
+ self._ensure_schema()
56
+
57
+ def emit(self, batch: list[dict[str, object]]) -> None:
58
+ """Insert the whole batch in one transaction, rolling back and retrying on error (FR-004)."""
59
+ if not batch:
60
+ return
61
+ for attempt in range(self.max_retries + 1):
62
+ try:
63
+ with self._conn.cursor() as cur:
64
+ for chunk in chunk_list(batch, self._chunk_size):
65
+ cur.executemany(self._insert_sql, [self._row(event) for event in chunk])
66
+ self._conn.commit()
67
+ return
68
+ except Exception as err: # isolation boundary: never crash the worker (FR-006)
69
+ self._conn.rollback()
70
+ if attempt < self.max_retries:
71
+ time.sleep(_BACKOFF_BASE * (2**attempt))
72
+ continue
73
+ self.failed += len(batch)
74
+ sys.stderr.write(
75
+ f"log-forge: PostgresSink abandoned {len(batch)} event(s) after "
76
+ f"{self.max_retries + 1} attempts ({err!r})\n"
77
+ )
78
+ return
79
+
80
+ def close(self) -> None:
81
+ """Commit pending work; close only an owned connection; idempotent (FR-005)."""
82
+ if self._closed:
83
+ return
84
+ self._conn.commit()
85
+ if self._owns_connection:
86
+ self._conn.close()
87
+ self._closed = True
88
+
89
+ # -- internals ----------------------------------------------------------------------
90
+
91
+ def _row(self, event: dict[str, object]) -> tuple[object, ...]:
92
+ return (*(event.get(col) for col in _COLUMNS), json.dumps(event))
93
+
94
+ def _ensure_schema(self) -> None:
95
+ columns = ", ".join(f"{col} TEXT" for col in _COLUMNS)
96
+ with self._conn.cursor() as cur:
97
+ cur.execute(
98
+ f"CREATE TABLE IF NOT EXISTS {self._table} "
99
+ f"(id BIGSERIAL PRIMARY KEY, {columns}, event JSONB NOT NULL)"
100
+ )
101
+ self._conn.commit()
@@ -0,0 +1,45 @@
1
+ """GooglePubSubSink — publish events to a Google Cloud Pub/Sub topic (arch §8, §9.1, SPEC-010).
2
+
3
+ A durable-buffer sink on ``google-cloud-pubsub`` (the optional ``gcp-pubsub`` extra, imported
4
+ lazily). ``publish()`` returns a future that resolves asynchronously; the sink accumulates the
5
+ batch's futures and resolves them on ``close()`` so buffered messages are flushed and publish errors
6
+ are counted and logged.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from typing import Any
14
+
15
+ __all__ = ["GooglePubSubSink"]
16
+
17
+
18
+ class GooglePubSubSink:
19
+ """A :class:`~log_forge.sinks.base.Sink` that publishes events to a Pub/Sub topic."""
20
+
21
+ def __init__(self, topic: str, *, client: Any = None) -> None:
22
+ if client is None:
23
+ from google.cloud import pubsub_v1 # type: ignore[import-not-found] # 'gcp-pubsub'
24
+
25
+ client = pubsub_v1.PublisherClient()
26
+ self.topic = topic
27
+ self.client = client
28
+ self.failed = 0
29
+ self._futures: list[Any] = []
30
+
31
+ def emit(self, batch: list[dict[str, object]]) -> None:
32
+ """Publish one message per event, retaining each future for flush on close (FR-008)."""
33
+ for event in batch:
34
+ future = self.client.publish(self.topic, data=json.dumps(event).encode("utf-8"))
35
+ self._futures.append(future)
36
+
37
+ def close(self) -> None:
38
+ """Resolve all pending publish futures, counting/logging errors; idempotent (FR-008)."""
39
+ for future in self._futures:
40
+ try:
41
+ future.result()
42
+ except Exception as err: # isolation boundary: never crash the worker (FR-011)
43
+ self.failed += 1
44
+ sys.stderr.write(f"log-forge: GooglePubSubSink publish failed: {err!r}\n")
45
+ self._futures.clear()
@@ -0,0 +1,129 @@
1
+ """RabbitMQSink — publish events to a RabbitMQ exchange (arch §8, §9.1, SPEC-010).
2
+
3
+ A durable-buffer sink on ``pika`` (the optional ``amqp`` extra, imported lazily). Each event is
4
+ published as a **persistent** message (delivery mode 2) to the configured exchange/routing key. A
5
+ dropped or closed connection is re-established within a bounded retry before the batch is abandoned
6
+ and counted; ``close()`` closes the channel and connection.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ import time
14
+ from typing import Any
15
+
16
+ __all__ = ["RabbitMQSink"]
17
+
18
+ _BACKOFF_BASE = 0.1
19
+ _PERSISTENT = 2 # pika delivery mode for persistent messages
20
+
21
+
22
+ class _PersistentProperties:
23
+ """Fallback message properties (delivery_mode=persistent) used when ``pika`` is not installed.
24
+
25
+ Real usage imports ``pika.BasicProperties``; this stand-in only exists so the sink is testable
26
+ with an injected fake connection in an environment without the ``amqp`` extra.
27
+ """
28
+
29
+ delivery_mode = _PERSISTENT
30
+
31
+
32
+ class RabbitMQSink:
33
+ """A :class:`~log_forge.sinks.base.Sink` that publishes persistent messages to RabbitMQ."""
34
+
35
+ def __init__(
36
+ self,
37
+ *,
38
+ exchange: str,
39
+ routing_key: str,
40
+ connection: Any = None,
41
+ url: str | None = None,
42
+ max_retries: int = 3,
43
+ ) -> None:
44
+ self._exchange = exchange
45
+ self._routing_key = routing_key
46
+ self._url = url
47
+ self._max_retries = max_retries
48
+ self._owns_connection = connection is None
49
+ self._connection = connection if connection is not None else self._connect()
50
+ self._channel: Any = None
51
+ self._properties: Any = None
52
+ self.failed = 0
53
+
54
+ def emit(self, batch: list[dict[str, object]]) -> None:
55
+ """Publish one persistent message per event, reconnecting on error (FR-006)."""
56
+ for event in batch:
57
+ self._publish(json.dumps(event).encode("utf-8"))
58
+
59
+ def close(self) -> None:
60
+ """Close the channel and (owned) connection; idempotent (FR-006)."""
61
+ if self._channel is not None:
62
+ _safe_close(self._channel)
63
+ self._channel = None
64
+ if self._connection is not None:
65
+ _safe_close(self._connection)
66
+ self._connection = None
67
+
68
+ # -- internals ----------------------------------------------------------------------
69
+
70
+ def _publish(self, body: bytes) -> None:
71
+ for attempt in range(self._max_retries + 1):
72
+ try:
73
+ self._active_channel().basic_publish(
74
+ exchange=self._exchange,
75
+ routing_key=self._routing_key,
76
+ body=body,
77
+ properties=self._persistent_properties(),
78
+ )
79
+ return
80
+ except Exception as err: # isolation boundary: never crash the worker (FR-011)
81
+ self._reset()
82
+ if attempt < self._max_retries:
83
+ time.sleep(_BACKOFF_BASE * (2**attempt))
84
+ continue
85
+ self.failed += 1
86
+ sys.stderr.write(
87
+ f"log-forge: RabbitMQSink abandoned a message after "
88
+ f"{self._max_retries + 1} attempts ({err!r})\n"
89
+ )
90
+ return
91
+
92
+ def _active_channel(self) -> Any:
93
+ if self._connection is None:
94
+ self._connection = self._connect()
95
+ if self._channel is None:
96
+ self._channel = self._connection.channel()
97
+ return self._channel
98
+
99
+ def _reset(self) -> None:
100
+ """Drop the channel (and an owned connection) so the next attempt reconnects."""
101
+ if self._channel is not None:
102
+ _safe_close(self._channel)
103
+ self._channel = None
104
+ if self._owns_connection and self._connection is not None:
105
+ _safe_close(self._connection)
106
+ self._connection = None
107
+
108
+ def _connect(self) -> Any:
109
+ import pika # type: ignore[import-not-found] # optional 'amqp' extra
110
+
111
+ params = pika.URLParameters(self._url) if self._url else pika.ConnectionParameters()
112
+ return pika.BlockingConnection(params)
113
+
114
+ def _persistent_properties(self) -> Any:
115
+ if self._properties is None:
116
+ try:
117
+ import pika # optional 'amqp' extra (type-ignored at the import in _connect)
118
+
119
+ self._properties = pika.BasicProperties(delivery_mode=_PERSISTENT)
120
+ except ImportError:
121
+ self._properties = _PersistentProperties()
122
+ return self._properties
123
+
124
+
125
+ def _safe_close(resource: Any) -> None:
126
+ try:
127
+ resource.close()
128
+ except Exception: # closing a broken channel/connection must not raise
129
+ pass
@@ -0,0 +1,90 @@
1
+ """RedisStreamsSink / RedisListSink — buffer events in Redis (arch §8, §9.1, SPEC-010).
2
+
3
+ Two durable-buffer sinks on the ``redis`` extra (``redis-py``, imported lazily): one appends to a
4
+ Redis **stream** (``XADD``), the other pushes onto a **list** (``RPUSH``). Each pipelines the whole
5
+ batch into a single round trip. A connection error is retried within a bounded count then counted and
6
+ logged; ``close()`` releases the connection only when the sink opened it (an injected client is not
7
+ closed). The module is named ``redis`` to match the extra, and imports the driver lazily so it never
8
+ shadows or requires the real ``redis`` package at import time.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sys
15
+ import time
16
+ from typing import Any
17
+
18
+ __all__ = ["RedisStreamsSink", "RedisListSink"]
19
+
20
+ _BACKOFF_BASE = 0.1
21
+
22
+
23
+ class _RedisSink:
24
+ """Shared pipelining, bounded retry, and ownership-aware close for the Redis sinks."""
25
+
26
+ def __init__(self, *, client: Any, url: str | None, max_retries: int) -> None:
27
+ self._owns_client = client is None
28
+ if client is None:
29
+ import redis # type: ignore[import-not-found] # optional 'redis' extra
30
+
31
+ client = redis.Redis.from_url(url) if url else redis.Redis()
32
+ self.client = client
33
+ self.max_retries = max_retries
34
+ self.failed = 0
35
+
36
+ def emit(self, batch: list[dict[str, object]]) -> None:
37
+ """Pipeline the whole batch into one round trip, retrying on connection error (FR-005)."""
38
+ if not batch:
39
+ return
40
+ for attempt in range(self.max_retries + 1):
41
+ try:
42
+ pipe = self.client.pipeline()
43
+ for event in batch:
44
+ self._stage(pipe, event)
45
+ pipe.execute()
46
+ return
47
+ except Exception as err: # isolation boundary: never crash the worker (FR-011)
48
+ if attempt < self.max_retries:
49
+ time.sleep(_BACKOFF_BASE * (2**attempt))
50
+ continue
51
+ self.failed += len(batch)
52
+ sys.stderr.write(
53
+ f"log-forge: {type(self).__name__} abandoned {len(batch)} event(s) after "
54
+ f"{self.max_retries + 1} attempts ({err!r})\n"
55
+ )
56
+ return
57
+
58
+ def close(self) -> None:
59
+ """Close the connection only if the sink owns it (FR-005)."""
60
+ if self._owns_client:
61
+ self.client.close()
62
+
63
+ def _stage(self, pipe: Any, event: dict[str, object]) -> None:
64
+ raise NotImplementedError
65
+
66
+
67
+ class RedisStreamsSink(_RedisSink):
68
+ """Append each event to a Redis stream via ``XADD``, pipelined per batch (FR-005)."""
69
+
70
+ def __init__(
71
+ self, stream: str, *, client: Any = None, url: str | None = None, max_retries: int = 3
72
+ ) -> None:
73
+ self._stream = stream
74
+ super().__init__(client=client, url=url, max_retries=max_retries)
75
+
76
+ def _stage(self, pipe: Any, event: dict[str, object]) -> None:
77
+ pipe.xadd(self._stream, {"event": json.dumps(event)})
78
+
79
+
80
+ class RedisListSink(_RedisSink):
81
+ """Push each event onto a Redis list via ``RPUSH``, pipelined per batch (FR-005)."""
82
+
83
+ def __init__(
84
+ self, key: str, *, client: Any = None, url: str | None = None, max_retries: int = 3
85
+ ) -> None:
86
+ self._key = key
87
+ super().__init__(client=client, url=url, max_retries=max_retries)
88
+
89
+ def _stage(self, pipe: Any, event: dict[str, object]) -> None:
90
+ pipe.rpush(self._key, json.dumps(event))
@@ -0,0 +1,136 @@
1
+ """SentrySink — route error-level events to Sentry (arch §8, SPEC-009).
2
+
3
+ Uses the ``sentry-sdk`` when it is installed (the optional ``sentry`` extra): ``import sentry_sdk``
4
+ happens lazily inside the sink, never at module top, so importing this module does not require the
5
+ extra. When the SDK is absent the sink falls back to POSTing a Sentry *envelope* over HTTP to the
6
+ DSN's ingest URL (reusing :class:`~log_forge.sinks.http.HTTPSink`). Only events at/above a
7
+ configurable ``min_level`` (default ``ERROR``) are sent; the rest are skipped.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import uuid
14
+ from typing import Any
15
+ from urllib.parse import urlparse
16
+
17
+ from log_forge.sinks.http import HTTPSink
18
+
19
+ __all__ = ["SentrySink"]
20
+
21
+ # log-forge level -> ordering rank (send when rank >= min_level's rank).
22
+ _LEVEL_RANK = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50}
23
+ # log-forge level -> Sentry level keyword.
24
+ _SENTRY_LEVEL = {
25
+ "DEBUG": "debug", "INFO": "info", "WARNING": "warning", "ERROR": "error", "CRITICAL": "fatal",
26
+ }
27
+
28
+
29
+ class SentrySink:
30
+ """A :class:`~log_forge.sinks.base.Sink` that captures qualifying events to Sentry (FR-011).
31
+
32
+ Attributes:
33
+ sent: Events captured/sent to Sentry.
34
+ skipped: Events below ``min_level`` (or without a usable level) that were not sent.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ dsn: str | None = None,
40
+ *,
41
+ min_level: str = "ERROR",
42
+ sdk: Any = None,
43
+ opener: Any = None,
44
+ max_retries: int = 3,
45
+ ) -> None:
46
+ self._dsn = dsn
47
+ self._min_rank = _LEVEL_RANK.get(min_level.upper(), _LEVEL_RANK["ERROR"])
48
+ self.sent = 0
49
+ self.skipped = 0
50
+ self._sdk = sdk if sdk is not None else _import_sdk()
51
+ self._http: HTTPSink | None = None
52
+ self._auth_header = ""
53
+ if self._sdk is None:
54
+ # No SDK: prepare the HTTP-envelope fallback, which needs a DSN to know where to POST.
55
+ if dsn is None:
56
+ raise ValueError(
57
+ "SentrySink without sentry-sdk requires a dsn for the HTTP-envelope fallback"
58
+ )
59
+ ingest_url, self._auth_header = _parse_dsn(dsn)
60
+ self._http = HTTPSink(ingest_url, opener=opener, max_retries=max_retries)
61
+
62
+ def emit(self, batch: list[dict[str, object]]) -> None:
63
+ """Capture each qualifying event via the SDK or the HTTP fallback (FR-011)."""
64
+ for event in batch:
65
+ if not self._qualifies(event):
66
+ self.skipped += 1
67
+ continue
68
+ if self._sdk is not None:
69
+ self._sdk.capture_event(self._sentry_event(event))
70
+ else:
71
+ self._post_envelope(event)
72
+ self.sent += 1
73
+
74
+ def close(self) -> None:
75
+ """Release the HTTP fallback resource, if any; idempotent (FR-012)."""
76
+ if self._http is not None:
77
+ self._http.close()
78
+
79
+ @property
80
+ def failed(self) -> int:
81
+ """Requests abandoned past the retry bound (HTTP fallback only)."""
82
+ return self._http.failed if self._http is not None else 0
83
+
84
+ # -- internals ----------------------------------------------------------------------
85
+
86
+ def _qualifies(self, event: dict[str, object]) -> bool:
87
+ level = event.get("level")
88
+ if not isinstance(level, str):
89
+ return False
90
+ return _LEVEL_RANK.get(level.upper(), -1) >= self._min_rank
91
+
92
+ def _sentry_event(self, event: dict[str, object]) -> dict[str, object]:
93
+ level = event.get("level")
94
+ sentry_level = _SENTRY_LEVEL.get(level.upper(), "error") if isinstance(level, str) else (
95
+ "error"
96
+ )
97
+ return {
98
+ "message": event.get("message", ""),
99
+ "level": sentry_level,
100
+ "logger": "log_forge",
101
+ "extra": event,
102
+ }
103
+
104
+ def _post_envelope(self, event: dict[str, object]) -> None:
105
+ assert self._http is not None
106
+ header = {"event_id": uuid.uuid4().hex, "dsn": self._dsn}
107
+ item_header = {"type": "event"}
108
+ body = (
109
+ json.dumps(header) + "\n"
110
+ + json.dumps(item_header) + "\n"
111
+ + json.dumps(self._sentry_event(event)) + "\n"
112
+ ).encode("utf-8")
113
+ self._http._send(
114
+ body,
115
+ content_type="application/x-sentry-envelope",
116
+ extra_headers={"X-Sentry-Auth": self._auth_header},
117
+ )
118
+
119
+
120
+ def _import_sdk() -> Any:
121
+ """Import ``sentry_sdk`` lazily; return ``None`` if the optional extra is not installed."""
122
+ try:
123
+ import sentry_sdk # type: ignore[import-not-found] # optional 'sentry' extra
124
+ except ImportError:
125
+ return None
126
+ return sentry_sdk
127
+
128
+
129
+ def _parse_dsn(dsn: str) -> tuple[str, str]:
130
+ """Derive ``(ingest_url, x_sentry_auth_header)`` from a Sentry DSN."""
131
+ parsed = urlparse(dsn)
132
+ project = parsed.path.lstrip("/")
133
+ port = f":{parsed.port}" if parsed.port else ""
134
+ ingest_url = f"{parsed.scheme}://{parsed.hostname}{port}/api/{project}/envelope/"
135
+ auth_header = f"Sentry sentry_key={parsed.username}, sentry_version=7, sentry_client=log-forge"
136
+ return ingest_url, auth_header
log_forge/sinks/sns.py ADDED
@@ -0,0 +1,82 @@
1
+ """SNSSink — publish events to an SNS topic (arch §8, SPEC-010).
2
+
3
+ Mirrors the SPEC-005 ``SQSSink`` partial-failure policy on SNS's ``publish_batch`` (≤ 10 entries per
4
+ request, ≤ 256 KB total). ``boto3`` is the optional ``aws`` extra, imported lazily. The response
5
+ ``Failed`` list is retried within a bounded count; entries still failing are counted and logged.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sys
12
+ from typing import Any
13
+
14
+ from log_forge.sinks._chunk import chunk_items
15
+
16
+ __all__ = ["SNSSink"]
17
+
18
+
19
+ class SNSSink:
20
+ """A :class:`~log_forge.sinks.base.Sink` that publishes events to an SNS topic."""
21
+
22
+ MAX_BATCH = 10 # publish_batch hard limit: entries per request
23
+ MAX_BYTES = 256 * 1024 # 256 KB per request
24
+
25
+ def __init__(self, topic_arn: str, *, client: Any = None, max_retries: int = 3) -> None:
26
+ if client is None:
27
+ import boto3 # type: ignore[import-not-found] # optional 'aws' extra
28
+
29
+ client = boto3.client("sns")
30
+ self.topic_arn = topic_arn
31
+ self.client = client
32
+ self.max_retries = max_retries
33
+ self.failed = 0
34
+ self.dropped_oversized = 0
35
+
36
+ def emit(self, batch: list[dict[str, object]]) -> None:
37
+ """Re-chunk to publish_batch limits and send each chunk, retrying failures (FR-010)."""
38
+ bodies = self._bodies(batch)
39
+ for chunk in chunk_items(
40
+ bodies, max_count=self.MAX_BATCH, max_bytes=self.MAX_BYTES, size_of=len
41
+ ):
42
+ self._send(chunk)
43
+
44
+ def close(self) -> None:
45
+ """No-op: the sink buffers nothing internally (FR-001)."""
46
+
47
+ # -- internals ----------------------------------------------------------------------
48
+
49
+ def _bodies(self, batch: list[dict[str, object]]) -> list[str]:
50
+ """Serialize each event, dropping any single message too large to ever fit (FR-011)."""
51
+ bodies: list[str] = []
52
+ for event in batch:
53
+ body = json.dumps(event)
54
+ if len(body.encode("utf-8")) > self.MAX_BYTES:
55
+ self.dropped_oversized += 1
56
+ sys.stderr.write(
57
+ f"log-forge: SNSSink dropped an event exceeding the "
58
+ f"{self.MAX_BYTES}-byte message limit\n"
59
+ )
60
+ continue
61
+ bodies.append(body)
62
+ return bodies
63
+
64
+ def _send(self, bodies: list[str]) -> None:
65
+ """Publish one chunk, retrying only the ``Failed`` entries (bounded) (FR-010)."""
66
+ entries = [{"Id": str(i), "Message": body} for i, body in enumerate(bodies)]
67
+ for attempt in range(self.max_retries + 1):
68
+ response = self.client.publish_batch(
69
+ TopicArn=self.topic_arn, PublishBatchRequestEntries=entries
70
+ )
71
+ failed = response.get("Failed", [])
72
+ if not failed:
73
+ return
74
+ failed_ids = {entry["Id"] for entry in failed}
75
+ entries = [entry for entry in entries if entry["Id"] in failed_ids]
76
+ if attempt >= self.max_retries:
77
+ self.failed += len(entries)
78
+ sys.stderr.write(
79
+ f"log-forge: {len(entries)} SNS message(s) still failing after "
80
+ f"{self.max_retries + 1} attempts; abandoned\n"
81
+ )
82
+ return
@@ -0,0 +1,51 @@
1
+ """SplunkHECSink — ship to Splunk's HTTP Event Collector (arch §8, SPEC-009).
2
+
3
+ An :class:`~log_forge.sinks.http.HTTPSink` specialization: each event is wrapped in a HEC envelope
4
+ (``{"event": ..., "time": <epoch>, "host": ..., "source": ...}``) and the batch is sent as HEC's
5
+ concatenated-JSON-objects body with an ``Authorization: Splunk <token>`` header. ``time`` (epoch
6
+ seconds) is parsed from 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_seconds
14
+ from log_forge.sinks.http import HTTPSink, merge_headers
15
+
16
+ __all__ = ["SplunkHECSink"]
17
+
18
+
19
+ class SplunkHECSink(HTTPSink):
20
+ """POST HEC envelopes to a Splunk HTTP Event Collector endpoint (FR-008)."""
21
+
22
+ def __init__(
23
+ self,
24
+ url: str,
25
+ token: str,
26
+ *,
27
+ host: str | None = None,
28
+ source: str = "log-forge",
29
+ **http_kwargs: object,
30
+ ) -> None:
31
+ self._host = host
32
+ self._source = source
33
+ headers = merge_headers({"Authorization": f"Splunk {token}"}, http_kwargs)
34
+ super().__init__(url, headers=headers, **http_kwargs) # type: ignore[arg-type]
35
+
36
+ def emit(self, batch: list[dict[str, object]]) -> None:
37
+ """Send the batch as HEC's concatenated JSON objects (FR-008)."""
38
+ if not batch:
39
+ return
40
+ body = "".join(json.dumps(self._envelope(event)) for event in batch).encode("utf-8")
41
+ self._send(body, content_type="application/json")
42
+
43
+ def _envelope(self, event: dict[str, object]) -> dict[str, object]:
44
+ envelope: dict[str, object] = {
45
+ "event": event,
46
+ "time": epoch_seconds(event.get("timestamp")),
47
+ "source": self._source,
48
+ }
49
+ if self._host is not None:
50
+ envelope["host"] = self._host
51
+ return envelope