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,100 @@
|
|
|
1
|
+
"""SQLiteSink — persist events as queryable rows in an embedded SQLite database (arch §8, SPEC-008).
|
|
2
|
+
|
|
3
|
+
For local dev, debugging, air-gapped hosts, or simple archival, an embedded SQLite file is a durable
|
|
4
|
+
sink you can open later and query with plain SQL. Each event is stored as its full JSON plus a few
|
|
5
|
+
extracted columns (identity + level + function) projected out for cheap filtering. Like every sink it
|
|
6
|
+
receives *already-built* event dicts and knows nothing about spans or context.
|
|
7
|
+
|
|
8
|
+
Standard library only (``sqlite3``) — no runtime dependency. A single-process, single-worker-thread
|
|
9
|
+
writer is assumed (arch §9); concurrent/cross-process writers and external databases (Postgres,
|
|
10
|
+
ClickHouse, …) are out of scope here (the latter is SPEC-011).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import sqlite3
|
|
18
|
+
|
|
19
|
+
__all__ = ["SQLiteSink"]
|
|
20
|
+
|
|
21
|
+
# Columns projected out of each event for cheap SQL filtering. ``event`` (full JSON) is stored
|
|
22
|
+
# alongside these and is the source of truth; these are convenience projections (NULL if absent).
|
|
23
|
+
_COLUMNS = ("log_id", "trace_id", "span_id", "timestamp", "level", "function")
|
|
24
|
+
|
|
25
|
+
# A table name is a config value, not untrusted input, but it is interpolated into DDL/DML (SQLite
|
|
26
|
+
# cannot parameterize identifiers), so restrict it to a plain SQL identifier to foreclose injection.
|
|
27
|
+
_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SQLiteSink:
|
|
31
|
+
"""A :class:`~log_forge.sinks.base.Sink` that batch-inserts events into a SQLite table.
|
|
32
|
+
|
|
33
|
+
By default (``create_table=True``) the sink owns its schema: it runs an idempotent
|
|
34
|
+
``CREATE TABLE IF NOT EXISTS`` so it works out of the box against a fresh database file. Pass
|
|
35
|
+
``create_table=False`` when the caller provisions the table themselves (e.g. via migrations); the
|
|
36
|
+
sink then runs no DDL and a missing/incompatible table surfaces as a normal ``sqlite3`` error at
|
|
37
|
+
insert time.
|
|
38
|
+
|
|
39
|
+
A ``connection`` may be injected (e.g. ``sqlite3.connect(":memory:")``) so tests never touch
|
|
40
|
+
disk. An injected connection is borrowed — committed but never closed by this sink; a connection
|
|
41
|
+
the sink opens itself is owned and closed on ``close()``.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
database: str,
|
|
47
|
+
*,
|
|
48
|
+
table: str = "log_events",
|
|
49
|
+
connection: sqlite3.Connection | None = None,
|
|
50
|
+
create_table: bool = True,
|
|
51
|
+
) -> None:
|
|
52
|
+
if not _IDENTIFIER.match(table):
|
|
53
|
+
raise ValueError(f"invalid table name {table!r}; expected a plain SQL identifier")
|
|
54
|
+
self._table = table
|
|
55
|
+
self._owns_connection = connection is None
|
|
56
|
+
# check_same_thread=False: the background worker (a different thread than the one that ran
|
|
57
|
+
# configure()) is the sole writer, so SQLite's same-thread guard would only get in the way
|
|
58
|
+
# (arch §9 single-writer assumption; concurrent writers are out of scope, SPEC-008).
|
|
59
|
+
self._conn = (
|
|
60
|
+
connection
|
|
61
|
+
if connection is not None
|
|
62
|
+
else sqlite3.connect(database, check_same_thread=False)
|
|
63
|
+
)
|
|
64
|
+
self._closed = False
|
|
65
|
+
if create_table:
|
|
66
|
+
self._ensure_schema()
|
|
67
|
+
|
|
68
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
69
|
+
"""Insert every event in one transaction; extracted columns default to NULL (FR-003)."""
|
|
70
|
+
rows = [
|
|
71
|
+
(*(event.get(col) for col in _COLUMNS), json.dumps(event)) for event in batch
|
|
72
|
+
]
|
|
73
|
+
placeholders = ", ".join("?" * (len(_COLUMNS) + 1))
|
|
74
|
+
columns = ", ".join((*_COLUMNS, "event"))
|
|
75
|
+
# ``with connection`` opens a transaction and commits on success / rolls back on error, so
|
|
76
|
+
# the whole batch lands atomically.
|
|
77
|
+
with self._conn:
|
|
78
|
+
self._conn.executemany(
|
|
79
|
+
f'INSERT INTO "{self._table}" ({columns}) VALUES ({placeholders})', rows
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def close(self) -> None:
|
|
83
|
+
"""Commit pending work; close only a connection the sink owns. Idempotent (FR-003)."""
|
|
84
|
+
if self._closed:
|
|
85
|
+
return
|
|
86
|
+
self._conn.commit()
|
|
87
|
+
if self._owns_connection:
|
|
88
|
+
self._conn.close()
|
|
89
|
+
self._closed = True
|
|
90
|
+
|
|
91
|
+
# -- internals ----------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def _ensure_schema(self) -> None:
|
|
94
|
+
"""Idempotently create the target table (``CREATE TABLE IF NOT EXISTS``)."""
|
|
95
|
+
columns = ", ".join(f"{col} TEXT" for col in _COLUMNS)
|
|
96
|
+
with self._conn:
|
|
97
|
+
self._conn.execute(
|
|
98
|
+
f'CREATE TABLE IF NOT EXISTS "{self._table}" '
|
|
99
|
+
f"(id INTEGER PRIMARY KEY AUTOINCREMENT, {columns}, event TEXT NOT NULL)"
|
|
100
|
+
)
|
log_forge/sinks/sqs.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""SQSSink — ship batches to an SQS queue (arch §8, §9.1, guide Phase 10).
|
|
2
|
+
|
|
3
|
+
SQS is the headline production path: a durable buffer that decouples the app from ELK
|
|
4
|
+
availability — events accumulate safely in the queue during downstream spikes/outages instead
|
|
5
|
+
of being lost or back-pressuring the app. A separate consumer indexes them into ELK (out of
|
|
6
|
+
scope here). Like every sink this receives *already-built* event dicts and knows nothing about
|
|
7
|
+
spans.
|
|
8
|
+
|
|
9
|
+
``boto3`` is an **optional** dependency (the ``aws`` extra): it is imported lazily inside the
|
|
10
|
+
sink, never at module top, so ``import log_forge.sinks.sqs`` — and the whole library — stays
|
|
11
|
+
dependency-free unless an ``SQSSink`` is actually instantiated without an injected client.
|
|
12
|
+
|
|
13
|
+
The worker (SPEC-004) batches by count and time, but SQS has hard per-request limits, so this
|
|
14
|
+
sink re-chunks every incoming batch on both dimensions: ≤ 10 messages **and** ≤ 256 KB per
|
|
15
|
+
``send_message_batch``. Partial failures (the response ``Failed`` list) are retried; a single
|
|
16
|
+
event too large to ever fit is dropped with a warning rather than crashing the batch.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import sys
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
__all__ = ["SQSSink"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SQSSink:
|
|
29
|
+
"""A :class:`~log_forge.sinks.base.Sink` that sends events to an SQS queue."""
|
|
30
|
+
|
|
31
|
+
MAX_BATCH = 10 # SQS SendMessageBatch hard limit: entries per request
|
|
32
|
+
MAX_BYTES = 256 * 1024 # SQS limit: 256 KB per request
|
|
33
|
+
|
|
34
|
+
def __init__(self, queue_url: str, client: Any = None, *, max_retries: int = 3) -> None:
|
|
35
|
+
if client is None:
|
|
36
|
+
import boto3 # type: ignore[import-not-found] # optional 'aws' extra
|
|
37
|
+
|
|
38
|
+
client = boto3.client("sqs")
|
|
39
|
+
self.queue_url = queue_url
|
|
40
|
+
self.client = client
|
|
41
|
+
self.max_retries = max_retries
|
|
42
|
+
self.dropped_oversized = 0 # events too large to ever fit one message
|
|
43
|
+
self.failed = 0 # entries still failing after the retry bound
|
|
44
|
+
|
|
45
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
46
|
+
"""Re-chunk ``batch`` to SQS limits and send each chunk (FR-001, FR-002)."""
|
|
47
|
+
for chunk in self._chunks(batch):
|
|
48
|
+
self._send(chunk)
|
|
49
|
+
|
|
50
|
+
def close(self) -> None:
|
|
51
|
+
"""No-op: the sink buffers nothing internally (FR-005)."""
|
|
52
|
+
|
|
53
|
+
# -- internals ----------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
def _chunks(self, batch: list[dict[str, object]]) -> list[list[str]]:
|
|
56
|
+
"""Split ``batch`` into sends of ≤ ``MAX_BATCH`` bodies and ≤ ``MAX_BYTES`` each.
|
|
57
|
+
|
|
58
|
+
Each event is serialized once with ``json.dumps``. An event whose serialized size
|
|
59
|
+
exceeds ``MAX_BYTES`` on its own can never fit a message, so it is dropped with a
|
|
60
|
+
counted warning (FR-004) instead of stalling the batch.
|
|
61
|
+
"""
|
|
62
|
+
chunks: list[list[str]] = []
|
|
63
|
+
current: list[str] = []
|
|
64
|
+
current_bytes = 0
|
|
65
|
+
for event in batch:
|
|
66
|
+
body = json.dumps(event)
|
|
67
|
+
size = len(body.encode("utf-8"))
|
|
68
|
+
if size > self.MAX_BYTES:
|
|
69
|
+
self.dropped_oversized += 1
|
|
70
|
+
sys.stderr.write(
|
|
71
|
+
f"log-forge: dropped an event of {size} bytes exceeding the "
|
|
72
|
+
f"{self.MAX_BYTES}-byte SQS message limit\n"
|
|
73
|
+
)
|
|
74
|
+
continue
|
|
75
|
+
if current and (
|
|
76
|
+
len(current) >= self.MAX_BATCH or current_bytes + size > self.MAX_BYTES
|
|
77
|
+
):
|
|
78
|
+
chunks.append(current)
|
|
79
|
+
current = []
|
|
80
|
+
current_bytes = 0
|
|
81
|
+
current.append(body)
|
|
82
|
+
current_bytes += size
|
|
83
|
+
if current:
|
|
84
|
+
chunks.append(current)
|
|
85
|
+
return chunks
|
|
86
|
+
|
|
87
|
+
def _send(self, bodies: list[str]) -> None:
|
|
88
|
+
"""Send one valid chunk, retrying only the ``Failed`` entries with a bounded count.
|
|
89
|
+
|
|
90
|
+
Successfully-sent entries are never re-sent; entries still failing past ``max_retries``
|
|
91
|
+
are counted (``failed``) and logged, not silently dropped (FR-003).
|
|
92
|
+
"""
|
|
93
|
+
entries = [{"Id": str(i), "MessageBody": body} for i, body in enumerate(bodies)]
|
|
94
|
+
for attempt in range(self.max_retries + 1):
|
|
95
|
+
response = self.client.send_message_batch(QueueUrl=self.queue_url, Entries=entries)
|
|
96
|
+
failed = response.get("Failed", [])
|
|
97
|
+
if not failed:
|
|
98
|
+
return
|
|
99
|
+
failed_ids = {entry["Id"] for entry in failed}
|
|
100
|
+
entries = [entry for entry in entries if entry["Id"] in failed_ids]
|
|
101
|
+
if attempt >= self.max_retries:
|
|
102
|
+
self.failed += len(entries)
|
|
103
|
+
sys.stderr.write(
|
|
104
|
+
f"log-forge: {len(entries)} SQS message(s) still failing after "
|
|
105
|
+
f"{self.max_retries + 1} attempts; abandoned\n"
|
|
106
|
+
)
|
|
107
|
+
return
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""StdoutSink — JSON lines to a stream (arch §8, guide Phase 5).
|
|
2
|
+
|
|
3
|
+
The zero-dependency default sink: great for local dev and container log scraping. Like every
|
|
4
|
+
sink it receives *already-built* event dicts and knows nothing about spans or context — that
|
|
5
|
+
dumbness is what makes sinks trivially swappable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from typing import TextIO
|
|
13
|
+
|
|
14
|
+
__all__ = ["StdoutSink"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StdoutSink:
|
|
18
|
+
"""Write each event as one JSON line to ``stream`` (default ``sys.stdout``)."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, stream: TextIO | None = None) -> None:
|
|
21
|
+
self._stream = stream if stream is not None else sys.stdout
|
|
22
|
+
|
|
23
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
24
|
+
"""Write every event in ``batch`` as a ``json.dumps`` line, then flush."""
|
|
25
|
+
for event in batch:
|
|
26
|
+
self._stream.write(json.dumps(event) + "\n")
|
|
27
|
+
self._stream.flush()
|
|
28
|
+
|
|
29
|
+
def close(self) -> None:
|
|
30
|
+
"""Flush the stream (nothing is buffered inside the sink itself)."""
|
|
31
|
+
self._stream.flush()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""SyslogSink — RFC 5424 syslog frames over UDP or TCP (arch §8, SPEC-009).
|
|
2
|
+
|
|
3
|
+
Formats each event as an RFC 5424 message (``<PRI>1 TIMESTAMP HOST APP PROCID MSGID SD MSG``) with
|
|
4
|
+
``PRI`` derived from a configurable facility and a severity mapped from the event ``level``. UDP
|
|
5
|
+
sends one datagram per event; TCP uses octet-counted framing (RFC 6587). Dependency-free (stdlib
|
|
6
|
+
``socket``, via :class:`~log_forge.sinks._socket.SocketTransport`).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import socket
|
|
14
|
+
|
|
15
|
+
from log_forge.sinks._socket import SocketTransport
|
|
16
|
+
|
|
17
|
+
__all__ = ["SyslogSink"]
|
|
18
|
+
|
|
19
|
+
# RFC 5424 numeric severities; log-forge levels mapped onto them (unknown level -> notice/5).
|
|
20
|
+
_SEVERITY = {"DEBUG": 7, "INFO": 6, "NOTICE": 5, "WARNING": 4, "ERROR": 3, "CRITICAL": 2}
|
|
21
|
+
_DEFAULT_SEVERITY = 5
|
|
22
|
+
|
|
23
|
+
# A subset of RFC 5424 facility keywords -> numeric code.
|
|
24
|
+
_FACILITY = {
|
|
25
|
+
"kern": 0, "user": 1, "mail": 2, "daemon": 3, "auth": 4, "syslog": 5, "lpr": 6, "news": 7,
|
|
26
|
+
"uucp": 8, "cron": 9, "authpriv": 10, "ftp": 11,
|
|
27
|
+
"local0": 16, "local1": 17, "local2": 18, "local3": 19,
|
|
28
|
+
"local4": 20, "local5": 21, "local6": 22, "local7": 23,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SyslogSink:
|
|
33
|
+
"""A :class:`~log_forge.sinks.base.Sink` that emits RFC 5424 frames over UDP/TCP (FR-006)."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
host: str,
|
|
38
|
+
port: int = 514,
|
|
39
|
+
*,
|
|
40
|
+
transport: str = "udp",
|
|
41
|
+
facility: str = "user",
|
|
42
|
+
app_name: str = "log-forge",
|
|
43
|
+
timeout: float = 5.0,
|
|
44
|
+
max_retries: int = 3,
|
|
45
|
+
) -> None:
|
|
46
|
+
if facility not in _FACILITY:
|
|
47
|
+
raise ValueError(f"invalid facility {facility!r}; expected one of {sorted(_FACILITY)}")
|
|
48
|
+
self._facility = _FACILITY[facility]
|
|
49
|
+
self._app_name = app_name
|
|
50
|
+
self._transport = transport
|
|
51
|
+
self._hostname = socket.gethostname() or "-"
|
|
52
|
+
self._procid = str(os.getpid())
|
|
53
|
+
self._socket = SocketTransport(
|
|
54
|
+
host, port, transport=transport, timeout=timeout, max_retries=max_retries
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
58
|
+
"""Frame each event as RFC 5424 and send it over the socket (FR-006)."""
|
|
59
|
+
if not batch:
|
|
60
|
+
return
|
|
61
|
+
self._socket.send_all([self._frame(event) for event in batch])
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
"""Close the underlying socket (FR-006)."""
|
|
65
|
+
self._socket.close()
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def failed(self) -> int:
|
|
69
|
+
"""Messages abandoned past the retry bound."""
|
|
70
|
+
return self._socket.failed
|
|
71
|
+
|
|
72
|
+
# -- internals ----------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def _frame(self, event: dict[str, object]) -> bytes:
|
|
75
|
+
"""Build one RFC 5424 message; TCP gets octet-counted framing (RFC 6587)."""
|
|
76
|
+
level = event.get("level")
|
|
77
|
+
severity = _SEVERITY.get(level.upper(), _DEFAULT_SEVERITY) if isinstance(level, str) else (
|
|
78
|
+
_DEFAULT_SEVERITY
|
|
79
|
+
)
|
|
80
|
+
pri = self._facility * 8 + severity
|
|
81
|
+
timestamp = event.get("timestamp")
|
|
82
|
+
ts = timestamp if isinstance(timestamp, str) else "-"
|
|
83
|
+
msg = json.dumps(event)
|
|
84
|
+
frame = f"<{pri}>1 {ts} {self._hostname} {self._app_name} {self._procid} - - {msg}"
|
|
85
|
+
data = frame.encode("utf-8")
|
|
86
|
+
if self._transport == "tcp":
|
|
87
|
+
return f"{len(data)} ".encode("ascii") + data # RFC 6587 octet counting
|
|
88
|
+
return data
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""TransformSink — reshape or redact each event before forwarding (arch §8, SPEC-006 FR-004).
|
|
2
|
+
|
|
3
|
+
Wraps an inner sink and maps every event through a user function on its way out — to redact a
|
|
4
|
+
field, add host metadata, rename keys, and so on. The function returning ``None`` drops that
|
|
5
|
+
event. The caller's batch and event dicts are never mutated in place: only the function's
|
|
6
|
+
return values are forwarded, so a transform must copy before mutating (see the spec's redact
|
|
7
|
+
example).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
from log_forge.sinks.base import Sink
|
|
15
|
+
|
|
16
|
+
__all__ = ["TransformSink"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TransformSink:
|
|
20
|
+
"""A :class:`~log_forge.sinks.base.Sink` that maps each event before forwarding.
|
|
21
|
+
|
|
22
|
+
``fn`` is applied to every event; a new list of the non-``None`` results is forwarded to
|
|
23
|
+
``inner``. When every event is dropped, ``inner.emit`` is not called.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
inner: Sink,
|
|
29
|
+
fn: Callable[[dict[str, object]], dict[str, object] | None],
|
|
30
|
+
) -> None:
|
|
31
|
+
self._inner = inner
|
|
32
|
+
self._fn = fn
|
|
33
|
+
|
|
34
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
35
|
+
"""Apply ``fn`` to each event, forwarding the non-``None`` results (FR-004)."""
|
|
36
|
+
transformed: list[dict[str, object]] = []
|
|
37
|
+
for event in batch:
|
|
38
|
+
result = self._fn(event)
|
|
39
|
+
if result is not None:
|
|
40
|
+
transformed.append(result)
|
|
41
|
+
if transformed:
|
|
42
|
+
self._inner.emit(transformed)
|
|
43
|
+
|
|
44
|
+
def close(self) -> None:
|
|
45
|
+
"""Close the inner sink (FR-004)."""
|
|
46
|
+
self._inner.close()
|
log_forge/sinks/util.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Small utility sinks: StderrSink, NullSink, MemorySink (arch §8, SPEC-008).
|
|
2
|
+
|
|
3
|
+
Three tiny zero-dependency sinks that round out the local family: send NDJSON to stderr (the
|
|
4
|
+
twelve-factor convention — logs on stderr, app output on stdout), discard everything (to disable
|
|
5
|
+
output or benchmark the pipeline), or collect events into an in-process list (for tests and
|
|
6
|
+
notebooks). Like every sink they operate purely on already-built event dicts (arch §8).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from typing import TextIO
|
|
13
|
+
|
|
14
|
+
from log_forge.sinks.stdout import StdoutSink
|
|
15
|
+
|
|
16
|
+
__all__ = ["StderrSink", "NullSink", "MemorySink"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StderrSink(StdoutSink):
|
|
20
|
+
"""The :class:`~log_forge.sinks.stdout.StdoutSink` shape, defaulting to ``sys.stderr`` (FR-004).
|
|
21
|
+
|
|
22
|
+
Writes each event as one ``json.dumps`` line and flushes, exactly like ``StdoutSink`` — only the
|
|
23
|
+
default stream differs. An explicit ``stream`` (e.g. a ``StringIO``) can be injected for capture.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, stream: TextIO | None = None) -> None:
|
|
27
|
+
super().__init__(stream if stream is not None else sys.stderr)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NullSink:
|
|
31
|
+
"""A :class:`~log_forge.sinks.base.Sink` that discards every event (FR-005).
|
|
32
|
+
|
|
33
|
+
Useful to disable output without unwiring the pipeline, or to benchmark everything up to the
|
|
34
|
+
sink. ``dropped`` counts how many events were discarded, so a benchmark can assert throughput.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self.dropped = 0
|
|
39
|
+
|
|
40
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
41
|
+
"""Discard the batch, counting the events dropped (FR-005)."""
|
|
42
|
+
self.dropped += len(batch)
|
|
43
|
+
|
|
44
|
+
def close(self) -> None:
|
|
45
|
+
"""No-op — nothing is held (FR-005)."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MemorySink:
|
|
49
|
+
"""A :class:`~log_forge.sinks.base.Sink` that collects events into an in-process list (FR-006).
|
|
50
|
+
|
|
51
|
+
``.events`` is a plain ``list`` exposing every event in arrival order, ideal for asserting in
|
|
52
|
+
tests or eyeballing in a notebook. With ``maxlen`` set it behaves as a bounded ring, keeping only
|
|
53
|
+
the most recent ``maxlen`` events; the list object identity is stable, so a held reference keeps
|
|
54
|
+
seeing updates.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, maxlen: int | None = None) -> None:
|
|
58
|
+
self.events: list[dict[str, object]] = []
|
|
59
|
+
self._maxlen = maxlen
|
|
60
|
+
|
|
61
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
62
|
+
"""Append the batch in order, trimming to the most recent ``maxlen`` if bounded (FR-006)."""
|
|
63
|
+
self.events.extend(batch)
|
|
64
|
+
if self._maxlen is not None and len(self.events) > self._maxlen:
|
|
65
|
+
del self.events[: len(self.events) - self._maxlen]
|
|
66
|
+
|
|
67
|
+
def close(self) -> None:
|
|
68
|
+
"""No-op — collected events remain readable after close (FR-006)."""
|
log_forge/worker.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Background flush worker — non-blocking span delivery (arch §9, guide Phase 9).
|
|
2
|
+
|
|
3
|
+
A finished span used to flush inline, blocking the decorated function on ``sink.emit``. The
|
|
4
|
+
``Worker`` moves that off the hot path: :meth:`submit` is a fast, in-process handoff to a
|
|
5
|
+
bounded queue drained by a daemon thread that batches events (by count *and* time) into a
|
|
6
|
+
single ``sink.emit`` call. A slow or down sink can never back-pressure the app — the queue is
|
|
7
|
+
bounded and overflow is dropped-newest with a counter (arch §9). Emit failures are retried
|
|
8
|
+
with backoff; a graceful :meth:`shutdown` drains the queue, emits the tail, and closes the
|
|
9
|
+
sink so buffered events survive process exit.
|
|
10
|
+
|
|
11
|
+
This module owns *delivery mechanics only*; it receives already-built event dicts and knows
|
|
12
|
+
nothing about spans or context (the same dumbness that makes sinks swappable).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import queue
|
|
18
|
+
import sys
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from typing import cast
|
|
22
|
+
|
|
23
|
+
from log_forge.sinks.base import Sink
|
|
24
|
+
|
|
25
|
+
__all__ = ["Worker"]
|
|
26
|
+
|
|
27
|
+
# Sentinel enqueued by shutdown() to wake a worker blocked in queue.get() so it stops promptly
|
|
28
|
+
# instead of waiting out the flush_interval. It is never emitted.
|
|
29
|
+
_SHUTDOWN = object()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Worker:
|
|
33
|
+
"""Owns a bounded queue + daemon thread that batches and flushes events to ``sink``."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
sink: Sink,
|
|
38
|
+
*,
|
|
39
|
+
batch_size: int = 10,
|
|
40
|
+
flush_interval: float = 1.0,
|
|
41
|
+
max_queue: int = 10_000,
|
|
42
|
+
max_retries: int = 3,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.sink = sink
|
|
45
|
+
self.batch_size = batch_size
|
|
46
|
+
self.flush_interval = flush_interval
|
|
47
|
+
self.max_retries = max_retries
|
|
48
|
+
self.dropped = 0 # submissions dropped because the queue was full (backpressure)
|
|
49
|
+
self.failed_batches = 0 # batches abandoned after exhausting retries (worker-thread only)
|
|
50
|
+
self._queue: queue.Queue[object] = queue.Queue(maxsize=max_queue)
|
|
51
|
+
self._stop = threading.Event()
|
|
52
|
+
self._shutdown_done = False
|
|
53
|
+
# Guards the `dropped` counter (incremented from any caller thread) and the shutdown
|
|
54
|
+
# once-only flag (shutdown may be called concurrently by atexit and user code).
|
|
55
|
+
self._lock = threading.Lock()
|
|
56
|
+
self._thread = threading.Thread(
|
|
57
|
+
target=self._run, name="log-forge-worker", daemon=True
|
|
58
|
+
)
|
|
59
|
+
self._thread.start()
|
|
60
|
+
|
|
61
|
+
def submit(self, events: list[dict[str, object]]) -> None:
|
|
62
|
+
"""Hand a finished span's events to the worker. Non-blocking.
|
|
63
|
+
|
|
64
|
+
Enqueues via ``put_nowait`` and returns immediately without touching the sink. When the
|
|
65
|
+
queue is full, drops this submission (drop-newest) and counts it in ``dropped`` rather
|
|
66
|
+
than blocking the caller (FR-001, FR-004).
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
self._queue.put_nowait(events)
|
|
70
|
+
except queue.Full:
|
|
71
|
+
with self._lock:
|
|
72
|
+
self.dropped += 1
|
|
73
|
+
|
|
74
|
+
def shutdown(self) -> None:
|
|
75
|
+
"""Stop the thread, drain + emit everything queued, then ``close()`` the sink.
|
|
76
|
+
|
|
77
|
+
Idempotent: a second call is a no-op (FR-005). Registered via ``atexit`` by the
|
|
78
|
+
decorator's lazy worker so a program that logs and exits immediately still flushes.
|
|
79
|
+
"""
|
|
80
|
+
with self._lock:
|
|
81
|
+
if self._shutdown_done:
|
|
82
|
+
return
|
|
83
|
+
self._shutdown_done = True
|
|
84
|
+
self._stop.set()
|
|
85
|
+
try:
|
|
86
|
+
self._queue.put_nowait(_SHUTDOWN) # wake a blocked get() for a prompt stop
|
|
87
|
+
except queue.Full:
|
|
88
|
+
pass
|
|
89
|
+
self._thread.join()
|
|
90
|
+
self.sink.close()
|
|
91
|
+
|
|
92
|
+
# -- worker thread ------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def _run(self) -> None:
|
|
95
|
+
"""Drain loop: accumulate event-lists and emit a batch on the count/time trigger."""
|
|
96
|
+
pending: list[list[dict[str, object]]] = []
|
|
97
|
+
last_flush = time.monotonic()
|
|
98
|
+
while not self._stop.is_set():
|
|
99
|
+
timeout = max(0.0, self.flush_interval - (time.monotonic() - last_flush))
|
|
100
|
+
try:
|
|
101
|
+
item = self._queue.get(timeout=timeout)
|
|
102
|
+
except queue.Empty:
|
|
103
|
+
item = None
|
|
104
|
+
if item is not None and item is not _SHUTDOWN:
|
|
105
|
+
pending.append(cast("list[dict[str, object]]", item))
|
|
106
|
+
now = time.monotonic()
|
|
107
|
+
if len(pending) >= self.batch_size or now - last_flush >= self.flush_interval:
|
|
108
|
+
if pending:
|
|
109
|
+
self._emit(pending)
|
|
110
|
+
pending = []
|
|
111
|
+
# Advance the window even when idle (pending empty). Otherwise last_flush never
|
|
112
|
+
# moves while the queue is empty, timeout collapses to 0.0, and get(timeout=0.0)
|
|
113
|
+
# busy-spins a core. Resetting it lets the next get() block a full interval.
|
|
114
|
+
last_flush = now
|
|
115
|
+
self._final_drain(pending)
|
|
116
|
+
|
|
117
|
+
def _final_drain(self, pending: list[list[dict[str, object]]]) -> None:
|
|
118
|
+
"""On stop, pull anything still queued and emit the tail as one final batch."""
|
|
119
|
+
while True:
|
|
120
|
+
try:
|
|
121
|
+
item = self._queue.get_nowait()
|
|
122
|
+
except queue.Empty:
|
|
123
|
+
break
|
|
124
|
+
if item is not None and item is not _SHUTDOWN:
|
|
125
|
+
pending.append(cast("list[dict[str, object]]", item))
|
|
126
|
+
if pending:
|
|
127
|
+
self._emit(pending)
|
|
128
|
+
|
|
129
|
+
def _emit(self, event_lists: list[list[dict[str, object]]]) -> None:
|
|
130
|
+
"""Flatten queued per-span event-lists into one batch and emit, retrying with backoff.
|
|
131
|
+
|
|
132
|
+
A failing ``sink.emit`` is retried up to ``max_retries`` times; past that the batch is
|
|
133
|
+
abandoned with a counted warning and draining continues, so a broken sink never crashes
|
|
134
|
+
the worker thread or the app (FR-002, FR-003).
|
|
135
|
+
"""
|
|
136
|
+
batch = [event for events in event_lists for event in events]
|
|
137
|
+
if not batch:
|
|
138
|
+
return
|
|
139
|
+
for attempt in range(self.max_retries + 1):
|
|
140
|
+
try:
|
|
141
|
+
self.sink.emit(batch)
|
|
142
|
+
return
|
|
143
|
+
except Exception: # noqa: BLE001 — any sink failure must not kill the worker thread
|
|
144
|
+
if attempt >= self.max_retries:
|
|
145
|
+
self.failed_batches += 1
|
|
146
|
+
sys.stderr.write(
|
|
147
|
+
f"log-forge: abandoned a batch of {len(batch)} event(s) after "
|
|
148
|
+
f"{self.max_retries + 1} failed emit attempts\n"
|
|
149
|
+
)
|
|
150
|
+
return
|
|
151
|
+
# Backoff between attempts; _stop.wait returns at once during shutdown, so a
|
|
152
|
+
# failing sink can't stall the drain past max_retries quick tries.
|
|
153
|
+
self._stop.wait(min(0.01 * (2**attempt), 0.5))
|