log-foundry 0.0.2.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- log_forge/__init__.py +45 -0
- log_forge/api.py +89 -0
- log_forge/config.py +79 -0
- log_forge/console.py +30 -0
- log_forge/context.py +62 -0
- log_forge/decorator.py +157 -0
- log_forge/ids.py +29 -0
- log_forge/model.py +106 -0
- log_forge/py.typed +0 -0
- log_forge/sinks/__init__.py +0 -0
- log_forge/sinks/_chunk.py +58 -0
- log_forge/sinks/_socket.py +105 -0
- log_forge/sinks/_time.py +28 -0
- log_forge/sinks/base.py +22 -0
- log_forge/sinks/callback.py +42 -0
- log_forge/sinks/clickhouse.py +111 -0
- log_forge/sinks/datadog.py +53 -0
- log_forge/sinks/elasticsearch.py +68 -0
- log_forge/sinks/eventhubs.py +110 -0
- log_forge/sinks/file.py +177 -0
- log_forge/sinks/filtering.py +71 -0
- log_forge/sinks/firehose.py +91 -0
- log_forge/sinks/honeycomb.py +38 -0
- log_forge/sinks/http.py +202 -0
- log_forge/sinks/kafka.py +71 -0
- log_forge/sinks/kinesis.py +99 -0
- log_forge/sinks/logging_sink.py +104 -0
- log_forge/sinks/logstash.py +73 -0
- log_forge/sinks/loki.py +48 -0
- log_forge/sinks/mongodb.py +105 -0
- log_forge/sinks/multi.py +53 -0
- log_forge/sinks/nats.py +72 -0
- log_forge/sinks/newrelic.py +28 -0
- log_forge/sinks/postgres.py +101 -0
- log_forge/sinks/pubsub.py +45 -0
- log_forge/sinks/rabbitmq.py +129 -0
- log_forge/sinks/redis.py +90 -0
- log_forge/sinks/sentry.py +136 -0
- log_forge/sinks/sns.py +82 -0
- log_forge/sinks/splunk.py +51 -0
- log_forge/sinks/sqlite.py +100 -0
- log_forge/sinks/sqs.py +107 -0
- log_forge/sinks/stdout.py +31 -0
- log_forge/sinks/syslog.py +88 -0
- log_forge/sinks/transform.py +46 -0
- log_forge/sinks/util.py +68 -0
- log_forge/worker.py +153 -0
- log_foundry-0.0.2.dev1.dist-info/METADATA +537 -0
- log_foundry-0.0.2.dev1.dist-info/RECORD +51 -0
- log_foundry-0.0.2.dev1.dist-info/WHEEL +4 -0
- log_foundry-0.0.2.dev1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|
log_forge/sinks/redis.py
ADDED
|
@@ -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
|