log-foundry 0.10.2.dev16__tar.gz → 0.10.2.dev17__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 (56) hide show
  1. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/PKG-INFO +1 -1
  2. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/pyproject.toml +1 -1
  3. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/_socket.py +30 -6
  4. log_foundry-0.10.2.dev17/src/log_foundry/sinks/base.py +119 -0
  5. log_foundry-0.10.2.dev17/src/log_foundry/sinks/elasticsearch.py +141 -0
  6. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/filtering.py +17 -1
  7. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/http.py +41 -14
  8. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/logstash.py +13 -0
  9. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/multi.py +27 -0
  10. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/sentry.py +64 -5
  11. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/syslog.py +13 -0
  12. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/transform.py +17 -1
  13. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/util.py +4 -0
  14. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/worker.py +21 -1
  15. log_foundry-0.10.2.dev16/src/log_foundry/sinks/base.py +0 -22
  16. log_foundry-0.10.2.dev16/src/log_foundry/sinks/elasticsearch.py +0 -68
  17. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/LICENSE +0 -0
  18. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/README.md +0 -0
  19. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/__init__.py +0 -0
  20. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/_diag.py +0 -0
  21. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/api.py +0 -0
  22. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/config.py +0 -0
  23. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/console.py +0 -0
  24. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/context.py +0 -0
  25. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/decorator.py +0 -0
  26. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/ids.py +0 -0
  27. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/model.py +0 -0
  28. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/py.typed +0 -0
  29. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sanitize.py +0 -0
  30. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/__init__.py +0 -0
  31. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/_batch.py +0 -0
  32. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/_chunk.py +0 -0
  33. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/_time.py +0 -0
  34. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/callback.py +0 -0
  35. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/clickhouse.py +0 -0
  36. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/datadog.py +0 -0
  37. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/eventhubs.py +0 -0
  38. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/file.py +0 -0
  39. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/firehose.py +0 -0
  40. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/honeycomb.py +0 -0
  41. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/kafka.py +0 -0
  42. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/kinesis.py +0 -0
  43. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/logging_sink.py +0 -0
  44. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/loki.py +0 -0
  45. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/mongodb.py +0 -0
  46. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/nats.py +0 -0
  47. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/newrelic.py +0 -0
  48. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/postgres.py +0 -0
  49. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/pubsub.py +0 -0
  50. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/rabbitmq.py +0 -0
  51. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/redis.py +0 -0
  52. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/sns.py +0 -0
  53. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/splunk.py +0 -0
  54. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/sqlite.py +0 -0
  55. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/sqs.py +0 -0
  56. {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/stdout.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev16
3
+ Version: 0.10.2.dev17
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.dev16"
23
+ version = "0.10.2.dev17"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -15,6 +15,7 @@ import socket
15
15
  import time
16
16
 
17
17
  from log_foundry import _diag
18
+ from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
18
19
 
19
20
  __all__ = ["SocketTransport"]
20
21
 
@@ -53,14 +54,35 @@ class SocketTransport:
53
54
  self._port = port
54
55
  self._transport = transport
55
56
  self._timeout = timeout
56
- self._max_retries = max_retries
57
+ # Floored for the reason ``Worker._emit`` floors its own (SPEC-021): a negative value
58
+ # made no attempt at all and abandoned the message without moving ``failed``.
59
+ self._max_retries = max(max_retries, 0)
57
60
  self._sock: socket.socket | None = None
58
61
  self.failed = 0
59
62
 
60
63
  def send_all(self, messages: list[bytes]) -> None:
61
- """Send each pre-framed message, reconnecting on error (FR-005, FR-006)."""
64
+ """Send each pre-framed message, reconnecting on error (FR-005, FR-006).
65
+
66
+ Raises :class:`~log_foundry.sinks.base.SinkDeliveryError` when **none** of the messages
67
+ reached the socket, so the sinks built on this transport propagate a dead destination to
68
+ the worker instead of reporting success (SPEC-026 FR-001). A partial send does not raise:
69
+ the worker's retry would re-send the messages that already landed.
70
+
71
+ An empty call is a no-op, not a total failure — ``delivered == 0`` there means there was
72
+ nothing to deliver.
73
+ """
74
+ delivered = 0
62
75
  for message in messages:
63
- self._send_one(message)
76
+ if self._send_one(message):
77
+ delivered += 1
78
+ if messages and delivered == 0:
79
+ raise SinkDeliveryError(
80
+ f"SocketTransport delivered none of {len(messages)} message(s)"
81
+ )
82
+
83
+ def losses(self) -> SinkLosses:
84
+ """Messages abandoned past the reconnect-retry bound (SPEC-026 FR-002). Never raises."""
85
+ return SinkLosses(dropped=0, failed=self.failed)
64
86
 
65
87
  def close(self) -> None:
66
88
  """Close the held socket, if any; idempotent (FR-005, FR-012)."""
@@ -68,14 +90,15 @@ class SocketTransport:
68
90
 
69
91
  # -- internals ----------------------------------------------------------------------
70
92
 
71
- def _send_one(self, message: bytes) -> None:
93
+ def _send_one(self, message: bytes) -> bool:
94
+ """Send one message within the retry bound; ``False`` once it is abandoned."""
72
95
  for attempt in range(self._max_retries + 1):
73
96
  try:
74
97
  if self._transport == "udp":
75
98
  self._socket().sendto(message, (self._host, self._port))
76
99
  else:
77
100
  self._socket().sendall(message)
78
- return
101
+ return True
79
102
  except OSError as err:
80
103
  self._reset() # force a fresh connection on the next attempt
81
104
  if attempt < self._max_retries:
@@ -92,7 +115,8 @@ class SocketTransport:
92
115
  f"SocketTransport, {self._max_retries + 1} attempt(s), "
93
116
  f"{type(err).__name__} {_diag.errno_of(err)}".rstrip(),
94
117
  )
95
- return
118
+ return False
119
+ return False # unreachable: the loop returns on every path (mypy needs the exit)
96
120
 
97
121
  def _socket(self) -> socket.socket:
98
122
  if self._sock is None:
@@ -0,0 +1,119 @@
1
+ """The sink interface (arch §8).
2
+
3
+ A sink is the swappable output transport. It receives *already-built, batched* event
4
+ dicts from the worker and knows nothing about spans or context — that dumbness is what
5
+ makes sinks trivially interchangeable (StdoutSink, SQSSink, …). Only the Protocol lives
6
+ here; concrete sinks arrive in later phases.
7
+
8
+ The interface carries two obligations beyond "put these somewhere" (SPEC-026), because the
9
+ library's whole loss-reporting apparatus is built on them:
10
+
11
+ * **Total failure raises.** A sink that delivered *none* of a batch must let something
12
+ propagate out of ``emit``. That is the signal the worker's bounded retry and
13
+ ``health().failed_batches`` are built on, and the one case where a retry cannot create
14
+ duplicates — nothing landed, so there is nothing downstream to duplicate.
15
+ * **Partial failure does not.** A batch where some records landed must not be retried
16
+ wholesale; it is counted and made readable through the optional ``losses()``.
17
+
18
+ A sink that absorbs a total failure and returns normally is a sink the worker believes:
19
+ the retry never engages, ``failed_batches`` stays at zero and ``flush()`` returns ``True``
20
+ while every event is lost. That reading is precisely what SPEC-017 existed to make
21
+ impossible, and what SPEC-026 generalized from ``MultiSink`` to the whole family.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from typing import NamedTuple, Protocol, runtime_checkable
27
+
28
+ __all__ = ["Sink", "SinkDeliveryError", "SinkLosses", "read_losses"]
29
+
30
+
31
+ class SinkDeliveryError(Exception):
32
+ """Raised by a sink whose ``emit`` delivered none of the batch (SPEC-026 FR-001).
33
+
34
+ A distinct type so an operator reading a ``stopped_reason`` or a diagnostic line can tell
35
+ "the destination refused everything" from "the sink itself has a bug". Sinks that already
36
+ have a natural exception to re-raise — a driver error, a ``MultiSink`` child's — re-raise
37
+ that instead: the contract is that *something* propagates, not that it must be this type.
38
+ """
39
+
40
+
41
+ class SinkLosses(NamedTuple):
42
+ """What a sink discarded or could not confirm, cumulative for its lifetime (SPEC-026 FR-002).
43
+
44
+ Two fields rather than one because the remedies differ: ``dropped`` means the destination
45
+ could never have accepted the event as built (an oversized record), so the fix is upstream in
46
+ what the application logs; ``failed`` means delivery was attempted and the destination did not
47
+ confirm it, so the fix is the destination or the network.
48
+
49
+ ``failed`` is an **upper bound** on loss, not a count of it. A sink that also raises on total
50
+ failure counts the attempt here *and* hands the batch back to the worker, whose retry may
51
+ then deliver it — so a transient outage leaves ``failed`` non-zero with nothing actually
52
+ lost. ``health().failed_batches`` is the worker-level record of a batch given up on for good;
53
+ this is the sink-level record of everything that did not go through first time.
54
+ """
55
+
56
+ dropped: int
57
+ failed: int
58
+
59
+
60
+ @runtime_checkable
61
+ class Sink(Protocol):
62
+ def emit(self, batch: list[dict[str, object]]) -> None:
63
+ """Ship a batch of serialized event dicts.
64
+
65
+ **Raise when the batch delivered nothing** and it was non-empty — the worker's bounded
66
+ retry and ``health().failed_batches`` depend on that signal, and a retry there cannot
67
+ duplicate anything (SPEC-026 FR-001). Raise *after* the sink's own retries are spent, so
68
+ the worker's retry composes on top rather than replacing it; a sink whose own budget
69
+ makes the worker's redundant should say so in its docstring rather than absorb silently.
70
+
71
+ **Do not raise on partial failure.** A batch where some records landed would be
72
+ re-delivered wholesale by the worker's retry, and duplicates downstream are worse than
73
+ the counted loss (SPEC-017 FR-004, SPEC-018). Report that through ``losses()`` instead.
74
+
75
+ ``emit([])`` is a no-op and never raises: an empty batch has not failed to deliver.
76
+ """
77
+ ...
78
+
79
+ def close(self) -> None:
80
+ """Flush and release any resources."""
81
+ ...
82
+
83
+ # ``losses()`` is optional, and deliberately *not* declared here: ``Sink`` is structural, and
84
+ # a third-party sink written against the pre-SPEC-026 interface must keep satisfying it.
85
+ # ``read_losses`` below is the probe; an absent method reads as "reports nothing".
86
+ #
87
+ # def losses(self) -> SinkLosses | None:
88
+ # """Cumulative loss this sink absorbed. Never raises; safe to call during emit.
89
+ #
90
+ # ``None`` is the same answer as having no method at all — "this sink reports
91
+ # nothing". A wrapper returns it when what it wraps reports nothing.
92
+ # """
93
+ # return SinkLosses(dropped=self._dropped, failed=self._failed)
94
+
95
+
96
+ def read_losses(sink: object) -> SinkLosses | None:
97
+ """Read a sink's optional ``losses()``; ``None`` when it reports nothing.
98
+
99
+ ``None`` covers four cases deliberately treated alike: no ``losses`` attribute, one that is
100
+ not callable, one that raised, and one that returned anything other than a ``SinkLosses`` —
101
+ including ``None`` itself, which the shipped wrapper sinks return when what they wrap
102
+ reports nothing.
103
+
104
+ The single reader for the optional half of the protocol (SPEC-026 FR-002), shared by
105
+ ``Worker.health`` and ``MultiSink.losses`` so the probe and its guarantees are written once.
106
+
107
+ Total by design. ``health()`` is documented "Never raises" and is the call an operator makes
108
+ when things are *already* going wrong, so a third-party sink with a broken accessor must not
109
+ be able to take the snapshot down with it. The returned shape is checked as well as the call:
110
+ a ``losses()`` returning something else would otherwise put an arbitrary object where callers
111
+ read two integers. Nothing is written to stderr — a poll happens as often as the caller likes,
112
+ and a broken accessor is a sink bug rather than a loss.
113
+ """
114
+ try:
115
+ accessor = getattr(sink, "losses", None)
116
+ losses = accessor() if callable(accessor) else None
117
+ except Exception:
118
+ return None
119
+ return losses if isinstance(losses, SinkLosses) else None
@@ -0,0 +1,141 @@
1
+ """ElasticsearchSink / OpenSearchSink — index events via the ``_bulk`` API (arch §8, SPEC-009).
2
+
3
+ A thin specialization of :class:`~log_foundry.sinks.http.HTTPSink`: each event becomes an action line
4
+ (``{"index": {"_index": target}}``) followed by its source line, POSTed to ``_bulk`` as newline-
5
+ delimited JSON. The bulk response is inspected per item so a partial failure is counted and logged
6
+ without discarding the successfully-indexed items. OpenSearch speaks the same bulk protocol, so
7
+ ``OpenSearchSink`` is a straight reuse (endpoint/auth differ only by configuration).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+
14
+ from log_foundry import _diag
15
+ from log_foundry.sinks._batch import usable_results
16
+ from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
17
+ from log_foundry.sinks.http import HTTPSink
18
+
19
+ __all__ = ["ElasticsearchSink", "OpenSearchSink"]
20
+
21
+
22
+ class ElasticsearchSink(HTTPSink):
23
+ """POST events to an Elasticsearch ``_bulk`` endpoint, parsing per-item errors (FR-003).
24
+
25
+ Attributes:
26
+ item_errors: Count of bulk items the server reported as failed (distinct from ``failed``,
27
+ which counts whole requests abandoned past the retry bound).
28
+ dropped_unadjudicated: Events whose outcome a ``_bulk`` response did not describe — its
29
+ ``items`` array did not line up with the batch sent, so no event in it could be
30
+ paired to an outcome. Abandoned rather than retried, for SPEC-018's reason: the
31
+ request succeeded, so re-sending would duplicate whatever landed.
32
+ """
33
+
34
+ def __init__(self, url: str, *, index: str, auth: str | tuple[str, str] | None = None,
35
+ **http_kwargs: object) -> None:
36
+ self._index = index
37
+ super().__init__(
38
+ url.rstrip("/") + "/_bulk", auth=auth, body_format="ndjson", **http_kwargs # type: ignore[arg-type]
39
+ )
40
+ self.item_errors = 0
41
+ self.dropped_unadjudicated = 0
42
+
43
+ def emit(self, batch: list[dict[str, object]]) -> None:
44
+ """Build the ``_bulk`` NDJSON payload, POST it, and parse the response items (FR-003)."""
45
+ if not batch:
46
+ return
47
+ lines: list[str] = []
48
+ for event in batch:
49
+ lines.append(json.dumps({"index": {"_index": self._index}}))
50
+ lines.append(json.dumps(event))
51
+ body = ("\n".join(lines) + "\n").encode("utf-8") # bulk must be newline-terminated
52
+ # An abandoned request raises out of ``_send`` now (SPEC-026 FR-001) — nothing was
53
+ # indexed, so there is no response to parse and nothing downstream to duplicate.
54
+ payload = self._send(body, content_type="application/x-ndjson")
55
+ if self._parse_bulk_response(payload, len(batch)):
56
+ # Every item carried an error, so the 200 indexed nothing: a total failure like any
57
+ # other, and it must reach the worker (FR-001). A retry cannot duplicate, and where
58
+ # the cause is permanent (a mapping conflict) the worker abandons the batch after
59
+ # its bound and records it — which beats a silent success. A response rejecting
60
+ # *some* items stays partial and is reported through ``losses()``.
61
+ raise SinkDeliveryError(
62
+ f"{type(self).__name__} indexed none of {len(batch)} event(s)"
63
+ )
64
+
65
+ def losses(self) -> SinkLosses:
66
+ """Abandoned requests plus server-rejected bulk items (SPEC-026 FR-002). Never raises.
67
+
68
+ All three are summed into ``failed`` because each is an event the server did not
69
+ confirm; they stay apart on the instance (``failed`` / ``item_errors`` /
70
+ ``dropped_unadjudicated``) for anyone who needs to tell "the request never landed" from
71
+ "the request landed and these items bounced" from "the response did not say".
72
+ """
73
+ return SinkLosses(
74
+ dropped=self.dropped_oversized,
75
+ failed=self.failed + self.item_errors + self.dropped_unadjudicated,
76
+ )
77
+
78
+ def _parse_bulk_response(self, payload: bytes, sent: int) -> bool:
79
+ """Count rejected items; return whether the response proves *nothing* was indexed.
80
+
81
+ The ``items`` array is positional — entry *i* describes event *i* — so the rule
82
+ ``sinks/_batch.py`` states applies here too (SPEC-018): a length disagreement is
83
+ evidence the arrays do not describe each other, not an invitation to read the overlap.
84
+ Counting errors does not depend on position, but concluding "all of them failed" does,
85
+ so that conclusion is gated on ``len(items) == sent``. Without the gate both directions
86
+ are wrong and both are reachable: a longer ``items`` array could make a partial success
87
+ raise (and the worker's retry would duplicate what landed), and a shorter one could
88
+ report a total failure as success.
89
+
90
+ Shape is adjudicated as well as length, through ``_batch.usable_results`` — the same
91
+ helper and the same reason: a list of non-mappings carries no per-record outcome that
92
+ can be read, so it describes nothing rather than describing success. And a body that
93
+ says ``errors: true`` while no entry yields a readable one contradicts itself, which is
94
+ the same "cannot tell" as a short array; reading it as "no items failed" is how a total
95
+ failure came back as success one level below the length check.
96
+
97
+ An array that cannot be adjudicated is counted on ``dropped_unadjudicated`` and left
98
+ alone — like ``KinesisSink``'s, the request itself succeeded, so re-sending it would
99
+ duplicate whatever did land. An unparseable or errors-free body returns ``False``: the
100
+ request succeeded, and a body this sink cannot read is not evidence against that.
101
+ """
102
+ try:
103
+ data = json.loads(payload)
104
+ except (ValueError, TypeError):
105
+ return False
106
+ if not isinstance(data, dict) or not data.get("errors"):
107
+ return False
108
+ items = usable_results(data.get("items"))
109
+ errors = sum(1 for item in items if _has_error(item))
110
+ if len(items) != sent or not errors:
111
+ # ``not errors`` is unadjudicable rather than clean *because* the server said
112
+ # ``errors: true``: the two disagree, and this sink is the one that cannot read the
113
+ # answer. An errors-free response never reaches here — it returned above.
114
+ self.dropped_unadjudicated += sent
115
+ _diag.lost(
116
+ "event",
117
+ sent,
118
+ f"{type(self).__name__} could not adjudicate a _bulk response "
119
+ f"({sent} event(s) sent, {len(items)} readable item(s), {errors} error(s)); "
120
+ f"not retried",
121
+ )
122
+ return False
123
+ # Unconditional only because the gate above already returned on ``not errors``; a
124
+ # loosened gate would start writing "lost 0 bulk item(s)" here.
125
+ self.item_errors += errors
126
+ _diag.lost("bulk item", errors, f"{type(self).__name__}, rejected by the server")
127
+ return errors == sent
128
+
129
+
130
+ def _has_error(item: dict[str, object]) -> bool:
131
+ """Whether a ``_bulk`` item reports a failure under *any* of its action keys.
132
+
133
+ Every value is examined, not just the first: a real response carries one key per item, but
134
+ reading only ``next(iter(...))`` would score an item whose error sits under a second key as
135
+ a success — and a miscounted success is what turns a total failure into a partial one.
136
+ """
137
+ return any(isinstance(result, dict) and result.get("error") for result in item.values())
138
+
139
+
140
+ class OpenSearchSink(ElasticsearchSink):
141
+ """OpenSearch reuses the Elasticsearch ``_bulk`` protocol verbatim (FR-003)."""
@@ -10,10 +10,12 @@ from __future__ import annotations
10
10
 
11
11
  from typing import TYPE_CHECKING
12
12
 
13
+ from log_foundry.sinks.base import read_losses
14
+
13
15
  if TYPE_CHECKING:
14
16
  from collections.abc import Callable
15
17
 
16
- from log_foundry.sinks.base import Sink
18
+ from log_foundry.sinks.base import Sink, SinkLosses
17
19
 
18
20
  __all__ = ["FilteringSink"]
19
21
 
@@ -69,6 +71,20 @@ class FilteringSink:
69
71
  return False
70
72
  return True
71
73
 
74
+ def losses(self) -> SinkLosses | None:
75
+ """Report the inner sink's losses (SPEC-026 FR-002). Never raises.
76
+
77
+ A wrapper that reported nothing would hide the destination it wraps: ``health().sink``
78
+ would read ``None`` for a ``FilteringSink`` in front of a sink that counts perfectly well.
79
+ Events this sink itself declines to forward are not loss — they are the configuration
80
+ working, the same reason ``NullSink`` reports nothing.
81
+
82
+ ``None`` passes through unchanged rather than becoming ``SinkLosses(0, 0)``: FR-003
83
+ distinguishes "the sink reports nothing" from "the sink reports no loss", and a wrapper
84
+ that flattened the two would claim a clean bill of health on a sink that never gave one.
85
+ """
86
+ return read_losses(self._inner)
87
+
72
88
  def close(self) -> None:
73
89
  """Close the inner sink (FR-003)."""
74
90
  self._inner.close()
@@ -19,9 +19,10 @@ import time
19
19
  import urllib.error
20
20
  import urllib.request
21
21
  from base64 import b64encode
22
- from typing import TYPE_CHECKING, Any
22
+ from typing import TYPE_CHECKING, Any, NoReturn
23
23
 
24
24
  from log_foundry import _diag
25
+ from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
25
26
 
26
27
  if TYPE_CHECKING:
27
28
  from collections.abc import Callable
@@ -77,7 +78,10 @@ class HTTPSink:
77
78
  self.body_format = body_format
78
79
  self.timeout = timeout
79
80
  self.gzip = gzip
80
- self.max_retries = max_retries
81
+ # Floored, as ``Worker._emit`` floors its own (SPEC-021): a negative value otherwise
82
+ # skipped the loop entirely, so the request was abandoned with no attempt made and no
83
+ # counter moved — reachable only by misconfiguration, but reachable.
84
+ self.max_retries = max(max_retries, 0)
81
85
  self._opener = opener if opener is not None else urllib.request.urlopen
82
86
  self.failed = 0
83
87
  self.dropped_oversized = 0
@@ -89,6 +93,15 @@ class HTTPSink:
89
93
  body, content_type = self._encode(batch)
90
94
  self._send(body, content_type=content_type)
91
95
 
96
+ def losses(self) -> SinkLosses:
97
+ """Oversized drops and abandoned requests (SPEC-026 FR-002). Never raises.
98
+
99
+ ``failed`` counts abandoned *requests*, not events — this class has no per-event outcome
100
+ to report, and an abandoned request took its whole body with it. Subclasses that do learn
101
+ per-record outcomes (``ElasticsearchSink``) add them.
102
+ """
103
+ return SinkLosses(dropped=self.dropped_oversized, failed=self.failed)
104
+
92
105
  def close(self) -> None:
93
106
  """No-op — ``urllib`` opens a fresh connection per request; idempotent (FR-012)."""
94
107
 
@@ -109,11 +122,20 @@ class HTTPSink:
109
122
  *,
110
123
  content_type: str,
111
124
  extra_headers: dict[str, str] | None = None,
112
- ) -> bytes | None:
113
- """POST ``body`` with bounded retry; return the response bytes, or ``None`` if abandoned.
125
+ ) -> bytes:
126
+ """POST ``body`` with bounded retry; return the response bytes.
114
127
 
115
128
  Subclasses that must inspect the response (e.g. Elasticsearch ``_bulk`` ``items``) use the
116
- returned bytes; a ``None`` return means the request was retried to exhaustion and counted.
129
+ returned bytes. A request retried to exhaustion is counted and then **raises**
130
+ :class:`~log_foundry.sinks.base.SinkDeliveryError` (SPEC-026 FR-001) — it used to return
131
+ ``None``, which every caller here spelled as "nothing to parse" and the worker read as a
132
+ successful emit. One request carries the whole batch, so an abandoned one delivered
133
+ nothing and is exactly the total failure the worker's retry exists for.
134
+
135
+ Raising from here rather than from each ``emit`` is deliberate: every platform subclass
136
+ (Datadog, Splunk, New Relic, Honeycomb, Loki, Logstash-HTTP) builds its own body and calls
137
+ this, so the rule reaches them without a line of their own. The one caller that must not
138
+ propagate is ``SentrySink``, which sends one envelope *per event* and therefore catches it.
117
139
  """
118
140
  headers, data = self._prepare(body, content_type, extra_headers)
119
141
  for attempt in range(self.max_retries + 1):
@@ -134,15 +156,14 @@ class HTTPSink:
134
156
  self._abandon(
135
157
  f"connection error, {type(err).__name__} {_diag.errno_of(err)}".rstrip()
136
158
  )
137
- return None
138
159
  if 200 <= status < 300:
139
160
  return payload
140
161
  if (status == 429 or 500 <= status < 600) and attempt < self.max_retries:
141
162
  self._sleep_backoff(attempt, retry_after)
142
163
  continue
143
164
  self._abandon(f"HTTP {status}")
144
- return None
145
- return None
165
+ # Unreachable: every path through the loop returns or raises. mypy needs the exit.
166
+ raise SinkDeliveryError(f"{type(self).__name__} made no attempt")
146
167
 
147
168
  def _prepare(
148
169
  self,
@@ -202,16 +223,22 @@ class HTTPSink:
202
223
  delay = retry_after if retry_after is not None else _BACKOFF_BASE * (2**attempt)
203
224
  time.sleep(delay)
204
225
 
205
- def _abandon(self, reason: str) -> None:
206
- """Count and log a request abandoned past the retry bound (FR-012).
226
+ def _abandon(self, reason: str) -> NoReturn:
227
+ """Count, log, then raise for a request abandoned past the retry bound (FR-012).
207
228
 
208
229
  ``reason`` carries only library-controlled values — an HTTP status, an exception type, an
209
- ``errno`` — never a server-supplied body or an exception's text (SPEC-029 FR-002).
230
+ ``errno`` — never a server-supplied body or an exception's text (SPEC-029 FR-002). It is
231
+ also the message of the raised error, for the same reason.
232
+
233
+ The counter moves *before* the raise: ``failed`` is this sink's own record of what it
234
+ could not put on the wire, and it must not depend on who catches what. It therefore
235
+ counts every worker retry attempt too, so it is an upper bound on loss rather than a
236
+ count of it — see :class:`~log_foundry.sinks.base.SinkLosses`.
210
237
  """
211
238
  self.failed += 1
212
- _diag.lost(
213
- "request", 1, f"{type(self).__name__}, {self.max_retries + 1} attempt(s), {reason}"
214
- )
239
+ detail = f"{type(self).__name__}, {self.max_retries + 1} attempt(s), {reason}"
240
+ _diag.lost("request", 1, detail)
241
+ raise SinkDeliveryError(detail)
215
242
 
216
243
 
217
244
  def _parse_retry_after(headers: Any) -> float | None:
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
  import json
15
15
 
16
16
  from log_foundry.sinks._socket import SocketTransport
17
+ from log_foundry.sinks.base import SinkLosses
17
18
  from log_foundry.sinks.http import HTTPSink
18
19
 
19
20
  __all__ = ["LogstashSink"]
@@ -73,3 +74,15 @@ class LogstashSink:
73
74
  return self._http.failed if self._http is not None else (
74
75
  self._socket.failed if self._socket is not None else 0
75
76
  )
77
+
78
+ def losses(self) -> SinkLosses:
79
+ """Delegate to whichever backend is held (SPEC-026 FR-002). Never raises.
80
+
81
+ Both backends raise on total failure of their own accord, so ``emit`` needs no rule of
82
+ its own — it forwards the whole batch to exactly one of them.
83
+ """
84
+ if self._http is not None:
85
+ return self._http.losses()
86
+ if self._socket is not None:
87
+ return self._socket.losses()
88
+ return SinkLosses(dropped=0, failed=0) # unreachable: __init__ sets exactly one
@@ -19,6 +19,7 @@ from __future__ import annotations
19
19
  from typing import TYPE_CHECKING
20
20
 
21
21
  from log_foundry import _diag
22
+ from log_foundry.sinks.base import SinkLosses, read_losses
22
23
 
23
24
  if TYPE_CHECKING:
24
25
  from log_foundry.sinks.base import Sink
@@ -73,6 +74,32 @@ class MultiSink:
73
74
  # misconfigured fan-out with no children would retry every batch to exhaustion.
74
75
  raise first_error
75
76
 
77
+ def losses(self) -> SinkLosses | None:
78
+ """Sum the children's losses so a fan-out reports the whole tree (SPEC-026 FR-002).
79
+
80
+ Never raises: a child without ``losses()`` contributes zero, and a child whose accessor
81
+ raises or returns the wrong shape is skipped rather than allowed to take the aggregate —
82
+ and ``health()`` with it — down. Nesting is handled for free, since a child ``MultiSink``
83
+ is just another sink with a ``losses()``.
84
+
85
+ ``MultiSink.failed`` is deliberately absent from the total. It counts child *calls* that
86
+ raised, not events, so adding it to a per-event figure would produce a number with no
87
+ unit. The children already report their own loss in events.
88
+
89
+ ``None`` when *no* child reported anything — including an empty fan-out. FR-003 separates
90
+ "reports nothing" from "reports no loss", and a tree of silent children has not given a
91
+ clean bill of health; summing them to zero would claim one on their behalf. One reporting
92
+ child is enough to make the total meaningful, since the silent ones contribute zero.
93
+ """
94
+ children = [read_losses(sink) for sink in self._sinks]
95
+ reported = [child for child in children if child is not None]
96
+ if not reported:
97
+ return None
98
+ return SinkLosses(
99
+ dropped=sum(child.dropped for child in reported),
100
+ failed=sum(child.failed for child in reported),
101
+ )
102
+
76
103
  def close(self) -> None:
77
104
  """Close every child, isolating a failing child so the rest still close (FR-002)."""
78
105
  for sink in self._sinks:
@@ -14,6 +14,8 @@ import uuid
14
14
  from typing import Any
15
15
  from urllib.parse import urlparse
16
16
 
17
+ from log_foundry import _diag
18
+ from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
17
19
  from log_foundry.sinks.http import HTTPSink
18
20
 
19
21
  __all__ = ["SentrySink"]
@@ -32,6 +34,8 @@ class SentrySink:
32
34
  Attributes:
33
35
  sent: Events captured/sent to Sentry.
34
36
  skipped: Events below ``min_level`` (or without a usable level) that were not sent.
37
+ transport_errors: Events whose send raised something other than an already-counted
38
+ abandonment — an SDK fault, or a response error ``HTTPSink`` does not retry.
35
39
  """
36
40
 
37
41
  def __init__(
@@ -47,6 +51,7 @@ class SentrySink:
47
51
  self._min_rank = _LEVEL_RANK.get(min_level.upper(), _LEVEL_RANK["ERROR"])
48
52
  self.sent = 0
49
53
  self.skipped = 0
54
+ self.transport_errors = 0
50
55
  self._sdk = sdk if sdk is not None else _import_sdk()
51
56
  self._http: HTTPSink | None = None
52
57
  self._auth_header = ""
@@ -60,16 +65,30 @@ class SentrySink:
60
65
  self._http = HTTPSink(ingest_url, opener=opener, max_retries=max_retries)
61
66
 
62
67
  def emit(self, batch: list[dict[str, object]]) -> None:
63
- """Capture each qualifying event via the SDK or the HTTP fallback (FR-011)."""
68
+ """Capture each qualifying event via the SDK or the HTTP fallback (FR-011).
69
+
70
+ One envelope per event, so an abandoned request is a *per-event* outcome: it is caught,
71
+ counted, and only re-raised when the whole batch got through to nothing (SPEC-026
72
+ FR-001). Letting the first failure propagate would hand the worker a batch whose earlier
73
+ events Sentry had already accepted, and the retry would duplicate them.
74
+
75
+ An event below ``min_level`` is skipped, not lost, so a batch of nothing but skipped
76
+ events is a successful emit — there was never anything to deliver.
77
+ """
78
+ attempted = delivered = 0
64
79
  for event in batch:
65
80
  if not self._qualifies(event):
66
81
  self.skipped += 1
67
82
  continue
68
- if self._sdk is not None:
69
- self._sdk.capture_event(self._sentry_event(event))
70
- else:
71
- self._post_envelope(event)
83
+ attempted += 1
84
+ if not self._capture(event):
85
+ continue
72
86
  self.sent += 1
87
+ delivered += 1
88
+ if attempted and not delivered:
89
+ raise SinkDeliveryError(
90
+ f"SentrySink delivered none of {attempted} qualifying event(s)"
91
+ )
73
92
 
74
93
  def close(self) -> None:
75
94
  """Release the HTTP fallback resource, if any; idempotent (FR-012)."""
@@ -81,8 +100,48 @@ class SentrySink:
81
100
  """Requests abandoned past the retry bound (HTTP fallback only)."""
82
101
  return self._http.failed if self._http is not None else 0
83
102
 
103
+ def losses(self) -> SinkLosses:
104
+ """Envelopes abandoned past the retry bound (SPEC-026 FR-002). Never raises.
105
+
106
+ ``skipped`` is deliberately **not** ``dropped``: an event below ``min_level`` was never
107
+ meant for Sentry, and reporting a configured filter as loss would make the alert idiom
108
+ fire on every INFO log. What *is* reported is an abandoned envelope (``failed``, counted
109
+ by ``HTTPSink``) and an event whose send raised (``transport_errors``) — on either
110
+ transport. Delivery the SDK accepts and then loses internally is the SDK's to report.
111
+ """
112
+ return SinkLosses(dropped=0, failed=self.failed + self.transport_errors)
113
+
84
114
  # -- internals ----------------------------------------------------------------------
85
115
 
116
+ def _capture(self, event: dict[str, object]) -> bool:
117
+ """Send one event by whichever transport is configured; ``False`` if it did not land.
118
+
119
+ One guard over **both** branches, catching ``Exception`` rather than an enumerated set.
120
+ Anything escaping here propagates mid-batch and hands the worker a batch Sentry has
121
+ already accepted the earlier events of, and the retry duplicates them — the failure this
122
+ per-event design exists to prevent. Enumerating was tried and was wrong twice over: the
123
+ SDK branch is third-party code that can raise anything, and ``HTTPSink._send`` catches
124
+ ``(URLError, OSError)``, which does **not** cover ``http.client.HTTPException`` — an
125
+ ``IncompleteRead`` off ``response.read()`` came straight through. A caller-injected
126
+ ``opener`` widens that further, since it is arbitrary code too.
127
+
128
+ ``SinkDeliveryError`` is caught separately only because ``HTTPSink._abandon`` has already
129
+ counted and announced it; counting it again here would double-report. Only the exception
130
+ type is ever written (arch §6).
131
+ """
132
+ try:
133
+ if self._sdk is not None:
134
+ self._sdk.capture_event(self._sentry_event(event))
135
+ else:
136
+ self._post_envelope(event)
137
+ except SinkDeliveryError:
138
+ return False # already counted and logged by HTTPSink._abandon
139
+ except Exception as err: # isolation boundary: one event must not fail the batch
140
+ self.transport_errors += 1
141
+ _diag.lost("event", 1, f"SentrySink, {type(err).__name__}")
142
+ return False
143
+ return True
144
+
86
145
  def _qualifies(self, event: dict[str, object]) -> bool:
87
146
  level = event.get("level")
88
147
  if not isinstance(level, str):
@@ -11,9 +11,13 @@ from __future__ import annotations
11
11
  import json
12
12
  import os
13
13
  import socket
14
+ from typing import TYPE_CHECKING
14
15
 
15
16
  from log_foundry.sinks._socket import SocketTransport
16
17
 
18
+ if TYPE_CHECKING:
19
+ from log_foundry.sinks.base import SinkLosses
20
+
17
21
  __all__ = ["SyslogSink"]
18
22
 
19
23
  # RFC 5424 numeric severities; log-foundry levels mapped onto them (unknown level -> notice/5).
@@ -69,6 +73,15 @@ class SyslogSink:
69
73
  """Messages abandoned past the retry bound."""
70
74
  return self._socket.failed
71
75
 
76
+ def losses(self) -> SinkLosses:
77
+ """Delegate to the socket transport (SPEC-026 FR-002). Never raises.
78
+
79
+ The transport raises on total failure, and ``emit`` hands it the whole batch in one
80
+ call, so a dead syslog destination now reaches ``health().failed_batches`` — the reading
81
+ SPEC-026 was written from, where ``flush()`` returned ``True`` and every frame was lost.
82
+ """
83
+ return self._socket.losses()
84
+
72
85
  # -- internals ----------------------------------------------------------------------
73
86
 
74
87
  def _frame(self, event: dict[str, object]) -> bytes:
@@ -11,10 +11,12 @@ from __future__ import annotations
11
11
 
12
12
  from typing import TYPE_CHECKING
13
13
 
14
+ from log_foundry.sinks.base import read_losses
15
+
14
16
  if TYPE_CHECKING:
15
17
  from collections.abc import Callable
16
18
 
17
- from log_foundry.sinks.base import Sink
19
+ from log_foundry.sinks.base import Sink, SinkLosses
18
20
 
19
21
  __all__ = ["TransformSink"]
20
22
 
@@ -44,6 +46,20 @@ class TransformSink:
44
46
  if transformed:
45
47
  self._inner.emit(transformed)
46
48
 
49
+ def losses(self) -> SinkLosses | None:
50
+ """Report the inner sink's losses (SPEC-026 FR-002). Never raises.
51
+
52
+ A wrapper that reported nothing would hide the destination it wraps: ``health().sink``
53
+ would read ``None`` for a ``TransformSink`` in front of a sink that counts perfectly well.
54
+ Events this sink itself declines to forward are not loss — they are the configuration
55
+ working, the same reason ``NullSink`` reports nothing.
56
+
57
+ ``None`` passes through unchanged rather than becoming ``SinkLosses(0, 0)``: FR-003
58
+ distinguishes "the sink reports nothing" from "the sink reports no loss", and a wrapper
59
+ that flattened the two would claim a clean bill of health on a sink that never gave one.
60
+ """
61
+ return read_losses(self._inner)
62
+
47
63
  def close(self) -> None:
48
64
  """Close the inner sink (FR-004)."""
49
65
  self._inner.close()
@@ -32,6 +32,10 @@ class NullSink:
32
32
 
33
33
  Useful to disable output without unwiring the pipeline, or to benchmark everything up to the
34
34
  sink. ``dropped`` counts how many events were discarded, so a benchmark can assert throughput.
35
+
36
+ Deliberately exposes no ``losses()`` (SPEC-026 FR-002): discarding is what this sink is *for*,
37
+ and reporting it as loss would make ``health()``'s alert idiom fire on every batch for anyone
38
+ who chose this sink to turn logging off. The counter stays readable on the instance.
35
39
  """
36
40
 
37
41
  def __init__(self) -> None:
@@ -27,7 +27,7 @@ from typing import TYPE_CHECKING, NamedTuple, cast
27
27
  from log_foundry import _diag
28
28
 
29
29
  if TYPE_CHECKING:
30
- from log_foundry.sinks.base import Sink
30
+ from log_foundry.sinks.base import Sink, SinkLosses
31
31
 
32
32
  __all__ = ["Health", "Worker"]
33
33
 
@@ -54,6 +54,11 @@ class Health(NamedTuple):
54
54
  logged report. Non-``None`` is categorically worse than the two counters above:
55
55
  they measure loss the worker absorbed and kept running through, this one means
56
56
  the worker is gone and nothing further will be delivered (SPEC-019 FR-003).
57
+ sink: The configured sink's own loss counters, or ``None`` when there is no worker or
58
+ the sink reports nothing (SPEC-026 FR-003). Nested rather than folded into the two
59
+ integers above because they mean different things: ``dropped`` here is backpressure
60
+ at the queue, ``dropped`` on the sink is an event the destination could never
61
+ accept, and one number would make the remedies indistinguishable.
57
62
  """
58
63
 
59
64
  queued: int
@@ -62,6 +67,9 @@ class Health(NamedTuple):
62
67
  # Defaulted so the zeroed snapshot in `decorator._worker_health` — and any third-party
63
68
  # construction — keeps working unchanged.
64
69
  stopped_reason: str | None = None
70
+ # Appended for the same reason `stopped_reason` was: attribute and index access to every
71
+ # field that came before stay exactly as they were.
72
+ sink: SinkLosses | None = None
65
73
 
66
74
 
67
75
  class _FlushMarker:
@@ -179,8 +187,20 @@ class Worker:
179
187
  dropped=dropped,
180
188
  failed_batches=failed_batches,
181
189
  stopped_reason=stopped_reason,
190
+ sink=self._sink_losses(),
182
191
  )
183
192
 
193
+ def _sink_losses(self) -> SinkLosses | None:
194
+ """Read the configured sink's optional ``losses()``; ``None`` when it has none (FR-003).
195
+
196
+ The probe and its guarantees live in ``sinks.base.read_losses`` — imported here rather
197
+ than at module scope, keeping ``worker`` free of a runtime dependency on ``sinks`` the
198
+ same way ``config`` does (the ``Sink`` Protocol itself is ``TYPE_CHECKING``-only).
199
+ """
200
+ from log_foundry.sinks.base import read_losses
201
+
202
+ return read_losses(self.sink)
203
+
184
204
  def flush(self, timeout: float | None = 5.0) -> bool:
185
205
  """Drain everything submitted before this call through the sink, without stopping.
186
206
 
@@ -1,22 +0,0 @@
1
- """The sink interface (arch §8).
2
-
3
- A sink is the swappable output transport. It receives *already-built, batched* event
4
- dicts from the worker and knows nothing about spans or context — that dumbness is what
5
- makes sinks trivially interchangeable (StdoutSink, SQSSink, …). Only the Protocol lives
6
- here; concrete sinks arrive in later phases.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- from typing import Protocol, runtime_checkable
12
-
13
-
14
- @runtime_checkable
15
- class Sink(Protocol):
16
- def emit(self, batch: list[dict[str, object]]) -> None:
17
- """Ship a batch of serialized event dicts."""
18
- ...
19
-
20
- def close(self) -> None:
21
- """Flush and release any resources."""
22
- ...
@@ -1,68 +0,0 @@
1
- """ElasticsearchSink / OpenSearchSink — index events via the ``_bulk`` API (arch §8, SPEC-009).
2
-
3
- A thin specialization of :class:`~log_foundry.sinks.http.HTTPSink`: each event becomes an action line
4
- (``{"index": {"_index": target}}``) followed by its source line, POSTed to ``_bulk`` as newline-
5
- delimited JSON. The bulk response is inspected per item so a partial failure is counted and logged
6
- without discarding the successfully-indexed items. OpenSearch speaks the same bulk protocol, so
7
- ``OpenSearchSink`` is a straight reuse (endpoint/auth differ only by configuration).
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import json
13
-
14
- from log_foundry import _diag
15
- from log_foundry.sinks.http import HTTPSink
16
-
17
- __all__ = ["ElasticsearchSink", "OpenSearchSink"]
18
-
19
-
20
- class ElasticsearchSink(HTTPSink):
21
- """POST events to an Elasticsearch ``_bulk`` endpoint, parsing per-item errors (FR-003).
22
-
23
- Attributes:
24
- item_errors: Count of bulk items the server reported as failed (distinct from ``failed``,
25
- which counts whole requests abandoned past the retry bound).
26
- """
27
-
28
- def __init__(self, url: str, *, index: str, auth: str | tuple[str, str] | None = None,
29
- **http_kwargs: object) -> None:
30
- self._index = index
31
- super().__init__(
32
- url.rstrip("/") + "/_bulk", auth=auth, body_format="ndjson", **http_kwargs # type: ignore[arg-type]
33
- )
34
- self.item_errors = 0
35
-
36
- def emit(self, batch: list[dict[str, object]]) -> None:
37
- """Build the ``_bulk`` NDJSON payload, POST it, and parse the response items (FR-003)."""
38
- if not batch:
39
- return
40
- lines: list[str] = []
41
- for event in batch:
42
- lines.append(json.dumps({"index": {"_index": self._index}}))
43
- lines.append(json.dumps(event))
44
- body = ("\n".join(lines) + "\n").encode("utf-8") # bulk must be newline-terminated
45
- payload = self._send(body, content_type="application/x-ndjson")
46
- if payload is not None:
47
- self._parse_bulk_response(payload)
48
-
49
- def _parse_bulk_response(self, payload: bytes) -> None:
50
- """Count items the bulk response flagged as errors; a partial failure keeps the rest."""
51
- try:
52
- data = json.loads(payload)
53
- except (ValueError, TypeError):
54
- return
55
- if not isinstance(data, dict) or not data.get("errors"):
56
- return
57
- errors = 0
58
- for item in data.get("items", []):
59
- result: object = next(iter(item.values()), {}) if isinstance(item, dict) else {}
60
- if isinstance(result, dict) and result.get("error"):
61
- errors += 1
62
- if errors:
63
- self.item_errors += errors
64
- _diag.lost("bulk item", errors, f"{type(self).__name__}, rejected by the server")
65
-
66
-
67
- class OpenSearchSink(ElasticsearchSink):
68
- """OpenSearch reuses the Elasticsearch ``_bulk`` protocol verbatim (FR-003)."""