log-foundry 0.10.2.dev29__tar.gz → 0.10.2.dev30__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 (55) hide show
  1. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/PKG-INFO +1 -1
  2. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/pyproject.toml +1 -1
  3. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/api.py +6 -0
  4. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/console.py +13 -2
  5. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/model.py +8 -7
  6. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sanitize.py +8 -2
  7. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/_socket.py +35 -7
  8. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/file.py +21 -7
  9. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/logstash.py +12 -0
  10. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/stdout.py +7 -2
  11. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/syslog.py +12 -0
  12. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/util.py +8 -3
  13. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/worker.py +6 -0
  14. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/LICENSE +0 -0
  15. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/README.md +0 -0
  16. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/__init__.py +0 -0
  17. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/_diag.py +0 -0
  18. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/config.py +0 -0
  19. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/context.py +0 -0
  20. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/decorator.py +0 -0
  21. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/ids.py +0 -0
  22. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/py.typed +0 -0
  23. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/__init__.py +0 -0
  24. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/_batch.py +0 -0
  25. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/_chunk.py +0 -0
  26. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/_retry.py +0 -0
  27. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/_time.py +0 -0
  28. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/base.py +0 -0
  29. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/callback.py +0 -0
  30. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/clickhouse.py +0 -0
  31. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/datadog.py +0 -0
  32. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/elasticsearch.py +0 -0
  33. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/eventhubs.py +0 -0
  34. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/filtering.py +0 -0
  35. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/firehose.py +0 -0
  36. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/honeycomb.py +0 -0
  37. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/http.py +0 -0
  38. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/kafka.py +0 -0
  39. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/kinesis.py +0 -0
  40. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/logging_sink.py +0 -0
  41. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/loki.py +0 -0
  42. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/mongodb.py +0 -0
  43. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/multi.py +0 -0
  44. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/nats.py +0 -0
  45. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/newrelic.py +0 -0
  46. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/postgres.py +0 -0
  47. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/pubsub.py +0 -0
  48. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/rabbitmq.py +0 -0
  49. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/redis.py +0 -0
  50. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/sentry.py +0 -0
  51. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/sns.py +0 -0
  52. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/splunk.py +0 -0
  53. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/sqlite.py +0 -0
  54. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/sqs.py +0 -0
  55. {log_foundry-0.10.2.dev29 → log_foundry-0.10.2.dev30}/src/log_foundry/sinks/transform.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev29
3
+ Version: 0.10.2.dev30
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.10.2.dev29"
23
+ version = "0.10.2.dev30"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -30,6 +30,12 @@ def _log(level: str, message: str, echo: bool, fields: dict[str, object]) -> Non
30
30
  the sink resolved through ``_ensure_sink`` so a zero-config orphan log falls back to
31
31
  ``StdoutSink`` rather than crashing.
32
32
 
33
+ That direct handoff is **settled, not pending**: SPEC-004's worker took over the traced
34
+ path and this branch was deliberately left synchronous, so a level call outside a span is
35
+ never silently dropped for want of a worker (``architecture.md`` §12 Resolved, "Orphan
36
+ logs"). It carried a comment saying the worker "will later" own it long after the
37
+ decision was made (SPEC-031 FR-003).
38
+
33
39
  The orphan branch is the one that reaches the sink on the caller's own thread, with no
34
40
  worker between them to absorb a failure, so the whole branch is guarded (SPEC-025
35
41
  FR-003) — ``_ensure_sink`` constructs the sink on first use, so a sink that fails to
@@ -16,13 +16,24 @@ class ConsoleWriter:
16
16
  operator sees it without waiting for the async flush. It is deliberately dumb, rendering
17
17
  an already-built event dict and knowing nothing about spans, and echo is additive — an
18
18
  echoed event still rides the normal pipeline to the sink.
19
+
20
+ The default stream is **stderr**, not stdout (SPEC-031 FR-003, which corrected two
21
+ documents that said otherwise). It is the twelve-factor convention ``StderrSink`` already
22
+ cites — logs on stderr, the application's own output on stdout — so an echo cannot corrupt
23
+ a program whose stdout is a data stream someone pipes.
19
24
  """
20
25
 
21
26
  def __init__(self, stream: TextIO | None = None) -> None:
22
- """Binds the writer to an output stream.
27
+ """Binds the writer to an output stream, once, at construction.
28
+
29
+ The binding is deliberate and permanent for the life of the writer: a later
30
+ ``contextlib.redirect_stderr`` or a test's capture of ``sys.stderr`` is not honoured,
31
+ because the attribute was resolved here. ``api._console`` is built at import, so in
32
+ practice a process's echo stream is fixed before any test runs. Passing ``stream=``
33
+ explicitly is how a caller — a test above all — captures the output (SPEC-031 FR-003).
23
34
 
24
35
  Args:
25
- stream: The stream to write to, defaulting to ``sys.stderr``.
36
+ stream: The stream to write to, defaulting to ``sys.stderr`` as resolved now.
26
37
 
27
38
  Returns:
28
39
  None.
@@ -8,6 +8,8 @@ from dataclasses import dataclass, field
8
8
  from datetime import UTC, datetime
9
9
  from typing import TYPE_CHECKING
10
10
 
11
+ from log_foundry.config import get_config
12
+ from log_foundry.ids import new_log_id
11
13
  from log_foundry.sanitize import sanitize_fields, truncate_str, truncate_tail
12
14
 
13
15
  if TYPE_CHECKING:
@@ -72,6 +74,12 @@ def build_event(
72
74
  bounded too, since both are caller-supplied text and leaving either out would keep
73
75
  ``info(huge_string)`` unbounded.
74
76
 
77
+ ``get_config`` and ``new_log_id`` are imported at module scope (SPEC-031 FR-004). They were
78
+ function-local to avoid a cycle, but there is none to avoid: neither ``config`` nor ``ids``
79
+ imports this module, and ``config``'s own back-references to ``decorator`` stay local for
80
+ the reason its docstrings give. This is the hottest path in the library, so resolving them
81
+ once at import is what ``sanitize`` already does with its one-time bindings.
82
+
75
83
  Args:
76
84
  span: The span the event belongs to, supplying identity and defaults.
77
85
  level: The severity label, such as ``"INFO"``.
@@ -85,9 +93,6 @@ def build_event(
85
93
  Raises:
86
94
  None.
87
95
  """
88
- from log_foundry.config import get_config
89
- from log_foundry.ids import new_log_id
90
-
91
96
  cfg = get_config()
92
97
  merged: dict[str, object] = {**cfg.defaults, **span.defaults, **baggage, **fields}
93
98
  safe, clipped = sanitize_fields(merged, cfg=cfg)
@@ -226,8 +231,6 @@ def end_event(
226
231
  Raises:
227
232
  None.
228
233
  """
229
- from log_foundry.config import get_config
230
-
231
234
  level = "INFO" if status == "ok" else "ERROR"
232
235
  event = build_event(span, level, _END_MESSAGE, fields={}, baggage={})
233
236
  event["duration_ms"] = (time.monotonic() - span.start_ts) * 1000.0
@@ -266,8 +269,6 @@ def backfill_baggage(span: Span, baggage: dict[str, object]) -> None:
266
269
  """
267
270
  if not baggage:
268
271
  return
269
- from log_foundry.config import get_config
270
-
271
272
  safe, clipped = sanitize_fields(baggage, cfg=get_config())
272
273
  for event in span.events:
273
274
  if event.get("message") in (_START_MESSAGE, _END_MESSAGE):
@@ -142,7 +142,7 @@ class _Coercer:
142
142
  ``build_event`` already allocates per event, it is noise.
143
143
  """
144
144
 
145
- __slots__ = ("_cfg", "_parents", "truncated")
145
+ __slots__ = ("_cfg", "_int_ceiling", "_parents", "truncated")
146
146
 
147
147
  def __init__(self, cfg: Config) -> None:
148
148
  """Starts a pass with the configured ceilings and an empty ancestor chain.
@@ -152,6 +152,11 @@ class _Coercer:
152
152
  recursion for the whole descent, so its ``id()`` cannot be recycled underneath us, and
153
153
  two siblings referencing the same object are not a cycle and must both render.
154
154
 
155
+ The integer ceiling is resolved here, once per pass rather than once per integer
156
+ (SPEC-031 FR-004): it reads ``sys.get_int_max_str_digits()``, which cannot change
157
+ during a coercion pass, and the alternative sat four lines below an ``int.__lt__``
158
+ binding justified by this being a per-value hot path.
159
+
155
160
  Args:
156
161
  cfg: The config supplying ``max_value_bytes``, ``max_keys`` and ``max_depth``.
157
162
 
@@ -162,6 +167,7 @@ class _Coercer:
162
167
  None.
163
168
  """
164
169
  self._cfg = cfg
170
+ self._int_ceiling = _int_digit_ceiling(cfg.max_value_bytes)
165
171
  self.truncated = False
166
172
  self._parents: list[int] = []
167
173
 
@@ -368,7 +374,7 @@ class _Coercer:
368
374
  """
369
375
  digits = value.bit_length() * _LOG10_2_NUM // _LOG10_2_DEN + 1
370
376
  rendered = digits + 1 if _INT_LT(value, 0) else digits
371
- if rendered <= _int_digit_ceiling(self._cfg.max_value_bytes):
377
+ if rendered <= self._int_ceiling:
372
378
  return value
373
379
  self.truncated = True
374
380
  return f"<int: ~{digits} digits>"
@@ -33,21 +33,45 @@ def _make_tcp(host: str, port: int, timeout: float) -> socket.socket:
33
33
  return socket.create_connection((host, port), timeout=timeout)
34
34
 
35
35
 
36
- def _make_udp() -> socket.socket:
37
- """Opens an unconnected UDP socket.
36
+ def _make_udp(host: str) -> socket.socket:
37
+ """Opens an unconnected UDP socket in an address family the host resolves to (SPEC-031).
38
+
39
+ The family is resolved rather than assumed: a hardcoded ``AF_INET`` made every ``sendto``
40
+ to an IPv6 destination fail, silently, until the retry bound abandoned the message. TCP
41
+ never had the defect because ``socket.create_connection`` resolves for itself.
42
+
43
+ **IPv4 wins when the host offers it**, and taking the first result instead was measured
44
+ losing logs. ``getaddrinfo`` sorts by RFC 6724, which puts AAAA first, so a dual-stack
45
+ name like ``localhost`` would move from IPv4 — where every deployment of this library has
46
+ sent — to IPv6, and a collector bound to ``0.0.0.0:514`` would never see the datagram. UDP
47
+ is unconnected, so that failure is *silent*: ``sendto`` succeeds locally, ``emit`` returns,
48
+ and no counter moves. FR-002 AC-2 requires delivery to a hostname to be unchanged, and
49
+ this is what makes it so while AC-1 still holds. It is a fixed preference, not
50
+ happy-eyeballs, address caching, or a setting — none of which this FR builds.
38
51
 
39
52
  This is a module-level seam so tests can substitute a fake socket without network access.
40
53
 
41
54
  Args:
42
- None.
55
+ host: The destination host, resolved to choose the family.
43
56
 
44
57
  Returns:
45
58
  The socket.
46
59
 
47
60
  Raises:
48
- OSError: If the socket cannot be created.
61
+ OSError: If the host resolves to nothing, or the socket cannot be created. Both reach
62
+ ``_send_one``'s handler, which counts and announces rather than raising — a
63
+ ``gaierror`` is an ``OSError``, so an unresolvable host fails exactly as an
64
+ unreachable one already did. The empty-result case is raised as one explicitly: CPython
65
+ raises rather than returning ``[]``, but indexing it would produce an ``IndexError``,
66
+ which is *not* an ``OSError`` and would escape that handler into the caller — the one
67
+ thing SPEC-025 says this library may never do.
49
68
  """
50
- return socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
69
+ families = [entry[0] for entry in socket.getaddrinfo(host, None, type=socket.SOCK_DGRAM)]
70
+ if not families:
71
+ raise socket.gaierror(socket.EAI_NONAME, "resolution returned no address family")
72
+ return socket.socket(
73
+ socket.AF_INET if socket.AF_INET in families else families[0], socket.SOCK_DGRAM
74
+ )
51
75
 
52
76
 
53
77
  class SocketTransport:
@@ -235,6 +259,10 @@ class SocketTransport:
235
259
  def _socket(self) -> socket.socket:
236
260
  """Returns the held socket, opening one if none is held.
237
261
 
262
+ The UDP address family is resolved here rather than per message, because this is the
263
+ only place a socket is created and the socket outlives every send made through it
264
+ (SPEC-031 FR-002).
265
+
238
266
  Args:
239
267
  None.
240
268
 
@@ -242,11 +270,11 @@ class SocketTransport:
242
270
  The socket.
243
271
 
244
272
  Raises:
245
- OSError: If the socket cannot be created or connected.
273
+ OSError: If the socket cannot be created, resolved or connected.
246
274
  """
247
275
  if self._sock is None:
248
276
  self._sock = (
249
- _make_udp() if self._transport == "udp" else _make_tcp(
277
+ _make_udp(self._host) if self._transport == "udp" else _make_tcp(
250
278
  self._host, self._port, self._timeout
251
279
  )
252
280
  )
@@ -113,12 +113,16 @@ class RotatingFileSink:
113
113
  Two independent triggers may be enabled, either or both. With a positive ``max_bytes`` it
114
114
  rotates before the write that would push the active file past that size, so the file never
115
115
  grows unbounded; with a ``when`` unit code and an interval it rotates on the first emit after
116
- that period has elapsed since the last rotation.
116
+ that period has elapsed since the last rotation. That period is measured on the monotonic
117
+ clock (SPEC-031 FR-001), so a wall-clock step in either direction neither defers a rotation
118
+ nor forces an early one.
117
119
 
118
120
  Rotation renames the active file through numbered backups, prunes any beyond the backup
119
121
  count, and opens a fresh active file — a backup count of zero keeps none, simply replacing
120
- the active file. No event is lost across a rotation, because the rotate happens before the
121
- pending event is written and the event lands in the fresh file.
122
+ the active file. Backups are numbered rather than timestamped, so no filename derives from
123
+ a clock at all and the monotonic deadline has no naming consequence. No event is lost across
124
+ a rotation, because the rotate happens before the pending event is written and the event
125
+ lands in the fresh file.
122
126
 
123
127
  A rotation rebinds the active stream, so it is the sink where concurrent writers did real
124
128
  damage: a second thread mid-``emit`` could write to the handle rotation had just closed, or
@@ -249,24 +253,34 @@ class RotatingFileSink:
249
253
  return unit * interval
250
254
 
251
255
  def _schedule_next(self) -> float | None:
252
- """Returns the wall-clock time of the next time-based rotation.
256
+ """Returns the monotonic-clock deadline for the next time-based rotation (SPEC-031).
257
+
258
+ Monotonic rather than wall-clock for the reason ``Span.start_ts`` is: a backward step —
259
+ an NTP correction, a container clock sync — larger than the interval would otherwise
260
+ defer every time-based rotation until wall-clock caught up, silently defeating this
261
+ class's promise to bound on-disk growth. Nothing here is a timestamp anyone reads; it is
262
+ only ever compared against another reading of the same clock.
253
263
 
254
264
  Args:
255
265
  None.
256
266
 
257
267
  Returns:
258
- The absolute time, or ``None`` when there is no time trigger.
268
+ The deadline as a ``time.monotonic()`` reading, or ``None`` when there is no time
269
+ trigger.
259
270
 
260
271
  Raises:
261
272
  None.
262
273
  """
263
274
  if self._interval_seconds is None:
264
275
  return None
265
- return time.time() + self._interval_seconds
276
+ return time.monotonic() + self._interval_seconds
266
277
 
267
278
  def _should_rotate(self, incoming: int) -> bool:
268
279
  """Decides whether to rotate before writing the next event.
269
280
 
281
+ The time trigger reads ``time.monotonic()``, the same clock :meth:`_schedule_next`
282
+ wrote the deadline on; comparing the two clocks is what the SPEC-031 fix removes.
283
+
270
284
  Args:
271
285
  incoming: The byte cost of the event about to be written.
272
286
 
@@ -282,7 +296,7 @@ class RotatingFileSink:
282
296
  and self._size + incoming > self._max_bytes
283
297
  ):
284
298
  return True
285
- return self._next_rollover is not None and time.time() >= self._next_rollover
299
+ return self._next_rollover is not None and time.monotonic() >= self._next_rollover
286
300
 
287
301
  def _rotate(self) -> None:
288
302
  """Closes the active file, shifts and prunes backups, then opens a fresh active file.
@@ -24,6 +24,18 @@ class LogstashSink:
24
24
  :class:`~log_foundry.sinks._socket.SocketTransport`. Either backend handles its own bounded
25
25
  retry and raises on total failure of its own accord, so ``emit`` needs no rule of its own.
26
26
 
27
+ In socket mode both IPv4 and IPv6 destinations are supported, over either transport
28
+ (SPEC-031 FR-002): the UDP socket's address family is resolved from ``host`` rather than
29
+ assumed, which is what an unconditional ``AF_INET`` used to make impossible.
30
+
31
+ **A hostname that resolves to both families goes to IPv4**, because that is where every
32
+ release before this one sent and moving it silently would strand a collector bound to
33
+ ``0.0.0.0``. The consequence is the mirror case, stated because nothing else states it: a
34
+ dual-stack *name* whose collector listens on IPv6 only will not be reached over UDP, and
35
+ since UDP is unconnected that failure is silent — no exception, no counter. Give the IPv6
36
+ literal, or a name with no ``A`` record, to select IPv6 for such a destination. TCP is
37
+ unaffected either way; ``create_connection`` tries each candidate in turn.
38
+
27
39
  It takes **no** transport lock (SPEC-028 FR-002) of its own: whichever backend it built owns
28
40
  that decision — ``SocketTransport`` locks its sends, ``HTTPSink`` holds no transport to
29
41
  guard. The post-close rule follows the same split (SPEC-032 FR-004), and the two modes
@@ -29,10 +29,15 @@ class StdoutSink:
29
29
  """
30
30
 
31
31
  def __init__(self, stream: TextIO | None = None) -> None:
32
- """Binds the sink to an output stream.
32
+ """Binds the sink to an output stream, once, at construction.
33
+
34
+ The binding is deliberate and permanent for the life of the sink: a later
35
+ ``contextlib.redirect_stdout`` or a test's capture of ``sys.stdout`` is not honoured,
36
+ because the attribute was resolved here. Passing ``stream=`` explicitly is how a caller
37
+ — a test above all — captures the output (SPEC-031 FR-003).
33
38
 
34
39
  Args:
35
- stream: The stream to write to, defaulting to ``sys.stdout``.
40
+ stream: The stream to write to, defaulting to ``sys.stdout`` as resolved now.
36
41
 
37
42
  Returns:
38
43
  None.
@@ -34,6 +34,18 @@ class SyslogSink:
34
34
  and a severity mapped from the event's level. UDP sends one datagram per event, TCP uses
35
35
  octet-counted framing (RFC 6587), and the whole sink is dependency-free.
36
36
 
37
+ Both IPv4 and IPv6 destinations are supported, on either transport (SPEC-031 FR-002): the
38
+ UDP socket's address family is resolved from ``host`` rather than assumed, which is what an
39
+ unconditional ``AF_INET`` used to make impossible.
40
+
41
+ **A hostname that resolves to both families goes to IPv4**, because that is where every
42
+ release before this one sent and moving it silently would strand a collector bound to
43
+ ``0.0.0.0``. The consequence is the mirror case, stated because nothing else states it: a
44
+ dual-stack *name* whose collector listens on IPv6 only will not be reached over UDP, and
45
+ since UDP is unconnected that failure is silent — no exception, no counter. Give the IPv6
46
+ literal, or a name with no ``A`` record, to select IPv6 for such a destination. TCP is
47
+ unaffected either way; ``create_connection`` tries each candidate in turn.
48
+
37
49
  It takes **no** transport lock (SPEC-028 FR-002) of its own: the socket it holds is a
38
50
  :class:`~log_foundry.sinks._socket.SocketTransport`, which locks its own sends. Its
39
51
  post-close refusal comes from there too (SPEC-032 FR-004) — a batch emitted after
@@ -20,11 +20,16 @@ class StderrSink(StdoutSink):
20
20
  """
21
21
 
22
22
  def __init__(self, stream: TextIO | None = None) -> None:
23
- """Binds the sink to an output stream.
23
+ """Binds the sink to an output stream, once, at construction.
24
+
25
+ The binding is resolved here and not re-read per write, so a later
26
+ ``contextlib.redirect_stderr`` is not honoured — the same property
27
+ :class:`~log_foundry.sinks.stdout.StdoutSink` documents (SPEC-031 FR-003), restated
28
+ because this override means none of that docstring is inherited.
24
29
 
25
30
  Args:
26
- stream: The stream to write to, defaulting to ``sys.stderr``. An explicit one, such
27
- as a ``StringIO``, can be injected for capture.
31
+ stream: The stream to write to, defaulting to ``sys.stderr`` as resolved now. An
32
+ explicit one, such as a ``StringIO``, can be injected for capture.
28
33
 
29
34
  Returns:
30
35
  None.
@@ -854,6 +854,12 @@ class Worker:
854
854
  this sweep, and that one waits out its timeout — then returns False, which is correct
855
855
  either way.
856
856
 
857
+ The reliance on ``queue.Queue``'s private ``mutex`` and ``queue`` is deliberate and is
858
+ recorded in ``architecture.md`` §13 Known Constraints (SPEC-031 FR-005): there is no
859
+ public "inspect without consuming", and the draining alternative would destroy the
860
+ evidence the terminal-failure line reports. A CPython change would surface as the test
861
+ that exercises this against a mixed queue, rather than as waiters silently timing out.
862
+
857
863
  Args:
858
864
  None.
859
865