log-foundry 0.10.2.dev23__tar.gz → 0.10.2.dev24__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.dev23 → log_foundry-0.10.2.dev24}/PKG-INFO +28 -10
  2. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/README.md +27 -9
  3. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/_socket.py +5 -2
  5. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/base.py +9 -0
  6. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/clickhouse.py +29 -15
  7. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/elasticsearch.py +9 -6
  8. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/eventhubs.py +9 -7
  9. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/firehose.py +14 -11
  10. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/http.py +6 -3
  11. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/kafka.py +8 -3
  12. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/kinesis.py +14 -11
  13. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/mongodb.py +21 -8
  14. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/multi.py +6 -4
  15. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/nats.py +6 -2
  16. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/postgres.py +45 -13
  17. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/pubsub.py +8 -3
  18. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/rabbitmq.py +7 -6
  19. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/redis.py +7 -6
  20. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/sentry.py +11 -8
  21. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/sns.py +9 -7
  22. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/sqs.py +11 -8
  23. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/util.py +4 -1
  24. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/LICENSE +0 -0
  25. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/__init__.py +0 -0
  26. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/_diag.py +0 -0
  27. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/api.py +0 -0
  28. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/config.py +0 -0
  29. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/console.py +0 -0
  30. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/context.py +0 -0
  31. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/decorator.py +0 -0
  32. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/ids.py +0 -0
  33. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/model.py +0 -0
  34. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/py.typed +0 -0
  35. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sanitize.py +0 -0
  36. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/__init__.py +0 -0
  37. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/_batch.py +0 -0
  38. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/_chunk.py +0 -0
  39. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/_retry.py +0 -0
  40. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/_time.py +0 -0
  41. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/callback.py +0 -0
  42. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/datadog.py +0 -0
  43. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/file.py +0 -0
  44. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/filtering.py +0 -0
  45. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/honeycomb.py +0 -0
  46. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/logging_sink.py +0 -0
  47. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/logstash.py +0 -0
  48. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/loki.py +0 -0
  49. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/newrelic.py +0 -0
  50. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/splunk.py +0 -0
  51. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/sqlite.py +0 -0
  52. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/stdout.py +0 -0
  53. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/syslog.py +0 -0
  54. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/src/log_foundry/sinks/transform.py +0 -0
  55. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev24}/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.dev23
3
+ Version: 0.10.2.dev24
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -669,7 +669,16 @@ in `CallbackSink`.
669
669
  #### Writing your own sink
670
670
 
671
671
  `Sink` is two required methods, `emit(batch)` and `close()`, plus two rules about *how* `emit`
672
- fails. They are not stylistic — the library's whole loss-reporting apparatus is built on them:
672
+ fails and one about *when* it is called. They are not stylistic — the library's whole
673
+ loss-reporting apparatus is built on them:
674
+
675
+ - **Tolerate concurrent calls.** `emit` may run on more than one thread at once, and `close` may
676
+ be called while an `emit` is in flight. The worker drains on its own thread, but a level call
677
+ made with no active span emits synchronously on the *caller's* thread — which is any thread of
678
+ your application. If your sink holds mutable transport state (a stream it rebinds, a socket it
679
+ reuses, a connection with transaction scope), guard it with a `threading.Lock` held for the
680
+ whole operation that assumes exclusivity. If it holds none, you need do nothing. The library
681
+ cannot serialize this for you: it does not own the calling thread.
673
682
 
674
683
  - **Raise when you delivered none of the batch**, after your own retries are spent. That is the
675
684
  signal the worker's bounded retry and `health().failed_batches` depend on, and the one case where
@@ -685,29 +694,38 @@ never engages, `failed_batches` stays at zero, and `flush()` returns `True` whil
685
694
  lost.
686
695
 
687
696
  Optionally add `losses()` to report what you absorbed. It must never raise and must be safe to call
688
- while `emit` is running (`health()` is a poll):
697
+ while `emit` is running (`health()` is a poll) — which means the counters need their own lock, kept
698
+ separate from the transport one so a poll never waits on an in-flight send:
689
699
 
690
700
  ```python
701
+ import threading
691
702
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
692
703
 
693
704
  class MySink:
694
705
  def __init__(self) -> None:
695
706
  self._dropped = self._failed = 0
707
+ self._lock = threading.Lock() # transport state
708
+ self._counter_lock = threading.Lock() # counters only, never held across I/O
696
709
 
697
710
  def emit(self, batch: list[dict[str, object]]) -> None:
698
711
  delivered = 0
699
- for chunk in self._chunks(batch):
700
- if self._send(chunk): # your own bounded retry
701
- delivered += len(chunk)
702
- else:
703
- self._failed += len(chunk)
712
+ with self._lock: # your connection, socket or stream
713
+ for chunk in self._chunks(batch):
714
+ if self._send(chunk): # your own bounded retry
715
+ delivered += len(chunk)
716
+ else:
717
+ with self._counter_lock: # transport -> counter, never the reverse
718
+ self._failed += len(chunk)
704
719
  if batch and not delivered:
705
720
  raise SinkDeliveryError(f"MySink delivered none of {len(batch)} event(s)")
706
721
 
707
722
  def losses(self) -> SinkLosses:
708
- return SinkLosses(dropped=self._dropped, failed=self._failed)
723
+ with self._counter_lock: # both fields from one instant
724
+ return SinkLosses(dropped=self._dropped, failed=self._failed)
709
725
 
710
- def close(self) -> None: ...
726
+ def close(self) -> None:
727
+ with self._lock: # never release under an active writer
728
+ ...
711
729
  ```
712
730
 
713
731
  `losses()` is optional and probed by name, so a sink written before it existed keeps working and
@@ -633,7 +633,16 @@ in `CallbackSink`.
633
633
  #### Writing your own sink
634
634
 
635
635
  `Sink` is two required methods, `emit(batch)` and `close()`, plus two rules about *how* `emit`
636
- fails. They are not stylistic — the library's whole loss-reporting apparatus is built on them:
636
+ fails and one about *when* it is called. They are not stylistic — the library's whole
637
+ loss-reporting apparatus is built on them:
638
+
639
+ - **Tolerate concurrent calls.** `emit` may run on more than one thread at once, and `close` may
640
+ be called while an `emit` is in flight. The worker drains on its own thread, but a level call
641
+ made with no active span emits synchronously on the *caller's* thread — which is any thread of
642
+ your application. If your sink holds mutable transport state (a stream it rebinds, a socket it
643
+ reuses, a connection with transaction scope), guard it with a `threading.Lock` held for the
644
+ whole operation that assumes exclusivity. If it holds none, you need do nothing. The library
645
+ cannot serialize this for you: it does not own the calling thread.
637
646
 
638
647
  - **Raise when you delivered none of the batch**, after your own retries are spent. That is the
639
648
  signal the worker's bounded retry and `health().failed_batches` depend on, and the one case where
@@ -649,29 +658,38 @@ never engages, `failed_batches` stays at zero, and `flush()` returns `True` whil
649
658
  lost.
650
659
 
651
660
  Optionally add `losses()` to report what you absorbed. It must never raise and must be safe to call
652
- while `emit` is running (`health()` is a poll):
661
+ while `emit` is running (`health()` is a poll) — which means the counters need their own lock, kept
662
+ separate from the transport one so a poll never waits on an in-flight send:
653
663
 
654
664
  ```python
665
+ import threading
655
666
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
656
667
 
657
668
  class MySink:
658
669
  def __init__(self) -> None:
659
670
  self._dropped = self._failed = 0
671
+ self._lock = threading.Lock() # transport state
672
+ self._counter_lock = threading.Lock() # counters only, never held across I/O
660
673
 
661
674
  def emit(self, batch: list[dict[str, object]]) -> None:
662
675
  delivered = 0
663
- for chunk in self._chunks(batch):
664
- if self._send(chunk): # your own bounded retry
665
- delivered += len(chunk)
666
- else:
667
- self._failed += len(chunk)
676
+ with self._lock: # your connection, socket or stream
677
+ for chunk in self._chunks(batch):
678
+ if self._send(chunk): # your own bounded retry
679
+ delivered += len(chunk)
680
+ else:
681
+ with self._counter_lock: # transport -> counter, never the reverse
682
+ self._failed += len(chunk)
668
683
  if batch and not delivered:
669
684
  raise SinkDeliveryError(f"MySink delivered none of {len(batch)} event(s)")
670
685
 
671
686
  def losses(self) -> SinkLosses:
672
- return SinkLosses(dropped=self._dropped, failed=self._failed)
687
+ with self._counter_lock: # both fields from one instant
688
+ return SinkLosses(dropped=self._dropped, failed=self._failed)
673
689
 
674
- def close(self) -> None: ...
690
+ def close(self) -> None:
691
+ with self._lock: # never release under an active writer
692
+ ...
675
693
  ```
676
694
 
677
695
  `losses()` is optional and probed by name, so a sink written before it existed keeps working and
@@ -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.dev23"
23
+ version = "0.10.2.dev24"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -108,6 +108,7 @@ class SocketTransport:
108
108
  self._max_retries = max(max_retries, 0)
109
109
  self._sock: socket.socket | None = None
110
110
  self.failed = 0
111
+ self._counter_lock = threading.Lock()
111
112
  self.stop_signal: threading.Event | None = None
112
113
  self._lock = threading.Lock()
113
114
 
@@ -153,7 +154,8 @@ class SocketTransport:
153
154
  Raises:
154
155
  None.
155
156
  """
156
- return SinkLosses(dropped=0, failed=self.failed)
157
+ with self._counter_lock:
158
+ return SinkLosses(dropped=0, failed=self.failed)
157
159
 
158
160
  def close(self) -> None:
159
161
  """Closes the held socket, if any (FR-005, FR-012).
@@ -205,7 +207,8 @@ class SocketTransport:
205
207
  if attempt < self._max_retries:
206
208
  wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
207
209
  continue
208
- self.failed += 1
210
+ with self._counter_lock:
211
+ self.failed += 1
209
212
  _diag.lost(
210
213
  "message",
211
214
  1,
@@ -57,6 +57,15 @@ class Sink(Protocol):
57
57
  ``None`` is the same answer as having no method at all, which is what keeps a third-party
58
58
  sink written against the pre-SPEC-026 interface satisfying this one. :func:`read_losses` is
59
59
  the probe.
60
+
61
+ "Safe to call during an emit" is a concurrency requirement once :meth:`emit` is (SPEC-028
62
+ FR-003). The shipped sinks keep their loss counters under a **dedicated** lock, separate
63
+ from whatever guards their transport: an increment is a read-modify-write that Python does
64
+ not promise is atomic, and the pair read must come from one instant rather than straddling
65
+ another thread's increments. It is deliberately not the transport lock — ``health()`` is
66
+ the call an operator makes when a destination is already hanging, and sharing one lock
67
+ would make that poll wait for an in-flight emit and its retry backoff. Where a sink holds
68
+ both, the order is always transport then counter, never the reverse.
60
69
  """
61
70
 
62
71
  def emit(self, batch: list[dict[str, object]]) -> None:
@@ -3,10 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from typing import TYPE_CHECKING, Any
7
-
8
- if TYPE_CHECKING:
9
- import threading
6
+ import threading
7
+ from typing import Any
10
8
 
11
9
  from log_foundry import _diag
12
10
  from log_foundry.sinks._chunk import chunk_list, valid_identifier
@@ -42,6 +40,12 @@ class ClickHouseSink:
42
40
  columns plus the full event as a ``String`` column, inserted in a single call per chunk. The
43
41
  sink is write-only, and the worst-case delay (SPEC-027 FR-005) is ``max_retries``
44
42
  interruptible waits per chunk, 0.7 s at the defaults.
43
+
44
+ The driver requirement satisfied (SPEC-028 FR-002): a ``clickhouse-connect`` client holds
45
+ per-session state across an insert and the project does not publish it as safe to share
46
+ between threads, so this sink serializes its use rather than assuming otherwise. A lock is
47
+ the conservative reading — one client per thread would be the alternative, and that is the
48
+ connection-pool design FR-002 puts out of scope.
45
49
  """
46
50
 
47
51
  def __init__(
@@ -81,6 +85,8 @@ class ClickHouseSink:
81
85
  self.stop_signal: threading.Event | None = None
82
86
  self.failed = 0
83
87
  self._closed = False
88
+ self._lock = threading.Lock()
89
+ self._counter_lock = threading.Lock()
84
90
  self._owns_client = client is None
85
91
  if client is None:
86
92
  import clickhouse_connect # type: ignore[import-not-found]
@@ -93,6 +99,9 @@ class ClickHouseSink:
93
99
  def losses(self) -> SinkLosses:
94
100
  """Reports rows in a chunk abandoned past the retry bound (SPEC-026 FR-002).
95
101
 
102
+ Reads under the counter lock rather than the emit lock (SPEC-028 FR-003), so a poll
103
+ never waits on an in-flight insert and its backoff.
104
+
96
105
  Args:
97
106
  None.
98
107
 
@@ -102,7 +111,8 @@ class ClickHouseSink:
102
111
  Raises:
103
112
  None.
104
113
  """
105
- return SinkLosses(dropped=0, failed=self.failed)
114
+ with self._counter_lock:
115
+ return SinkLosses(dropped=0, failed=self.failed)
106
116
 
107
117
  def emit(self, batch: list[dict[str, object]]) -> None:
108
118
  """Inserts each chunk as one columnar call, retrying on failure (FR-002).
@@ -122,16 +132,18 @@ class ClickHouseSink:
122
132
  if not batch:
123
133
  return
124
134
  chunks = inserted = 0
125
- for chunk in chunk_list(batch, self._chunk_size):
126
- chunks += 1
127
- inserted += self._insert([self._row(event) for event in chunk])
135
+ with self._lock:
136
+ for chunk in chunk_list(batch, self._chunk_size):
137
+ chunks += 1
138
+ inserted += self._insert([self._row(event) for event in chunk])
128
139
  if chunks and not inserted:
129
140
  raise SinkDeliveryError(f"ClickHouseSink inserted none of {chunks} chunk(s)")
130
141
 
131
142
  def close(self) -> None:
132
143
  """Closes the client only if the sink owns it (FR-005).
133
144
 
134
- Idempotent.
145
+ Idempotent, and takes the emit lock so the client is never closed mid-insert
146
+ (SPEC-028 FR-002).
135
147
 
136
148
  Args:
137
149
  None.
@@ -142,11 +154,12 @@ class ClickHouseSink:
142
154
  Raises:
143
155
  Exception: Whatever the client raises on close.
144
156
  """
145
- if self._closed:
146
- return
147
- if self._owns_client:
148
- self.client.close()
149
- self._closed = True
157
+ with self._lock:
158
+ if self._closed:
159
+ return
160
+ if self._owns_client:
161
+ self.client.close()
162
+ self._closed = True
150
163
 
151
164
  def _row(self, event: dict[str, object]) -> list[object]:
152
165
  """Builds one row: the extracted columns, then the whole event as JSON.
@@ -182,7 +195,8 @@ class ClickHouseSink:
182
195
  if attempt < self.max_retries:
183
196
  wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
184
197
  continue
185
- self.failed += len(rows)
198
+ with self._counter_lock:
199
+ self.failed += len(rows)
186
200
  _diag.lost(
187
201
  "row",
188
202
  len(rows),
@@ -98,10 +98,11 @@ class ElasticsearchSink(HTTPSink):
98
98
  Raises:
99
99
  None.
100
100
  """
101
- return SinkLosses(
102
- dropped=self.dropped_oversized,
103
- failed=self.failed + self.item_errors + self.dropped_unadjudicated,
104
- )
101
+ with self._counter_lock:
102
+ return SinkLosses(
103
+ dropped=self.dropped_oversized,
104
+ failed=self.failed + self.item_errors + self.dropped_unadjudicated,
105
+ )
105
106
 
106
107
  def _parse_bulk_response(self, payload: bytes, sent: int) -> bool:
107
108
  """Counts rejected items and reports whether the response proves nothing was indexed.
@@ -139,7 +140,8 @@ class ElasticsearchSink(HTTPSink):
139
140
  items = usable_results(data.get("items"))
140
141
  errors = sum(1 for item in items if _has_error(item))
141
142
  if len(items) != sent or not errors:
142
- self.dropped_unadjudicated += sent
143
+ with self._counter_lock:
144
+ self.dropped_unadjudicated += sent
143
145
  _diag.lost(
144
146
  "event",
145
147
  sent,
@@ -148,7 +150,8 @@ class ElasticsearchSink(HTTPSink):
148
150
  f"not retried",
149
151
  )
150
152
  return False
151
- self.item_errors += errors
153
+ with self._counter_lock:
154
+ self.item_errors += errors
152
155
  _diag.lost("bulk item", errors, f"{type(self).__name__}, rejected by the server")
153
156
  return errors == sent
154
157
 
@@ -3,10 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from typing import TYPE_CHECKING, Any
7
-
8
- if TYPE_CHECKING:
9
- import threading
6
+ import threading
7
+ from typing import Any
10
8
 
11
9
  from log_foundry import _diag
12
10
  from log_foundry.sinks._retry import wait
@@ -69,6 +67,7 @@ class AzureEventHubsSink:
69
67
  self.stop_signal: threading.Event | None = None
70
68
  self.failed = 0
71
69
  self.dropped_oversized = 0
70
+ self._counter_lock = threading.Lock()
72
71
 
73
72
  def losses(self) -> SinkLosses:
74
73
  """Reports oversized drops and events in a batch abandoned past the bound (FR-002).
@@ -82,7 +81,8 @@ class AzureEventHubsSink:
82
81
  Raises:
83
82
  None.
84
83
  """
85
- return SinkLosses(dropped=self.dropped_oversized, failed=self.failed)
84
+ with self._counter_lock:
85
+ return SinkLosses(dropped=self.dropped_oversized, failed=self.failed)
86
86
 
87
87
  def emit(self, batch: list[dict[str, object]]) -> None:
88
88
  """Packs events into batches within the size limit and sends each (FR-009).
@@ -118,7 +118,8 @@ class AzureEventHubsSink:
118
118
  delivered += self._send(current)
119
119
  current = self.producer.create_batch()
120
120
  if not _try_add(current, data):
121
- self.dropped_oversized += 1
121
+ with self._counter_lock:
122
+ self.dropped_oversized += 1
122
123
  _diag.lost("event", 1, "AzureEventHubsSink, too large for an empty 1 MB batch")
123
124
  if len(current) > 0:
124
125
  attempted += 1
@@ -167,7 +168,8 @@ class AzureEventHubsSink:
167
168
  if attempt < self.max_retries:
168
169
  wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
169
170
  continue
170
- self.failed += len(event_batch)
171
+ with self._counter_lock:
172
+ self.failed += len(event_batch)
171
173
  _diag.lost(
172
174
  "event",
173
175
  len(event_batch),
@@ -3,10 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from typing import TYPE_CHECKING, Any
7
-
8
- if TYPE_CHECKING:
9
- import threading
6
+ import threading
7
+ from typing import Any
10
8
 
11
9
  from log_foundry import _diag
12
10
  from log_foundry.sinks._batch import adjudicate_positional, usable_results
@@ -67,6 +65,7 @@ class FirehoseSink:
67
65
  self.failed = 0
68
66
  self.dropped_oversized = 0
69
67
  self.dropped_unadjudicated = 0
68
+ self._counter_lock = threading.Lock()
70
69
 
71
70
  def emit(self, batch: list[dict[str, object]]) -> None:
72
71
  """Re-chunks to the request limits and sends each chunk, retrying failures (FR-004).
@@ -126,10 +125,11 @@ class FirehoseSink:
126
125
  Raises:
127
126
  None.
128
127
  """
129
- return SinkLosses(
130
- dropped=self.dropped_oversized,
131
- failed=self.failed + self.dropped_unadjudicated,
132
- )
128
+ with self._counter_lock:
129
+ return SinkLosses(
130
+ dropped=self.dropped_oversized,
131
+ failed=self.failed + self.dropped_unadjudicated,
132
+ )
133
133
 
134
134
  def close(self) -> None:
135
135
  """Does nothing, since the sink buffers nothing internally (FR-001).
@@ -160,7 +160,8 @@ class FirehoseSink:
160
160
  for event in batch:
161
161
  data = json.dumps(event).encode("utf-8")
162
162
  if len(data) > self.MAX_RECORD_BYTES:
163
- self.dropped_oversized += 1
163
+ with self._counter_lock:
164
+ self.dropped_oversized += 1
164
165
  _diag.lost(
165
166
  "event",
166
167
  1,
@@ -201,7 +202,8 @@ class FirehoseSink:
201
202
  results = usable_results(response.get("RequestResponses"))
202
203
  verdict = adjudicate_positional(records, results)
203
204
  if verdict.unadjudicated:
204
- self.dropped_unadjudicated += verdict.unadjudicated
205
+ with self._counter_lock:
206
+ self.dropped_unadjudicated += verdict.unadjudicated
205
207
  _diag.lost(
206
208
  "record",
207
209
  verdict.unadjudicated,
@@ -216,7 +218,8 @@ class FirehoseSink:
216
218
  if attempt < self.max_retries:
217
219
  wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
218
220
  if attempt >= self.max_retries:
219
- self.failed += len(records)
221
+ with self._counter_lock:
222
+ self.failed += len(records)
220
223
  _diag.lost(
221
224
  "record",
222
225
  len(records),
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import gzip as _gzip
6
6
  import json
7
+ import threading
7
8
  import urllib.error
8
9
  import urllib.request
9
10
  from base64 import b64encode
@@ -14,7 +15,6 @@ from log_foundry.sinks._retry import clamp_server_delay, wait
14
15
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
15
16
 
16
17
  if TYPE_CHECKING:
17
- import threading
18
18
  from collections.abc import Callable
19
19
 
20
20
  __all__ = ["HTTPSink", "merge_headers"]
@@ -129,6 +129,7 @@ class HTTPSink:
129
129
  self._opener = opener if opener is not None else urllib.request.urlopen
130
130
  self.failed = 0
131
131
  self.dropped_oversized = 0
132
+ self._counter_lock = threading.Lock()
132
133
 
133
134
  def emit(self, batch: list[dict[str, object]]) -> None:
134
135
  """Serializes the batch per the configured body format and POSTs it (FR-001).
@@ -161,7 +162,8 @@ class HTTPSink:
161
162
  Raises:
162
163
  None.
163
164
  """
164
- return SinkLosses(dropped=self.dropped_oversized, failed=self.failed)
165
+ with self._counter_lock:
166
+ return SinkLosses(dropped=self.dropped_oversized, failed=self.failed)
165
167
 
166
168
  def close(self) -> None:
167
169
  """Does nothing, since ``urllib`` opens a fresh connection per request (FR-012).
@@ -377,7 +379,8 @@ class HTTPSink:
377
379
  Raises:
378
380
  SinkDeliveryError: Always.
379
381
  """
380
- self.failed += 1
382
+ with self._counter_lock:
383
+ self.failed += 1
381
384
  detail = f"{type(self).__name__}, {self.max_retries + 1} attempt(s), {reason}"
382
385
  _diag.lost("request", 1, detail)
383
386
  raise SinkDeliveryError(detail)
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import threading
6
7
  from typing import Any
7
8
 
8
9
  from log_foundry import _diag
@@ -61,6 +62,7 @@ class KafkaSink:
61
62
  self.key_field = key_field
62
63
  self.failed = 0
63
64
  self.rejected = 0
65
+ self._counter_lock = threading.Lock()
64
66
 
65
67
  def losses(self) -> SinkLosses:
66
68
  """Reports refused and undelivered messages (SPEC-026 FR-002).
@@ -82,7 +84,8 @@ class KafkaSink:
82
84
  Raises:
83
85
  None.
84
86
  """
85
- return SinkLosses(dropped=self.rejected, failed=self.failed)
87
+ with self._counter_lock:
88
+ return SinkLosses(dropped=self.rejected, failed=self.failed)
86
89
 
87
90
  def emit(self, batch: list[dict[str, object]]) -> None:
88
91
  """Produces one message per event and serves delivery callbacks without blocking.
@@ -108,7 +111,8 @@ class KafkaSink:
108
111
  try:
109
112
  self.producer.produce(self.topic, value=body, key=key, callback=self._on_delivery)
110
113
  except Exception as err:
111
- self.rejected += 1
114
+ with self._counter_lock:
115
+ self.rejected += 1
112
116
  _diag.lost("message", 1, f"KafkaSink produce, {type(err).__name__}")
113
117
  continue
114
118
  accepted += 1
@@ -167,7 +171,8 @@ class KafkaSink:
167
171
  None.
168
172
  """
169
173
  if err is not None:
170
- self.failed += 1
174
+ with self._counter_lock:
175
+ self.failed += 1
171
176
  _diag.lost("message", 1, f"KafkaSink delivery, {type(err).__name__}{_code(err)}")
172
177
 
173
178
 
@@ -3,10 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from typing import TYPE_CHECKING, Any
7
-
8
- if TYPE_CHECKING:
9
- import threading
6
+ import threading
7
+ from typing import Any
10
8
 
11
9
  from log_foundry import _diag
12
10
  from log_foundry.sinks._batch import adjudicate_positional, usable_results
@@ -76,6 +74,7 @@ class KinesisSink:
76
74
  self.failed = 0
77
75
  self.dropped_oversized = 0
78
76
  self.dropped_unadjudicated = 0
77
+ self._counter_lock = threading.Lock()
79
78
 
80
79
  def emit(self, batch: list[dict[str, object]]) -> None:
81
80
  """Re-chunks to the request limits and sends each chunk, retrying failures (FR-003).
@@ -135,10 +134,11 @@ class KinesisSink:
135
134
  Raises:
136
135
  None.
137
136
  """
138
- return SinkLosses(
139
- dropped=self.dropped_oversized,
140
- failed=self.failed + self.dropped_unadjudicated,
141
- )
137
+ with self._counter_lock:
138
+ return SinkLosses(
139
+ dropped=self.dropped_oversized,
140
+ failed=self.failed + self.dropped_unadjudicated,
141
+ )
142
142
 
143
143
  def close(self) -> None:
144
144
  """Does nothing, since the sink buffers nothing internally (FR-001).
@@ -169,7 +169,8 @@ class KinesisSink:
169
169
  for event in batch:
170
170
  data = json.dumps(event).encode("utf-8")
171
171
  if len(data) > self.MAX_RECORD_BYTES:
172
- self.dropped_oversized += 1
172
+ with self._counter_lock:
173
+ self.dropped_oversized += 1
173
174
  _diag.lost(
174
175
  "event",
175
176
  1,
@@ -209,7 +210,8 @@ class KinesisSink:
209
210
  results = usable_results(response.get("Records"))
210
211
  verdict = adjudicate_positional(records, results)
211
212
  if verdict.unadjudicated:
212
- self.dropped_unadjudicated += verdict.unadjudicated
213
+ with self._counter_lock:
214
+ self.dropped_unadjudicated += verdict.unadjudicated
213
215
  _diag.lost(
214
216
  "record",
215
217
  verdict.unadjudicated,
@@ -223,7 +225,8 @@ class KinesisSink:
223
225
  if attempt < self.max_retries:
224
226
  wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
225
227
  if attempt >= self.max_retries:
226
- self.failed += len(records)
228
+ with self._counter_lock:
229
+ self.failed += len(records)
227
230
  _diag.lost(
228
231
  "record",
229
232
  len(records),