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