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.
- log_foundry-0.0.2.dev1/LICENSE +21 -0
- log_foundry-0.0.2.dev1/PKG-INFO +537 -0
- log_foundry-0.0.2.dev1/README.md +501 -0
- log_foundry-0.0.2.dev1/pyproject.toml +91 -0
- log_foundry-0.0.2.dev1/src/log_forge/__init__.py +45 -0
- log_foundry-0.0.2.dev1/src/log_forge/api.py +89 -0
- log_foundry-0.0.2.dev1/src/log_forge/config.py +79 -0
- log_foundry-0.0.2.dev1/src/log_forge/console.py +30 -0
- log_foundry-0.0.2.dev1/src/log_forge/context.py +62 -0
- log_foundry-0.0.2.dev1/src/log_forge/decorator.py +157 -0
- log_foundry-0.0.2.dev1/src/log_forge/ids.py +29 -0
- log_foundry-0.0.2.dev1/src/log_forge/model.py +106 -0
- log_foundry-0.0.2.dev1/src/log_forge/py.typed +0 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/__init__.py +0 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/_chunk.py +58 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/_socket.py +105 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/_time.py +28 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/base.py +22 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/callback.py +42 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/clickhouse.py +111 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/datadog.py +53 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/elasticsearch.py +68 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/eventhubs.py +110 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/file.py +177 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/filtering.py +71 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/firehose.py +91 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/honeycomb.py +38 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/http.py +202 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/kafka.py +71 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/kinesis.py +99 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/logging_sink.py +104 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/logstash.py +73 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/loki.py +48 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/mongodb.py +105 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/multi.py +53 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/nats.py +72 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/newrelic.py +28 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/postgres.py +101 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/pubsub.py +45 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/rabbitmq.py +129 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/redis.py +90 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/sentry.py +136 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/sns.py +82 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/splunk.py +51 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/sqlite.py +100 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/sqs.py +107 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/stdout.py +31 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/syslog.py +88 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/transform.py +46 -0
- log_foundry-0.0.2.dev1/src/log_forge/sinks/util.py +68 -0
- 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
|
+

|
|
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
|
+
|