log-foundry 0.6.1.dev2__tar.gz → 0.6.1.dev3__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 (53) hide show
  1. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/PKG-INFO +11 -2
  2. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/README.md +10 -1
  3. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/pyproject.toml +1 -1
  4. log_foundry-0.6.1.dev3/src/log_foundry/sinks/_batch.py +70 -0
  5. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/firehose.py +23 -9
  6. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/kinesis.py +22 -12
  7. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/LICENSE +0 -0
  8. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/__init__.py +0 -0
  9. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/api.py +0 -0
  10. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/config.py +0 -0
  11. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/console.py +0 -0
  12. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/context.py +0 -0
  13. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/decorator.py +0 -0
  14. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/ids.py +0 -0
  15. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/model.py +0 -0
  16. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/py.typed +0 -0
  17. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sanitize.py +0 -0
  18. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/__init__.py +0 -0
  19. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/_chunk.py +0 -0
  20. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/_socket.py +0 -0
  21. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/_time.py +0 -0
  22. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/base.py +0 -0
  23. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/callback.py +0 -0
  24. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/clickhouse.py +0 -0
  25. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/datadog.py +0 -0
  26. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/elasticsearch.py +0 -0
  27. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/eventhubs.py +0 -0
  28. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/file.py +0 -0
  29. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/filtering.py +0 -0
  30. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/honeycomb.py +0 -0
  31. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/http.py +0 -0
  32. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/kafka.py +0 -0
  33. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/logging_sink.py +0 -0
  34. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/logstash.py +0 -0
  35. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/loki.py +0 -0
  36. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/mongodb.py +0 -0
  37. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/multi.py +0 -0
  38. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/nats.py +0 -0
  39. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/newrelic.py +0 -0
  40. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/postgres.py +0 -0
  41. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/pubsub.py +0 -0
  42. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/rabbitmq.py +0 -0
  43. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/redis.py +0 -0
  44. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/sentry.py +0 -0
  45. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/sns.py +0 -0
  46. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/splunk.py +0 -0
  47. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/sqlite.py +0 -0
  48. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/sqs.py +0 -0
  49. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/stdout.py +0 -0
  50. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/syslog.py +0 -0
  51. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/transform.py +0 -0
  52. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/sinks/util.py +0 -0
  53. {log_foundry-0.6.1.dev2 → log_foundry-0.6.1.dev3}/src/log_foundry/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.6.1.dev2
3
+ Version: 0.6.1.dev3
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -394,7 +394,8 @@ A few conventions hold across every sink below:
394
394
  - **Ownership.** A resource the sink opens itself is closed on `shutdown()`; an injected one is left
395
395
  open for you to manage.
396
396
  - **Never crashes the app.** A failing sink is retried with backoff and then counted (`.failed`,
397
- `.dropped_oversized`, …) rather than raised — a broken destination degrades logging, nothing more.
397
+ `.dropped_oversized`, `.dropped_unadjudicated`, …) rather than raised — a broken destination
398
+ degrades logging, nothing more.
398
399
  The one deliberate exception is a `MultiSink` whose children *all* failed: it re-raises so the
399
400
  worker's retry engages, since nothing was delivered and there are no duplicates to risk. That
400
401
  still doesn't reach your code — the worker is what catches it.
@@ -513,6 +514,14 @@ chain — log-foundry adds none of its own. Each re-chunks every batch to the se
513
514
  limits, retries partial failures, and drops any single event too large to ever fit (counted on
514
515
  `.dropped_oversized`).
515
516
 
517
+ `KinesisSink` and `FirehoseSink` learn which records failed **positionally** — the response carries a
518
+ parallel array with no ids — so they check that it describes as many records as were sent before
519
+ acting on it. A response that doesn't is not used to adjudicate any record in the chunk: the chunk is
520
+ abandoned rather than re-sent (some of it almost certainly landed), counted on
521
+ `.dropped_unadjudicated`, and named on stderr. A non-zero value there is real loss, and normally
522
+ means the client isn't AWS-shaped. `SQSSink` and `SNSSink` correlate by explicit `Id` instead, so
523
+ they can't mis-pair and have no such counter.
524
+
516
525
  | Sink | Import from | Configure |
517
526
  |---|---|---|
518
527
  | `SQSSink` | `log_foundry.sinks.sqs` | `SQSSink(queue_url, *, max_retries=3, fifo=None, message_group_id=None, message_deduplication_id=None)` — the headline production path: a durable buffer in front of ELK, absorbing downstream spikes/outages. Standard **and** FIFO queues |
@@ -358,7 +358,8 @@ A few conventions hold across every sink below:
358
358
  - **Ownership.** A resource the sink opens itself is closed on `shutdown()`; an injected one is left
359
359
  open for you to manage.
360
360
  - **Never crashes the app.** A failing sink is retried with backoff and then counted (`.failed`,
361
- `.dropped_oversized`, …) rather than raised — a broken destination degrades logging, nothing more.
361
+ `.dropped_oversized`, `.dropped_unadjudicated`, …) rather than raised — a broken destination
362
+ degrades logging, nothing more.
362
363
  The one deliberate exception is a `MultiSink` whose children *all* failed: it re-raises so the
363
364
  worker's retry engages, since nothing was delivered and there are no duplicates to risk. That
364
365
  still doesn't reach your code — the worker is what catches it.
@@ -477,6 +478,14 @@ chain — log-foundry adds none of its own. Each re-chunks every batch to the se
477
478
  limits, retries partial failures, and drops any single event too large to ever fit (counted on
478
479
  `.dropped_oversized`).
479
480
 
481
+ `KinesisSink` and `FirehoseSink` learn which records failed **positionally** — the response carries a
482
+ parallel array with no ids — so they check that it describes as many records as were sent before
483
+ acting on it. A response that doesn't is not used to adjudicate any record in the chunk: the chunk is
484
+ abandoned rather than re-sent (some of it almost certainly landed), counted on
485
+ `.dropped_unadjudicated`, and named on stderr. A non-zero value there is real loss, and normally
486
+ means the client isn't AWS-shaped. `SQSSink` and `SNSSink` correlate by explicit `Id` instead, so
487
+ they can't mis-pair and have no such counter.
488
+
480
489
  | Sink | Import from | Configure |
481
490
  |---|---|---|
482
491
  | `SQSSink` | `log_foundry.sinks.sqs` | `SQSSink(queue_url, *, max_retries=3, fifo=None, message_group_id=None, message_deduplication_id=None)` — the headline production path: a durable buffer in front of ELK, absorbing downstream spikes/outages. Standard **and** FIFO queues |
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.6.1.dev2"
23
+ version = "0.6.1.dev3"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.34"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -0,0 +1,70 @@
1
+ """Shared adjudication of a positional batch response (SPEC-018).
2
+
3
+ Some batch APIs report per-record outcomes **positionally**: the response carries a parallel array
4
+ with no identifiers, so entry *i* describes record *i*. That correlation holds only while the two
5
+ arrays are the same length, and a disagreement is evidence they are not aligned — not an
6
+ invitation to use the overlapping prefix. Pairing them anyway truncates silently, which reads
7
+ downstream as "everything landed" for records the destination never confirmed.
8
+
9
+ This module makes the precondition explicit: either the response describes the whole chunk and the
10
+ caller acts on it, or it does not and the caller abandons the chunk audibly. Id-keyed responses
11
+ (``SQSSink``, ``SNSSink``) select by id and cannot truncate, so they do not come through here.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, NamedTuple
17
+
18
+ __all__ = ["Adjudication", "adjudicate_positional", "usable_results"]
19
+
20
+
21
+ class Adjudication[T](NamedTuple):
22
+ """The outcome of pairing a positional batch response against the records it should describe.
23
+
24
+ ``retry`` is non-empty only when ``unadjudicated`` is ``0``: the response either describes the
25
+ chunk or it does not. The two are never both non-zero.
26
+
27
+ Attributes:
28
+ retry: Records the response explicitly flagged as failed.
29
+ unadjudicated: Records whose outcome the response did not describe — ``0`` on a
30
+ well-formed response.
31
+ """
32
+
33
+ retry: list[T]
34
+ unadjudicated: int
35
+
36
+
37
+ def usable_results(results: Any) -> list[dict[str, Any]]:
38
+ """Return ``results`` if it is a list of mappings, else an empty list.
39
+
40
+ The response comes off a client the sink does not control, so the field may be any shape at
41
+ all — ``None``, a scalar, a list of non-mappings. None of those carry per-record outcomes that
42
+ can be read, so each is treated as describing nothing, which routes it to the same counted,
43
+ audible abandonment as a length mismatch. Raising instead would put the malformed-client case
44
+ back on the path this module exists to take it off.
45
+ """
46
+ if isinstance(results, list) and all(isinstance(result, dict) for result in results):
47
+ return results
48
+ return []
49
+
50
+
51
+ def adjudicate_positional[T](
52
+ records: list[T],
53
+ results: list[dict[str, Any]],
54
+ *,
55
+ error_key: str = "ErrorCode",
56
+ ) -> Adjudication[T]:
57
+ """Pair a positional batch response against the records it should describe.
58
+
59
+ Returns the subset of ``records`` whose paired ``results`` entry carries a truthy ``error_key``.
60
+ If the arrays disagree in length — including a ``results`` the caller defaulted to empty
61
+ because the response omitted it — nothing is selected and the chunk is reported unadjudicated.
62
+ ``error_key`` is a parameter so a future positional response naming its error differently needs
63
+ no fork of this rule.
64
+ """
65
+ if len(results) != len(records):
66
+ return Adjudication([], len(records))
67
+ return Adjudication(
68
+ [record for record, result in zip(records, results, strict=True) if result.get(error_key)],
69
+ 0,
70
+ )
@@ -12,13 +12,23 @@ import json
12
12
  import sys
13
13
  from typing import Any
14
14
 
15
+ from log_foundry.sinks._batch import adjudicate_positional, usable_results
15
16
  from log_foundry.sinks._chunk import chunk_items
16
17
 
17
18
  __all__ = ["FirehoseSink"]
18
19
 
19
20
 
20
21
  class FirehoseSink:
21
- """A :class:`~log_foundry.sinks.base.Sink` that writes events to a Firehose delivery stream."""
22
+ """A :class:`~log_foundry.sinks.base.Sink` that writes events to a Firehose delivery stream.
23
+
24
+ Three counters report what was not delivered: ``failed`` (the delivery stream told us these
25
+ failed, and they still failed after ``max_retries``), ``dropped_oversized`` (too large for the
26
+ per-record limit to ever accept), and ``dropped_unadjudicated`` (a ``put_record_batch`` response
27
+ whose ``RequestResponses`` did not describe the chunk that was sent, so no record in it could be
28
+ paired to an outcome). A non-zero ``dropped_unadjudicated`` means those records were abandoned
29
+ without the stream ever confirming them — treat it as loss, and as a sign the client is not
30
+ AWS-shaped.
31
+ """
22
32
 
23
33
  MAX_RECORDS = 500 # put_record_batch hard limit: records per request
24
34
  MAX_REQUEST_BYTES = 4 * 1024 * 1024 # 4 MB per put_record_batch request
@@ -34,6 +44,7 @@ class FirehoseSink:
34
44
  self.max_retries = max_retries
35
45
  self.failed = 0
36
46
  self.dropped_oversized = 0
47
+ self.dropped_unadjudicated = 0
37
48
 
38
49
  def emit(self, batch: list[dict[str, object]]) -> None:
39
50
  """Re-chunk to put_record_batch limits and send each chunk, retrying failures (FR-004)."""
@@ -74,14 +85,17 @@ class FirehoseSink:
74
85
  )
75
86
  if not response.get("FailedPutCount"):
76
87
  return
77
- results = response.get("RequestResponses", [])
78
- records = [
79
- record
80
- # strict=False states today's behaviour. A short `results` would silently
81
- # truncate this retry list — see the note in kinesis.py.
82
- for record, result in zip(records, results, strict=False)
83
- if result.get("ErrorCode")
84
- ]
88
+ results = usable_results(response.get("RequestResponses"))
89
+ verdict = adjudicate_positional(records, results)
90
+ if verdict.unadjudicated:
91
+ self.dropped_unadjudicated += verdict.unadjudicated
92
+ sys.stderr.write(
93
+ f"log-foundry: FirehoseSink could not adjudicate a put_record_batch response "
94
+ f"({len(records)} record(s) sent, {len(results)} result(s) returned); "
95
+ f"{verdict.unadjudicated} record(s) abandoned\n"
96
+ )
97
+ return
98
+ records = verdict.retry
85
99
  if not records:
86
100
  return
87
101
  if attempt >= self.max_retries:
@@ -13,13 +13,22 @@ import json
13
13
  import sys
14
14
  from typing import Any
15
15
 
16
+ from log_foundry.sinks._batch import adjudicate_positional, usable_results
16
17
  from log_foundry.sinks._chunk import chunk_items
17
18
 
18
19
  __all__ = ["KinesisSink"]
19
20
 
20
21
 
21
22
  class KinesisSink:
22
- """A :class:`~log_foundry.sinks.base.Sink` that writes events to a Kinesis Data Stream."""
23
+ """A :class:`~log_foundry.sinks.base.Sink` that writes events to a Kinesis Data Stream.
24
+
25
+ Three counters report what was not delivered: ``failed`` (the stream told us these failed, and
26
+ they still failed after ``max_retries``), ``dropped_oversized`` (too large for the per-record
27
+ limit to ever accept), and ``dropped_unadjudicated`` (a ``put_records`` response whose results
28
+ array did not describe the chunk that was sent, so no record in it could be paired to an
29
+ outcome). A non-zero ``dropped_unadjudicated`` means those records were abandoned without the
30
+ stream ever confirming them — treat it as loss, and as a sign the client is not AWS-shaped.
31
+ """
23
32
 
24
33
  MAX_RECORDS = 500 # put_records hard limit: records per request
25
34
  MAX_REQUEST_BYTES = 5 * 1024 * 1024 # 5 MB per put_records request
@@ -43,6 +52,7 @@ class KinesisSink:
43
52
  self.max_retries = max_retries
44
53
  self.failed = 0
45
54
  self.dropped_oversized = 0
55
+ self.dropped_unadjudicated = 0
46
56
 
47
57
  def emit(self, batch: list[dict[str, object]]) -> None:
48
58
  """Re-chunk to put_records limits and send each chunk, retrying failures (FR-003)."""
@@ -82,17 +92,17 @@ class KinesisSink:
82
92
  response = self.client.put_records(StreamName=self.stream_name, Records=records)
83
93
  if not response.get("FailedRecordCount"):
84
94
  return
85
- results = response.get("Records", [])
86
- records = [
87
- record
88
- # strict=False states today's behaviour rather than changing it. Note the
89
- # latent case it preserves: if `results` came back shorter than `records`
90
- # (or empty, via the `.get` default above), zip truncates, `records` empties
91
- # and the batch reports success — the silent-loss shape SPEC-017 went after.
92
- # Making that raise is a behaviour change, so it belongs in its own spec.
93
- for record, result in zip(records, results, strict=False)
94
- if result.get("ErrorCode")
95
- ]
95
+ results = usable_results(response.get("Records"))
96
+ verdict = adjudicate_positional(records, results)
97
+ if verdict.unadjudicated:
98
+ self.dropped_unadjudicated += verdict.unadjudicated
99
+ sys.stderr.write(
100
+ f"log-foundry: KinesisSink could not adjudicate a put_records response "
101
+ f"({len(records)} record(s) sent, {len(results)} result(s) returned); "
102
+ f"{verdict.unadjudicated} record(s) abandoned\n"
103
+ )
104
+ return
105
+ records = verdict.retry
96
106
  if not records:
97
107
  return
98
108
  if attempt >= self.max_retries: