log-foundry 0.10.2.dev55__tar.gz → 0.10.2.dev56__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 (59) hide show
  1. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/PKG-INFO +1 -1
  2. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/pyproject.toml +1 -1
  3. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/_fork.py +91 -10
  4. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/base.py +27 -0
  5. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/file.py +97 -0
  6. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/LICENSE +0 -0
  7. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/README.md +0 -0
  8. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/__init__.py +0 -0
  9. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/_diag.py +0 -0
  10. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/_lifecycle.py +0 -0
  11. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/api.py +0 -0
  12. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/config.py +0 -0
  13. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/console.py +0 -0
  14. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/context.py +0 -0
  15. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/decorator.py +0 -0
  16. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/ids.py +0 -0
  17. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/model.py +0 -0
  18. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/py.typed +0 -0
  19. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/results.py +0 -0
  20. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sanitize.py +0 -0
  21. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/__init__.py +0 -0
  22. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/_batch.py +0 -0
  23. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/_chunk.py +0 -0
  24. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/_retry.py +0 -0
  25. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/_socket.py +0 -0
  26. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/_time.py +0 -0
  27. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/callback.py +0 -0
  28. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/clickhouse.py +0 -0
  29. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/datadog.py +0 -0
  30. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/elasticsearch.py +0 -0
  31. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/eventhubs.py +0 -0
  32. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/filtering.py +0 -0
  33. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/firehose.py +0 -0
  34. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/honeycomb.py +0 -0
  35. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/http.py +0 -0
  36. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/kafka.py +0 -0
  37. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/kinesis.py +0 -0
  38. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/logging_sink.py +0 -0
  39. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/logstash.py +0 -0
  40. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/loki.py +0 -0
  41. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/memory.py +0 -0
  42. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/mongodb.py +0 -0
  43. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/multi.py +0 -0
  44. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/nats.py +0 -0
  45. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/newrelic.py +0 -0
  46. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/null.py +0 -0
  47. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/postgres.py +0 -0
  48. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/pubsub.py +0 -0
  49. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/rabbitmq.py +0 -0
  50. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/redis.py +0 -0
  51. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/sentry.py +0 -0
  52. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/sns.py +0 -0
  53. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/splunk.py +0 -0
  54. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/sqlite.py +0 -0
  55. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/sqs.py +0 -0
  56. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/stdout.py +0 -0
  57. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/syslog.py +0 -0
  58. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/src/log_foundry/sinks/transform.py +0 -0
  59. {log_foundry-0.10.2.dev55 → log_foundry-0.10.2.dev56}/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.10.2.dev55
3
+ Version: 0.10.2.dev56
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.dev55"
23
+ version = "0.10.2.dev56"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -330,7 +330,43 @@ def _fresh_primitive(value: Any, memo: dict[int, Any], keepalive: list[Any]) ->
330
330
  return fresh
331
331
 
332
332
 
333
- def _reinit_primitives() -> None:
333
+ _DISCARD_HOOK = "discard_buffered_after_fork"
334
+ """The optional sink member a forked child asks to strand the parent's pending bytes.
335
+
336
+ Probed by name, as ``losses()`` and ``log_foundry_stop_signal`` are, so no existing sink stops
337
+ satisfying ``Sink`` by not having it. ``sinks/base.py`` states the contract; this is the only
338
+ place the name is read.
339
+ """
340
+
341
+
342
+ def _offers_discard(holder: object) -> bool:
343
+ """Whether this object carries FR-004's buffer hook, without letting the question raise.
344
+
345
+ Every other read the walk makes is guarded individually, and this one has to be too: an
346
+ owned ``__getattr__`` raising anything but ``AttributeError`` would abort the walk, and
347
+ what is lost then is not a buffer but the **lock repair** for everything the walk had not
348
+ reached yet — the hang this module exists to remove, arrived at through the probe for a
349
+ different hazard. Nothing owned defines ``__getattr__`` today.
350
+
351
+ Callability is part of the question, so an attribute of that name which is not a method is
352
+ simply not the hook.
353
+
354
+ Args:
355
+ holder: Any object the walk has entered.
356
+
357
+ Returns:
358
+ Whether it offers the hook.
359
+
360
+ Raises:
361
+ None.
362
+ """
363
+ try:
364
+ return callable(getattr(holder, _DISCARD_HOOK, None))
365
+ except Exception:
366
+ return False
367
+
368
+
369
+ def _reinit_primitives() -> list[Any]:
334
370
  """Replaces every lock and event this package owns, wherever the walk reaches one.
335
371
 
336
372
  An inherited ``Lock`` stays locked with no owner — measured, ``acquire(timeout=1)`` returns
@@ -345,11 +381,17 @@ def _reinit_primitives() -> None:
345
381
  events measured 202 ms, against 0.45 ms idle. That is accepted rather than bounded: a cap
346
382
  would be a lock this cannot promise to find, and the alternative to finding it is a hang.
347
383
 
384
+ **The sinks carrying FR-004's buffer hook are collected on this same walk** rather than by
385
+ a second one, because the traversal is the expensive part and its cost is proportional to
386
+ caller data — paying 202 ms twice on a process that forks is a worse trade than one function
387
+ reporting what it passed. Collected, not called: a hook may take a lock, and the contract is
388
+ that every lock is the child's own before any of them runs (FR-001 AC-2).
389
+
348
390
  Args:
349
391
  None.
350
392
 
351
393
  Returns:
352
- None.
394
+ The owned instances carrying :data:`_DISCARD_HOOK`, in the order the walk reached them.
353
395
 
354
396
  Raises:
355
397
  None.
@@ -357,6 +399,7 @@ def _reinit_primitives() -> None:
357
399
  memo: dict[int, Any] = {}
358
400
  keepalive: list[Any] = []
359
401
  seen: set[int] = set()
402
+ buffered: list[Any] = []
360
403
  stack: list[Any] = [
361
404
  module
362
405
  for name, module in list(sys.modules.items())
@@ -369,14 +412,49 @@ def _reinit_primitives() -> None:
369
412
  seen.add(id(holder))
370
413
  if _is_container(holder):
371
414
  stack.extend(child for child in _container_children(holder) if _is_traversable(child))
372
- if not isinstance(holder, types.ModuleType | type) and not _is_owned(holder):
373
- continue
415
+ if not isinstance(holder, types.ModuleType | type):
416
+ if not _is_owned(holder):
417
+ continue
418
+ if _offers_discard(holder):
419
+ buffered.append(holder)
374
420
  for name, value in _namespace_items(holder):
375
421
  fresh = _fresh_primitive(value, memo, keepalive)
376
422
  if fresh is not None:
377
423
  _assign(holder, name, fresh)
378
424
  elif _is_traversable(value):
379
425
  stack.append(value)
426
+ return buffered
427
+
428
+
429
+ def _discard_buffers(holders: list[Any]) -> None:
430
+ """Asks each sink that owns a buffered stream to strand what it inherited (FR-004).
431
+
432
+ A fork landing inside ``emit`` — after the write loop, before the flush — leaves the child
433
+ holding the parent's unflushed bytes, which both processes then write: measured, the event
434
+ at the fork point appeared on disk twice. Without a ``before`` handler there is nowhere to
435
+ empty the buffer from (FR-001), so the child throws its copy away instead.
436
+
437
+ Args:
438
+ holders: What :func:`_reinit_primitives` collected on its walk.
439
+
440
+ Returns:
441
+ None.
442
+
443
+ Raises:
444
+ None. One sink's failure is absorbed separately from the rest, so a hook that cannot
445
+ strand its buffer costs that sink a duplicated batch rather than costing every other
446
+ sink its repair — and the consequence is named, because an absorbed failure is
447
+ invisible apart from that line.
448
+ """
449
+ for holder in holders:
450
+ try:
451
+ getattr(holder, _DISCARD_HOOK)()
452
+ except Exception as exc:
453
+ _diag.absorbed(
454
+ "discarding an inherited buffer after a fork",
455
+ exc,
456
+ f"{type(holder).__name__} may write the parent's pending bytes again",
457
+ )
380
458
 
381
459
 
382
460
  def register_child_handler(fn: Callable[[], None]) -> None:
@@ -414,11 +492,12 @@ def _reinit_after_fork() -> None:
414
492
  """Repairs the library in a child that has just returned from ``fork``.
415
493
 
416
494
  **The order of work here is the contract** (FR-001 AC-2): locks and events first, then the
417
- registered handlers. A lock re-initialised *after* a handler that takes it is a handler
418
- that hangs, and it hangs on the child's only thread with nothing to interrupt it. Work that
419
- must happen before *any* handler belongs inline between the two steps rather than registered
420
- — the buffer discard FR-004 adds is the case, and registering it would put it after
421
- ``decorator``'s rebuild, which has started a live drain thread by then.
495
+ buffer discard, then the registered handlers. A lock re-initialised *after* a handler that
496
+ takes it is a handler that hangs, and it hangs on the child's only thread with nothing to
497
+ interrupt it. Work that must happen before *any* handler stays inline between the two steps
498
+ rather than being registered — the discard is the case, and registering it would put it
499
+ after ``decorator``'s rebuild, which has started a live drain thread by then, emitting into
500
+ the very sink whose buffer is still the parent's.
422
501
 
423
502
  Args:
424
503
  None.
@@ -436,10 +515,12 @@ def _reinit_after_fork() -> None:
436
515
  registers another simply causes it to run — and :func:`register_child_handler` is the
437
516
  only writer, appending only.
438
517
  """
518
+ buffered: list[Any] = []
439
519
  try:
440
- _reinit_primitives()
520
+ buffered = _reinit_primitives()
441
521
  except Exception as exc:
442
522
  _diag.absorbed("repairing the library after a fork", exc, "this child may block or lose")
523
+ _discard_buffers(buffered)
443
524
  for handler in _child_handlers:
444
525
  try:
445
526
  handler()
@@ -89,6 +89,33 @@ class Sink(Protocol):
89
89
  already used, with a ``threading.Event``. Reproduced. A prefixed name cannot collide by
90
90
  accident, and the cost is one verbose attribute on the sinks that want interruptibility.
91
91
 
92
+ A fifth is optional in the same way and exists for one event only:
93
+ ``discard_buffered_after_fork() -> None``. A sink that owns a **buffered** stream — one it
94
+ opened itself, rather than the process's ``sys.stdout`` — inherits the parent's unflushed
95
+ bytes in a forked child, and both processes then write them (SPEC-039 FR-004). Defining this
96
+ method is how a sink says it can strand what it inherited; a sink holding no buffer of its
97
+ own needs nothing here. It is *called* rather than assigned, which is why it carries no
98
+ ``log_foundry_`` prefix: the collision the fourth member's name guards against is an
99
+ attribute the library writes onto an object it does not own.
100
+
101
+ **Which sinks are actually asked is narrower than "whichever define it", and the boundary is
102
+ the same one every fork repair has.** The child's repair walks this package's own objects —
103
+ a sink whose class is defined here or **inherits** one that is, ``Sink`` included, and the
104
+ **owned** sinks such an object holds, ``MultiSink``'s children among them. A third-party
105
+ sink satisfying this Protocol *structurally*, which is how every shipped sink satisfies it,
106
+ is outside that walk exactly as its locks are (SPEC-039 FR-005): its hook is never called,
107
+ and a fork mid-batch there can still duplicate — including when a wrapper this library owns
108
+ is holding it. Inheriting from ``Sink`` or from a shipped sink is what brings it inside. A
109
+ **wrapper** sink the walk enters need do nothing itself, because the owned sinks it holds
110
+ are reached directly rather than through it.
111
+
112
+ **It runs in a child that has not yet returned from ``fork``, on that child's only thread,
113
+ and must not block.** Nothing else is running there, so there is no holder a lock could be
114
+ waiting for — and no watchdog to end a wait taken anyway, since a pending alarm does not
115
+ survive the fork and this runs before any application code can arm one. It may raise: the
116
+ library absorbs and announces that, and the cost is a child that may write the parent's
117
+ pending bytes a second time.
118
+
92
119
  "Safe to call during an emit" is a concurrency requirement once :meth:`emit` is (SPEC-028
93
120
  FR-003). The shipped sinks keep their loss counters under a **dedicated** lock, separate
94
121
  from whatever guards their transport: an increment is a read-modify-write that Python does
@@ -20,6 +20,46 @@ _WHEN_SECONDS = {
20
20
  }
21
21
 
22
22
 
23
+ def _reopen_discarding(stream: TextIO, path: str, encoding: str) -> TextIO:
24
+ """Strands an inherited stream's pending bytes and returns a fresh one on the same path.
25
+
26
+ ``os.dup2`` points the inherited descriptor at ``os.devnull``, so the buffer this process
27
+ inherited can only ever reach the null device — whether it is flushed deliberately, by the
28
+ interpreter at exit, or by the garbage collector when the old object is dropped. The two
29
+ steps are written in this order for readability and **not** because the order is what makes
30
+ it safe: the replacement takes a different descriptor either way, since the inherited one is
31
+ still occupied, and nothing else is running to flush anything in between. Reopening rather
32
+ than reusing the descriptor is what gives the child a stream of its own, and it picks up the
33
+ currently active file if the parent rotated.
34
+
35
+ ``dup2`` leaves the redirected descriptor **inheritable** across an ``exec``, where the one
36
+ ``open`` produced carried ``O_CLOEXEC``. That is a behaviour change and it is harmless: the
37
+ descriptor names the null device, so what an exec'd process inherits is a handle on nothing.
38
+
39
+ Args:
40
+ stream: The inherited stream, still holding whatever the parent had not flushed.
41
+ path: The file to reopen. **Append mode**, never write mode: the child shares this file
42
+ with the parent and with whatever was written before either existed, so truncating here
43
+ would destroy a log to protect it — strictly worse than the duplication this prevents.
44
+ encoding: The text encoding to open it in, carried across so a child does not start
45
+ writing a differently-encoded second half into the parent's file.
46
+
47
+ Returns:
48
+ The replacement stream.
49
+
50
+ Raises:
51
+ OSError: If the descriptor cannot be redirected or the path cannot be reopened.
52
+ ValueError: If the stream has no usable descriptor, which means there is no inherited
53
+ buffer this can strand and the caller must not carry on as though there were.
54
+ """
55
+ devnull = os.open(os.devnull, os.O_WRONLY)
56
+ try:
57
+ os.dup2(devnull, stream.fileno())
58
+ finally:
59
+ os.close(devnull)
60
+ return open(path, "a", encoding=encoding)
61
+
62
+
23
63
  class FileSink:
24
64
  """A :class:`~log_foundry.sinks.base.Sink` that appends events as NDJSON to one file.
25
65
 
@@ -84,6 +124,35 @@ class FileSink:
84
124
  self._stream.write(json.dumps(event) + "\n")
85
125
  self._stream.flush()
86
126
 
127
+ def discard_buffered_after_fork(self) -> None:
128
+ """Throws away the parent's unflushed bytes in a forked child (SPEC-039 FR-004).
129
+
130
+ ``emit`` writes a whole batch into a **buffered** stream and flushes once at the end, so
131
+ a fork landing inside it leaves both processes holding the same pending bytes and both
132
+ writing them: measured, the event at the fork point appeared on disk twice. Without a
133
+ ``before`` handler there is nowhere to empty the buffer from (FR-001), so the child
134
+ strands it instead — the parent's copy is untouched and still reaches disk exactly once.
135
+
136
+ No lock is taken, for the reason ``decorator._rebuild_worker_after_fork`` gives: there is
137
+ one thread here by construction, and the lock was re-initialised moments earlier, so
138
+ taking it could only wait on a holder that cannot exist. A hook that blocks here blocks a
139
+ child that has not yet returned from ``fork``, where no watchdog can reach it.
140
+
141
+ Args:
142
+ None.
143
+
144
+ Returns:
145
+ None.
146
+
147
+ Raises:
148
+ OSError: If the descriptor cannot be redirected or the file cannot be reopened.
149
+ ValueError: If the stream has no usable descriptor. ``_fork`` absorbs and announces
150
+ either, since a child that cannot strand its buffer still has working locks.
151
+ """
152
+ if self._closed:
153
+ return
154
+ self._stream = _reopen_discarding(self._stream, self._path, self._encoding)
155
+
87
156
  def close(self) -> None:
88
157
  """Flushes and closes the file handle, with a second call a no-op (FR-001).
89
158
 
@@ -221,6 +290,34 @@ class RotatingFileSink:
221
290
  self._size += data
222
291
  self._stream.flush()
223
292
 
293
+ def discard_buffered_after_fork(self) -> None:
294
+ """Throws away the parent's unflushed bytes in a forked child (SPEC-039 FR-004).
295
+
296
+ Identical to :meth:`FileSink.discard_buffered_after_fork` and measured on this class
297
+ too, because the window is the same one: a whole batch written into a buffered stream
298
+ and flushed once at the end.
299
+
300
+ ``_size`` is deliberately left as the parent set it. It counts bytes this sink believes
301
+ are in the active file, and the moment two processes append to one path that is an
302
+ approximation whichever way it is computed (FR-005 AC-1) — re-reading the file's size
303
+ here would claim a precision a shared handle cannot support, and the only consequence of
304
+ the stale count is a rotation that fires marginally early.
305
+
306
+ Args:
307
+ None.
308
+
309
+ Returns:
310
+ None.
311
+
312
+ Raises:
313
+ OSError: If the descriptor cannot be redirected or the file cannot be reopened.
314
+ ValueError: If the stream has no usable descriptor. ``_fork`` absorbs and announces
315
+ either, since a child that cannot strand its buffer still has working locks.
316
+ """
317
+ if self._closed:
318
+ return
319
+ self._stream = _reopen_discarding(self._stream, self._path, self._encoding)
320
+
224
321
  def close(self) -> None:
225
322
  """Flushes and closes the active handle, with a second call a no-op (FR-002).
226
323