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.
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/PKG-INFO +20 -2
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/README.md +19 -1
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/pyproject.toml +1 -1
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/__init__.py +24 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/decorator.py +14 -1
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/model.py +10 -4
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sanitize.py +8 -3
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/multi.py +5 -1
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/worker.py +55 -4
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/LICENSE +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/api.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/config.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/console.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/context.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/_socket.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/clickhouse.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/eventhubs.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/filtering.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/firehose.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/http.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/kinesis.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/logstash.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/mongodb.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/postgres.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/pubsub.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/rabbitmq.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/redis.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/sentry.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/sns.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/sqs.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/syslog.py +0 -0
- {log_foundry-0.5.1.dev8 → log_foundry-0.5.1.dev10}/src/log_foundry/sinks/transform.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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:
|
|
@@ -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.
|
|
74
|
-
|
|
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":
|
|
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
|
-
|
|
258
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|