log-foundry 0.10.2.dev22__tar.gz → 0.10.2.dev23__tar.gz

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 (55) hide show
  1. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/PKG-INFO +1 -1
  2. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/pyproject.toml +1 -1
  3. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/_socket.py +25 -10
  4. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/base.py +19 -0
  5. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/file.py +56 -23
  6. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/sqlite.py +26 -13
  7. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/LICENSE +0 -0
  8. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/README.md +0 -0
  9. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/__init__.py +0 -0
  10. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/_diag.py +0 -0
  11. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/api.py +0 -0
  12. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/config.py +0 -0
  13. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/console.py +0 -0
  14. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/context.py +0 -0
  15. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/decorator.py +0 -0
  16. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/ids.py +0 -0
  17. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/model.py +0 -0
  18. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/py.typed +0 -0
  19. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sanitize.py +0 -0
  20. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/__init__.py +0 -0
  21. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/_batch.py +0 -0
  22. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/_chunk.py +0 -0
  23. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/_retry.py +0 -0
  24. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/_time.py +0 -0
  25. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/callback.py +0 -0
  26. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/clickhouse.py +0 -0
  27. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/datadog.py +0 -0
  28. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/elasticsearch.py +0 -0
  29. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/eventhubs.py +0 -0
  30. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/filtering.py +0 -0
  31. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/firehose.py +0 -0
  32. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/honeycomb.py +0 -0
  33. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/http.py +0 -0
  34. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/kafka.py +0 -0
  35. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/kinesis.py +0 -0
  36. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/logging_sink.py +0 -0
  37. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/logstash.py +0 -0
  38. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/loki.py +0 -0
  39. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/mongodb.py +0 -0
  40. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/multi.py +0 -0
  41. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/nats.py +0 -0
  42. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/newrelic.py +0 -0
  43. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/postgres.py +0 -0
  44. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/pubsub.py +0 -0
  45. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/rabbitmq.py +0 -0
  46. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/redis.py +0 -0
  47. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/sentry.py +0 -0
  48. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/sns.py +0 -0
  49. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/splunk.py +0 -0
  50. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/sqs.py +0 -0
  51. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/stdout.py +0 -0
  52. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/syslog.py +0 -0
  53. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/transform.py +0 -0
  54. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/sinks/util.py +0 -0
  55. {log_foundry-0.10.2.dev22 → log_foundry-0.10.2.dev23}/src/log_foundry/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev22
3
+ Version: 0.10.2.dev23
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.10.2.dev22"
23
+ version = "0.10.2.dev23"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -3,10 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import socket
6
- from typing import TYPE_CHECKING
7
-
8
- if TYPE_CHECKING:
9
- import threading
6
+ import threading
10
7
 
11
8
  from log_foundry import _diag
12
9
  from log_foundry.sinks._retry import wait
@@ -64,6 +61,14 @@ class SocketTransport:
64
61
  100-message batch against a dead destination is roughly 70 s of backoff on the single drain
65
62
  thread at the defaults. The wait is interruptible, so ``shutdown()`` cuts it short.
66
63
 
64
+ Sends are serialized on a lock (SPEC-028 FR-002). One TCP connection is shared by every
65
+ caller, and ``sendall`` gives no atomicity against a concurrent one: two interleaved calls
66
+ splice their bytes into the stream, which turns octet-counted syslog framing into a sequence
67
+ the receiver cannot resynchronize — it reads the next frame's length from the middle of the
68
+ previous frame's payload and is lost for the life of the connection. That the lock is held
69
+ across the backoff waits is deliberate; they are interruptible, so a ``shutdown()`` releases
70
+ it promptly.
71
+
67
72
  Attributes:
68
73
  failed: Messages abandoned past the reconnect-retry bound.
69
74
  """
@@ -104,10 +109,15 @@ class SocketTransport:
104
109
  self._sock: socket.socket | None = None
105
110
  self.failed = 0
106
111
  self.stop_signal: threading.Event | None = None
112
+ self._lock = threading.Lock()
107
113
 
108
114
  def send_all(self, messages: list[bytes]) -> None:
109
115
  """Sends each pre-framed message, reconnecting on error (FR-005, FR-006).
110
116
 
117
+ The lock spans the whole call, not each message: it also guards ``_sock``, which a
118
+ reconnect rebinds, so releasing between messages would let another thread send on a
119
+ socket this one is about to reset (SPEC-028 FR-002).
120
+
111
121
  Args:
112
122
  messages: The exact bytes to put on the wire, one call per message.
113
123
 
@@ -121,10 +131,11 @@ class SocketTransport:
121
131
  would re-send the messages that already landed, and an empty call is a no-op rather
122
132
  than a total failure.
123
133
  """
124
- delivered = 0
125
- for message in messages:
126
- if self._send_one(message):
127
- delivered += 1
134
+ with self._lock:
135
+ delivered = 0
136
+ for message in messages:
137
+ if self._send_one(message):
138
+ delivered += 1
128
139
  if messages and delivered == 0:
129
140
  raise SinkDeliveryError(
130
141
  f"SocketTransport delivered none of {len(messages)} message(s)"
@@ -147,7 +158,10 @@ class SocketTransport:
147
158
  def close(self) -> None:
148
159
  """Closes the held socket, if any (FR-005, FR-012).
149
160
 
150
- Idempotent.
161
+ Idempotent, and takes the send lock so it never closes the socket out from under an
162
+ in-flight ``send_all`` (SPEC-028 FR-002). ``_reset`` does not take the lock itself,
163
+ because ``_send_one`` calls it while ``send_all`` already holds one — the lock is
164
+ deliberately not re-entrant, so that path must stay the only unlocked caller.
151
165
 
152
166
  Args:
153
167
  None.
@@ -158,7 +172,8 @@ class SocketTransport:
158
172
  Raises:
159
173
  None.
160
174
  """
161
- self._reset()
175
+ with self._lock:
176
+ self._reset()
162
177
 
163
178
  def _send_one(self, message: bytes) -> bool:
164
179
  """Sends one message within the retry bound.
@@ -62,6 +62,18 @@ class Sink(Protocol):
62
62
  def emit(self, batch: list[dict[str, object]]) -> None:
63
63
  """Ships a batch of serialized event dicts.
64
64
 
65
+ **May be called concurrently from more than one thread, and must tolerate it**
66
+ (SPEC-028 FR-001). The background worker drains on its own thread, and a level call
67
+ made with no active span emits synchronously on the *caller's* thread (arch §12), which
68
+ may be any of the application's — an audit observed one sink object entered by two
69
+ application threads and the worker at once. An implementation holding mutable transport
70
+ state, such as a stream it rebinds, a reused socket or a connection with transaction
71
+ scope, must serialize access to it for the whole span of the operation that assumes
72
+ exclusivity. One that holds no such state need do nothing.
73
+
74
+ This is a requirement on implementations, not a promise the library serializes on their
75
+ behalf. It cannot: the orphan path runs on a thread the library does not own.
76
+
65
77
  Raise when the batch delivered nothing and it was non-empty — the worker's bounded
66
78
  retry and ``health().failed_batches`` depend on that signal, and a retry there cannot
67
79
  duplicate anything (SPEC-026 FR-001). Raise after the sink's own retries are spent, so
@@ -88,6 +100,13 @@ class Sink(Protocol):
88
100
  def close(self) -> None:
89
101
  """Flushes and releases any resources.
90
102
 
103
+ **May be called while an ``emit`` is in flight on another thread** (SPEC-028 FR-001).
104
+ It must therefore either wait for that emit or become a no-op, and must never release a
105
+ resource a concurrent ``emit`` is about to use — a half-released transport is worse than
106
+ an unreleased one, because the emit then fails against a closed handle instead of
107
+ succeeding. Taking the same lock ``emit`` takes satisfies this; so does an idempotent
108
+ guard checked under that lock.
109
+
91
110
  Args:
92
111
  None.
93
112
 
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ import threading
7
8
  import time
8
9
  from typing import TextIO
9
10
 
@@ -22,8 +23,12 @@ class FileSink:
22
23
 
23
24
  Not every deployment ships to a cloud queue; local dev, debugging, air-gapped hosts and
24
25
  simple archival just want events on the local disk. Writes are synchronous stdlib calls
25
- only, and a single-process, single-worker-thread writer is assumed (arch §9) — cross-process
26
- coordination is out of scope.
26
+ only.
27
+
28
+ Writers within the process are serialized on a lock, because ``emit`` may be called
29
+ concurrently (SPEC-028 FR-002) — this module claimed a single worker thread until that spec
30
+ measured the orphan path emitting on application threads at the same time. Cross-*process*
31
+ coordination remains out of scope: two processes appending to one path are on their own.
27
32
  """
28
33
 
29
34
  def __init__(self, path: str, *, encoding: str = "utf-8") -> None:
@@ -47,10 +52,16 @@ class FileSink:
47
52
  self._encoding = encoding
48
53
  self._stream: TextIO = open(path, "a", encoding=encoding)
49
54
  self._closed = False
55
+ self._lock = threading.Lock()
50
56
 
51
57
  def emit(self, batch: list[dict[str, object]]) -> None:
52
58
  """Writes every event as one newline-terminated ``json.dumps`` line, then flushes.
53
59
 
60
+ The lock covers the whole batch rather than each line (SPEC-028 FR-002). A text stream
61
+ does not promise that one ``write`` is atomic against another, so per-line locking could
62
+ still interleave two events' bytes; batch-wide locking also keeps a batch contiguous in
63
+ the file, which is what makes the output readable.
64
+
54
65
  Args:
55
66
  batch: The events to write.
56
67
 
@@ -60,13 +71,17 @@ class FileSink:
60
71
  Raises:
61
72
  OSError: If the write or flush fails (FR-001).
62
73
  """
63
- for event in batch:
64
- self._stream.write(json.dumps(event) + "\n")
65
- self._stream.flush()
74
+ with self._lock:
75
+ for event in batch:
76
+ self._stream.write(json.dumps(event) + "\n")
77
+ self._stream.flush()
66
78
 
67
79
  def close(self) -> None:
68
80
  """Flushes and closes the file handle, with a second call a no-op (FR-001).
69
81
 
82
+ Taking the same lock ``emit`` takes means a close waits for an in-flight write rather
83
+ than pulling the stream out from under it (SPEC-028 FR-002).
84
+
70
85
  Args:
71
86
  None.
72
87
 
@@ -76,11 +91,12 @@ class FileSink:
76
91
  Raises:
77
92
  OSError: If the flush or close fails.
78
93
  """
79
- if self._closed:
80
- return
81
- self._stream.flush()
82
- self._stream.close()
83
- self._closed = True
94
+ with self._lock:
95
+ if self._closed:
96
+ return
97
+ self._stream.flush()
98
+ self._stream.close()
99
+ self._closed = True
84
100
 
85
101
 
86
102
  class RotatingFileSink:
@@ -95,6 +111,11 @@ class RotatingFileSink:
95
111
  count, and opens a fresh active file — a backup count of zero keeps none, simply replacing
96
112
  the active file. No event is lost across a rotation, because the rotate happens before the
97
113
  pending event is written and the event lands in the fresh file.
114
+
115
+ A rotation rebinds the active stream, so it is the sink where concurrent writers did real
116
+ damage: a second thread mid-``emit`` could write to the handle rotation had just closed, or
117
+ to the pre-rotation file it had already renamed away. Both are serialized on a lock
118
+ (SPEC-028 FR-002).
98
119
  """
99
120
 
100
121
  def __init__(
@@ -136,10 +157,16 @@ class RotatingFileSink:
136
157
  self._size = os.path.getsize(path) if os.path.exists(path) else 0
137
158
  self._next_rollover = self._schedule_next()
138
159
  self._closed = False
160
+ self._lock = threading.Lock()
139
161
 
140
162
  def emit(self, batch: list[dict[str, object]]) -> None:
141
163
  """Appends each event, rotating first whenever a size or time trigger fires (FR-002).
142
164
 
165
+ The lock spans the whole batch, so the decide-rotate-write-account sequence is
166
+ indivisible (SPEC-028 FR-002). Guarding only ``_rotate`` would not be enough: the
167
+ ``_should_rotate`` check and the write that follows it must see the same stream, or a
168
+ rotation between them sends the line to a closed handle.
169
+
143
170
  Args:
144
171
  batch: The events to write.
145
172
 
@@ -149,18 +176,23 @@ class RotatingFileSink:
149
176
  Raises:
150
177
  OSError: If a write, flush or rotation fails.
151
178
  """
152
- for event in batch:
153
- line = json.dumps(event) + "\n"
154
- data = len(line.encode(self._encoding))
155
- if self._should_rotate(data):
156
- self._rotate()
157
- self._stream.write(line)
158
- self._size += data
159
- self._stream.flush()
179
+ with self._lock:
180
+ for event in batch:
181
+ line = json.dumps(event) + "\n"
182
+ data = len(line.encode(self._encoding))
183
+ if self._should_rotate(data):
184
+ self._rotate()
185
+ self._stream.write(line)
186
+ self._size += data
187
+ self._stream.flush()
160
188
 
161
189
  def close(self) -> None:
162
190
  """Flushes and closes the active handle, with a second call a no-op (FR-002).
163
191
 
192
+ Taking the same lock ``emit`` takes means a close waits for an in-flight write, and in
193
+ particular never lands between a rotation and the write it was making room for
194
+ (SPEC-028 FR-002).
195
+
164
196
  Args:
165
197
  None.
166
198
 
@@ -170,11 +202,12 @@ class RotatingFileSink:
170
202
  Raises:
171
203
  OSError: If the flush or close fails.
172
204
  """
173
- if self._closed:
174
- return
175
- self._stream.flush()
176
- self._stream.close()
177
- self._closed = True
205
+ with self._lock:
206
+ if self._closed:
207
+ return
208
+ self._stream.flush()
209
+ self._stream.close()
210
+ self._closed = True
178
211
 
179
212
  @staticmethod
180
213
  def _rollover_seconds(when: str | None, interval: int) -> float | None:
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import sqlite3
7
+ import threading
7
8
 
8
9
  from log_foundry.sinks._chunk import valid_identifier
9
10
 
@@ -18,8 +19,14 @@ class SQLiteSink:
18
19
  For local dev, debugging, air-gapped hosts or simple archival, an embedded SQLite file is a
19
20
  durable sink you can open later and query with plain SQL. Each event is stored as its full
20
21
  JSON — the source of truth — plus a few columns projected out for cheap filtering, which are
21
- ``NULL`` when absent. Standard library only, and a single-process, single-worker-thread
22
- writer is assumed (arch §9).
22
+ ``NULL`` when absent. Standard library only.
23
+
24
+ The driver requirement satisfied (SPEC-028 FR-002): ``check_same_thread=False`` switches off
25
+ ``sqlite3``'s own same-thread guard, and ``with connection`` is a transaction scope on the
26
+ shared connection rather than a per-caller one — two threads inside it at once share a single
27
+ implicit transaction, so one thread's rollback discards rows the other had already inserted.
28
+ A lock restores the one-writer-at-a-time the guard used to enforce. Cross-*process* writers
29
+ to one database file remain out of scope.
23
30
  """
24
31
 
25
32
  def __init__(
@@ -33,8 +40,9 @@ class SQLiteSink:
33
40
  """Connects to the database and, by default, provisions the schema.
34
41
 
35
42
  The connection is opened with ``check_same_thread=False``: the background worker is a
36
- different thread from the one that ran ``configure()`` and is the sole writer, so
37
- SQLite's same-thread guard would only get in the way.
43
+ different thread from the one that ran ``configure()``, so SQLite's same-thread guard
44
+ would reject every insert. It is not the sole writer — the orphan path emits on the
45
+ caller's thread (SPEC-028) — which is why the lock below replaces what the guard gave up.
38
46
 
39
47
  Args:
40
48
  database: The database file to open, ignored when a connection is injected.
@@ -61,6 +69,7 @@ class SQLiteSink:
61
69
  else sqlite3.connect(database, check_same_thread=False)
62
70
  )
63
71
  self._closed = False
72
+ self._lock = threading.Lock()
64
73
  if create_table:
65
74
  self._ensure_schema()
66
75
 
@@ -68,7 +77,9 @@ class SQLiteSink:
68
77
  """Inserts every event in one transaction (FR-003).
69
78
 
70
79
  ``with connection`` opens a transaction and commits on success or rolls back on error,
71
- so the whole batch lands atomically.
80
+ so the whole batch lands atomically. The lock holds that transaction to one thread
81
+ (SPEC-028 FR-002); the row-building above it needs no protection, but sits inside for
82
+ simplicity and costs nothing an uncontended lock does not.
72
83
 
73
84
  Args:
74
85
  batch: The events to insert.
@@ -84,7 +95,7 @@ class SQLiteSink:
84
95
  ]
85
96
  placeholders = ", ".join("?" * (len(_COLUMNS) + 1))
86
97
  columns = ", ".join((*_COLUMNS, "event"))
87
- with self._conn:
98
+ with self._lock, self._conn:
88
99
  self._conn.executemany(
89
100
  f'INSERT INTO "{self._table}" ({columns}) VALUES ({placeholders})', rows
90
101
  )
@@ -92,7 +103,8 @@ class SQLiteSink:
92
103
  def close(self) -> None:
93
104
  """Commits pending work and closes only a connection the sink owns (FR-003).
94
105
 
95
- Idempotent.
106
+ Idempotent, and takes the emit lock so it never closes the connection out from under an
107
+ in-flight transaction (SPEC-028 FR-002).
96
108
 
97
109
  Args:
98
110
  None.
@@ -103,12 +115,13 @@ class SQLiteSink:
103
115
  Raises:
104
116
  sqlite3.Error: If the commit or close fails.
105
117
  """
106
- if self._closed:
107
- return
108
- self._conn.commit()
109
- if self._owns_connection:
110
- self._conn.close()
111
- self._closed = True
118
+ with self._lock:
119
+ if self._closed:
120
+ return
121
+ self._conn.commit()
122
+ if self._owns_connection:
123
+ self._conn.close()
124
+ self._closed = True
112
125
 
113
126
  def _ensure_schema(self) -> None:
114
127
  """Idempotently creates the target table.