log-foundry 0.0.2.dev1__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 (51) hide show
  1. log_foundry-0.0.2.dev1/LICENSE +21 -0
  2. log_foundry-0.0.2.dev1/PKG-INFO +537 -0
  3. log_foundry-0.0.2.dev1/README.md +501 -0
  4. log_foundry-0.0.2.dev1/pyproject.toml +91 -0
  5. log_foundry-0.0.2.dev1/src/log_forge/__init__.py +45 -0
  6. log_foundry-0.0.2.dev1/src/log_forge/api.py +89 -0
  7. log_foundry-0.0.2.dev1/src/log_forge/config.py +79 -0
  8. log_foundry-0.0.2.dev1/src/log_forge/console.py +30 -0
  9. log_foundry-0.0.2.dev1/src/log_forge/context.py +62 -0
  10. log_foundry-0.0.2.dev1/src/log_forge/decorator.py +157 -0
  11. log_foundry-0.0.2.dev1/src/log_forge/ids.py +29 -0
  12. log_foundry-0.0.2.dev1/src/log_forge/model.py +106 -0
  13. log_foundry-0.0.2.dev1/src/log_forge/py.typed +0 -0
  14. log_foundry-0.0.2.dev1/src/log_forge/sinks/__init__.py +0 -0
  15. log_foundry-0.0.2.dev1/src/log_forge/sinks/_chunk.py +58 -0
  16. log_foundry-0.0.2.dev1/src/log_forge/sinks/_socket.py +105 -0
  17. log_foundry-0.0.2.dev1/src/log_forge/sinks/_time.py +28 -0
  18. log_foundry-0.0.2.dev1/src/log_forge/sinks/base.py +22 -0
  19. log_foundry-0.0.2.dev1/src/log_forge/sinks/callback.py +42 -0
  20. log_foundry-0.0.2.dev1/src/log_forge/sinks/clickhouse.py +111 -0
  21. log_foundry-0.0.2.dev1/src/log_forge/sinks/datadog.py +53 -0
  22. log_foundry-0.0.2.dev1/src/log_forge/sinks/elasticsearch.py +68 -0
  23. log_foundry-0.0.2.dev1/src/log_forge/sinks/eventhubs.py +110 -0
  24. log_foundry-0.0.2.dev1/src/log_forge/sinks/file.py +177 -0
  25. log_foundry-0.0.2.dev1/src/log_forge/sinks/filtering.py +71 -0
  26. log_foundry-0.0.2.dev1/src/log_forge/sinks/firehose.py +91 -0
  27. log_foundry-0.0.2.dev1/src/log_forge/sinks/honeycomb.py +38 -0
  28. log_foundry-0.0.2.dev1/src/log_forge/sinks/http.py +202 -0
  29. log_foundry-0.0.2.dev1/src/log_forge/sinks/kafka.py +71 -0
  30. log_foundry-0.0.2.dev1/src/log_forge/sinks/kinesis.py +99 -0
  31. log_foundry-0.0.2.dev1/src/log_forge/sinks/logging_sink.py +104 -0
  32. log_foundry-0.0.2.dev1/src/log_forge/sinks/logstash.py +73 -0
  33. log_foundry-0.0.2.dev1/src/log_forge/sinks/loki.py +48 -0
  34. log_foundry-0.0.2.dev1/src/log_forge/sinks/mongodb.py +105 -0
  35. log_foundry-0.0.2.dev1/src/log_forge/sinks/multi.py +53 -0
  36. log_foundry-0.0.2.dev1/src/log_forge/sinks/nats.py +72 -0
  37. log_foundry-0.0.2.dev1/src/log_forge/sinks/newrelic.py +28 -0
  38. log_foundry-0.0.2.dev1/src/log_forge/sinks/postgres.py +101 -0
  39. log_foundry-0.0.2.dev1/src/log_forge/sinks/pubsub.py +45 -0
  40. log_foundry-0.0.2.dev1/src/log_forge/sinks/rabbitmq.py +129 -0
  41. log_foundry-0.0.2.dev1/src/log_forge/sinks/redis.py +90 -0
  42. log_foundry-0.0.2.dev1/src/log_forge/sinks/sentry.py +136 -0
  43. log_foundry-0.0.2.dev1/src/log_forge/sinks/sns.py +82 -0
  44. log_foundry-0.0.2.dev1/src/log_forge/sinks/splunk.py +51 -0
  45. log_foundry-0.0.2.dev1/src/log_forge/sinks/sqlite.py +100 -0
  46. log_foundry-0.0.2.dev1/src/log_forge/sinks/sqs.py +107 -0
  47. log_foundry-0.0.2.dev1/src/log_forge/sinks/stdout.py +31 -0
  48. log_foundry-0.0.2.dev1/src/log_forge/sinks/syslog.py +88 -0
  49. log_foundry-0.0.2.dev1/src/log_forge/sinks/transform.py +46 -0
  50. log_foundry-0.0.2.dev1/src/log_forge/sinks/util.py +68 -0
  51. log_foundry-0.0.2.dev1/src/log_forge/worker.py +153 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Griffith
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,537 @@
1
+ Metadata-Version: 2.4
2
+ Name: log-foundry
3
+ Version: 0.0.2.dev1
4
+ Summary: Generate logs for your console and JSON events for downstream consumption.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Author: Andrew Griffith
8
+ Requires-Python: >=3.13
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Provides-Extra: amqp
13
+ Provides-Extra: aws
14
+ Provides-Extra: azure-eventhubs
15
+ Provides-Extra: clickhouse
16
+ Provides-Extra: gcp-pubsub
17
+ Provides-Extra: kafka
18
+ Provides-Extra: mongo
19
+ Provides-Extra: nats
20
+ Provides-Extra: postgres
21
+ Provides-Extra: redis
22
+ Provides-Extra: sentry
23
+ Requires-Dist: azure-eventhub (>=5.11) ; extra == "azure-eventhubs"
24
+ Requires-Dist: boto3 (>=1.34) ; extra == "aws"
25
+ Requires-Dist: clickhouse-connect (>=0.7) ; (python_version < "3.15") and (extra == "clickhouse")
26
+ Requires-Dist: confluent-kafka (>=2.0) ; extra == "kafka"
27
+ Requires-Dist: google-cloud-pubsub (>=2.18) ; extra == "gcp-pubsub"
28
+ Requires-Dist: nats-py (>=2.6) ; extra == "nats"
29
+ Requires-Dist: pika (>=1.3) ; extra == "amqp"
30
+ Requires-Dist: psycopg[binary] (>=3.1) ; extra == "postgres"
31
+ Requires-Dist: pymongo (>=4.6) ; extra == "mongo"
32
+ Requires-Dist: redis (>=5.0) ; extra == "redis"
33
+ Requires-Dist: sentry-sdk (>=2.0) ; extra == "sentry"
34
+ Description-Content-Type: text/markdown
35
+
36
+ # log-forge
37
+
38
+ Consistent, structured (JSON) logs for every decorated function call — correlated by shared
39
+ trace/span IDs, ready to ship to any of 30-plus built-in sinks (stdout by default; SQS → ELK is
40
+ the headline production path).
41
+
42
+ `log-forge` owns the **logs** pillar of observability. You decorate a function with `@trace`;
43
+ it emits one identically-shaped JSON record when the call starts and another when it ends
44
+ (with duration and status), stitched together by W3C-compatible trace and span IDs so nested
45
+ calls form a tree you can query later.
46
+
47
+ - **Zero runtime dependencies** — the core pulls in nothing; every sink that needs a third-party client (boto3, kafka, redis, …) sits behind its own optional extra, lazily imported.
48
+ - **Fully typed** — `mypy --strict`, ships a PEP 561 `py.typed` marker.
49
+ - **Structured, never free-form** — every event is the same named-field JSON shape.
50
+ - **Safe by default** — never captures your arguments or return values (no accidental PII/secret leakage), and the decorator **never swallows exceptions**.
51
+ - **Correct under threads and asyncio** — context propagates via `contextvars`.
52
+ - **Non-blocking delivery** — finished spans are handed to a background worker; your code never
53
+ blocks on sink I/O, and a graceful drain at exit means buffered events aren't lost.
54
+
55
+ ---
56
+
57
+ ## Requirements
58
+
59
+ - **Python ≥ 3.13**
60
+
61
+ ## Installation
62
+
63
+ `log-forge` is not yet published to PyPI. Install from source:
64
+
65
+ ```bash
66
+ # from a clone of this repo
67
+ poetry install # or: pip install .
68
+
69
+ # with an optional sink extra (e.g. the AWS sinks)
70
+ poetry install -E aws # or: pip install '.[aws]'
71
+ ```
72
+
73
+ Once published, the intended install will be:
74
+
75
+ ```bash
76
+ pip install log-foundry # core, zero dependencies
77
+ pip install 'log-foundry[aws]' # + boto3 for the SQS/SNS/Kinesis/Firehose sinks
78
+ ```
79
+
80
+ > **Note the name split.** The distribution is **`log-foundry`** on PyPI, but the import name
81
+ > stays **`log_forge`** — `pip install log-foundry`, then `import log_forge`. PyPI rejects
82
+ > `log-forge` as too similar to the unrelated, pre-existing `logforge` project.
83
+
84
+ ### Optional extras
85
+
86
+ The core is dependency-free. Each sink built on a third-party client lives behind its own extra
87
+ (the client is imported lazily, only when you construct that sink). All other sinks — stdout,
88
+ file, SQLite, the stdlib-`logging` bridge, and every HTTP/socket platform sink (Elasticsearch,
89
+ Loki, Logstash, Syslog, Datadog, Splunk, New Relic, Honeycomb) — need **no** extra.
90
+
91
+ | Extra | Installs | Enables |
92
+ |---|---|---|
93
+ | `aws` | `boto3` | `SQSSink`, `SNSSink`, `KinesisSink`, `FirehoseSink` |
94
+ | `sentry` | `sentry-sdk` | `SentrySink` via the SDK (a raw-HTTP fallback works without it) |
95
+ | `kafka` | `confluent-kafka` | `KafkaSink` |
96
+ | `redis` | `redis` | `RedisStreamsSink`, `RedisListSink` |
97
+ | `amqp` | `pika` | `RabbitMQSink` |
98
+ | `nats` | `nats-py` | `NATSSink` |
99
+ | `gcp-pubsub` | `google-cloud-pubsub` | `GooglePubSubSink` |
100
+ | `azure-eventhubs` | `azure-eventhub` | `AzureEventHubsSink` |
101
+ | `mongo` | `pymongo` | `MongoDBSink` |
102
+ | `postgres` | `psycopg[binary]` | `PostgresSink` |
103
+ | `clickhouse` | `clickhouse-connect` | `ClickHouseSink` |
104
+
105
+ ## Quickstart
106
+
107
+ ```python
108
+ import log_forge as lf
109
+
110
+ # Call once at startup. These values are stamped onto every event.
111
+ lf.configure(service="billing-api", version="1.4.2", env="prod")
112
+
113
+ @lf.trace
114
+ def charge(order_id: str) -> int:
115
+ return compute_tax(order_id)
116
+
117
+ @lf.trace(name="tax.compute", defaults={"component": "tax"})
118
+ def compute_tax(order_id: str) -> int:
119
+ return 42
120
+
121
+ charge("ord_123")
122
+ ```
123
+
124
+ With no sink configured, events are written as JSON lines to stdout. The call above emits
125
+ four events — a `span.start` / `span.end` pair per function — all sharing one `trace_id`,
126
+ with the child span pointing at its parent via `parent_span_id`:
127
+
128
+ ```json
129
+ {"timestamp": "2026-07-10T00:57:10.411Z", "level": "INFO", "message": "span.start", "trace_id": "8ab2add1480f8f6a52fe97cd23ae6f36", "span_id": "6aeb63c0eba85bf4", "parent_span_id": "b02197e75f40eb81", "log_id": "754adb40e10c445f9ec9e23a2f3dcbf2", "function": "tax.compute", "service": "billing-api", "version": "1.4.2", "env": "prod", "fields": {"component": "tax"}}
130
+ {"timestamp": "2026-07-10T00:57:10.411Z", "level": "INFO", "message": "span.end", "trace_id": "8ab2add1480f8f6a52fe97cd23ae6f36", "span_id": "6aeb63c0eba85bf4", "parent_span_id": "b02197e75f40eb81", "log_id": "3af73c51540848afbeaba9fdf7a9dce8", "function": "tax.compute", "service": "billing-api", "version": "1.4.2", "env": "prod", "fields": {"component": "tax"}, "duration_ms": 0.018, "status": "ok"}
131
+ {"timestamp": "2026-07-10T00:57:10.411Z", "level": "INFO", "message": "span.start", "trace_id": "8ab2add1480f8f6a52fe97cd23ae6f36", "span_id": "b02197e75f40eb81", "parent_span_id": null, "log_id": "e789f7e5268b46d8b779c9cbcdde8656", "function": "charge", "service": "billing-api", "version": "1.4.2", "env": "prod", "fields": {}}
132
+ {"timestamp": "2026-07-10T00:57:10.411Z", "level": "INFO", "message": "span.end", "trace_id": "8ab2add1480f8f6a52fe97cd23ae6f36", "span_id": "b02197e75f40eb81", "parent_span_id": null, "log_id": "8f3dbfcfcf4a45f688c738eefef882b0", "function": "charge", "service": "billing-api", "version": "1.4.2", "env": "prod", "fields": {}, "duration_ms": 0.326, "status": "ok"}
133
+ ```
134
+
135
+ > **Note on ordering:** the child span (`tax.compute`) finishes first, so its events flush
136
+ > before the parent's. Correlate by `trace_id` / `parent_span_id`, not by line order.
137
+
138
+ ## How it works
139
+
140
+ ![log-forge pipeline: a traced call opens a span, gathers events, then closes and hands off to a background worker that batches events and ships them to a sink. Steps 1–4 run on your thread; the worker and sink run on a background thread. Support modules — config, ids, model, context, console — assist every step.](docs/assets/pipeline.svg)
141
+
142
+ A traced call travels through a small pipeline. The first four steps run on your own thread
143
+ and are deliberately fast; the last two run on a background thread so your code never waits on
144
+ the destination.
145
+
146
+ 1. **You call the code** — a `@trace` function, or one of the `debug`/`info`/… emitters.
147
+ 2. **A span opens** — a record of this one call. It inherits the current trace and parent (see
148
+ below), or starts a fresh trace if nothing is active.
149
+ 3. **Events gather on the span** — an automatic `span.start`, then any events you emit, held in
150
+ memory as one bundle rather than written line by line.
151
+ 4. **The span closes and hands off** — on return *or* exception, a `span.end` event is added
152
+ (with duration and status), and the whole bundle is handed to the background worker. This
153
+ hand-off is instant and never blocks; on an exception the original error is re-raised unchanged.
154
+ 5. **The worker batches** — the worker groups bundles and flushes them together (see below).
155
+ 6. **The sink ships them out** — `StdoutSink` by default, or `SQSSink` in production.
156
+
157
+ Supporting this path are a handful of single-concept modules: `config` (the process-wide
158
+ `service`/`version`/`env` and the sink), `ids` (trace/span/log ids), `model` (assembles the one
159
+ JSON shape), `context` (holds the current span and baggage), and `console` (the optional
160
+ instant `echo=` line).
161
+
162
+ ### Building the trace tree
163
+
164
+ Nested calls form a tree through a stack of open spans kept in a `contextvars` context — the
165
+ top of the stack is the "current" span. When a traced function starts, it reads the current
166
+ span: if one exists, the new span copies its `trace_id` and records its `span_id` as
167
+ `parent_span_id`; if the stack is empty, the new span starts a fresh trace with no parent. The
168
+ new span is then pushed, so anything it calls sees *it* as the parent. On exit the span is
169
+ popped by restoring the stack to its exact prior state (via a token, not a blind pop), which
170
+ stays correct even when code branches into concurrent tasks. Because the stack lives in a
171
+ context variable, every thread and asyncio task gets its own isolated copy — so `asyncio.gather`
172
+ children share their parent's trace, and baggage set in one task never leaks into a sibling.
173
+
174
+ ### When the worker flushes
175
+
176
+ The worker is one background thread with a bounded queue in front of it. `submit` drops a
177
+ finished span's events into the queue and returns; the worker drains the queue into a small
178
+ pending pile and flushes that pile to the sink on whichever of two triggers fires first:
179
+
180
+ - **By count** — once ~10 span bundles have accumulated (note: that's 10 *spans*, and each span
181
+ carries at least its start/end pair, so a flush is usually well over 10 records). All pending
182
+ bundles are flattened into a single `sink.emit` call.
183
+ - **By time** — once ~1 second has passed since the last flush, so an idle app never holds logs
184
+ indefinitely. (The loop advances its flush timestamp even when idle, so an empty queue sleeps
185
+ quietly instead of busy-spinning.)
186
+
187
+ A failing `sink.emit` is retried a few times with growing backoff; past that the batch is
188
+ abandoned with a counted warning and draining continues — a broken sink degrades logging but
189
+ never crashes the worker or the app. If the bounded queue fills completely, new submissions are
190
+ dropped (newest-first) and counted rather than blocking your code. On `shutdown()` the worker
191
+ stops, sweeps anything still queued into one final batch, emits it, and closes the sink.
192
+
193
+ ## Usage
194
+
195
+ ### `configure(...)`
196
+
197
+ Set process-wide settings once at startup. Every argument is keyword-only and optional;
198
+ repeated calls **compose** (only what you pass is applied) rather than reset.
199
+
200
+ ```python
201
+ lf.configure(
202
+ service="billing-api", # stamped as "service" on every event
203
+ version="1.4.2", # stamped as "version"
204
+ env="prod", # stamped as "env"
205
+ sink=MyCustomSink(), # defaults to StdoutSink if never set
206
+ defaults={"region": "us-east-1"}, # base fields merged into every event's "fields"
207
+ )
208
+ ```
209
+
210
+ If you never set a `sink`, the first decorated call falls back to `StdoutSink()`, so `@trace`
211
+ works with zero configuration.
212
+
213
+ ### `@trace`
214
+
215
+ Decorate any **synchronous** function. Usable bare or with arguments:
216
+
217
+ ```python
218
+ @lf.trace # span name = func.__qualname__
219
+ def handler(): ...
220
+
221
+ @lf.trace(name="checkout", defaults={"component": "cart"})
222
+ def process(): ...
223
+ ```
224
+
225
+ - `name` — override the span name (defaults to the function's `__qualname__`).
226
+ - `defaults` — per-decorator fields merged into every event this span emits.
227
+
228
+ The **outermost** decorated call starts a new trace; every nested decorated call becomes a
229
+ child span within it. On an exception, the decorator records `status="error"` plus the
230
+ exception type and formatted stack, then **re-raises the original exception unchanged** — it
231
+ never swallows errors.
232
+
233
+ **Async is supported.** Apply `@trace` to an `async def` and it traces the coroutine's actual
234
+ run — the span opens when the coroutine starts and closes when the awaits complete, not when the
235
+ coroutine object is created. `contextvars` keeps the trace correct across `await` points and
236
+ concurrent tasks: children awaited under one parent (e.g. via `asyncio.gather`) share the
237
+ parent's `trace_id` and link to its `span_id`, and baggage set in one task never leaks into a
238
+ sibling. A cancelled coroutine is recorded as `status="error"` and the `CancelledError` re-raised.
239
+
240
+ ```python
241
+ @lf.trace
242
+ async def fetch(user_id: int) -> dict:
243
+ lf.info("fetching", user_id=user_id)
244
+ return await load(user_id)
245
+
246
+ @lf.trace
247
+ async def load(user_id: int) -> dict:
248
+ ...
249
+
250
+ await fetch(4127) # one trace_id; load's parent_span_id == fetch's span_id
251
+ ```
252
+
253
+ ### Logging inside a span
254
+
255
+ Emit your own structured events from inside a decorated call with the level functions
256
+ `debug` / `info` / `warning` / `error` / `critical`. Each appends one event to the current
257
+ span, so the whole call's logs flush together and share its `trace_id` / `span_id`. Keyword
258
+ arguments land in the event's `fields`; the function name is captured, but arguments and
259
+ return values never are.
260
+
261
+ ```python
262
+ @lf.trace
263
+ def process_payment(user_id: int) -> str:
264
+ lf.set_baggage(request_id="req-123") # rides every event emitted below, in this trace
265
+ lf.info("charging card", user_id=user_id)
266
+ lf.info("payment complete", echo=True) # also printed to the console, immediately
267
+ return "ok"
268
+ ```
269
+
270
+ - **`set_baggage(**kv)`** — attach trace-scoped context that is merged into the `fields` of
271
+ every subsequent event in the same execution flow. Precedence, lowest to highest: config
272
+ `defaults` → span `defaults` → baggage → per-call `fields`.
273
+ - **`echo=True`** — *additionally* write a human-readable `LEVEL message` line to the console
274
+ (`sys.stderr` by default), synchronously, without waiting for the async flush. The event
275
+ still rides the normal pipeline to the sink — echo never redirects.
276
+ - **Orphan logs** — a level call made with no active span is not dropped: it emits a standalone
277
+ one-event span with a fresh `trace_id`, flushed straight to the sink.
278
+
279
+ ### Sinks
280
+
281
+ A **sink** is the swappable output transport — any object satisfying the `Sink` protocol. It
282
+ receives already-built, batched event dicts and knows nothing about spans or context:
283
+
284
+ ```python
285
+ class Sink(Protocol):
286
+ def emit(self, batch: list[dict[str, object]]) -> None: ...
287
+ def close(self) -> None: ...
288
+ ```
289
+
290
+ Wire one up by passing an instance to `configure(sink=...)`; if you never do, the first decorated
291
+ call falls back to `StdoutSink()`. Sinks are **not** re-exported at the top level — import each
292
+ from its own module, e.g. `from log_forge.sinks.sqs import SQSSink`.
293
+
294
+ A few conventions hold across every sink below:
295
+
296
+ - **Extras.** The core is dependency-free. A sink built on a third-party client sits behind the
297
+ optional extra named in its table (blank = zero-dependency, stdlib only); the client is imported
298
+ lazily, so `import log_forge.sinks.<x>` never fails for a missing dependency — only *constructing*
299
+ the sink without an injected client does. See [Optional extras](#optional-extras).
300
+ - **Injection.** Sinks backed by an external resource accept an injected client/connection/stream
301
+ (`client=`, `connection=`, `producer=`, `stream=`, `opener=`) for testing or bespoke configuration.
302
+ The tables show the destination-defining arguments only; sinks that retry also take `max_retries`.
303
+ - **Ownership.** A resource the sink opens itself is closed on `shutdown()`; an injected one is left
304
+ open for you to manage.
305
+ - **Never crashes the app.** A failing sink is retried with backoff and then counted (`.failed`,
306
+ `.dropped_oversized`, …) rather than raised — a broken destination degrades logging, nothing more.
307
+
308
+ #### Built-in, zero-dependency
309
+
310
+ | Sink | Import from | Configure |
311
+ |---|---|---|
312
+ | `StdoutSink` | `log_forge.sinks.stdout` | `StdoutSink(stream=sys.stdout)` — one JSON line per event; the zero-config default |
313
+ | `StderrSink` | `log_forge.sinks.util` | `StderrSink(stream=sys.stderr)` — same, on stderr (twelve-factor) |
314
+ | `NullSink` | `log_forge.sinks.util` | `NullSink()` — discard everything; `.dropped` counts events |
315
+ | `MemorySink` | `log_forge.sinks.util` | `MemorySink(maxlen=None)` — collect into `.events` (a bounded ring when `maxlen` is set) |
316
+
317
+ ```python
318
+ from log_forge.sinks.stdout import StdoutSink
319
+ lf.configure(sink=StdoutSink()) # explicit; also the zero-config default
320
+ ```
321
+
322
+ #### Composition & adapters (zero-dependency)
323
+
324
+ `configure(sink=...)` takes a single sink, so compose these to filter, reshape, fan out, or bridge
325
+ to a plain callable.
326
+
327
+ | Sink | Import from | Configure |
328
+ |---|---|---|
329
+ | `MultiSink` | `log_forge.sinks.multi` | `MultiSink(*sinks)` — forward each batch to every child; a failing child is isolated and counted on `.failed` |
330
+ | `FilteringSink` | `log_forge.sinks.filtering` | `FilteringSink(inner, *, predicate=None, min_level=None)` — forward only events passing `predicate` and/or at/above `min_level` |
331
+ | `TransformSink` | `log_forge.sinks.transform` | `TransformSink(inner, fn)` — map each event through `fn` before forwarding; return `None` to drop one |
332
+ | `CallbackSink` | `log_forge.sinks.callback` | `CallbackSink(fn, *, on_close=None)` — hand each batch to any callable |
333
+
334
+ ```python
335
+ from log_forge.sinks.multi import MultiSink
336
+ from log_forge.sinks.filtering import FilteringSink
337
+ from log_forge.sinks.stdout import StdoutSink
338
+ from log_forge.sinks.sqs import SQSSink
339
+
340
+ lf.configure(sink=MultiSink(
341
+ StdoutSink(), # echo everything locally
342
+ FilteringSink(SQSSink(queue_url="…"), min_level="WARNING"), # only WARNING+ to SQS
343
+ ))
344
+ ```
345
+
346
+ `min_level` is one of `DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL` (case-insensitive); an event whose
347
+ level is unknown or missing fails open (is forwarded).
348
+
349
+ #### Standard-library `logging` bridge (zero-dependency)
350
+
351
+ | Sink | Import from | Configure |
352
+ |---|---|---|
353
+ | `LoggingSink` | `log_forge.sinks.logging_sink` | `LoggingSink(logger=None, *, default_level="INFO")` — emit each event as a `logging.LogRecord` |
354
+
355
+ Hands every event to a `logging.Logger` (default `logging.getLogger("log_forge")`) so your existing
356
+ handlers, formatters, and `logging.config` apply. Identity fields and the nested `fields` are
357
+ attached to each record; the sink never configures or tears down logging itself.
358
+
359
+ #### Local file & embedded (zero-dependency)
360
+
361
+ | Sink | Import from | Configure |
362
+ |---|---|---|
363
+ | `FileSink` | `log_forge.sinks.file` | `FileSink(path, *, encoding="utf-8")` — append NDJSON to one file |
364
+ | `RotatingFileSink` | `log_forge.sinks.file` | `RotatingFileSink(path, *, max_bytes=0, backup_count=0, when=None, interval=1)` — rotate by size and/or time, keeping `backup_count` numbered backups |
365
+ | `SQLiteSink` | `log_forge.sinks.sqlite` | `SQLiteSink(database, *, table="log_events", create_table=True)` — batch-insert into an embedded SQLite DB |
366
+
367
+ `RotatingFileSink`'s time trigger uses a `when` unit code — `"S"`/`"M"`/`"H"`/`"D"` — times `interval`
368
+ (either trigger, or both, can be enabled). `SQLiteSink` stores each event as full JSON plus projected
369
+ `log_id`/`trace_id`/`span_id`/`timestamp`/`level`/`function` columns; pass `create_table=False` when
370
+ you provision the table yourself.
371
+
372
+ ```python
373
+ from log_forge.sinks.file import RotatingFileSink
374
+ lf.configure(sink=RotatingFileSink("app.log.jsonl", max_bytes=10_000_000, backup_count=5))
375
+ ```
376
+
377
+ #### HTTP & self-hosted platforms (zero-dependency)
378
+
379
+ All build on `HTTPSink` (stdlib `urllib`): they POST batches with bounded `429`/`5xx` retry
380
+ (honoring `Retry-After`) and need **no** extra. On the specialized sinks, `**http_kwargs` forwards
381
+ to `HTTPSink` (`headers=`, `auth=`, `gzip=`, `timeout=`, `max_retries=`).
382
+
383
+ | Sink | Import from | Configure |
384
+ |---|---|---|
385
+ | `HTTPSink` | `log_forge.sinks.http` | `HTTPSink(url, *, method="POST", headers=None, auth=None, body_format="ndjson", timeout=5.0, gzip=False, max_retries=3)` — generic POST. `auth` is a bearer-token `str` or `(user, pass)` for basic; `body_format` is `"ndjson"` or `"json_array"` |
386
+ | `ElasticsearchSink` | `log_forge.sinks.elasticsearch` | `ElasticsearchSink(url, *, index, auth=None, **http_kwargs)` — POST to `_bulk`, parsing per-item errors (`.item_errors`) |
387
+ | `OpenSearchSink` | `log_forge.sinks.elasticsearch` | same signature as `ElasticsearchSink` (identical bulk protocol) |
388
+ | `LokiSink` | `log_forge.sinks.loki` | `LokiSink(url, *, labels=("service", "env", "level"), **http_kwargs)` — Grafana Loki push API |
389
+ | `LogstashSink` | `log_forge.sinks.logstash` | `LogstashSink(url=…, **http_kwargs)` for HTTP, **or** `LogstashSink(host=…, port=…, transport="tcp")` for a raw TCP/UDP socket |
390
+ | `SyslogSink` | `log_forge.sinks.syslog` | `SyslogSink(host, port=514, *, transport="udp", facility="user", app_name="log-forge")` — RFC 5424 over UDP/TCP |
391
+
392
+ ```python
393
+ from log_forge.sinks.elasticsearch import ElasticsearchSink
394
+ lf.configure(sink=ElasticsearchSink("https://es.internal:9200", index="app-logs",
395
+ auth=("elastic", "…")))
396
+ ```
397
+
398
+ #### SaaS platforms
399
+
400
+ Also HTTP-based. All are zero-dependency **except** `SentrySink`, which prefers the `sentry-sdk`
401
+ (the `sentry` extra) and falls back to raw HTTP envelopes when it isn't installed.
402
+
403
+ | Sink | Import from | Extra | Configure |
404
+ |---|---|---|---|
405
+ | `DatadogSink` | `log_forge.sinks.datadog` | — | `DatadogSink(api_key, *, site="datadoghq.com", service=None, ddtags=None)` |
406
+ | `SplunkHECSink` | `log_forge.sinks.splunk` | — | `SplunkHECSink(url, token, *, host=None, source="log-forge")` — HTTP Event Collector |
407
+ | `NewRelicSink` | `log_forge.sinks.newrelic` | — | `NewRelicSink(api_key, *, region="US")` — `region` is `"US"` or `"EU"` |
408
+ | `HoneycombSink` | `log_forge.sinks.honeycomb` | — | `HoneycombSink(api_key, dataset, *, url="https://api.honeycomb.io")` |
409
+ | `SentrySink` | `log_forge.sinks.sentry` | `sentry` | `SentrySink(dsn=None, *, min_level="ERROR")` — sends only `min_level`+ events |
410
+
411
+ With the `sentry` extra installed, `SentrySink` captures via `sentry_sdk.capture_event` (initialize
412
+ the SDK yourself with `sentry_sdk.init(...)`); without it, pass `dsn=` and events are POSTed as
413
+ Sentry envelopes over HTTP.
414
+
415
+ #### AWS — the durable-buffer path (`aws` extra)
416
+
417
+ `pip install 'log-foundry[aws]'` (pulls `boto3`). Credentials and region come from boto3's standard
418
+ chain — log-forge adds none of its own. Each re-chunks every batch to the service's hard per-request
419
+ limits, retries partial failures, and drops any single event too large to ever fit (counted on
420
+ `.dropped_oversized`).
421
+
422
+ | Sink | Import from | Configure |
423
+ |---|---|---|
424
+ | `SQSSink` | `log_forge.sinks.sqs` | `SQSSink(queue_url, *, max_retries=3)` — the headline production path: a durable buffer in front of ELK, absorbing downstream spikes/outages |
425
+ | `SNSSink` | `log_forge.sinks.sns` | `SNSSink(topic_arn, *, max_retries=3)` |
426
+ | `KinesisSink` | `log_forge.sinks.kinesis` | `KinesisSink(stream_name, *, partition_key_field="trace_id", max_retries=3)` |
427
+ | `FirehoseSink` | `log_forge.sinks.firehose` | `FirehoseSink(delivery_stream, *, max_retries=3)` |
428
+
429
+ ```python
430
+ from log_forge.sinks.sqs import SQSSink
431
+ lf.configure(service="payments",
432
+ sink=SQSSink(queue_url="https://sqs.us-east-1.amazonaws.com/123456789012/logs"))
433
+ ```
434
+
435
+ Consuming from the buffer and indexing into ELK is a separate component, outside this library.
436
+
437
+ #### Queue & stream
438
+
439
+ Each needs its own extra (lazy-imported). All publish + retry within a bound and close cleanly.
440
+
441
+ | Sink | Import from | Extra | Configure |
442
+ |---|---|---|---|
443
+ | `KafkaSink` | `log_forge.sinks.kafka` | `kafka` | `KafkaSink(topic, *, bootstrap_servers="…", key_field="trace_id")` |
444
+ | `RedisStreamsSink` | `log_forge.sinks.redis` | `redis` | `RedisStreamsSink(stream, *, url=None)` — `XADD` |
445
+ | `RedisListSink` | `log_forge.sinks.redis` | `redis` | `RedisListSink(key, *, url=None)` — `RPUSH` |
446
+ | `RabbitMQSink` | `log_forge.sinks.rabbitmq` | `amqp` | `RabbitMQSink(*, exchange, routing_key, url=None)` — persistent messages |
447
+ | `NATSSink` | `log_forge.sinks.nats` | `nats` | `NATSSink(subject, *, jetstream=False, servers=None)` |
448
+ | `GooglePubSubSink` | `log_forge.sinks.pubsub` | `gcp-pubsub` | `GooglePubSubSink(topic)` |
449
+ | `AzureEventHubsSink` | `log_forge.sinks.eventhubs` | `azure-eventhubs` | `AzureEventHubsSink(*, connection_str="…", eventhub=None)` |
450
+
451
+ ```python
452
+ from log_forge.sinks.kafka import KafkaSink
453
+ lf.configure(sink=KafkaSink("app-logs", bootstrap_servers="broker:9092"))
454
+ ```
455
+
456
+ #### Databases
457
+
458
+ Write-only inserts (querying is the downstream tool's job); each needs its own extra.
459
+
460
+ | Sink | Import from | Extra | Configure |
461
+ |---|---|---|---|
462
+ | `MongoDBSink` | `log_forge.sinks.mongodb` | `mongo` | `MongoDBSink(*, uri="…", database="…", collection="…")` |
463
+ | `PostgresSink` | `log_forge.sinks.postgres` | `postgres` | `PostgresSink(table, *, dsn="…", create_table=False)` — JSONB `event` column + extracted columns |
464
+ | `ClickHouseSink` | `log_forge.sinks.clickhouse` | `clickhouse` | `ClickHouseSink(table, *, dsn="…", create_table=False)` — MergeTree, columnar insert |
465
+
466
+ `PostgresSink` / `ClickHouseSink` default `create_table=False` (you own the schema and indexes); set
467
+ it `True` for an idempotent `CREATE TABLE IF NOT EXISTS` convenience.
468
+
469
+ Prefer a destination not listed here? Implement the two-method `Sink` protocol yourself, or wrap any
470
+ callable in `CallbackSink`.
471
+
472
+ ### Flushing and shutdown
473
+
474
+ Delivery is off the hot path. When a span ends, its events are handed to a per-process
475
+ background worker via a fast, non-blocking submit — your function returns without waiting on
476
+ the sink. The worker batches events (by count and time), emits them on its own thread, retries
477
+ a failing sink with backoff, and applies backpressure so a slow or down sink can never block or
478
+ back-pressure the app: when its bounded queue is full it drops the newest submissions and counts
479
+ them (`worker.dropped`) rather than stalling.
480
+
481
+ Because delivery is asynchronous, drain before the process exits:
482
+
483
+ ```python
484
+ import log_forge as lf
485
+
486
+ lf.shutdown() # flush buffered events and close the sink; blocks until drained
487
+ ```
488
+
489
+ `shutdown()` is also registered via `atexit`, so a normal exit flushes automatically — call it
490
+ explicitly when you need to be certain the tail reached the sink before a fast exit (e.g. at the
491
+ end of a short script or an AWS Lambda handler). It is idempotent.
492
+
493
+ ## Event schema
494
+
495
+ Every event is the same shape (arch §6). Boundary events add a few fields:
496
+
497
+ | Field | Always | Description |
498
+ |---|---|---|
499
+ | `timestamp` | ✓ | UTC ISO-8601, millisecond precision, `Z` suffix |
500
+ | `level` | ✓ | `INFO` / `ERROR` / … |
501
+ | `message` | ✓ | `span.start` / `span.end` for boundaries |
502
+ | `trace_id` | ✓ | 16 bytes / 32 hex — shared across a trace (W3C-compatible) |
503
+ | `span_id` | ✓ | 8 bytes / 16 hex — unique per call |
504
+ | `parent_span_id` | ✓ | parent's `span_id`, or `null` at the trace root |
505
+ | `log_id` | ✓ | UUID, unique per event |
506
+ | `function` | ✓ | span name |
507
+ | `service` / `version` / `env` | ✓ | from `configure(...)` |
508
+ | `fields` | ✓ | merged user fields (config `defaults` → span `defaults` → …) |
509
+ | `duration_ms` | span.end | wall time from a monotonic delta |
510
+ | `status` | span.end | `"ok"` or `"error"` |
511
+ | `error` | on failure | `{"type": ..., "stack": ...}` |
512
+
513
+ IDs are [W3C Trace Context](https://www.w3.org/TR/trace-context/)-compatible by design, so the
514
+ logs can later correlate with distributed traces cheaply.
515
+
516
+ ## Development
517
+
518
+ ```bash
519
+ poetry install --with dev # set up (Python 3.13)
520
+ poetry run pytest # test
521
+ poetry run ruff check . # lint (line-length 100)
522
+ poetry run mypy # typecheck (strict, over src/)
523
+ ```
524
+
525
+ **CI** ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs ruff → mypy → pytest on
526
+ every pull request and on push to `main`. A second workflow
527
+ ([`spec-lint.yml`](.github/workflows/spec-lint.yml)) lints the design specs under `docs/specs/`.
528
+
529
+ The library uses a src layout (`src/log_forge/`) with a single concept per module: `config`,
530
+ `ids`, `model`, `context`, `decorator`, `api`, `console`, `worker`, and the `sinks/` package (the
531
+ `base` protocol, `stdout`, and one module per sink family — see [Sinks](#sinks)).
532
+ Deeper design docs live in [`docs/`](docs/) — start with [`docs/architecture.md`](docs/architecture.md).
533
+
534
+ ## License
535
+
536
+ [MIT](LICENSE) © Andrew Griffith
537
+