log-foundry 0.4.1.dev2__tar.gz → 0.4.1.dev4__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 (52) hide show
  1. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/PKG-INFO +47 -2
  2. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/README.md +46 -1
  3. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/pyproject.toml +1 -1
  4. log_foundry-0.4.1.dev4/src/log_foundry/sinks/sqs.py +238 -0
  5. log_foundry-0.4.1.dev2/src/log_foundry/sinks/sqs.py +0 -107
  6. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/LICENSE +0 -0
  7. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/__init__.py +0 -0
  8. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/api.py +0 -0
  9. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/config.py +0 -0
  10. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/console.py +0 -0
  11. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/context.py +0 -0
  12. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/decorator.py +0 -0
  13. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/ids.py +0 -0
  14. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/model.py +0 -0
  15. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/py.typed +0 -0
  16. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/__init__.py +0 -0
  17. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/_chunk.py +0 -0
  18. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/_socket.py +0 -0
  19. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/_time.py +0 -0
  20. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/base.py +0 -0
  21. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/callback.py +0 -0
  22. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/clickhouse.py +0 -0
  23. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/datadog.py +0 -0
  24. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/elasticsearch.py +0 -0
  25. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/eventhubs.py +0 -0
  26. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/file.py +0 -0
  27. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/filtering.py +0 -0
  28. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/firehose.py +0 -0
  29. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/honeycomb.py +0 -0
  30. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/http.py +0 -0
  31. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/kafka.py +0 -0
  32. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/kinesis.py +0 -0
  33. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/logging_sink.py +0 -0
  34. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/logstash.py +0 -0
  35. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/loki.py +0 -0
  36. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/mongodb.py +0 -0
  37. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/multi.py +0 -0
  38. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/nats.py +0 -0
  39. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/newrelic.py +0 -0
  40. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/postgres.py +0 -0
  41. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/pubsub.py +0 -0
  42. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/rabbitmq.py +0 -0
  43. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/redis.py +0 -0
  44. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/sentry.py +0 -0
  45. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/sns.py +0 -0
  46. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/splunk.py +0 -0
  47. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/sqlite.py +0 -0
  48. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/stdout.py +0 -0
  49. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/syslog.py +0 -0
  50. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/transform.py +0 -0
  51. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/src/log_foundry/sinks/util.py +0 -0
  52. {log_foundry-0.4.1.dev2 → log_foundry-0.4.1.dev4}/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.4.1.dev2
3
+ Version: 0.4.1.dev4
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -512,7 +512,7 @@ limits, retries partial failures, and drops any single event too large to ever f
512
512
 
513
513
  | Sink | Import from | Configure |
514
514
  |---|---|---|
515
- | `SQSSink` | `log_foundry.sinks.sqs` | `SQSSink(queue_url, *, max_retries=3)` — the headline production path: a durable buffer in front of ELK, absorbing downstream spikes/outages |
515
+ | `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 |
516
516
  | `SNSSink` | `log_foundry.sinks.sns` | `SNSSink(topic_arn, *, max_retries=3)` |
517
517
  | `KinesisSink` | `log_foundry.sinks.kinesis` | `KinesisSink(stream_name, *, partition_key_field="trace_id", max_retries=3)` |
518
518
  | `FirehoseSink` | `log_foundry.sinks.firehose` | `FirehoseSink(delivery_stream, *, max_retries=3)` |
@@ -525,6 +525,51 @@ lf.configure(service="payments",
525
525
 
526
526
  Consuming from the buffer and indexing into ELK is a separate component, outside this library.
527
527
 
528
+ `SQSSink` does not retry a message SQS rejects as a **sender fault** — the retry would re-send it
529
+ byte-identical, so it can only fail the same way. Those are counted on `.failed` immediately and
530
+ the SQS error code is named on stderr. Throttles and internal errors are still retried up to
531
+ `max_retries`.
532
+
533
+ ##### FIFO queues
534
+
535
+ A queue URL ending in `.fifo` switches `SQSSink` into FIFO mode automatically — AWS requires the
536
+ suffix on every FIFO queue, so nothing needs configuring:
537
+
538
+ ```python
539
+ SQSSink(queue_url="https://sqs.us-east-1.amazonaws.com/123456789012/logs.fifo")
540
+ ```
541
+
542
+ Each message then carries a **`MessageGroupId`**, which defaults to the event's own `trace_id`.
543
+ SQS guarantees ordering *within* a group, and a trace is exactly the unit whose events should stay
544
+ ordered — while separate traces land in separate groups, so the queue delivers them in parallel
545
+ instead of serializing your whole process behind one group. (`KinesisSink` partitions on `trace_id`
546
+ by default for the same reason.) The **`MessageDeduplicationId`** defaults to the event's `log_id`,
547
+ already a per-event UUID, so SQS's five-minute deduplication window never collapses two distinct
548
+ records.
549
+
550
+ Override the group with a constant or a callable:
551
+
552
+ ```python
553
+ # One group for the whole process — strict global ordering, capped at ~300 msg/s.
554
+ SQSSink(queue_url=FIFO_URL, message_group_id="payments")
555
+
556
+ # Group by anything on the event. Baggage lands in `fields`, so this groups by tenant
557
+ # and falls back to per-trace when unset:
558
+ SQSSink(queue_url=FIFO_URL,
559
+ message_group_id=lambda e: str(e["fields"].get("tenant_id") or e["trace_id"]))
560
+ ```
561
+
562
+ Pass `fifo=True` or `fifo=False` to override the URL-based detection. Standard queues are entirely
563
+ unaffected — their messages carry neither parameter.
564
+
565
+ Two things worth knowing:
566
+
567
+ - **Ordering is best-effort across a retry.** If one message fails and a same-group message ahead
568
+ of it succeeded, the retry lands after it. Holding a whole group back on a single failure would
569
+ trade log delivery for ordering you can rebuild from `timestamp`, so the sink doesn't.
570
+ - **FIFO queues cap throughput** at 300 messages/second (3,000 with batching), or higher in
571
+ high-throughput mode. That's queue-side configuration, not something the library sets.
572
+
528
573
  #### Queue & stream
529
574
 
530
575
  Each needs its own extra (lazy-imported). All publish + retry within a bound and close cleanly.
@@ -476,7 +476,7 @@ limits, retries partial failures, and drops any single event too large to ever f
476
476
 
477
477
  | Sink | Import from | Configure |
478
478
  |---|---|---|
479
- | `SQSSink` | `log_foundry.sinks.sqs` | `SQSSink(queue_url, *, max_retries=3)` — the headline production path: a durable buffer in front of ELK, absorbing downstream spikes/outages |
479
+ | `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 |
480
480
  | `SNSSink` | `log_foundry.sinks.sns` | `SNSSink(topic_arn, *, max_retries=3)` |
481
481
  | `KinesisSink` | `log_foundry.sinks.kinesis` | `KinesisSink(stream_name, *, partition_key_field="trace_id", max_retries=3)` |
482
482
  | `FirehoseSink` | `log_foundry.sinks.firehose` | `FirehoseSink(delivery_stream, *, max_retries=3)` |
@@ -489,6 +489,51 @@ lf.configure(service="payments",
489
489
 
490
490
  Consuming from the buffer and indexing into ELK is a separate component, outside this library.
491
491
 
492
+ `SQSSink` does not retry a message SQS rejects as a **sender fault** — the retry would re-send it
493
+ byte-identical, so it can only fail the same way. Those are counted on `.failed` immediately and
494
+ the SQS error code is named on stderr. Throttles and internal errors are still retried up to
495
+ `max_retries`.
496
+
497
+ ##### FIFO queues
498
+
499
+ A queue URL ending in `.fifo` switches `SQSSink` into FIFO mode automatically — AWS requires the
500
+ suffix on every FIFO queue, so nothing needs configuring:
501
+
502
+ ```python
503
+ SQSSink(queue_url="https://sqs.us-east-1.amazonaws.com/123456789012/logs.fifo")
504
+ ```
505
+
506
+ Each message then carries a **`MessageGroupId`**, which defaults to the event's own `trace_id`.
507
+ SQS guarantees ordering *within* a group, and a trace is exactly the unit whose events should stay
508
+ ordered — while separate traces land in separate groups, so the queue delivers them in parallel
509
+ instead of serializing your whole process behind one group. (`KinesisSink` partitions on `trace_id`
510
+ by default for the same reason.) The **`MessageDeduplicationId`** defaults to the event's `log_id`,
511
+ already a per-event UUID, so SQS's five-minute deduplication window never collapses two distinct
512
+ records.
513
+
514
+ Override the group with a constant or a callable:
515
+
516
+ ```python
517
+ # One group for the whole process — strict global ordering, capped at ~300 msg/s.
518
+ SQSSink(queue_url=FIFO_URL, message_group_id="payments")
519
+
520
+ # Group by anything on the event. Baggage lands in `fields`, so this groups by tenant
521
+ # and falls back to per-trace when unset:
522
+ SQSSink(queue_url=FIFO_URL,
523
+ message_group_id=lambda e: str(e["fields"].get("tenant_id") or e["trace_id"]))
524
+ ```
525
+
526
+ Pass `fifo=True` or `fifo=False` to override the URL-based detection. Standard queues are entirely
527
+ unaffected — their messages carry neither parameter.
528
+
529
+ Two things worth knowing:
530
+
531
+ - **Ordering is best-effort across a retry.** If one message fails and a same-group message ahead
532
+ of it succeeded, the retry lands after it. Holding a whole group back on a single failure would
533
+ trade log delivery for ordering you can rebuild from `timestamp`, so the sink doesn't.
534
+ - **FIFO queues cap throughput** at 300 messages/second (3,000 with batching), or higher in
535
+ high-throughput mode. That's queue-side configuration, not something the library sets.
536
+
492
537
  #### Queue & stream
493
538
 
494
539
  Each needs its own extra (lazy-imported). All publish + retry within a bound and close cleanly.
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.4.1.dev2"
23
+ version = "0.4.1.dev4"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.34"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -0,0 +1,238 @@
1
+ """SQSSink — ship batches to an SQS queue (arch §8, §9.1, guide Phase 10).
2
+
3
+ SQS is the headline production path: a durable buffer that decouples the app from ELK
4
+ availability — events accumulate safely in the queue during downstream spikes/outages instead
5
+ of being lost or back-pressuring the app. A separate consumer indexes them into ELK (out of
6
+ scope here). Like every sink this receives *already-built* event dicts and knows nothing about
7
+ spans.
8
+
9
+ ``boto3`` is an **optional** dependency (the ``aws`` extra): it is imported lazily inside the
10
+ sink, never at module top, so ``import log_foundry.sinks.sqs`` — and the whole library — stays
11
+ dependency-free unless an ``SQSSink`` is actually instantiated without an injected client.
12
+
13
+ The worker (SPEC-004) batches by count and time, but SQS has hard per-request limits, so this
14
+ sink re-chunks every incoming batch on both dimensions: ≤ 10 messages **and** ≤ 256 KB per
15
+ ``send_message_batch``. Partial failures (the response ``Failed`` list) are retried; a single
16
+ event too large to ever fit is dropped with a warning rather than crashing the batch.
17
+
18
+ Both queue types are supported (SPEC-016). A ``.fifo`` queue URL selects FIFO behaviour, where
19
+ every entry additionally carries a ``MessageGroupId`` — SQS orders messages *within* a group, and
20
+ the default group is the event's own ``trace_id``, which is exactly the unit whose events should
21
+ stay ordered. Per-trace groups also keep traces independent, so the queue delivers them in
22
+ parallel instead of serializing the whole process behind one group. Standard queues are
23
+ untouched: their entries carry ``Id`` and ``MessageBody`` and nothing else.
24
+
25
+ Ordering is best-effort across a retry boundary: if one entry fails while a same-group entry
26
+ ahead of it succeeded, the retried entry lands *after* it. Holding a whole group back on one
27
+ failure would trade log delivery for ordering the consumer can rebuild from ``timestamp``, so it
28
+ is not done.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import sys
35
+ from collections.abc import Callable
36
+ from typing import Any, NamedTuple
37
+
38
+ __all__ = ["SQSSink"]
39
+
40
+ DEFAULT_GROUP_ID = "log-foundry"
41
+ """Fallback group for an event carrying no usable ``trace_id`` — never send an empty group id."""
42
+
43
+ MAX_ID_LEN = 128
44
+ """SQS maximum length for ``MessageGroupId`` and ``MessageDeduplicationId``."""
45
+
46
+ GroupIdSource = str | Callable[[dict[str, object]], str] | None
47
+ DedupIdSource = Callable[[dict[str, object]], str] | None
48
+
49
+
50
+ class _Prepared(NamedTuple):
51
+ """One event serialized and costed, with its FIFO ids resolved (``None`` on a standard queue).
52
+
53
+ The ids are derived **once**, here, rather than again at send time: the deduplication
54
+ fallback mints a fresh UUID, so deriving twice would bill the byte budget for one value and
55
+ put a different one on the wire.
56
+ """
57
+
58
+ body: str
59
+ group_id: str | None
60
+ dedup_id: str | None
61
+
62
+
63
+ def _bounded(raw: str, fallback: str) -> str:
64
+ """Normalize a derived id: blank falls back, over-long truncates to the SQS maximum."""
65
+ cleaned = raw.strip()
66
+ if not cleaned:
67
+ return fallback
68
+ return cleaned[:MAX_ID_LEN]
69
+
70
+
71
+ class SQSSink:
72
+ """A :class:`~log_foundry.sinks.base.Sink` that sends events to an SQS queue."""
73
+
74
+ MAX_BATCH = 10 # SQS SendMessageBatch hard limit: entries per request
75
+ MAX_BYTES = 256 * 1024 # SQS limit: 256 KB per request
76
+
77
+ def __init__(
78
+ self,
79
+ queue_url: str,
80
+ client: Any = None,
81
+ *,
82
+ max_retries: int = 3,
83
+ fifo: bool | None = None,
84
+ message_group_id: GroupIdSource = None,
85
+ message_deduplication_id: DedupIdSource = None,
86
+ ) -> None:
87
+ if isinstance(message_group_id, str) and not message_group_id.strip():
88
+ raise ValueError(
89
+ "message_group_id must be a non-empty string; pass None to group by trace_id"
90
+ )
91
+ if client is None:
92
+ import boto3 # type: ignore[import-not-found] # optional 'aws' extra
93
+
94
+ client = boto3.client("sqs")
95
+ self.queue_url = queue_url
96
+ self.client = client
97
+ self.max_retries = max_retries
98
+ # AWS requires every FIFO queue name to end in '.fifo', so the suffix is a contract
99
+ # rather than a guess — but an explicit flag still wins. Decided once, not per emit.
100
+ self.fifo = queue_url.endswith(".fifo") if fifo is None else fifo
101
+ self.message_group_id = message_group_id
102
+ self.message_deduplication_id = message_deduplication_id
103
+ self.dropped_oversized = 0 # events too large to ever fit one message
104
+ self.failed = 0 # entries still failing after the retry bound
105
+
106
+ def emit(self, batch: list[dict[str, object]]) -> None:
107
+ """Re-chunk ``batch`` to SQS limits and send each chunk (FR-001, FR-002)."""
108
+ for chunk in self._chunks(batch):
109
+ self._send(chunk)
110
+
111
+ def close(self) -> None:
112
+ """No-op: the sink buffers nothing internally (FR-005)."""
113
+
114
+ # -- internals ----------------------------------------------------------------------
115
+
116
+ def _group_id(self, event: dict[str, object]) -> str:
117
+ """Resolve one event's ``MessageGroupId`` (SPEC-016 FR-002).
118
+
119
+ A constant is used as given; a callable is asked; otherwise the event's own
120
+ ``trace_id`` groups it. Every route is bounded by :func:`_bounded`, so a caller's
121
+ callable returning ``""`` cannot put an empty parameter on the wire.
122
+ """
123
+ source = self.message_group_id
124
+ if isinstance(source, str):
125
+ raw = source
126
+ elif source is not None:
127
+ raw = source(event)
128
+ else:
129
+ raw = str(event.get("trace_id", ""))
130
+ return _bounded(raw, DEFAULT_GROUP_ID)
131
+
132
+ def _dedup_id(self, event: dict[str, object]) -> str:
133
+ """Resolve one event's ``MessageDeduplicationId`` (SPEC-016 FR-003).
134
+
135
+ Defaults to the event's ``log_id`` — already a per-event UUID, so SQS's five-minute
136
+ deduplication window can never collapse two genuinely distinct records. The fallback
137
+ mints a fresh id for the same reason: a shared constant would make unrelated events
138
+ deduplicate each other.
139
+ """
140
+ source = self.message_deduplication_id
141
+ raw = source(event) if source is not None else str(event.get("log_id", ""))
142
+ if raw.strip():
143
+ return _bounded(raw, "")
144
+ from log_foundry.ids import new_log_id
145
+
146
+ return new_log_id()
147
+
148
+ def _chunks(self, batch: list[dict[str, object]]) -> list[list[_Prepared]]:
149
+ """Split ``batch`` into sends of ≤ ``MAX_BATCH`` entries and ≤ ``MAX_BYTES`` each.
150
+
151
+ Each event is serialized once with ``json.dumps``. An event whose serialized size
152
+ exceeds ``MAX_BYTES`` on its own can never fit a message, so it is dropped with a
153
+ counted warning (FR-004) instead of stalling the batch — judged on the body alone, so
154
+ an event's droppability does not depend on which queue type it is bound for.
155
+
156
+ On a FIFO queue the resolved ids are costed alongside the body (SPEC-016 FR-005): they
157
+ travel in the same request, so leaving them out of the budget could overshoot 256 KB.
158
+ """
159
+ chunks: list[list[_Prepared]] = []
160
+ current: list[_Prepared] = []
161
+ current_bytes = 0
162
+ for event in batch:
163
+ body = json.dumps(event)
164
+ size = len(body.encode("utf-8"))
165
+ if size > self.MAX_BYTES:
166
+ self.dropped_oversized += 1
167
+ sys.stderr.write(
168
+ f"log-foundry: dropped an event of {size} bytes exceeding the "
169
+ f"{self.MAX_BYTES}-byte SQS message limit\n"
170
+ )
171
+ continue
172
+ group_id: str | None = None
173
+ dedup_id: str | None = None
174
+ if self.fifo:
175
+ group_id = self._group_id(event)
176
+ dedup_id = self._dedup_id(event)
177
+ size += len(group_id.encode("utf-8")) + len(dedup_id.encode("utf-8"))
178
+ if current and (
179
+ len(current) >= self.MAX_BATCH or current_bytes + size > self.MAX_BYTES
180
+ ):
181
+ chunks.append(current)
182
+ current = []
183
+ current_bytes = 0
184
+ current.append(_Prepared(body, group_id, dedup_id))
185
+ current_bytes += size
186
+ if current:
187
+ chunks.append(current)
188
+ return chunks
189
+
190
+ def _send(self, prepared: list[_Prepared]) -> None:
191
+ """Send one valid chunk, retrying only the ``Failed`` entries with a bounded count.
192
+
193
+ Successfully-sent entries are never re-sent; entries still failing past ``max_retries``
194
+ are counted (``failed``) and logged, not silently dropped (FR-003). The FIFO parameters
195
+ are attached only when the queue is FIFO, so a standard queue's entries are exactly
196
+ ``Id`` + ``MessageBody`` (SPEC-016 FR-004).
197
+
198
+ Entries SQS marks ``SenderFault`` are abandoned rather than retried (SPEC-016 FR-006):
199
+ a retry re-sends them byte-identical, so a fault in the request itself can only fail
200
+ the same way. Throttles and internal errors carry ``SenderFault: false`` and are still
201
+ retried under the bound.
202
+ """
203
+ entries: list[dict[str, str]] = []
204
+ for i, item in enumerate(prepared):
205
+ entry = {"Id": str(i), "MessageBody": item.body}
206
+ if item.group_id is not None:
207
+ entry["MessageGroupId"] = item.group_id
208
+ if item.dedup_id is not None:
209
+ entry["MessageDeduplicationId"] = item.dedup_id
210
+ entries.append(entry)
211
+ for attempt in range(self.max_retries + 1):
212
+ response = self.client.send_message_batch(QueueUrl=self.queue_url, Entries=entries)
213
+ failed = response.get("Failed", [])
214
+ if not failed:
215
+ return
216
+ # Abandon sender faults immediately and name the code: the retry would re-send the
217
+ # entry byte-identical, and the code is the only thing that makes a rejection
218
+ # diagnosable from the log line alone. A missing flag is treated as retryable, so
219
+ # an unfamiliar response shape degrades to the old behaviour rather than dropping.
220
+ sender_faults = [item for item in failed if item.get("SenderFault")]
221
+ if sender_faults:
222
+ self.failed += len(sender_faults)
223
+ sys.stderr.write(
224
+ f"log-foundry: {len(sender_faults)} SQS message(s) rejected as invalid "
225
+ f"(first code: {sender_faults[0].get('Code', 'unknown')}); not retried\n"
226
+ )
227
+
228
+ retryable_ids = {item["Id"] for item in failed if not item.get("SenderFault")}
229
+ if not retryable_ids:
230
+ return
231
+ entries = [entry for entry in entries if entry["Id"] in retryable_ids]
232
+ if attempt >= self.max_retries:
233
+ self.failed += len(entries)
234
+ sys.stderr.write(
235
+ f"log-foundry: {len(entries)} SQS message(s) still failing after "
236
+ f"{self.max_retries + 1} attempts; abandoned\n"
237
+ )
238
+ return
@@ -1,107 +0,0 @@
1
- """SQSSink — ship batches to an SQS queue (arch §8, §9.1, guide Phase 10).
2
-
3
- SQS is the headline production path: a durable buffer that decouples the app from ELK
4
- availability — events accumulate safely in the queue during downstream spikes/outages instead
5
- of being lost or back-pressuring the app. A separate consumer indexes them into ELK (out of
6
- scope here). Like every sink this receives *already-built* event dicts and knows nothing about
7
- spans.
8
-
9
- ``boto3`` is an **optional** dependency (the ``aws`` extra): it is imported lazily inside the
10
- sink, never at module top, so ``import log_foundry.sinks.sqs`` — and the whole library — stays
11
- dependency-free unless an ``SQSSink`` is actually instantiated without an injected client.
12
-
13
- The worker (SPEC-004) batches by count and time, but SQS has hard per-request limits, so this
14
- sink re-chunks every incoming batch on both dimensions: ≤ 10 messages **and** ≤ 256 KB per
15
- ``send_message_batch``. Partial failures (the response ``Failed`` list) are retried; a single
16
- event too large to ever fit is dropped with a warning rather than crashing the batch.
17
- """
18
-
19
- from __future__ import annotations
20
-
21
- import json
22
- import sys
23
- from typing import Any
24
-
25
- __all__ = ["SQSSink"]
26
-
27
-
28
- class SQSSink:
29
- """A :class:`~log_foundry.sinks.base.Sink` that sends events to an SQS queue."""
30
-
31
- MAX_BATCH = 10 # SQS SendMessageBatch hard limit: entries per request
32
- MAX_BYTES = 256 * 1024 # SQS limit: 256 KB per request
33
-
34
- def __init__(self, queue_url: str, client: Any = None, *, max_retries: int = 3) -> None:
35
- if client is None:
36
- import boto3 # type: ignore[import-not-found] # optional 'aws' extra
37
-
38
- client = boto3.client("sqs")
39
- self.queue_url = queue_url
40
- self.client = client
41
- self.max_retries = max_retries
42
- self.dropped_oversized = 0 # events too large to ever fit one message
43
- self.failed = 0 # entries still failing after the retry bound
44
-
45
- def emit(self, batch: list[dict[str, object]]) -> None:
46
- """Re-chunk ``batch`` to SQS limits and send each chunk (FR-001, FR-002)."""
47
- for chunk in self._chunks(batch):
48
- self._send(chunk)
49
-
50
- def close(self) -> None:
51
- """No-op: the sink buffers nothing internally (FR-005)."""
52
-
53
- # -- internals ----------------------------------------------------------------------
54
-
55
- def _chunks(self, batch: list[dict[str, object]]) -> list[list[str]]:
56
- """Split ``batch`` into sends of ≤ ``MAX_BATCH`` bodies and ≤ ``MAX_BYTES`` each.
57
-
58
- Each event is serialized once with ``json.dumps``. An event whose serialized size
59
- exceeds ``MAX_BYTES`` on its own can never fit a message, so it is dropped with a
60
- counted warning (FR-004) instead of stalling the batch.
61
- """
62
- chunks: list[list[str]] = []
63
- current: list[str] = []
64
- current_bytes = 0
65
- for event in batch:
66
- body = json.dumps(event)
67
- size = len(body.encode("utf-8"))
68
- if size > self.MAX_BYTES:
69
- self.dropped_oversized += 1
70
- sys.stderr.write(
71
- f"log-foundry: dropped an event of {size} bytes exceeding the "
72
- f"{self.MAX_BYTES}-byte SQS message limit\n"
73
- )
74
- continue
75
- if current and (
76
- len(current) >= self.MAX_BATCH or current_bytes + size > self.MAX_BYTES
77
- ):
78
- chunks.append(current)
79
- current = []
80
- current_bytes = 0
81
- current.append(body)
82
- current_bytes += size
83
- if current:
84
- chunks.append(current)
85
- return chunks
86
-
87
- def _send(self, bodies: list[str]) -> None:
88
- """Send one valid chunk, retrying only the ``Failed`` entries with a bounded count.
89
-
90
- Successfully-sent entries are never re-sent; entries still failing past ``max_retries``
91
- are counted (``failed``) and logged, not silently dropped (FR-003).
92
- """
93
- entries = [{"Id": str(i), "MessageBody": body} for i, body in enumerate(bodies)]
94
- for attempt in range(self.max_retries + 1):
95
- response = self.client.send_message_batch(QueueUrl=self.queue_url, Entries=entries)
96
- failed = response.get("Failed", [])
97
- if not failed:
98
- return
99
- failed_ids = {entry["Id"] for entry in failed}
100
- entries = [entry for entry in entries if entry["Id"] in failed_ids]
101
- if attempt >= self.max_retries:
102
- self.failed += len(entries)
103
- sys.stderr.write(
104
- f"log-foundry: {len(entries)} SQS message(s) still failing after "
105
- f"{self.max_retries + 1} attempts; abandoned\n"
106
- )
107
- return