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.
Files changed (93) hide show
  1. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/PKG-INFO +1 -1
  2. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/pyproject.toml +1 -1
  3. log_foundry-0.10.2.dev22/src/log_foundry/__init__.py +143 -0
  4. log_foundry-0.10.2.dev22/src/log_foundry/_diag.py +233 -0
  5. log_foundry-0.10.2.dev22/src/log_foundry/api.py +161 -0
  6. log_foundry-0.10.2.dev22/src/log_foundry/config.py +159 -0
  7. log_foundry-0.10.2.dev22/src/log_foundry/console.py +50 -0
  8. log_foundry-0.10.2.dev22/src/log_foundry/context.py +359 -0
  9. log_foundry-0.10.2.dev22/src/log_foundry/decorator.py +551 -0
  10. log_foundry-0.10.2.dev22/src/log_foundry/ids.py +179 -0
  11. log_foundry-0.10.2.dev22/src/log_foundry/model.py +278 -0
  12. log_foundry-0.10.2.dev22/src/log_foundry/sanitize.py +467 -0
  13. log_foundry-0.10.2.dev22/src/log_foundry/sinks/_batch.py +81 -0
  14. log_foundry-0.10.2.dev22/src/log_foundry/sinks/_chunk.py +90 -0
  15. log_foundry-0.10.2.dev22/src/log_foundry/sinks/_retry.py +89 -0
  16. log_foundry-0.10.2.dev22/src/log_foundry/sinks/_socket.py +240 -0
  17. log_foundry-0.10.2.dev22/src/log_foundry/sinks/_time.py +46 -0
  18. log_foundry-0.10.2.dev22/src/log_foundry/sinks/base.py +130 -0
  19. log_foundry-0.10.2.dev22/src/log_foundry/sinks/callback.py +71 -0
  20. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/clickhouse.py +100 -28
  21. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/datadog.py +43 -8
  22. log_foundry-0.10.2.dev22/src/log_foundry/sinks/elasticsearch.py +179 -0
  23. log_foundry-0.10.2.dev22/src/log_foundry/sinks/eventhubs.py +217 -0
  24. log_foundry-0.10.2.dev22/src/log_foundry/sinks/file.py +274 -0
  25. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/filtering.py +91 -27
  26. log_foundry-0.10.2.dev22/src/log_foundry/sinks/firehose.py +226 -0
  27. log_foundry-0.10.2.dev22/src/log_foundry/sinks/honeycomb.py +62 -0
  28. log_foundry-0.10.2.dev22/src/log_foundry/sinks/http.py +407 -0
  29. log_foundry-0.10.2.dev22/src/log_foundry/sinks/kafka.py +198 -0
  30. log_foundry-0.10.2.dev22/src/log_foundry/sinks/kinesis.py +233 -0
  31. log_foundry-0.10.2.dev22/src/log_foundry/sinks/logging_sink.py +160 -0
  32. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/logstash.py +94 -21
  33. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/loki.py +31 -10
  34. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/mongodb.py +81 -34
  35. log_foundry-0.10.2.dev22/src/log_foundry/sinks/multi.py +189 -0
  36. log_foundry-0.10.2.dev22/src/log_foundry/sinks/nats.py +151 -0
  37. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/newrelic.py +19 -7
  38. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/postgres.py +90 -28
  39. log_foundry-0.10.2.dev22/src/log_foundry/sinks/pubsub.py +117 -0
  40. log_foundry-0.10.2.dev22/src/log_foundry/sinks/rabbitmq.py +264 -0
  41. log_foundry-0.10.2.dev22/src/log_foundry/sinks/redis.py +212 -0
  42. log_foundry-0.10.2.dev22/src/log_foundry/sinks/sentry.py +344 -0
  43. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/sns.py +85 -31
  44. log_foundry-0.10.2.dev22/src/log_foundry/sinks/splunk.py +87 -0
  45. log_foundry-0.10.2.dev22/src/log_foundry/sinks/sqlite.py +130 -0
  46. log_foundry-0.10.2.dev22/src/log_foundry/sinks/sqs.py +375 -0
  47. log_foundry-0.10.2.dev22/src/log_foundry/sinks/stdout.py +61 -0
  48. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/syslog.py +102 -21
  49. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/transform.py +75 -18
  50. log_foundry-0.10.2.dev22/src/log_foundry/sinks/util.py +140 -0
  51. log_foundry-0.10.2.dev22/src/log_foundry/worker.py +714 -0
  52. log_foundry-0.10.2.dev20/src/log_foundry/__init__.py +0 -140
  53. log_foundry-0.10.2.dev20/src/log_foundry/_diag.py +0 -215
  54. log_foundry-0.10.2.dev20/src/log_foundry/api.py +0 -108
  55. log_foundry-0.10.2.dev20/src/log_foundry/config.py +0 -124
  56. log_foundry-0.10.2.dev20/src/log_foundry/console.py +0 -30
  57. log_foundry-0.10.2.dev20/src/log_foundry/context.py +0 -294
  58. log_foundry-0.10.2.dev20/src/log_foundry/decorator.py +0 -442
  59. log_foundry-0.10.2.dev20/src/log_foundry/ids.py +0 -109
  60. log_foundry-0.10.2.dev20/src/log_foundry/model.py +0 -227
  61. log_foundry-0.10.2.dev20/src/log_foundry/sanitize.py +0 -368
  62. log_foundry-0.10.2.dev20/src/log_foundry/sinks/_batch.py +0 -70
  63. log_foundry-0.10.2.dev20/src/log_foundry/sinks/_chunk.py +0 -60
  64. log_foundry-0.10.2.dev20/src/log_foundry/sinks/_retry.py +0 -103
  65. log_foundry-0.10.2.dev20/src/log_foundry/sinks/_socket.py +0 -147
  66. log_foundry-0.10.2.dev20/src/log_foundry/sinks/_time.py +0 -28
  67. log_foundry-0.10.2.dev20/src/log_foundry/sinks/base.py +0 -121
  68. log_foundry-0.10.2.dev20/src/log_foundry/sinks/callback.py +0 -45
  69. log_foundry-0.10.2.dev20/src/log_foundry/sinks/elasticsearch.py +0 -141
  70. log_foundry-0.10.2.dev20/src/log_foundry/sinks/eventhubs.py +0 -150
  71. log_foundry-0.10.2.dev20/src/log_foundry/sinks/file.py +0 -175
  72. log_foundry-0.10.2.dev20/src/log_foundry/sinks/firehose.py +0 -180
  73. log_foundry-0.10.2.dev20/src/log_foundry/sinks/honeycomb.py +0 -38
  74. log_foundry-0.10.2.dev20/src/log_foundry/sinks/http.py +0 -287
  75. log_foundry-0.10.2.dev20/src/log_foundry/sinks/kafka.py +0 -133
  76. log_foundry-0.10.2.dev20/src/log_foundry/sinks/kinesis.py +0 -185
  77. log_foundry-0.10.2.dev20/src/log_foundry/sinks/logging_sink.py +0 -104
  78. log_foundry-0.10.2.dev20/src/log_foundry/sinks/multi.py +0 -139
  79. log_foundry-0.10.2.dev20/src/log_foundry/sinks/nats.py +0 -88
  80. log_foundry-0.10.2.dev20/src/log_foundry/sinks/pubsub.py +0 -79
  81. log_foundry-0.10.2.dev20/src/log_foundry/sinks/rabbitmq.py +0 -158
  82. log_foundry-0.10.2.dev20/src/log_foundry/sinks/redis.py +0 -115
  83. log_foundry-0.10.2.dev20/src/log_foundry/sinks/sentry.py +0 -218
  84. log_foundry-0.10.2.dev20/src/log_foundry/sinks/splunk.py +0 -51
  85. log_foundry-0.10.2.dev20/src/log_foundry/sinks/sqlite.py +0 -95
  86. log_foundry-0.10.2.dev20/src/log_foundry/sinks/sqs.py +0 -329
  87. log_foundry-0.10.2.dev20/src/log_foundry/sinks/stdout.py +0 -31
  88. log_foundry-0.10.2.dev20/src/log_foundry/sinks/util.py +0 -72
  89. log_foundry-0.10.2.dev20/src/log_foundry/worker.py +0 -625
  90. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/LICENSE +0 -0
  91. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/README.md +0 -0
  92. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/py.typed +0 -0
  93. {log_foundry-0.10.2.dev20 → log_foundry-0.10.2.dev22}/src/log_foundry/sinks/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev20
3
+ Version: 0.10.2.dev22
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.10.2.dev20"
23
+ version = "0.10.2.dev22"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -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)