log-foundry 0.4.1.dev2__py3-none-any.whl → 0.4.1.dev3__py3-none-any.whl

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/sinks/sqs.py CHANGED
@@ -14,16 +14,59 @@ The worker (SPEC-004) batches by count and time, but SQS has hard per-request li
14
14
  sink re-chunks every incoming batch on both dimensions: ≤ 10 messages **and** ≤ 256 KB per
15
15
  ``send_message_batch``. Partial failures (the response ``Failed`` list) are retried; a single
16
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.
17
29
  """
18
30
 
19
31
  from __future__ import annotations
20
32
 
21
33
  import json
22
34
  import sys
23
- from typing import Any
35
+ from collections.abc import Callable
36
+ from typing import Any, NamedTuple
24
37
 
25
38
  __all__ = ["SQSSink"]
26
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
+
27
70
 
28
71
  class SQSSink:
29
72
  """A :class:`~log_foundry.sinks.base.Sink` that sends events to an SQS queue."""
@@ -31,7 +74,20 @@ class SQSSink:
31
74
  MAX_BATCH = 10 # SQS SendMessageBatch hard limit: entries per request
32
75
  MAX_BYTES = 256 * 1024 # SQS limit: 256 KB per request
33
76
 
34
- def __init__(self, queue_url: str, client: Any = None, *, max_retries: int = 3) -> None:
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
+ )
35
91
  if client is None:
36
92
  import boto3 # type: ignore[import-not-found] # optional 'aws' extra
37
93
 
@@ -39,6 +95,11 @@ class SQSSink:
39
95
  self.queue_url = queue_url
40
96
  self.client = client
41
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
42
103
  self.dropped_oversized = 0 # events too large to ever fit one message
43
104
  self.failed = 0 # entries still failing after the retry bound
44
105
 
@@ -52,15 +113,51 @@ class SQSSink:
52
113
 
53
114
  # -- internals ----------------------------------------------------------------------
54
115
 
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.
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.
57
150
 
58
151
  Each event is serialized once with ``json.dumps``. An event whose serialized size
59
152
  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.
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.
61
158
  """
62
- chunks: list[list[str]] = []
63
- current: list[str] = []
159
+ chunks: list[list[_Prepared]] = []
160
+ current: list[_Prepared] = []
64
161
  current_bytes = 0
65
162
  for event in batch:
66
163
  body = json.dumps(event)
@@ -72,25 +169,40 @@ class SQSSink:
72
169
  f"{self.MAX_BYTES}-byte SQS message limit\n"
73
170
  )
74
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"))
75
178
  if current and (
76
179
  len(current) >= self.MAX_BATCH or current_bytes + size > self.MAX_BYTES
77
180
  ):
78
181
  chunks.append(current)
79
182
  current = []
80
183
  current_bytes = 0
81
- current.append(body)
184
+ current.append(_Prepared(body, group_id, dedup_id))
82
185
  current_bytes += size
83
186
  if current:
84
187
  chunks.append(current)
85
188
  return chunks
86
189
 
87
- def _send(self, bodies: list[str]) -> None:
190
+ def _send(self, prepared: list[_Prepared]) -> None:
88
191
  """Send one valid chunk, retrying only the ``Failed`` entries with a bounded count.
89
192
 
90
193
  Successfully-sent entries are never re-sent; entries still failing past ``max_retries``
91
- are counted (``failed``) and logged, not silently dropped (FR-003).
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).
92
197
  """
93
- entries = [{"Id": str(i), "MessageBody": body} for i, body in enumerate(bodies)]
198
+ entries: list[dict[str, str]] = []
199
+ for i, item in enumerate(prepared):
200
+ entry = {"Id": str(i), "MessageBody": item.body}
201
+ if item.group_id is not None:
202
+ entry["MessageGroupId"] = item.group_id
203
+ if item.dedup_id is not None:
204
+ entry["MessageDeduplicationId"] = item.dedup_id
205
+ entries.append(entry)
94
206
  for attempt in range(self.max_retries + 1):
95
207
  response = self.client.send_message_batch(QueueUrl=self.queue_url, Entries=entries)
96
208
  failed = response.get("Failed", [])
@@ -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.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
@@ -39,13 +39,13 @@ log_foundry/sinks/sentry.py,sha256=EfOpEjcJZvYZCaSIVMbpLOxzaxwo7e5zHWfFhzNuCJU,5
39
39
  log_foundry/sinks/sns.py,sha256=J3ILBbyzl6jwf_A2wuIsrWjKUItcPRMXEm7bj4g2zSs,3304
40
40
  log_foundry/sinks/splunk.py,sha256=JlPOC1MRMrRbT7RoV87bMc_azdoPYvghlcfsAJacRuI,1874
41
41
  log_foundry/sinks/sqlite.py,sha256=lD4289rsArlqnFNmMGewLeHexdK-Hp-3Z11GsEqKLgM,4621
42
- log_foundry/sinks/sqs.py,sha256=uQdN9kNpVZeEIKpm2eN8oiWQjvKeAYeD2Ra9FbJxfh0,4799
42
+ log_foundry/sinks/sqs.py,sha256=aEQvjKso71MGfQ9ZVkCCWIm81a1RQJHaNRVJOdTTdrA,10005
43
43
  log_foundry/sinks/stdout.py,sha256=X_hVTUAAsppx1k8znuBveOZZwj3FqcTwLH185_JWJUU,1052
44
44
  log_foundry/sinks/syslog.py,sha256=CAQ957jBIlAzrLyrOmtzspqcnAFBfYHGrIN8I35DGMw,3381
45
45
  log_foundry/sinks/transform.py,sha256=eeZpaMxc7urrZ07Vc4GQ-sIq2mTYhoA_dI4Ur4Mmb4s,1613
46
46
  log_foundry/sinks/util.py,sha256=SCYFxW9Jm-bIVsyOKir5JWWbwP7u5VFnhNmncMIkwBM,2759
47
47
  log_foundry/worker.py,sha256=bbBwMwecVOBu1k6rbSLPwM0zzxN-ebGdy5X2Mo5Q14s,11014
48
- log_foundry-0.4.1.dev2.dist-info/METADATA,sha256=IkkNXeEVzy1S4hQv_Hs8Hyo9LXbgSneoujobMsRrEHk,38154
49
- log_foundry-0.4.1.dev2.dist-info/WHEEL,sha256=eY7nduwzv-ldUxpzbRlxwvC693Hg6PX8bWDjEHjZ_dk,88
50
- log_foundry-0.4.1.dev2.dist-info/licenses/LICENSE,sha256=HaCckyCX8P6TR8gY5xyEsFi050Hs2kl-JqaO9Vi0Sw0,1072
51
- log_foundry-0.4.1.dev2.dist-info/RECORD,,
48
+ log_foundry-0.4.1.dev3.dist-info/METADATA,sha256=1lvQt16ntpUB8iSd7vC_Eyuax4ZInfCINl1Bw3bRu_g,38154
49
+ log_foundry-0.4.1.dev3.dist-info/WHEEL,sha256=eY7nduwzv-ldUxpzbRlxwvC693Hg6PX8bWDjEHjZ_dk,88
50
+ log_foundry-0.4.1.dev3.dist-info/licenses/LICENSE,sha256=HaCckyCX8P6TR8gY5xyEsFi050Hs2kl-JqaO9Vi0Sw0,1072
51
+ log_foundry-0.4.1.dev3.dist-info/RECORD,,