log-foundry 0.10.2.dev126__tar.gz → 0.10.2.dev127__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.dev126 → log_foundry-0.10.2.dev127}/PKG-INFO +10 -3
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/README.md +9 -2
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/pyproject.toml +1 -1
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/_diag.py +11 -1
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/config.py +42 -3
- log_foundry-0.10.2.dev127/src/log_foundry/console.py +131 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/decorator.py +106 -8
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sanitize.py +147 -28
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/kinesis.py +7 -5
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/worker.py +2 -3
- log_foundry-0.10.2.dev126/src/log_foundry/console.py +0 -61
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/LICENSE +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/__init__.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/_fork.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/_lifecycle.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/api.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/context.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/model.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/results.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/_batch.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/_retry.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/_socket.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/clickhouse.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/eventhubs.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/filtering.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/firehose.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/http.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/logstash.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/memory.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/mongodb.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/multi.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/null.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/postgres.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/pubsub.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/rabbitmq.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/redis.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/sentry.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/sns.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/sqs.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/syslog.py +0 -0
- {log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/transform.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: log-foundry
|
|
3
|
-
Version: 0.10.2.
|
|
3
|
+
Version: 0.10.2.dev127
|
|
4
4
|
Summary: Generate logs for your console and JSON events for downstream consumption.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -357,9 +357,14 @@ def handler(): ...
|
|
|
357
357
|
def process(): ...
|
|
358
358
|
```
|
|
359
359
|
|
|
360
|
-
- `name` — override the span name (defaults to the function's `__qualname__
|
|
360
|
+
- `name` — override the span name (defaults to the function's `__qualname__`; a callable with
|
|
361
|
+
none — a `functools.partial`, a callable instance — is named after its type).
|
|
361
362
|
- `defaults` — per-decorator fields merged into every event this span emits.
|
|
362
363
|
|
|
364
|
+
`@trace` refuses at decoration what it cannot trace, with a `TypeError` naming the function:
|
|
365
|
+
a `@classmethod` or `@staticmethod` written *below* it (put them above), a bare string
|
|
366
|
+
(`@trace("checkout")` meant `@trace(name="checkout")`), and anything not callable.
|
|
367
|
+
|
|
363
368
|
The **outermost** decorated call starts a new trace; every nested decorated call becomes a
|
|
364
369
|
child span within it. On an exception, the decorator records `status="error"` plus an `error`
|
|
365
370
|
sub-document carrying the exception's type, module, message and formatted stack, then
|
|
@@ -415,7 +420,9 @@ def process_payment(user_id: int) -> str:
|
|
|
415
420
|
[`reset_context()`](#clearing-context-in-a-long-lived-process).
|
|
416
421
|
- **`echo=True`** — *additionally* write a human-readable `LEVEL message` line to the console
|
|
417
422
|
(`sys.stderr` by default), synchronously, without waiting for the async flush. The event
|
|
418
|
-
still rides the normal pipeline to the sink — echo never redirects.
|
|
423
|
+
still rides the normal pipeline to the sink — echo never redirects. A console that has gone
|
|
424
|
+
away for good (a broken pipe, a closed stream) is announced once and echo is switched off for
|
|
425
|
+
the rest of the process; any other console fault is announced on a throttle.
|
|
419
426
|
- **`fields={...}`** — the escape hatch. `message`, `echo` and `fields` are **reserved**: they
|
|
420
427
|
are parameters, so `info("x", echo="the payload we echoed back")` would switch on the console
|
|
421
428
|
line instead of recording a field. Pass them — and any key that is not a Python identifier —
|
|
@@ -306,9 +306,14 @@ def handler(): ...
|
|
|
306
306
|
def process(): ...
|
|
307
307
|
```
|
|
308
308
|
|
|
309
|
-
- `name` — override the span name (defaults to the function's `__qualname__
|
|
309
|
+
- `name` — override the span name (defaults to the function's `__qualname__`; a callable with
|
|
310
|
+
none — a `functools.partial`, a callable instance — is named after its type).
|
|
310
311
|
- `defaults` — per-decorator fields merged into every event this span emits.
|
|
311
312
|
|
|
313
|
+
`@trace` refuses at decoration what it cannot trace, with a `TypeError` naming the function:
|
|
314
|
+
a `@classmethod` or `@staticmethod` written *below* it (put them above), a bare string
|
|
315
|
+
(`@trace("checkout")` meant `@trace(name="checkout")`), and anything not callable.
|
|
316
|
+
|
|
312
317
|
The **outermost** decorated call starts a new trace; every nested decorated call becomes a
|
|
313
318
|
child span within it. On an exception, the decorator records `status="error"` plus an `error`
|
|
314
319
|
sub-document carrying the exception's type, module, message and formatted stack, then
|
|
@@ -364,7 +369,9 @@ def process_payment(user_id: int) -> str:
|
|
|
364
369
|
[`reset_context()`](#clearing-context-in-a-long-lived-process).
|
|
365
370
|
- **`echo=True`** — *additionally* write a human-readable `LEVEL message` line to the console
|
|
366
371
|
(`sys.stderr` by default), synchronously, without waiting for the async flush. The event
|
|
367
|
-
still rides the normal pipeline to the sink — echo never redirects.
|
|
372
|
+
still rides the normal pipeline to the sink — echo never redirects. A console that has gone
|
|
373
|
+
away for good (a broken pipe, a closed stream) is announced once and echo is switched off for
|
|
374
|
+
the rest of the process; any other console fault is announced on a throttle.
|
|
368
375
|
- **`fields={...}`** — the escape hatch. `message`, `echo` and `fields` are **reserved**: they
|
|
369
376
|
are parameters, so `info("x", echo="the payload we echoed back")` would switch on the console
|
|
370
377
|
line instead of recording a field. Pass them — and any key that is not a Python identifier —
|
|
@@ -74,7 +74,7 @@ keywords = [
|
|
|
74
74
|
# vulnerability-reporting channel. The repository is still named `log-forge` — the ORIGINAL name,
|
|
75
75
|
# which PyPI rejected for the distribution — so these URLs deliberately do not match the package
|
|
76
76
|
# name. See the note on `name` above before "correcting" them.
|
|
77
|
-
version = "0.10.2.
|
|
77
|
+
version = "0.10.2.dev127"
|
|
78
78
|
|
|
79
79
|
[project.urls]
|
|
80
80
|
Homepage = "https://github.com/agriffi10/log-forge"
|
|
@@ -22,7 +22,17 @@ from __future__ import annotations
|
|
|
22
22
|
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
|
-
__all__ = ["absorbed", "errno_of", "lost", "rejected"]
|
|
25
|
+
__all__ = ["WARN_EVERY", "absorbed", "errno_of", "lost", "rejected"]
|
|
26
|
+
|
|
27
|
+
WARN_EVERY = 1000
|
|
28
|
+
"""The period of every per-event throttle in the library (SPEC-017 FR-005, SPEC-055 FR-005).
|
|
29
|
+
|
|
30
|
+
A site that can fail once per event writes its line on the first failure and then on every
|
|
31
|
+
``WARN_EVERY``-th, carrying the running total, because a line per failure is its own outage and
|
|
32
|
+
silence is how the failure survives to production. One definition, here, because the worker's
|
|
33
|
+
queue-full and post-shutdown sites and the console writer's stream-fault site all throttle on
|
|
34
|
+
the same period, and two constants stating one number disagree eventually.
|
|
35
|
+
"""
|
|
26
36
|
|
|
27
37
|
_MAX_DETAIL = 200
|
|
28
38
|
|
|
@@ -88,6 +88,40 @@ def _require_positive(name: str, value: int | None) -> None:
|
|
|
88
88
|
raise ValueError(f"{name} must be >= 1, got {value}")
|
|
89
89
|
|
|
90
90
|
|
|
91
|
+
def _require_text(name: str, value: object) -> str | None:
|
|
92
|
+
"""Rejects a stamp that is not a ``str`` or cannot encode as UTF-8 (SPEC-055 FR-001).
|
|
93
|
+
|
|
94
|
+
``service``, ``version`` and ``env`` are copied from the config into every event without
|
|
95
|
+
passing through ``sanitize`` — the one route by which a string reaches an event unbounded
|
|
96
|
+
and uncoerced, which is deliberate on the hottest path in the library. So the check is at
|
|
97
|
+
the door instead (invariant 13): a lone surrogate here would otherwise cost every batch on
|
|
98
|
+
every sink that binds ``service`` as a column, for the life of the process, and an ``int``
|
|
99
|
+
would land in a ``str`` field. What is stored is ``str.__str__(value)``, an exact ``str``,
|
|
100
|
+
so a ``StrEnum`` member is accepted and a ``str`` subclass instance never reaches an event.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
name: The setting's name, used in the error message.
|
|
104
|
+
value: The proposed stamp, or ``None`` to skip the check.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The stamp as an exact ``str``, or ``None`` when it was not supplied.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
TypeError: If the value is present and not a ``str``.
|
|
111
|
+
ValueError: If the value is present and does not encode as UTF-8.
|
|
112
|
+
"""
|
|
113
|
+
if value is None:
|
|
114
|
+
return None
|
|
115
|
+
if not isinstance(value, str):
|
|
116
|
+
raise TypeError(f"{name} must be a str, got {type(value).__name__}")
|
|
117
|
+
plain = str.__str__(value)
|
|
118
|
+
try:
|
|
119
|
+
plain.encode("utf-8")
|
|
120
|
+
except UnicodeEncodeError:
|
|
121
|
+
raise ValueError(f"{name} must be encodable as UTF-8; it carries a lone surrogate") from None
|
|
122
|
+
return plain
|
|
123
|
+
|
|
124
|
+
|
|
91
125
|
def configure(
|
|
92
126
|
*,
|
|
93
127
|
service: str | None = None,
|
|
@@ -104,8 +138,9 @@ def configure(
|
|
|
104
138
|
|
|
105
139
|
Only the arguments passed are applied, so repeated calls compose rather than reset. If
|
|
106
140
|
no sink has ever been set, this defaults to :class:`~log_foundry.sinks.stdout.StdoutSink`,
|
|
107
|
-
the zero-dependency dev default (arch §8). Every ceiling is validated before
|
|
108
|
-
assigned
|
|
141
|
+
the zero-dependency dev default (arch §8). Every ceiling and every stamp is validated before
|
|
142
|
+
anything is assigned — and before the sink is stamped into the ownership record — so a
|
|
143
|
+
rejected call leaves the config and the record exactly as it found them.
|
|
109
144
|
|
|
110
145
|
A ``sink=`` passed after logging has already started is the one argument that needs more
|
|
111
146
|
than an assignment, because whatever is delivering captured its sink before the call
|
|
@@ -161,12 +196,16 @@ def configure(
|
|
|
161
196
|
None.
|
|
162
197
|
|
|
163
198
|
Raises:
|
|
164
|
-
ValueError: If any ceiling is less than 1.
|
|
199
|
+
ValueError: If any ceiling is less than 1, or a stamp does not encode as UTF-8.
|
|
200
|
+
TypeError: If ``service``, ``version`` or ``env`` is not a ``str`` (SPEC-055 FR-001).
|
|
165
201
|
"""
|
|
166
202
|
_require_positive("max_value_bytes", max_value_bytes)
|
|
167
203
|
_require_positive("max_stack_bytes", max_stack_bytes)
|
|
168
204
|
_require_positive("max_keys", max_keys)
|
|
169
205
|
_require_positive("max_depth", max_depth)
|
|
206
|
+
service = _require_text("service", service)
|
|
207
|
+
version = _require_text("version", version)
|
|
208
|
+
env = _require_text("env", env)
|
|
170
209
|
|
|
171
210
|
changed: dict[str, object] = {
|
|
172
211
|
name: value
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Console echo — a synchronous, human-readable output path (SPEC-002 FR-002)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
from typing import TextIO
|
|
8
|
+
|
|
9
|
+
from log_foundry import _diag
|
|
10
|
+
|
|
11
|
+
__all__ = ["ConsoleWriter"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConsoleWriter:
|
|
15
|
+
"""Renders events as human-readable ``LEVEL message`` lines to a stream.
|
|
16
|
+
|
|
17
|
+
This is separate from the async :class:`~log_foundry.sinks.base.Sink`: where the sink
|
|
18
|
+
ships structured JSON downstream, the console writer surfaces a line immediately so an
|
|
19
|
+
operator sees it without waiting for the async flush. It is deliberately dumb, rendering
|
|
20
|
+
an already-built event dict and knowing nothing about spans, and echo is additive — an
|
|
21
|
+
echoed event still rides the normal pipeline to the sink.
|
|
22
|
+
|
|
23
|
+
The default stream is **stderr**, not stdout (SPEC-031 FR-003, which corrected two
|
|
24
|
+
documents that said otherwise). It is the twelve-factor convention ``StderrSink`` already
|
|
25
|
+
cites — logs on stderr, the application's own output on stdout — so an echo cannot corrupt
|
|
26
|
+
a program whose stdout is a data stream someone pipes.
|
|
27
|
+
|
|
28
|
+
**The writer owns its stream's failures** (SPEC-055 FR-005). ``api._log`` used to absorb
|
|
29
|
+
them one line at a time: measured, 200,000 echoed events piped into ``head -1`` wrote
|
|
30
|
+
199,970 identical stderr lines, one per event, forever. A ``BrokenPipeError``, or the
|
|
31
|
+
``ValueError`` a closed file raises, is a stream that will not come back, so it is announced
|
|
32
|
+
once and echo is disabled for the life of the writer. Any other ``Exception`` may be
|
|
33
|
+
transient — ``EAGAIN`` on a non-blocking terminal, ``ENOSPC`` on a file — so the writer keeps
|
|
34
|
+
trying and announces the first failure and then every :data:`_diag.WARN_EVERY`-th with the
|
|
35
|
+
running total, the queue-full site's idiom. The state lives on the writer rather than the
|
|
36
|
+
module so a ``ConsoleWriter(stream=…)`` built for a test starts clean, and the counter is
|
|
37
|
+
taken under a lock on the failure path only, since ``_log`` reaches the process-global
|
|
38
|
+
writer from arbitrary application threads; the disable flag is a set-only latch read
|
|
39
|
+
unlocked, as ``Worker.submit`` reads ``_shutdown_done``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, stream: TextIO | None = None) -> None:
|
|
43
|
+
"""Binds the writer to an output stream, once, at construction.
|
|
44
|
+
|
|
45
|
+
The binding is deliberate and permanent for the life of the writer: a later
|
|
46
|
+
``contextlib.redirect_stderr`` or a test's capture of ``sys.stderr`` is not honoured,
|
|
47
|
+
because the attribute was resolved here. ``api._console`` is built at import, so in
|
|
48
|
+
practice a process's echo stream is fixed before any test runs. Passing ``stream=``
|
|
49
|
+
explicitly is how a caller — a test above all — captures the output (SPEC-031 FR-003).
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
stream: The stream to write to, defaulting to ``sys.stderr`` as resolved now.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
None.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
None.
|
|
59
|
+
"""
|
|
60
|
+
self._stream = stream if stream is not None else sys.stderr
|
|
61
|
+
self._disabled = False
|
|
62
|
+
self._failures = 0
|
|
63
|
+
self._lock = threading.Lock()
|
|
64
|
+
|
|
65
|
+
def write(self, event: dict[str, object]) -> None:
|
|
66
|
+
"""Writes one ``{level:<7} {message}`` line and flushes immediately.
|
|
67
|
+
|
|
68
|
+
A stream fault is absorbed here rather than propagated: a permanent one disables the
|
|
69
|
+
writer after one announcement, a transient one is counted and announced on the throttle
|
|
70
|
+
(see the class docstring). The ``KeyError`` for an event missing ``level`` or
|
|
71
|
+
``message`` still escapes, because that is a library defect rather than a stream fault,
|
|
72
|
+
and ``api._log``'s outer guard is the total guard for it.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
event: An already-built event carrying ``level`` and ``message``.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
None.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
KeyError: If the event is missing ``level`` or ``message``.
|
|
82
|
+
"""
|
|
83
|
+
if self._disabled:
|
|
84
|
+
return
|
|
85
|
+
level = str(event["level"])
|
|
86
|
+
message = str(event["message"])
|
|
87
|
+
try:
|
|
88
|
+
self._stream.write(f"{level:<7} {message}\n")
|
|
89
|
+
self._stream.flush()
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
if _is_permanent(exc):
|
|
92
|
+
self._disabled = True
|
|
93
|
+
_diag.absorbed(
|
|
94
|
+
"echoing to the console",
|
|
95
|
+
exc,
|
|
96
|
+
"echo is disabled for the life of this writer; events still reach the sink",
|
|
97
|
+
)
|
|
98
|
+
return
|
|
99
|
+
with self._lock:
|
|
100
|
+
self._failures += 1
|
|
101
|
+
total = self._failures
|
|
102
|
+
if total == 1 or total % _diag.WARN_EVERY == 0:
|
|
103
|
+
_diag.absorbed(
|
|
104
|
+
"echoing to the console",
|
|
105
|
+
exc,
|
|
106
|
+
f"{total} echo(es) failed; count is cumulative",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _is_permanent(exc: Exception) -> bool:
|
|
111
|
+
"""Decides whether a stream fault means the stream will not come back.
|
|
112
|
+
|
|
113
|
+
A ``BrokenPipeError`` is a reader that has gone, and the ``ValueError`` a closed file raises
|
|
114
|
+
is a stream nothing will reopen. ``UnicodeEncodeError`` is *also* a ``ValueError`` and is
|
|
115
|
+
neither: it is one message a strict-ASCII console could not render, and the next plain
|
|
116
|
+
message would write fine — measured, a bare ``except ValueError`` latched echo off for the
|
|
117
|
+
rest of the process on the first ``"café"``. So the ``UnicodeError`` family is excluded and
|
|
118
|
+
takes the throttle with every other transient fault.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
exc: The exception the stream raised.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Whether to disable echo for the life of the writer.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
None.
|
|
128
|
+
"""
|
|
129
|
+
if isinstance(exc, BrokenPipeError):
|
|
130
|
+
return True
|
|
131
|
+
return isinstance(exc, ValueError) and not isinstance(exc, UnicodeError)
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import functools
|
|
7
|
+
import inspect
|
|
7
8
|
import threading
|
|
8
9
|
from collections.abc import Callable
|
|
9
10
|
from time import monotonic
|
|
@@ -628,6 +629,94 @@ def _close_span(span: Span, status: str, exc: BaseException | None) -> None:
|
|
|
628
629
|
_flush(span)
|
|
629
630
|
|
|
630
631
|
|
|
632
|
+
def _refuse_unusable(fn: object) -> None:
|
|
633
|
+
"""Refuses, at decoration, a callable ``@trace`` could not trace (SPEC-055 FR-002).
|
|
634
|
+
|
|
635
|
+
Invariant 13: a bad argument is refused where it is written, never on the first call. A
|
|
636
|
+
``classmethod`` or ``staticmethod`` object is a decorator applied in the wrong order —
|
|
637
|
+
``classmethod`` is not callable, so the wrapper would fail every call, and ``staticmethod``
|
|
638
|
+
*is* callable but the wrapper replaces the descriptor, so an instance call would hand
|
|
639
|
+
``self`` to a function declared without one. A ``str`` is the slip ``@trace("checkout")``,
|
|
640
|
+
which meant ``name=``. Anything else that is not callable is refused by type.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
fn: Whatever was handed to the decorator.
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
None.
|
|
647
|
+
|
|
648
|
+
Raises:
|
|
649
|
+
TypeError: If ``fn`` is a ``classmethod`` or ``staticmethod`` object, a ``str``, or not
|
|
650
|
+
callable at all.
|
|
651
|
+
Exception: Whatever the callable's own attribute access raises — a ``__class__`` property,
|
|
652
|
+
say — at decoration, in the caller's frame, which is invariant 13's moment.
|
|
653
|
+
"""
|
|
654
|
+
if isinstance(fn, (classmethod, staticmethod)):
|
|
655
|
+
kind = type(fn).__name__
|
|
656
|
+
inner = getattr(fn.__func__, "__qualname__", "the function")
|
|
657
|
+
raise TypeError(
|
|
658
|
+
f"@trace cannot wrap the {kind} object around {inner}; put @{kind} above @trace, "
|
|
659
|
+
f"so that @trace decorates the plain function"
|
|
660
|
+
)
|
|
661
|
+
if isinstance(fn, str):
|
|
662
|
+
raise TypeError(
|
|
663
|
+
f"@trace needs a callable, got str; pass a span name as @trace(name={fn!r})"
|
|
664
|
+
)
|
|
665
|
+
if not callable(fn):
|
|
666
|
+
raise TypeError(f"@trace needs a callable, got {type(fn).__name__}")
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _span_name(fn: object) -> str:
|
|
670
|
+
"""Resolves the span name once, at decoration, from any callable (SPEC-055 FR-002).
|
|
671
|
+
|
|
672
|
+
``fn.__qualname__`` where the callable has one, else its type's name — so a
|
|
673
|
+
``functools.partial`` traces as ``partial`` and a callable instance as its class, where
|
|
674
|
+
reading the attribute per call inside the wrapper raised ``AttributeError`` into the
|
|
675
|
+
application on every call. A ``__qualname__`` that exists but is not a ``str`` needs no
|
|
676
|
+
clause: ``functools.wraps`` copies it onto the wrapper and Python refuses a non-``str``
|
|
677
|
+
there before any wrapper exists.
|
|
678
|
+
|
|
679
|
+
Args:
|
|
680
|
+
fn: The callable being decorated.
|
|
681
|
+
|
|
682
|
+
Returns:
|
|
683
|
+
The span name.
|
|
684
|
+
|
|
685
|
+
Raises:
|
|
686
|
+
Exception: Whatever the callable's own ``__qualname__`` lookup raises other than
|
|
687
|
+
``AttributeError``, at decoration, in the caller's frame.
|
|
688
|
+
"""
|
|
689
|
+
qualname = getattr(fn, "__qualname__", None)
|
|
690
|
+
return qualname if isinstance(qualname, str) else type(fn).__name__
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _is_async(fn: object) -> bool:
|
|
694
|
+
"""Decides the async wrapper, consulting a callable instance's ``__call__`` (SPEC-055 FR-002).
|
|
695
|
+
|
|
696
|
+
``asyncio.iscoroutinefunction`` sees through a ``functools.partial`` but reads a callable
|
|
697
|
+
instance whose ``__call__`` is ``async def`` as synchronous, so accepting instances at all
|
|
698
|
+
(FR-002) would have handed one the sync wrapper and closed the span before the coroutine
|
|
699
|
+
ran — the generator finding's shape again. The type's ``__call__`` is asked too, for
|
|
700
|
+
anything that is not a plain function; a ``partial``'s or a class's is a slot wrapper and
|
|
701
|
+
reads as synchronous, which is correct.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
fn: The callable being decorated.
|
|
705
|
+
|
|
706
|
+
Returns:
|
|
707
|
+
Whether the async wrapper applies.
|
|
708
|
+
|
|
709
|
+
Raises:
|
|
710
|
+
Exception: Whatever the callable's own ``__class__`` or ``__call__`` lookup raises, at
|
|
711
|
+
decoration, in the caller's frame.
|
|
712
|
+
"""
|
|
713
|
+
if asyncio.iscoroutinefunction(fn):
|
|
714
|
+
return True
|
|
715
|
+
if inspect.isfunction(fn):
|
|
716
|
+
return False
|
|
717
|
+
return asyncio.iscoroutinefunction(type(fn).__call__)
|
|
718
|
+
|
|
719
|
+
|
|
631
720
|
@overload
|
|
632
721
|
def trace(func: F) -> F: ...
|
|
633
722
|
@overload
|
|
@@ -646,7 +735,9 @@ def trace(
|
|
|
646
735
|
|
|
647
736
|
Args:
|
|
648
737
|
func: The function being decorated when used bare, otherwise ``None``.
|
|
649
|
-
name: Overrides the span name, which defaults to ``func.__qualname__
|
|
738
|
+
name: Overrides the span name, which defaults to ``func.__qualname__`` where the callable
|
|
739
|
+
has one and to its type's name otherwise — ``partial`` for a ``functools.partial``, the
|
|
740
|
+
class name for a callable instance (SPEC-055 FR-002).
|
|
650
741
|
defaults: Per-decorator default fields added to every event on the span. Any
|
|
651
742
|
``Mapping`` is accepted, and a copy is taken **once here**, at decoration — not per
|
|
652
743
|
call. Before SPEC-051 FR-002 the caller's own object was bound to every span and read
|
|
@@ -657,18 +748,21 @@ def trace(
|
|
|
657
748
|
The wrapped function, or a decorator when called with arguments.
|
|
658
749
|
|
|
659
750
|
Raises:
|
|
660
|
-
|
|
751
|
+
TypeError: At decoration, for a ``classmethod`` or ``staticmethod`` object (the decorators
|
|
752
|
+
are in the wrong order), a ``str`` (``name=`` was meant), anything not callable, or a
|
|
753
|
+
``name=`` that is not a ``str`` (SPEC-055 FR-002, invariant 13).
|
|
661
754
|
"""
|
|
662
755
|
span_defaults = None if defaults is None else dict(defaults)
|
|
663
756
|
|
|
664
757
|
def decorate(fn: F) -> F:
|
|
665
758
|
"""Wraps one function, selecting the sync or async wrapper at decoration time.
|
|
666
759
|
|
|
667
|
-
:func:`
|
|
760
|
+
:func:`_is_async` makes the choice once. The two wrappers are
|
|
668
761
|
deliberate near-duplicates — the only difference is ``await fn(...)`` — because the
|
|
669
762
|
sync/async split is a hard boundary, and ``contextvars`` already propagates the span
|
|
670
763
|
stack and baggage across ``await`` points and concurrent tasks (arch §5), so the async
|
|
671
|
-
span opens when the coroutine actually runs and closes when it finishes.
|
|
764
|
+
span opens when the coroutine actually runs and closes when it finishes. The span name
|
|
765
|
+
is resolved here, once, rather than read from ``fn`` on every call (SPEC-055 FR-002).
|
|
672
766
|
|
|
673
767
|
Args:
|
|
674
768
|
fn: The function to wrap.
|
|
@@ -677,9 +771,13 @@ def trace(
|
|
|
677
771
|
The wrapper, typed as the original function.
|
|
678
772
|
|
|
679
773
|
Raises:
|
|
680
|
-
|
|
774
|
+
TypeError: From :func:`_refuse_unusable`, at decoration.
|
|
681
775
|
"""
|
|
682
|
-
|
|
776
|
+
_refuse_unusable(fn)
|
|
777
|
+
if name is not None and not isinstance(name, str):
|
|
778
|
+
raise TypeError(f"@trace name= must be a str, got {type(name).__name__}")
|
|
779
|
+
span_name = name or _span_name(fn)
|
|
780
|
+
if _is_async(fn):
|
|
683
781
|
|
|
684
782
|
@functools.wraps(fn)
|
|
685
783
|
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
@@ -704,7 +802,7 @@ def trace(
|
|
|
704
802
|
Raises:
|
|
705
803
|
BaseException: Whatever the wrapped coroutine raises, unchanged.
|
|
706
804
|
"""
|
|
707
|
-
span, token, scope = _begin(
|
|
805
|
+
span, token, scope = _begin(span_name, span_defaults)
|
|
708
806
|
status, error = "ok", None
|
|
709
807
|
try:
|
|
710
808
|
result = await fn(*args, **kwargs)
|
|
@@ -740,7 +838,7 @@ def trace(
|
|
|
740
838
|
Raises:
|
|
741
839
|
BaseException: Whatever the wrapped function raises, unchanged.
|
|
742
840
|
"""
|
|
743
|
-
span, token, scope = _begin(
|
|
841
|
+
span, token, scope = _begin(span_name, span_defaults)
|
|
744
842
|
status, error = "ok", None
|
|
745
843
|
try:
|
|
746
844
|
result = fn(*args, **kwargs)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import re
|
|
5
6
|
import sys
|
|
6
7
|
from collections.abc import Iterable, Mapping, Sequence
|
|
7
8
|
from datetime import date, datetime, time
|
|
@@ -40,6 +41,27 @@ The same reasoning as :data:`_INT_LT` beside it, and as ``_placeholder``'s refus
|
|
|
40
41
|
``repr()``: what the library writes about a value must be computed by the library.
|
|
41
42
|
"""
|
|
42
43
|
|
|
44
|
+
_STR_STR = str.__str__
|
|
45
|
+
"""``str.__str__``, unbound, so a ``str`` subclass cannot divert the measurement (SPEC-055 FR-001).
|
|
46
|
+
|
|
47
|
+
On an exact ``str`` it is the identity, so the hot path pays nothing; on a subclass it returns a
|
|
48
|
+
plain copy without consulting the subclass's own ``__str__`` or ``encode``. Measured before it:
|
|
49
|
+
``info(BadEncode("x"))``, where ``encode`` raises, cost the event — absorbed and announced, but
|
|
50
|
+
lost — because ``truncate_str`` called the value's own ``encode`` to measure it. The same
|
|
51
|
+
reasoning as :data:`_INT_LT` and :data:`_FLOAT_REPR`: what the library measures about a value
|
|
52
|
+
must be computed by the library.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
_SURROGATES = re.compile("[\ud800-\udfff]")
|
|
56
|
+
_REPLACEMENT = "\ufffd"
|
|
57
|
+
"""One U+FFFD per lone surrogate, which neither built-in error handler gives (SPEC-055 FR-001).
|
|
58
|
+
|
|
59
|
+
``errors="replace"`` on an *encode* writes ``?`` and loses the byte; a ``surrogatepass`` encode
|
|
60
|
+
followed by a ``replace`` decode writes three U+FFFD for one surrogate, because the decoder
|
|
61
|
+
rejects each of the three bytes separately. The substitution runs only after a strict encode has
|
|
62
|
+
already failed, so the common case never reaches it.
|
|
63
|
+
"""
|
|
64
|
+
|
|
43
65
|
_PLAIN_SCALARS: frozenset[type] = frozenset({int, bool})
|
|
44
66
|
|
|
45
67
|
_TEXTLIKE: tuple[type, ...] = (str, bytes, bytearray, memoryview)
|
|
@@ -69,25 +91,38 @@ def _int_digit_ceiling(max_value_bytes: int) -> int:
|
|
|
69
91
|
return max_value_bytes if limit <= 0 else min(max_value_bytes, limit)
|
|
70
92
|
|
|
71
93
|
|
|
72
|
-
def _measured(value: str) -> bytes:
|
|
73
|
-
"""Returns
|
|
94
|
+
def _measured(value: str) -> tuple[str, bytes, bool]:
|
|
95
|
+
"""Returns a string as an exact ``str`` that encodes strictly, its bytes, and whether it changed.
|
|
96
|
+
|
|
97
|
+
A string carrying an unpaired surrogate — anything that went through ``surrogateescape``,
|
|
98
|
+
such as ``os.fsdecode`` of an undecodable filename — raises ``UnicodeEncodeError`` on a bare
|
|
99
|
+
``.encode("utf-8")``. Before SPEC-055 this tolerated that with ``errors="replace"`` to
|
|
100
|
+
*measure* the string and then returned the caller's string unchanged, so the surrogate left
|
|
101
|
+
assembly intact: the JSON sinks escaped it, and every sink that hands a raw ``str`` to a
|
|
102
|
+
driver — ``SQLiteSink``'s ``function`` column above all — raised on it and cost the whole
|
|
103
|
+
batch its innocent neighbours. Invariant 8 says every string in the event encodes as UTF-8,
|
|
104
|
+
so the replaced string is what is returned now, and the flag reports the substitution.
|
|
74
105
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
raise. ``errors="replace"`` is the same tolerance ``context`` applies when measuring an
|
|
79
|
-
inbound baggage header.
|
|
106
|
+
The strict encode is tried first, because it is the common case and the C-level fast path;
|
|
107
|
+
the regex runs only after it has failed. The value is first passed through
|
|
108
|
+
:data:`_STR_STR`, so what is measured, and what is returned, is always an exact ``str``.
|
|
80
109
|
|
|
81
110
|
Args:
|
|
82
111
|
value: The string to measure.
|
|
83
112
|
|
|
84
113
|
Returns:
|
|
85
|
-
The
|
|
114
|
+
The exact ``str`` as measured, its strict UTF-8 bytes, and ``True`` when a surrogate was
|
|
115
|
+
replaced.
|
|
86
116
|
|
|
87
117
|
Raises:
|
|
88
118
|
None.
|
|
89
119
|
"""
|
|
90
|
-
|
|
120
|
+
plain = _STR_STR(value)
|
|
121
|
+
try:
|
|
122
|
+
return plain, plain.encode("utf-8"), False
|
|
123
|
+
except UnicodeEncodeError:
|
|
124
|
+
clean = _SURROGATES.sub(_REPLACEMENT, plain)
|
|
125
|
+
return clean, clean.encode("utf-8"), True
|
|
91
126
|
|
|
92
127
|
|
|
93
128
|
def truncate_str(value: str, max_bytes: int) -> tuple[str, bool]:
|
|
@@ -99,19 +134,25 @@ def truncate_str(value: str, max_bytes: int) -> tuple[str, bool]:
|
|
|
99
134
|
mid-sequence; where there is no room for anything but the marker, a marker alone is still
|
|
100
135
|
the honest answer.
|
|
101
136
|
|
|
137
|
+
The result is an exact ``str`` that encodes as UTF-8 strictly, never the caller's own object
|
|
138
|
+
(SPEC-055 FR-001): a lone surrogate is replaced by U+FFFD before the measurement, and the
|
|
139
|
+
flag is therefore "the string was altered" rather than only "the ceiling fired" — a
|
|
140
|
+
substitution nobody can see is a silent change to the data, which is the rule
|
|
141
|
+
:meth:`_Coercer.real` already applies to a non-finite float.
|
|
142
|
+
|
|
102
143
|
Args:
|
|
103
144
|
value: The string to clip.
|
|
104
145
|
max_bytes: The ceiling, in UTF-8 bytes.
|
|
105
146
|
|
|
106
147
|
Returns:
|
|
107
|
-
The clipped string and whether
|
|
148
|
+
The clipped string and whether it was altered — clipped, or a surrogate replaced.
|
|
108
149
|
|
|
109
150
|
Raises:
|
|
110
151
|
None.
|
|
111
152
|
"""
|
|
112
|
-
raw = _measured(value)
|
|
153
|
+
plain, raw, replaced = _measured(value)
|
|
113
154
|
if len(raw) <= max_bytes:
|
|
114
|
-
return
|
|
155
|
+
return plain, replaced
|
|
115
156
|
budget = max_bytes - _MARKER_BYTES
|
|
116
157
|
if budget <= 0:
|
|
117
158
|
return TRUNCATION_MARKER, True
|
|
@@ -125,21 +166,22 @@ def truncate_tail(value: str, max_bytes: int) -> tuple[str, bool]:
|
|
|
125
166
|
message and the innermost frames last, so the head of an over-long traceback is the least
|
|
126
167
|
useful part of it. The zero-budget guard is not merely defensive — ``raw[-budget:]`` would
|
|
127
168
|
return the whole string at a budget of zero, and ``max_stack_bytes`` may legally be smaller
|
|
128
|
-
than the marker.
|
|
169
|
+
than the marker. The result is an exact, strictly encodable ``str`` on the same terms as
|
|
170
|
+
:func:`truncate_str`.
|
|
129
171
|
|
|
130
172
|
Args:
|
|
131
173
|
value: The string to clip.
|
|
132
174
|
max_bytes: The ceiling, in UTF-8 bytes.
|
|
133
175
|
|
|
134
176
|
Returns:
|
|
135
|
-
The clipped string and whether
|
|
177
|
+
The clipped string and whether it was altered — clipped, or a surrogate replaced.
|
|
136
178
|
|
|
137
179
|
Raises:
|
|
138
180
|
None.
|
|
139
181
|
"""
|
|
140
|
-
raw = _measured(value)
|
|
182
|
+
plain, raw, replaced = _measured(value)
|
|
141
183
|
if len(raw) <= max_bytes:
|
|
142
|
-
return
|
|
184
|
+
return plain, replaced
|
|
143
185
|
budget = max_bytes - _MARKER_BYTES
|
|
144
186
|
if budget <= 0:
|
|
145
187
|
return TRUNCATION_MARKER, True
|
|
@@ -269,9 +311,9 @@ class _Coercer:
|
|
|
269
311
|
if isinstance(value, Decimal):
|
|
270
312
|
return self.text(str(value))
|
|
271
313
|
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
272
|
-
return self.text(bytes(value)
|
|
314
|
+
return self.text(self._decoded(bytes(value)))
|
|
273
315
|
if isinstance(value, str):
|
|
274
|
-
return self.text(
|
|
316
|
+
return self.text(value)
|
|
275
317
|
if isinstance(value, Mapping):
|
|
276
318
|
return self.mapping(value, depth)
|
|
277
319
|
if isinstance(value, (set, frozenset)):
|
|
@@ -280,6 +322,30 @@ class _Coercer:
|
|
|
280
322
|
return self.members(value, depth)
|
|
281
323
|
return self._placeholder(value)
|
|
282
324
|
|
|
325
|
+
def _decoded(self, value: bytes) -> str:
|
|
326
|
+
"""Decodes bytes as UTF-8, replacing what does not decode and recording that it did.
|
|
327
|
+
|
|
328
|
+
Strict first, and only on ``UnicodeDecodeError`` with ``errors="replace"`` (SPEC-055
|
|
329
|
+
FR-001): before this the replacement was unconditional and silent, so the same
|
|
330
|
+
undecodable byte was marked when it arrived as a ``str`` and unmarked when it arrived as
|
|
331
|
+
``bytes``. The rule is the one every other substitution here follows — a change nobody
|
|
332
|
+
can see is a change that sets ``truncated``.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
value: The bytes to decode.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
The decoded text, with U+FFFD where a byte sequence was not UTF-8.
|
|
339
|
+
|
|
340
|
+
Raises:
|
|
341
|
+
None.
|
|
342
|
+
"""
|
|
343
|
+
try:
|
|
344
|
+
return value.decode("utf-8")
|
|
345
|
+
except UnicodeDecodeError:
|
|
346
|
+
self.truncated = True
|
|
347
|
+
return value.decode("utf-8", errors="replace")
|
|
348
|
+
|
|
283
349
|
def mapping(self, value: Mapping[Any, object], depth: int) -> object:
|
|
284
350
|
"""Coerces a mapping, capping it at ``max_keys`` and guarding against a cycle.
|
|
285
351
|
|
|
@@ -341,12 +407,46 @@ class _Coercer:
|
|
|
341
407
|
self._parents.pop()
|
|
342
408
|
|
|
343
409
|
def key(self, key: object) -> str:
|
|
344
|
-
"""Coerces a mapping key to a bounded string,
|
|
410
|
+
"""Coerces a mapping key to a bounded string, and is total (SPEC-055 FR-004).
|
|
411
|
+
|
|
412
|
+
Whatever the key's own ``__str__``, ``bit_length`` or ``__lt__`` raises is caught here
|
|
413
|
+
rather than in :meth:`value`, whose guard sits around the *enclosing* mapping: measured,
|
|
414
|
+
``{"sib": 1, KeyBoom(): 2}`` reached the sink as ``<unserializable: dict>`` with
|
|
415
|
+
``truncated`` unset, so one hostile key took every sibling with it — the exact outcome
|
|
416
|
+
the integer branch below was written to prevent for one kind of key. The key becomes a
|
|
417
|
+
``<unserializable key: T>`` placeholder naming its type, on :meth:`_placeholder`'s
|
|
418
|
+
no-``repr`` rule, and ``truncated`` is set: a value placeholder is visible on its own,
|
|
419
|
+
but a key that could not be rendered is a substitution the reader must be told about.
|
|
420
|
+
|
|
421
|
+
Two hostile keys of one type collide on one placeholder and the later wins, which is
|
|
422
|
+
accepted: the alternative is a placeholder carrying something of the key's identity,
|
|
423
|
+
which is what arch §6 forbids, and the collision is still marked because each replaced
|
|
424
|
+
key sets ``truncated``. The guard is here rather than in :meth:`mapping`'s loop because
|
|
425
|
+
the loop's other two failures — the ``max_keys`` cap and a value that cannot be coerced
|
|
426
|
+
— already have owners, and a third ``try`` around the loop would hide which one fired.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
key: The mapping key, of any type.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
The bounded string form of the key, or the key placeholder.
|
|
433
|
+
|
|
434
|
+
Raises:
|
|
435
|
+
None.
|
|
436
|
+
"""
|
|
437
|
+
try:
|
|
438
|
+
return self._key(key)
|
|
439
|
+
except Exception:
|
|
440
|
+
self.truncated = True
|
|
441
|
+
return self.text(f"<unserializable key: {_type_name(key)}>")
|
|
442
|
+
|
|
443
|
+
def _key(self, key: object) -> str:
|
|
444
|
+
"""Renders one mapping key, since JSON object keys are always strings.
|
|
345
445
|
|
|
346
446
|
An integer key goes through :meth:`integer` first. A bare ``str()`` here would raise on
|
|
347
447
|
an over-long one — the very ``ValueError`` this module exists to keep away from a sink
|
|
348
|
-
— and the failure would be caught up in :meth:`
|
|
349
|
-
|
|
448
|
+
— and the failure would be caught up in :meth:`key`, replacing the key with a
|
|
449
|
+
placeholder where its digits would have done. ``bool``
|
|
350
450
|
is excluded because ``True`` must render as the key ``"True"``, not ``"1"``.
|
|
351
451
|
|
|
352
452
|
A float key goes through :meth:`real` for the same reason, and its ``text()`` wrap is
|
|
@@ -362,7 +462,7 @@ class _Coercer:
|
|
|
362
462
|
The bounded string form of the key.
|
|
363
463
|
|
|
364
464
|
Raises:
|
|
365
|
-
Exception: Whatever the key's own ``__str__`` raises; :meth:`
|
|
465
|
+
Exception: Whatever the key's own ``__str__`` raises; :meth:`key` is the guard.
|
|
366
466
|
"""
|
|
367
467
|
if isinstance(key, str):
|
|
368
468
|
return self.text(key)
|
|
@@ -458,7 +558,9 @@ class _Coercer:
|
|
|
458
558
|
A type name rather than ``repr(value)`` on purpose: arch §6 refuses to auto-capture
|
|
459
559
|
argument and return values so the library cannot leak secrets or PII, and ``repr()`` of
|
|
460
560
|
an arbitrary object routinely prints attribute values — a credential held on a client
|
|
461
|
-
object would land in the log.
|
|
561
|
+
object would land in the log. The placeholder goes through :meth:`text` like any other
|
|
562
|
+
string (SPEC-055 FR-004): ``type.__name__`` is writable, so a type named with more than
|
|
563
|
+
``max_value_bytes`` would otherwise be the one string in the event no ceiling reached.
|
|
462
564
|
|
|
463
565
|
Args:
|
|
464
566
|
value: The value that could not be coerced.
|
|
@@ -469,11 +571,28 @@ class _Coercer:
|
|
|
469
571
|
Raises:
|
|
470
572
|
None. A pathological metaclass is still not this module's problem.
|
|
471
573
|
"""
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
574
|
+
return self.text(f"<unserializable: {_type_name(value)}>")
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _type_name(value: object) -> str:
|
|
578
|
+
"""Returns a value's type name, or ``"?"`` when even that cannot be read.
|
|
579
|
+
|
|
580
|
+
Shared by both placeholders so the no-``repr`` rule has one implementation. A metaclass
|
|
581
|
+
whose ``__name__`` is a raising property is the case the fallback exists for.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
value: The value whose type is named.
|
|
585
|
+
|
|
586
|
+
Returns:
|
|
587
|
+
The type's ``__name__``, or ``"?"``.
|
|
588
|
+
|
|
589
|
+
Raises:
|
|
590
|
+
None.
|
|
591
|
+
"""
|
|
592
|
+
try:
|
|
593
|
+
return str(type(value).__name__)
|
|
594
|
+
except Exception:
|
|
595
|
+
return "?"
|
|
477
596
|
|
|
478
597
|
|
|
479
598
|
def coerce(value: object, *, cfg: Config) -> object:
|
|
@@ -288,11 +288,13 @@ MAX_PARTITION_KEY_BYTES = 256
|
|
|
288
288
|
def _partition_key(raw: str) -> str:
|
|
289
289
|
"""Bounds a partition key to the service's 256 **bytes**, never 256 characters.
|
|
290
290
|
|
|
291
|
-
Both encodes carry an ``errors=`` and both are load-bearing.
|
|
292
|
-
surrogate
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
291
|
+
Both encodes carry an ``errors=`` and both are load-bearing. Assembly replaces a lone
|
|
292
|
+
surrogate since SPEC-055 FR-001, so an event field cannot carry one — but this key is derived
|
|
293
|
+
from whatever field the caller named, on a batch a ``TransformSink`` or a caller's own
|
|
294
|
+
``Sink`` may have rewritten after assembly, and a bare ``encode("utf-8")`` on a surrogate
|
|
295
|
+
raises ``UnicodeEncodeError`` — a raw exception out of ``emit``, which is the failure SPEC-048
|
|
296
|
+
exists to remove rather than introduce. ``errors="ignore"`` on the decode drops a character
|
|
297
|
+
the byte cut landed inside, so the result always decodes cleanly.
|
|
296
298
|
|
|
297
299
|
``sanitize.truncate_str`` is the inventoried byte-bounded clipper and is deliberately **not**
|
|
298
300
|
reused: it appends a truncation marker, and a marker inside a partition key changes the shard
|
|
@@ -27,7 +27,6 @@ module at module scope. Re-exported rather than relocated outright so that
|
|
|
27
27
|
SPEC-034 froze — keeps naming the same object.
|
|
28
28
|
"""
|
|
29
29
|
|
|
30
|
-
_DROP_WARN_EVERY = 1000
|
|
31
30
|
|
|
32
31
|
_PUT_POLL_SECONDS = 0.05
|
|
33
32
|
"""How long a full-queue ``flush()`` waits before re-asking whether the drain was abandoned.
|
|
@@ -567,7 +566,7 @@ class Worker:
|
|
|
567
566
|
with self._lock:
|
|
568
567
|
self.dropped += 1
|
|
569
568
|
total = self.dropped
|
|
570
|
-
if total == 1 or total %
|
|
569
|
+
if total == 1 or total % _diag.WARN_EVERY == 0:
|
|
571
570
|
_diag.lost("submission", total, "log queue full; count is cumulative")
|
|
572
571
|
return
|
|
573
572
|
if not retired and self._shutdown_done:
|
|
@@ -594,7 +593,7 @@ class Worker:
|
|
|
594
593
|
with self._lock:
|
|
595
594
|
self.submitted_after_shutdown += 1
|
|
596
595
|
total = self.submitted_after_shutdown
|
|
597
|
-
if total == 1 or total %
|
|
596
|
+
if total == 1 or total % _diag.WARN_EVERY == 0:
|
|
598
597
|
_diag.lost(
|
|
599
598
|
"submission",
|
|
600
599
|
total,
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
"""Console echo — a synchronous, human-readable output path (SPEC-002 FR-002)."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import sys
|
|
6
|
-
from typing import TextIO
|
|
7
|
-
|
|
8
|
-
__all__ = ["ConsoleWriter"]
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class ConsoleWriter:
|
|
12
|
-
"""Renders events as human-readable ``LEVEL message`` lines to a stream.
|
|
13
|
-
|
|
14
|
-
This is separate from the async :class:`~log_foundry.sinks.base.Sink`: where the sink
|
|
15
|
-
ships structured JSON downstream, the console writer surfaces a line immediately so an
|
|
16
|
-
operator sees it without waiting for the async flush. It is deliberately dumb, rendering
|
|
17
|
-
an already-built event dict and knowing nothing about spans, and echo is additive — an
|
|
18
|
-
echoed event still rides the normal pipeline to the sink.
|
|
19
|
-
|
|
20
|
-
The default stream is **stderr**, not stdout (SPEC-031 FR-003, which corrected two
|
|
21
|
-
documents that said otherwise). It is the twelve-factor convention ``StderrSink`` already
|
|
22
|
-
cites — logs on stderr, the application's own output on stdout — so an echo cannot corrupt
|
|
23
|
-
a program whose stdout is a data stream someone pipes.
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
def __init__(self, stream: TextIO | None = None) -> None:
|
|
27
|
-
"""Binds the writer to an output stream, once, at construction.
|
|
28
|
-
|
|
29
|
-
The binding is deliberate and permanent for the life of the writer: a later
|
|
30
|
-
``contextlib.redirect_stderr`` or a test's capture of ``sys.stderr`` is not honoured,
|
|
31
|
-
because the attribute was resolved here. ``api._console`` is built at import, so in
|
|
32
|
-
practice a process's echo stream is fixed before any test runs. Passing ``stream=``
|
|
33
|
-
explicitly is how a caller — a test above all — captures the output (SPEC-031 FR-003).
|
|
34
|
-
|
|
35
|
-
Args:
|
|
36
|
-
stream: The stream to write to, defaulting to ``sys.stderr`` as resolved now.
|
|
37
|
-
|
|
38
|
-
Returns:
|
|
39
|
-
None.
|
|
40
|
-
|
|
41
|
-
Raises:
|
|
42
|
-
None.
|
|
43
|
-
"""
|
|
44
|
-
self._stream = stream if stream is not None else sys.stderr
|
|
45
|
-
|
|
46
|
-
def write(self, event: dict[str, object]) -> None:
|
|
47
|
-
"""Writes one ``{level:<7} {message}`` line and flushes immediately.
|
|
48
|
-
|
|
49
|
-
Args:
|
|
50
|
-
event: An already-built event carrying ``level`` and ``message``.
|
|
51
|
-
|
|
52
|
-
Returns:
|
|
53
|
-
None.
|
|
54
|
-
|
|
55
|
-
Raises:
|
|
56
|
-
KeyError: If the event is missing ``level`` or ``message``.
|
|
57
|
-
"""
|
|
58
|
-
level = str(event["level"])
|
|
59
|
-
message = str(event["message"])
|
|
60
|
-
self._stream.write(f"{level:<7} {message}\n")
|
|
61
|
-
self._stream.flush()
|
|
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
|
{log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/elasticsearch.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{log_foundry-0.10.2.dev126 → log_foundry-0.10.2.dev127}/src/log_foundry/sinks/logging_sink.py
RENAMED
|
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
|