log-foundry 0.5.1.dev8__tar.gz → 0.5.1.dev10__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.5.1.dev8 → log_foundry-0.5.1.dev10}/PKG-INFO +20 -2
  2. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/README.md +19 -1
  3. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/pyproject.toml +1 -1
  4. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/__init__.py +24 -0
  5. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/decorator.py +14 -1
  6. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/model.py +10 -4
  7. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sanitize.py +8 -3
  8. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/multi.py +5 -1
  9. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/worker.py +55 -4
  10. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/LICENSE +0 -0
  11. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/api.py +0 -0
  12. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/config.py +0 -0
  13. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/console.py +0 -0
  14. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/context.py +0 -0
  15. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/ids.py +0 -0
  16. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/py.typed +0 -0
  17. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/__init__.py +0 -0
  18. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/_chunk.py +0 -0
  19. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/_socket.py +0 -0
  20. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/_time.py +0 -0
  21. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/base.py +0 -0
  22. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/callback.py +0 -0
  23. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/clickhouse.py +0 -0
  24. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/datadog.py +0 -0
  25. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/elasticsearch.py +0 -0
  26. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/eventhubs.py +0 -0
  27. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/file.py +0 -0
  28. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/filtering.py +0 -0
  29. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/firehose.py +0 -0
  30. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/honeycomb.py +0 -0
  31. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/http.py +0 -0
  32. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/kafka.py +0 -0
  33. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/kinesis.py +0 -0
  34. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/logging_sink.py +0 -0
  35. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/logstash.py +0 -0
  36. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/loki.py +0 -0
  37. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/mongodb.py +0 -0
  38. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/nats.py +0 -0
  39. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/newrelic.py +0 -0
  40. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/postgres.py +0 -0
  41. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/pubsub.py +0 -0
  42. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/rabbitmq.py +0 -0
  43. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/redis.py +0 -0
  44. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/sentry.py +0 -0
  45. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/sns.py +0 -0
  46. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/splunk.py +0 -0
  47. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/sqlite.py +0 -0
  48. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/sqs.py +0 -0
  49. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/stdout.py +0 -0
  50. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/syslog.py +0 -0
  51. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/transform.py +0 -0
  52. {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.5.1.dev8
3
+ Version: 0.5.1.dev10
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -395,6 +395,9 @@ A few conventions hold across every sink below:
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
397
  `.dropped_oversized`, …) rather than raised — a broken destination degrades logging, nothing more.
398
+ The one deliberate exception is a `MultiSink` whose children *all* failed: it re-raises so the
399
+ worker's retry engages, since nothing was delivered and there are no duplicates to risk. That
400
+ still doesn't reach your code — the worker is what catches it.
398
401
 
399
402
  #### Built-in, zero-dependency
400
403
 
@@ -612,7 +615,22 @@ background worker via a fast, non-blocking submit — your function returns with
612
615
  the sink. The worker batches events (by count and time), emits them on its own thread, retries
613
616
  a failing sink with backoff, and applies backpressure so a slow or down sink can never block or
614
617
  back-pressure the app: when its bounded queue is full it drops the newest submissions and counts
615
- them (`worker.dropped`) rather than stalling.
618
+ them rather than stalling.
619
+
620
+ Those losses are deliberate, so the library gives you a way to notice them. `log_foundry.health()`
621
+ returns a snapshot of the worker's counters:
622
+
623
+ ```python
624
+ h = log_foundry.health()
625
+ if h.dropped or h.failed_batches:
626
+ ... # logs were silently lost — worth an alert
627
+ ```
628
+
629
+ `dropped` counts submissions discarded because the queue filled; `failed_batches` counts batches
630
+ abandoned after the retry budget was spent. Overflow also warns on stderr — on the first drop and
631
+ every thousandth after it, since overflow is a high-rate condition and a line per drop would be its
632
+ own outage. A process that has never logged has no worker, and asking after its health does not
633
+ create one.
616
634
 
617
635
  Because delivery is asynchronous, drain before the process exits. There are two drains, and
618
636
  which one you want depends on whether the process is about to end:
@@ -359,6 +359,9 @@ A few conventions hold across every sink below:
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
361
  `.dropped_oversized`, …) rather than raised — a broken destination degrades logging, nothing more.
362
+ The one deliberate exception is a `MultiSink` whose children *all* failed: it re-raises so the
363
+ worker's retry engages, since nothing was delivered and there are no duplicates to risk. That
364
+ still doesn't reach your code — the worker is what catches it.
362
365
 
363
366
  #### Built-in, zero-dependency
364
367
 
@@ -576,7 +579,22 @@ background worker via a fast, non-blocking submit — your function returns with
576
579
  the sink. The worker batches events (by count and time), emits them on its own thread, retries
577
580
  a failing sink with backoff, and applies backpressure so a slow or down sink can never block or
578
581
  back-pressure the app: when its bounded queue is full it drops the newest submissions and counts
579
- them (`worker.dropped`) rather than stalling.
582
+ them rather than stalling.
583
+
584
+ Those losses are deliberate, so the library gives you a way to notice them. `log_foundry.health()`
585
+ returns a snapshot of the worker's counters:
586
+
587
+ ```python
588
+ h = log_foundry.health()
589
+ if h.dropped or h.failed_batches:
590
+ ... # logs were silently lost — worth an alert
591
+ ```
592
+
593
+ `dropped` counts submissions discarded because the queue filled; `failed_batches` counts batches
594
+ abandoned after the retry budget was spent. Overflow also warns on stderr — on the first drop and
595
+ every thousandth after it, since overflow is a high-rate condition and a line per drop would be its
596
+ own outage. A process that has never logged has no worker, and asking after its health does not
597
+ create one.
580
598
 
581
599
  Because delivery is asynchronous, drain before the process exits. There are two drains, and
582
600
  which one you want depends on whether the process is about to end:
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.5.1.dev8"
23
+ version = "0.5.1.dev10"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.34"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -18,6 +18,7 @@ from log_foundry.context import (
18
18
  current_traceparent,
19
19
  )
20
20
  from log_foundry.decorator import continue_trace, trace
21
+ from log_foundry.worker import Health
21
22
 
22
23
  try:
23
24
  # Distribution name ("log-foundry") differs from the import name ("log_foundry").
@@ -49,6 +50,27 @@ def flush(timeout: float | None = 5.0) -> bool:
49
50
  return _flush_worker(timeout)
50
51
 
51
52
 
53
+ def health() -> Health:
54
+ """Snapshot the background worker's delivery counters (SPEC-017 FR-005). Never raises.
55
+
56
+ Returns ``queued`` / ``dropped`` / ``failed_batches``. A non-zero ``dropped`` means the queue
57
+ filled and submissions were discarded to keep your code non-blocking; a non-zero
58
+ ``failed_batches`` means a sink stayed broken through the whole retry budget. Both are
59
+ losses the library absorbs on purpose, and this is how you notice them::
60
+
61
+ h = log_foundry.health()
62
+ if h.dropped or h.failed_batches:
63
+ ... # raise an alert; logs were silently lost
64
+
65
+ A process that has never logged has no worker, and asking after its health does not create
66
+ one — the snapshot is simply zeroed. Valid after :func:`shutdown`, which leaves the final
67
+ counters readable.
68
+ """
69
+ from log_foundry.decorator import _worker_health
70
+
71
+ return _worker_health()
72
+
73
+
52
74
  def shutdown() -> None:
53
75
  """Flush buffered events and close the sink, blocking until drained. Idempotent.
54
76
 
@@ -80,5 +102,7 @@ __all__ = [
80
102
  "current_baggage_header",
81
103
  "flush",
82
104
  "shutdown",
105
+ "health",
106
+ "Health",
83
107
  "__version__",
84
108
  ]
@@ -40,7 +40,7 @@ from log_foundry.ids import (
40
40
  parse_traceparent,
41
41
  )
42
42
  from log_foundry.model import Span, backfill_baggage, end_event, start_event
43
- from log_foundry.worker import Worker
43
+ from log_foundry.worker import Health, Worker
44
44
 
45
45
  __all__ = ["trace", "continue_trace"]
46
46
 
@@ -235,6 +235,19 @@ def _flush_worker(timeout: float | None = 5.0) -> bool:
235
235
  return False
236
236
 
237
237
 
238
+ def _worker_health() -> Health:
239
+ """Snapshot the process worker's counters, or zeros if none was ever created.
240
+
241
+ Backs :func:`log_foundry.health` (SPEC-017 FR-005). Like :func:`_flush_worker` this
242
+ deliberately does *not* call :func:`_get_worker`: starting a thread and registering an
243
+ ``atexit`` drain in order to report three zeros would be pure cost.
244
+ """
245
+ worker = _worker
246
+ if worker is None:
247
+ return Health(queued=0, dropped=0, failed_batches=0)
248
+ return worker.health()
249
+
250
+
238
251
  def _flush(span: Span) -> None:
239
252
  """Hand the finished span's events to the background worker — non-blocking (FR-001).
240
253
 
@@ -70,8 +70,13 @@ def build_event(
70
70
  → per-call ``fields`` (arch §5.1). Later sources win on a key conflict.
71
71
 
72
72
  The merged mapping is coerced and size-bounded here (SPEC-017 FR-001/FR-002), which is what
73
- makes every sink's bare ``json.dumps`` safe without any of them changing. ``message`` is
74
- bounded too: it is a base field, but unlike the other eleven it is caller-supplied free text.
73
+ makes every sink's bare ``json.dumps`` safe without any of them changing.
74
+
75
+ ``message`` and ``function`` are bounded too. They are base fields, but unlike the other ten
76
+ both are caller-supplied text: ``message`` directly, and ``function`` via ``@trace(name=...)``
77
+ or — on the orphan path, where ``api`` names the standalone span after the message itself —
78
+ from the same unbounded string. Leaving either out would keep ``info(huge_string)`` unbounded,
79
+ which is the hole this ceiling exists to close.
75
80
  """
76
81
  from log_foundry.config import get_config
77
82
  from log_foundry.ids import new_log_id
@@ -80,6 +85,7 @@ def build_event(
80
85
  merged: dict[str, object] = {**cfg.defaults, **span.defaults, **baggage, **fields}
81
86
  safe, clipped = sanitize_fields(merged, cfg=cfg)
82
87
  bounded_message, message_clipped = truncate_str(message, cfg.max_value_bytes)
88
+ bounded_function, function_clipped = truncate_str(span.name, cfg.max_value_bytes)
83
89
  event: dict[str, object] = {
84
90
  "timestamp": _iso_now(),
85
91
  "level": level,
@@ -88,13 +94,13 @@ def build_event(
88
94
  "span_id": span.span_id,
89
95
  "parent_span_id": span.parent_span_id,
90
96
  "log_id": new_log_id(),
91
- "function": span.name,
97
+ "function": bounded_function,
92
98
  "service": cfg.service,
93
99
  "version": cfg.version,
94
100
  "env": cfg.env,
95
101
  }
96
102
  # Before ``fields`` so it reads ahead of the payload blob in a rendered log line.
97
- if clipped or message_clipped:
103
+ if clipped or message_clipped or function_clipped:
98
104
  event[_TRUNCATED] = True
99
105
  event["fields"] = safe
100
106
  return event
@@ -254,8 +254,9 @@ class _Coercer:
254
254
  def coerce(value: object, *, cfg: Config) -> object:
255
255
  """Return a JSON-serializable, size-bounded equivalent of ``value``. Never raises.
256
256
 
257
- Use :func:`sanitize_fields` for a whole event mappingit reports whether a ceiling fired,
258
- which this cannot.
257
+ ``value`` is treated as a field value, i.e. depth 0 the same level it would occupy inside
258
+ an event's ``fields``. Use :func:`sanitize_fields` for a whole event mapping; it reports
259
+ whether a ceiling fired, which this cannot.
259
260
  """
260
261
  return _Coercer(cfg).value(value, 0)
261
262
 
@@ -271,7 +272,11 @@ def sanitize_fields(
271
272
  """
272
273
  coercer = _Coercer(cfg)
273
274
  try:
274
- result = coercer.mapping(fields, 0)
275
+ # ``-1`` so the *values* of the top-level mapping sit at depth 0: ``fields`` is the event's
276
+ # payload container, not a level of nesting the caller chose. Otherwise ``max_depth=1``
277
+ # would replace every field value with ``<depth limit>`` and emit a uniformly empty event,
278
+ # which is exactly what FR-006's validation exists to prevent.
279
+ result = coercer.mapping(fields, -1)
275
280
  except Exception: # noqa: BLE001 — belt and braces: `_Coercer.value` is already total, but
276
281
  return {}, True # a hostile top-level mapping must not reach the caller either.
277
282
  if not isinstance(result, dict):
@@ -27,7 +27,11 @@ class MultiSink:
27
27
  """A :class:`~log_foundry.sinks.base.Sink` that forwards each batch to every child sink.
28
28
 
29
29
  Attributes:
30
- failed: Count of child ``emit``/``close`` calls that raised and were isolated.
30
+ failed: Count of child ``emit``/``close`` **calls** that raised. Note this counts calls,
31
+ not batches: since total failure re-raises (SPEC-017 FR-004), the worker retries the
32
+ same batch, so one batch against an all-down fan-out of *n* children increments this
33
+ by *n* per attempt — ``n * (max_retries + 1)`` in total, with a stderr line each.
34
+ That is the visible cost of the loss no longer being silent.
31
35
  """
32
36
 
33
37
  def __init__(self, *sinks: Sink) -> None:
@@ -23,16 +23,36 @@ import queue
23
23
  import sys
24
24
  import threading
25
25
  import time
26
- from typing import cast
26
+ from typing import NamedTuple, cast
27
27
 
28
28
  from log_foundry.sinks.base import Sink
29
29
 
30
- __all__ = ["Worker"]
30
+ __all__ = ["Health", "Worker"]
31
31
 
32
32
  # Sentinel enqueued by shutdown() to wake a worker blocked in queue.get() so it stops promptly
33
33
  # instead of waiting out the flush_interval. It is never emitted.
34
34
  _SHUTDOWN = object()
35
35
 
36
+ # Queue overflow is a high-rate condition by nature: a line per dropped submission would be its
37
+ # own outage. Warn on the first drop, then every this-many-th (SPEC-017 FR-005).
38
+ _DROP_WARN_EVERY = 1000
39
+
40
+
41
+ class Health(NamedTuple):
42
+ """A point-in-time snapshot of the worker's delivery counters (SPEC-017 FR-005).
43
+
44
+ Attributes:
45
+ queued: Submissions currently buffered. Approximate by nature — it is read without
46
+ stopping the world, and briefly counts the internal flush/shutdown markers
47
+ alongside real submissions.
48
+ dropped: Submissions discarded because the queue was full (backpressure).
49
+ failed_batches: Batches abandoned after the retry budget was spent.
50
+ """
51
+
52
+ queued: int
53
+ dropped: int
54
+ failed_batches: int
55
+
36
56
 
37
57
  class _FlushMarker:
38
58
  """A drain request travelling the queue in FIFO order (SPEC-013 FR-002).
@@ -87,13 +107,41 @@ class Worker:
87
107
 
88
108
  Enqueues via ``put_nowait`` and returns immediately without touching the sink. When the
89
109
  queue is full, drops this submission (drop-newest) and counts it in ``dropped`` rather
90
- than blocking the caller (FR-001, FR-004).
110
+ than blocking the caller (FR-001, FR-004), warning on a throttle (SPEC-017 FR-005).
91
111
  """
92
112
  try:
93
113
  self._queue.put_nowait(events)
94
114
  except queue.Full:
95
115
  with self._lock:
96
116
  self.dropped += 1
117
+ total = self.dropped # read under the lock: every value is produced exactly once
118
+ # Written outside the lock deliberately. stderr can block on a slow reader, and
119
+ # ``_lock`` also guards flush()/shutdown()'s once-only flag — holding it across a
120
+ # blocking write would let a wedged console stall the drain path, not just other
121
+ # submitters. Lines may therefore interleave out of order under concurrency; the
122
+ # counts they carry are still exact.
123
+ if total == 1 or total % _DROP_WARN_EVERY == 0:
124
+ try:
125
+ sys.stderr.write(
126
+ f"log-foundry: log queue full, dropped {total} submission(s) so far\n"
127
+ )
128
+ except Exception: # noqa: BLE001 — submit() runs on the *caller's* thread, so an
129
+ # unwritable stderr (closed fd, broken pipe, daemonized process) would raise
130
+ # straight into the app. A diagnostic about dropped logs must never itself be
131
+ # the reason a decorated function fails. The counter is already recorded.
132
+ pass
133
+
134
+ def health(self) -> Health:
135
+ """Snapshot the delivery counters (SPEC-017 FR-005). Never raises.
136
+
137
+ Valid after :meth:`shutdown` — the counters are plain integers that outlive the thread,
138
+ and the final drain consumes the queue, so ``queued`` reads 0 rather than a stale marker.
139
+ """
140
+ with self._lock:
141
+ dropped, failed_batches = self.dropped, self.failed_batches
142
+ return Health(
143
+ queued=self._queue.qsize(), dropped=dropped, failed_batches=failed_batches
144
+ )
97
145
 
98
146
  def flush(self, timeout: float | None = 5.0) -> bool:
99
147
  """Drain everything submitted before this call through the sink, without stopping.
@@ -221,7 +269,10 @@ class Worker:
221
269
  return
222
270
  except Exception: # noqa: BLE001 — any sink failure must not kill the worker thread
223
271
  if attempt >= self.max_retries:
224
- self.failed_batches += 1
272
+ # Under the lock so a concurrent health() sees a coherent snapshot rather
273
+ # than a half-updated pair. No deadlock: shutdown() releases before join().
274
+ with self._lock:
275
+ self.failed_batches += 1
225
276
  sys.stderr.write(
226
277
  f"log-foundry: abandoned a batch of {len(batch)} event(s) after "
227
278
  f"{self.max_retries + 1} failed emit attempts\n"