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.
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/PKG-INFO +1 -1
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/pyproject.toml +1 -1
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/_socket.py +30 -6
- log_foundry-0.10.2.dev17/src/log_foundry/sinks/base.py +119 -0
- log_foundry-0.10.2.dev17/src/log_foundry/sinks/elasticsearch.py +141 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/filtering.py +17 -1
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/http.py +41 -14
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/logstash.py +13 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/multi.py +27 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/sentry.py +64 -5
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/syslog.py +13 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/transform.py +17 -1
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/util.py +4 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/worker.py +21 -1
- log_foundry-0.10.2.dev16/src/log_foundry/sinks/base.py +0 -22
- log_foundry-0.10.2.dev16/src/log_foundry/sinks/elasticsearch.py +0 -68
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/LICENSE +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/README.md +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/__init__.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/_diag.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/api.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/config.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/console.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/context.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/decorator.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/model.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sanitize.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/_batch.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/clickhouse.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/eventhubs.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/firehose.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/kinesis.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/mongodb.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/postgres.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/pubsub.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/rabbitmq.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/redis.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/sns.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/sqs.py +0 -0
- {log_foundry-0.10.2.dev16 → log_foundry-0.10.2.dev17}/src/log_foundry/sinks/stdout.py +0 -0
|
@@ -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
|
-
|
|
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) ->
|
|
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
|
-
|
|
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
|
|
113
|
-
"""POST ``body`` with bounded retry; return the response bytes
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
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) ->
|
|
206
|
-
"""Count
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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)."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|