log-foundry 0.10.2.dev20__tar.gz → 0.10.2.dev22__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.10.2.dev20 → log_foundry-0.10.2.dev22}/PKG-INFO +1 -1
- {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/pyproject.toml +1 -1
- log_foundry-0.10.2.dev22/src/log_foundry/__init__.py +143 -0
- log_foundry-0.10.2.dev22/src/log_foundry/_diag.py +233 -0
- log_foundry-0.10.2.dev22/src/log_foundry/api.py +161 -0
- log_foundry-0.10.2.dev22/src/log_foundry/config.py +159 -0
- log_foundry-0.10.2.dev22/src/log_foundry/console.py +50 -0
- log_foundry-0.10.2.dev22/src/log_foundry/context.py +359 -0
- log_foundry-0.10.2.dev22/src/log_foundry/decorator.py +551 -0
- log_foundry-0.10.2.dev22/src/log_foundry/ids.py +179 -0
- log_foundry-0.10.2.dev22/src/log_foundry/model.py +278 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sanitize.py +467 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/_batch.py +81 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/_chunk.py +90 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/_retry.py +89 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/_socket.py +240 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/_time.py +46 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/base.py +130 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/callback.py +71 -0
- {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/clickhouse.py +100 -28
- {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/datadog.py +43 -8
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/elasticsearch.py +179 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/eventhubs.py +217 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/file.py +274 -0
- {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/filtering.py +91 -27
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/firehose.py +226 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/honeycomb.py +62 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/http.py +407 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/kafka.py +198 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/kinesis.py +233 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/logging_sink.py +160 -0
- {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/logstash.py +94 -21
- {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/loki.py +31 -10
- {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/mongodb.py +81 -34
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/multi.py +189 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/nats.py +151 -0
- {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/newrelic.py +19 -7
- {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/postgres.py +90 -28
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/pubsub.py +117 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/rabbitmq.py +264 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/redis.py +212 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/sentry.py +344 -0
- {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/sns.py +85 -31
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/splunk.py +87 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/sqlite.py +130 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/sqs.py +375 -0
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/stdout.py +61 -0
- {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/syslog.py +102 -21
- {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/transform.py +75 -18
- log_foundry-0.10.2.dev22/src/log_foundry/sinks/util.py +140 -0
- log_foundry-0.10.2.dev22/src/log_foundry/worker.py +714 -0
- log_foundry-0.10.2.dev20/src/log_foundry/__init__.py +0 -140
- log_foundry-0.10.2.dev20/src/log_foundry/_diag.py +0 -215
- log_foundry-0.10.2.dev20/src/log_foundry/api.py +0 -108
- log_foundry-0.10.2.dev20/src/log_foundry/config.py +0 -124
- log_foundry-0.10.2.dev20/src/log_foundry/console.py +0 -30
- log_foundry-0.10.2.dev20/src/log_foundry/context.py +0 -294
- log_foundry-0.10.2.dev20/src/log_foundry/decorator.py +0 -442
- log_foundry-0.10.2.dev20/src/log_foundry/ids.py +0 -109
- log_foundry-0.10.2.dev20/src/log_foundry/model.py +0 -227
- log_foundry-0.10.2.dev20/src/log_foundry/sanitize.py +0 -368
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/_batch.py +0 -70
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/_chunk.py +0 -60
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/_retry.py +0 -103
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/_socket.py +0 -147
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/_time.py +0 -28
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/base.py +0 -121
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/callback.py +0 -45
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/elasticsearch.py +0 -141
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/eventhubs.py +0 -150
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/file.py +0 -175
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/firehose.py +0 -180
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/honeycomb.py +0 -38
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/http.py +0 -287
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/kafka.py +0 -133
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/kinesis.py +0 -185
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/logging_sink.py +0 -104
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/multi.py +0 -139
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/nats.py +0 -88
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/pubsub.py +0 -79
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/rabbitmq.py +0 -158
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/redis.py +0 -115
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/sentry.py +0 -218
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/splunk.py +0 -51
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/sqlite.py +0 -95
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/sqs.py +0 -329
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/stdout.py +0 -31
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/util.py +0 -72
- log_foundry-0.10.2.dev20/src/log_foundry/worker.py +0 -625
- {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/LICENSE +0 -0
- {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/README.md +0 -0
- {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/__init__.py +0 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""log_foundry — consistent, structured logs per decorated function call."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError
|
|
4
|
+
from importlib.metadata import version as _dist_version
|
|
5
|
+
|
|
6
|
+
from log_foundry.api import critical, debug, error, info, set_baggage, warning
|
|
7
|
+
from log_foundry.config import configure, get_config
|
|
8
|
+
from log_foundry.context import (
|
|
9
|
+
current_baggage_header,
|
|
10
|
+
current_trace_context,
|
|
11
|
+
current_traceparent,
|
|
12
|
+
reset_context,
|
|
13
|
+
)
|
|
14
|
+
from log_foundry.decorator import continue_trace, trace
|
|
15
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
16
|
+
from log_foundry.worker import DEFAULT_SHUTDOWN_TIMEOUT, Health
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
__version__ = _dist_version("log-foundry")
|
|
20
|
+
except PackageNotFoundError:
|
|
21
|
+
__version__ = "0.0.0"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def flush(timeout: float | None = 5.0) -> bool:
|
|
25
|
+
"""Drains buffered events through the sink without closing it.
|
|
26
|
+
|
|
27
|
+
This is the drain for a process that is frozen rather than exited — an AWS Lambda handler
|
|
28
|
+
must drain before it returns, but will be invoked again on the same warm container. Call it
|
|
29
|
+
in a ``finally``: the invocation worth logging is the one that failed. Unlike
|
|
30
|
+
:func:`shutdown` the background worker stays alive and the sink stays open, so logging
|
|
31
|
+
continues normally afterwards.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
timeout: Seconds to wait for the drain. ``None`` waits indefinitely, which is unsafe in
|
|
35
|
+
any environment with an execution deadline — it converts "some logs were lost" into
|
|
36
|
+
"the invocation timed out".
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
True when the events submitted before this call reached the sink. False if the drain did
|
|
40
|
+
not complete within the timeout, if the worker has already been shut down or has died, or
|
|
41
|
+
if a batch was abandoned while this call was outstanding (SPEC-021 FR-001) — so True means
|
|
42
|
+
the events were delivered, not merely that a drain took place. Events submitted
|
|
43
|
+
concurrently by another thread may or may not be included, since the caller cannot have
|
|
44
|
+
meant those, and a batch lost before the call belongs to :func:`health`.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
None.
|
|
48
|
+
"""
|
|
49
|
+
from log_foundry.decorator import _flush_worker
|
|
50
|
+
|
|
51
|
+
return _flush_worker(timeout)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def health() -> Health:
|
|
55
|
+
"""Snapshots the background worker's delivery counters (SPEC-017 FR-005).
|
|
56
|
+
|
|
57
|
+
A non-zero ``dropped`` means the queue filled and submissions were discarded to keep your
|
|
58
|
+
code non-blocking, and a non-zero ``failed_batches`` means a sink stayed broken through the
|
|
59
|
+
whole retry budget — both are losses the library absorbs on purpose, and this is how you
|
|
60
|
+
notice them. A non-``None`` ``stopped_reason`` is worse than either: the background thread
|
|
61
|
+
died on that exception type, so nothing further will be delivered at all (SPEC-019)::
|
|
62
|
+
|
|
63
|
+
h = log_foundry.health()
|
|
64
|
+
if h.dropped or h.failed_batches or h.stopped_reason or (
|
|
65
|
+
h.sink and (h.sink.dropped or h.sink.failed)
|
|
66
|
+
):
|
|
67
|
+
... # raise an alert; logs were silently lost
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
None.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The snapshot: ``queued``, ``dropped``, ``failed_batches``, ``stopped_reason`` and
|
|
74
|
+
``sink``. The last is the configured sink's own
|
|
75
|
+
:class:`~log_foundry.sinks.base.SinkLosses` — loss the sink absorbed rather than the
|
|
76
|
+
worker (SPEC-026) — and is ``None`` when no worker exists or the sink reports nothing.
|
|
77
|
+
Its ``dropped`` is not the worker's: the worker's is backpressure at the queue, the
|
|
78
|
+
sink's is an event that never reached the wire, and the stderr line names which. Its
|
|
79
|
+
``failed`` is an upper bound on loss rather than a count of it, since a sink that raises
|
|
80
|
+
on total failure counts the attempt and hands the batch back for the worker to retry. A
|
|
81
|
+
process that has never logged has no worker, and asking does not create one — the
|
|
82
|
+
snapshot is simply zeroed. Valid after :func:`shutdown`.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
None.
|
|
86
|
+
"""
|
|
87
|
+
from log_foundry.decorator import _worker_health
|
|
88
|
+
|
|
89
|
+
return _worker_health()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def shutdown(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
|
|
93
|
+
"""Flushes buffered events and closes the sink, blocking until drained.
|
|
94
|
+
|
|
95
|
+
This is terminal — the worker does not come back — and idempotent. Do not call it
|
|
96
|
+
per-invocation in a serverless handler: the first invocation on a warm container would log
|
|
97
|
+
and every later one would silently log nothing; use :func:`flush` there. It is also
|
|
98
|
+
registered via ``atexit``, so call it explicitly only when you want to be certain the tail
|
|
99
|
+
of the queue reached the sink before a fast process exit (SPEC-004 FR-005).
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
timeout: Seconds bounding the wait for the background thread (SPEC-027 FR-004). ``None``
|
|
103
|
+
waits indefinitely, which is what this did unconditionally before and is still
|
|
104
|
+
available on request, but is unsafe anywhere with an execution deadline — ``atexit`` is
|
|
105
|
+
one such place, where a sink blocked in a network call would hold the process open. An
|
|
106
|
+
expired shutdown reports a ``stopped_reason`` of ``"ShutdownTimeout"`` and leaves the
|
|
107
|
+
sink open, since the drain thread may still be inside ``emit``.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
None.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
None.
|
|
114
|
+
"""
|
|
115
|
+
from log_foundry.decorator import _shutdown_worker
|
|
116
|
+
|
|
117
|
+
_shutdown_worker(timeout)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = [
|
|
121
|
+
"DEFAULT_SHUTDOWN_TIMEOUT",
|
|
122
|
+
"Health",
|
|
123
|
+
"SinkDeliveryError",
|
|
124
|
+
"SinkLosses",
|
|
125
|
+
"__version__",
|
|
126
|
+
"configure",
|
|
127
|
+
"continue_trace",
|
|
128
|
+
"critical",
|
|
129
|
+
"current_baggage_header",
|
|
130
|
+
"current_trace_context",
|
|
131
|
+
"current_traceparent",
|
|
132
|
+
"debug",
|
|
133
|
+
"error",
|
|
134
|
+
"flush",
|
|
135
|
+
"get_config",
|
|
136
|
+
"health",
|
|
137
|
+
"info",
|
|
138
|
+
"reset_context",
|
|
139
|
+
"set_baggage",
|
|
140
|
+
"shutdown",
|
|
141
|
+
"trace",
|
|
142
|
+
"warning",
|
|
143
|
+
]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""The library's own diagnostic channel — one line on stderr, for what it lost or absorbed.
|
|
2
|
+
|
|
3
|
+
Three rules, all load-bearing (SPEC-029 FR-004):
|
|
4
|
+
|
|
5
|
+
* The exception's type, never its message. A message can carry a value from the event that
|
|
6
|
+
provoked it, and arch §6 keeps user data out of anything the library emits about itself.
|
|
7
|
+
``PostgresSink`` is the sharpest case: a psycopg error repr reprints the failing statement
|
|
8
|
+
and its bound parameters, so a naive diagnostic would reprint the event, PII included.
|
|
9
|
+
* No stream fault escapes. These writers are called from the guards that exist to stop an
|
|
10
|
+
exception reaching the caller. A ``BaseException`` still passes through, as everywhere else
|
|
11
|
+
in this library: a ``KeyboardInterrupt`` mid-write is the operator's intent.
|
|
12
|
+
* Record first, announce second. A counter moves before its line is attempted, at every call
|
|
13
|
+
site, because the write is best-effort and the counter ``health()`` reports is not.
|
|
14
|
+
|
|
15
|
+
Stderr stays the channel: routing through the ``logging`` module would make the library's own
|
|
16
|
+
failure reports depend on machinery that may be the thing failing. This module must import
|
|
17
|
+
nothing from its own package — it is reached at module scope while the package is still
|
|
18
|
+
partially initialised, and resolves only because it is a leaf.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
__all__ = ["absorbed", "errno_of", "lost", "rejected"]
|
|
26
|
+
|
|
27
|
+
_MAX_DETAIL = 200
|
|
28
|
+
|
|
29
|
+
_MAX_REJECTED_ECHO = 64
|
|
30
|
+
|
|
31
|
+
_ESCAPES = {"\t": "\\t", "\n": "\\n", "\r": "\\r"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def absorbed(where: str, exc: BaseException, detail: str = "") -> None:
|
|
35
|
+
"""Reports a failure the library swallowed rather than propagated.
|
|
36
|
+
|
|
37
|
+
Only the exception's type is written, never its message: a message can carry a value
|
|
38
|
+
from the event that provoked it, and arch §6 keeps user data out of anything the library
|
|
39
|
+
emits about itself. A stream fault is swallowed because these writers are called from the
|
|
40
|
+
``except`` and ``finally`` blocks that exist to stop an exception reaching the caller,
|
|
41
|
+
though a ``BaseException`` from the write still propagates.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
where: What was being attempted, as a participle phrase that reads after "while", such
|
|
45
|
+
as ``"closing a span"``. A literal, never a runtime value.
|
|
46
|
+
exc: The absorbed exception. Only ``type(exc).__name__`` is written.
|
|
47
|
+
detail: Optional consequence for the reader, appended after a semicolon — most usefully
|
|
48
|
+
what was lost, since an absorbed failure is invisible apart from this line. Escaped
|
|
49
|
+
and truncated to ``_MAX_DETAIL``; must not be derived from the exception's text.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
None.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
None on a stream fault. The channel of last resort has no fallback of its own, and
|
|
56
|
+
raising from the guard that protects the caller's exception would be worse (arch §4).
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
sys.stderr.write(
|
|
60
|
+
f"log-foundry: absorbed a failure while {_escape(where)} "
|
|
61
|
+
f"({_escape(type(exc).__name__)}){_suffix(detail)}\n"
|
|
62
|
+
)
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def lost(what: str, count: int, detail: str = "") -> None:
|
|
68
|
+
"""Reports a counted loss — events, messages, batches or rows that will not be delivered.
|
|
69
|
+
|
|
70
|
+
Callers record the counter before calling this, because the write is best-effort by
|
|
71
|
+
construction and the counter ``health()`` reports must not be able to ride on it.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
what: Singular noun for the unit lost, such as ``"event"`` or ``"row"``. Rendered with
|
|
75
|
+
the corpus-wide ``(s)`` suffix, so it must be a literal, never a runtime value.
|
|
76
|
+
count: How many this line reports as lost, normally the increment the caller's counter
|
|
77
|
+
has just taken. A throttled site instead passes its running total, because a line
|
|
78
|
+
written on every thousandth drop saying "lost 1" would read as one loss rather than a
|
|
79
|
+
thousand, and such a site says so in ``detail``.
|
|
80
|
+
detail: Optional circumstances — the sink's class name, the attempt count, an
|
|
81
|
+
``errno``, an exception type. Escaped and truncated to ``_MAX_DETAIL``.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
None.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
None on a stream fault; a ``BaseException`` from the write still propagates.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
sys.stderr.write(f"log-foundry: lost {count} {_escape(what)}(s){_suffix(detail)}\n")
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def rejected(reason: str, value: object) -> None:
|
|
96
|
+
"""Reports an inbound trace context the library refused to adopt (SPEC-014).
|
|
97
|
+
|
|
98
|
+
The offending value is echoed as a bounded ``repr`` — the one place in the library where a
|
|
99
|
+
``repr`` is correct, because the input is an inbound header rather than an exception and
|
|
100
|
+
its exact shape is what makes a rejection diagnosable. The ``repr`` is escaped afterwards
|
|
101
|
+
even though ``repr`` of a ``str`` is already printable, because ``repr`` runs
|
|
102
|
+
``__repr__``, which is user code free to return a raw newline; escaping the result is the
|
|
103
|
+
difference between "the built-ins happen to escape" and "this cannot forge a line".
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
reason: Why it was refused, such as ``"unparseable traceparent"``. A literal, never a
|
|
107
|
+
runtime value: unlike the echo, it is not bounded.
|
|
108
|
+
value: The refused value, echoed as a ``repr`` bounded to ``_MAX_REJECTED_ECHO``,
|
|
109
|
+
because unbounded it would be a log-injection surface.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
None.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
None on a stream fault; a ``BaseException`` from the write still propagates.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
shown = _escape(repr(value))
|
|
119
|
+
if len(shown) > _MAX_REJECTED_ECHO:
|
|
120
|
+
shown = shown[:_MAX_REJECTED_ECHO] + "…"
|
|
121
|
+
sys.stderr.write(
|
|
122
|
+
f"log-foundry: ignoring inbound trace context ({_escape(reason)}): {shown}\n"
|
|
123
|
+
)
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def errno_of(exc: BaseException) -> str:
|
|
129
|
+
"""Returns ``"errno=N"`` for an exception carrying one, else an empty string.
|
|
130
|
+
|
|
131
|
+
This is the library-controlled detail that makes an ``OSError`` line actionable:
|
|
132
|
+
"connection refused" and "host unknown" share a type name and are told apart only by the
|
|
133
|
+
code, and an integer from the OS is not caller data. ``urllib``'s ``URLError`` carries the
|
|
134
|
+
underlying socket error on ``.reason`` rather than setting its own ``errno``, so that is
|
|
135
|
+
consulted too.
|
|
136
|
+
|
|
137
|
+
The value is rendered through ``int()`` rather than interpolated as found, because
|
|
138
|
+
``isinstance(x, int)`` admits any subclass and a driver's error code is routinely one — an
|
|
139
|
+
``IntEnum`` or a bespoke class whose ``__str__`` returns whatever its author chose, which
|
|
140
|
+
an f-string would write verbatim. That is the leak this helper exists to be the
|
|
141
|
+
alternative to.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
exc: The exception to inspect.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The rendered ``errno=N``, or an empty string when there is no integer code.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
None. Attribute access on an arbitrary exception can run a property that raises, and a
|
|
151
|
+
helper for a diagnostic must not become one.
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
code = getattr(exc, "errno", None)
|
|
155
|
+
if code is None:
|
|
156
|
+
code = getattr(getattr(exc, "reason", None), "errno", None)
|
|
157
|
+
return f"errno={int(code)}" if isinstance(code, int) else ""
|
|
158
|
+
except Exception:
|
|
159
|
+
return ""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _suffix(detail: str) -> str:
|
|
163
|
+
"""Renders a detail as a bounded, escaped ``"; ..."`` suffix.
|
|
164
|
+
|
|
165
|
+
Escaping precedes truncation so the bound applies to what is written, and so truncation
|
|
166
|
+
can only remove characters from an already-safe string rather than split an escape into
|
|
167
|
+
something that is not one.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
detail: The caller-supplied detail, possibly empty.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
The suffix, or an empty string when there is no detail or it cannot be rendered.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
None. This runs inside the caller's f-string, so a detail that cannot be rendered would
|
|
177
|
+
otherwise take the whole line with it, including the count an operator cannot
|
|
178
|
+
reconstruct.
|
|
179
|
+
"""
|
|
180
|
+
if not detail:
|
|
181
|
+
return ""
|
|
182
|
+
try:
|
|
183
|
+
shown = _escape(detail)
|
|
184
|
+
if len(shown) > _MAX_DETAIL:
|
|
185
|
+
shown = shown[:_MAX_DETAIL] + "…"
|
|
186
|
+
return f"; {shown}"
|
|
187
|
+
except Exception:
|
|
188
|
+
return ""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _escape(text: str) -> str:
|
|
192
|
+
"""Renders every non-printable character visibly, so nothing in the text can forge a line.
|
|
193
|
+
|
|
194
|
+
``str.isprintable()`` is the test rather than a table over ``range(0x20)``: it is
|
|
195
|
+
``False`` for the C0 block and for DEL, the C1 block, U+0085, U+2028, U+2029, and the bidi
|
|
196
|
+
format characters. Python's own ``splitlines()`` breaks on three of those a C0 table
|
|
197
|
+
misses, so a reader doing the obvious thing would see a forged ``log-foundry:`` line that
|
|
198
|
+
a newline count says is not there. The whole-string fast path keeps the common case to a
|
|
199
|
+
single C-level call, and space is printable, so ordinary text is untouched.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
text: The string to render safely.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
The text with every non-printable character escaped.
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
None.
|
|
209
|
+
"""
|
|
210
|
+
if text.isprintable():
|
|
211
|
+
return text
|
|
212
|
+
return "".join(char if char.isprintable() else _escaped(char) for char in text)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _escaped(char: str) -> str:
|
|
216
|
+
"""Escapes one non-printable character, following Python's own ``repr`` conventions.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
char: The single character to escape.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
The escape sequence for that character.
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
None.
|
|
226
|
+
"""
|
|
227
|
+
escape = _ESCAPES.get(char)
|
|
228
|
+
if escape is not None:
|
|
229
|
+
return escape
|
|
230
|
+
code = ord(char)
|
|
231
|
+
if code <= 0xFF:
|
|
232
|
+
return f"\\x{code:02x}"
|
|
233
|
+
return f"\\u{code:04x}" if code <= 0xFFFF else f"\\U{code:08x}"
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""User-facing log emitters + baggage re-export (SPEC-002, arch §6)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from log_foundry import _diag, context
|
|
6
|
+
from log_foundry.config import _ensure_sink
|
|
7
|
+
from log_foundry.console import ConsoleWriter
|
|
8
|
+
from log_foundry.context import set_baggage
|
|
9
|
+
from log_foundry.ids import new_span_id, new_trace_id
|
|
10
|
+
from log_foundry.model import Span, build_event
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"critical",
|
|
14
|
+
"debug",
|
|
15
|
+
"error",
|
|
16
|
+
"info",
|
|
17
|
+
"set_baggage",
|
|
18
|
+
"warning",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
_console = ConsoleWriter()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _log(level: str, message: str, echo: bool, fields: dict[str, object]) -> None:
|
|
25
|
+
"""Builds one event at the given level and routes it, honoring ``echo``.
|
|
26
|
+
|
|
27
|
+
Inside a span the event is appended to that span's queue and flushed together at span
|
|
28
|
+
end. With no active span it becomes a standalone one-event span — fresh ``trace_id``,
|
|
29
|
+
``parent_span_id`` of ``None`` — flushed directly so nothing is dropped (FR-004), with
|
|
30
|
+
the sink resolved through ``_ensure_sink`` so a zero-config orphan log falls back to
|
|
31
|
+
``StdoutSink`` rather than crashing.
|
|
32
|
+
|
|
33
|
+
The orphan branch is the one that reaches the sink on the caller's own thread, with no
|
|
34
|
+
worker between them to absorb a failure, so the whole branch is guarded (SPEC-025
|
|
35
|
+
FR-003) — ``_ensure_sink`` constructs the sink on first use, so a sink that fails to
|
|
36
|
+
build raises here too. The in-span branch is deliberately left untouched, since it only
|
|
37
|
+
appends to a list. The echo runs after the emit, so a closed or redirected stream never
|
|
38
|
+
costs the event itself.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
level: The severity label to stamp on the event.
|
|
42
|
+
message: The caller-supplied message text.
|
|
43
|
+
echo: Whether to also write a human-readable console line.
|
|
44
|
+
fields: Per-call fields merged into the event.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
None.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
None. A logging call must never hand the application an exception from a destination
|
|
51
|
+
it never chose to talk to; absorbed faults are reported through ``_diag``.
|
|
52
|
+
"""
|
|
53
|
+
baggage = context.get_baggage()
|
|
54
|
+
span = context.current_span()
|
|
55
|
+
event: dict[str, object] | None = None
|
|
56
|
+
if span is not None:
|
|
57
|
+
event = build_event(span, level, message, fields=fields, baggage=baggage)
|
|
58
|
+
span.events.append(event)
|
|
59
|
+
else:
|
|
60
|
+
try:
|
|
61
|
+
orphan = Span(
|
|
62
|
+
trace_id=new_trace_id(),
|
|
63
|
+
span_id=new_span_id(),
|
|
64
|
+
parent_span_id=None,
|
|
65
|
+
name=message,
|
|
66
|
+
start_ts=0.0,
|
|
67
|
+
)
|
|
68
|
+
event = build_event(orphan, level, message, fields=fields, baggage=baggage)
|
|
69
|
+
_ensure_sink().emit([event])
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
_diag.absorbed("emitting an orphan log", exc, "the event was lost")
|
|
72
|
+
if echo and event is not None:
|
|
73
|
+
try:
|
|
74
|
+
_console.write(event)
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
_diag.absorbed("echoing to the console", exc)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def debug(message: str, *, echo: bool = False, **fields: object) -> None:
|
|
80
|
+
"""Emits a ``DEBUG`` event on the current span, or a standalone orphan span.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
message: The message text.
|
|
84
|
+
echo: Whether to also write a human-readable console line.
|
|
85
|
+
**fields: Per-call structured fields.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
None.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
None.
|
|
92
|
+
"""
|
|
93
|
+
_log("DEBUG", message, echo, fields)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def info(message: str, *, echo: bool = False, **fields: object) -> None:
|
|
97
|
+
"""Emits an ``INFO`` event on the current span, or a standalone orphan span.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
message: The message text.
|
|
101
|
+
echo: Whether to also write a human-readable console line.
|
|
102
|
+
**fields: Per-call structured fields.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
None.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
None.
|
|
109
|
+
"""
|
|
110
|
+
_log("INFO", message, echo, fields)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def warning(message: str, *, echo: bool = False, **fields: object) -> None:
|
|
114
|
+
"""Emits a ``WARNING`` event on the current span, or a standalone orphan span.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
message: The message text.
|
|
118
|
+
echo: Whether to also write a human-readable console line.
|
|
119
|
+
**fields: Per-call structured fields.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
None.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
None.
|
|
126
|
+
"""
|
|
127
|
+
_log("WARNING", message, echo, fields)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def error(message: str, *, echo: bool = False, **fields: object) -> None:
|
|
131
|
+
"""Emits an ``ERROR`` event on the current span, or a standalone orphan span.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
message: The message text.
|
|
135
|
+
echo: Whether to also write a human-readable console line.
|
|
136
|
+
**fields: Per-call structured fields.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
None.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
None.
|
|
143
|
+
"""
|
|
144
|
+
_log("ERROR", message, echo, fields)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def critical(message: str, *, echo: bool = False, **fields: object) -> None:
|
|
148
|
+
"""Emits a ``CRITICAL`` event on the current span, or a standalone orphan span.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
message: The message text.
|
|
152
|
+
echo: Whether to also write a human-readable console line.
|
|
153
|
+
**fields: Per-call structured fields.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
None.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
None.
|
|
160
|
+
"""
|
|
161
|
+
_log("CRITICAL", message, echo, fields)
|