streamgate 3.0.0__tar.gz → 3.1.0__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 (81) hide show
  1. {streamgate-3.0.0 → streamgate-3.1.0}/CHANGELOG.md +43 -0
  2. {streamgate-3.0.0 → streamgate-3.1.0}/CONFIGURATION.md +21 -0
  3. {streamgate-3.0.0 → streamgate-3.1.0}/PKG-INFO +15 -6
  4. {streamgate-3.0.0 → streamgate-3.1.0}/README.md +14 -5
  5. {streamgate-3.0.0 → streamgate-3.1.0}/README.zh-CN.md +13 -4
  6. {streamgate-3.0.0 → streamgate-3.1.0}/examples/http_probe/consume.py +4 -4
  7. {streamgate-3.0.0 → streamgate-3.1.0}/examples/http_probe/produce.py +4 -4
  8. {streamgate-3.0.0 → streamgate-3.1.0}/examples/prod_pipeline/consume.py +4 -4
  9. {streamgate-3.0.0 → streamgate-3.1.0}/examples/prod_pipeline/produce.py +10 -4
  10. {streamgate-3.0.0 → streamgate-3.1.0}/examples/pure_pipeline/consume.py +4 -3
  11. {streamgate-3.0.0 → streamgate-3.1.0}/examples/pure_pipeline/produce.py +4 -4
  12. {streamgate-3.0.0 → streamgate-3.1.0}/examples/redis_admission/produce.py +4 -4
  13. {streamgate-3.0.0 → streamgate-3.1.0}/examples/sqlite_upsert/consume.py +4 -4
  14. {streamgate-3.0.0 → streamgate-3.1.0}/examples/sqlite_upsert/produce.py +4 -4
  15. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/__init__.py +3 -1
  16. streamgate-3.1.0/src/streamgate/obs/__init__.py +9 -0
  17. streamgate-3.1.0/src/streamgate/obs/formatter.py +57 -0
  18. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/obs/logging.py +4 -2
  19. streamgate-3.0.0/src/streamgate/transport/__init__.py +0 -0
  20. {streamgate-3.0.0 → streamgate-3.1.0}/.github/workflows/ci.yml +0 -0
  21. {streamgate-3.0.0 → streamgate-3.1.0}/.github/workflows/release.yml +0 -0
  22. {streamgate-3.0.0 → streamgate-3.1.0}/.gitignore +0 -0
  23. {streamgate-3.0.0 → streamgate-3.1.0}/AGENTS.md +0 -0
  24. {streamgate-3.0.0 → streamgate-3.1.0}/CONTRIBUTING.md +0 -0
  25. {streamgate-3.0.0 → streamgate-3.1.0}/LICENSE +0 -0
  26. {streamgate-3.0.0 → streamgate-3.1.0}/examples/docker-compose.yml +0 -0
  27. {streamgate-3.0.0 → streamgate-3.1.0}/examples/http_probe/README.md +0 -0
  28. {streamgate-3.0.0 → streamgate-3.1.0}/examples/http_probe/health_server.py +0 -0
  29. {streamgate-3.0.0 → streamgate-3.1.0}/examples/http_probe/models.py +0 -0
  30. {streamgate-3.0.0 → streamgate-3.1.0}/examples/prod_pipeline/README.md +0 -0
  31. {streamgate-3.0.0 → streamgate-3.1.0}/examples/prod_pipeline/health_server.py +0 -0
  32. {streamgate-3.0.0 → streamgate-3.1.0}/examples/prod_pipeline/models.py +0 -0
  33. {streamgate-3.0.0 → streamgate-3.1.0}/examples/pure_pipeline/README.md +0 -0
  34. {streamgate-3.0.0 → streamgate-3.1.0}/examples/pure_pipeline/models.py +0 -0
  35. {streamgate-3.0.0 → streamgate-3.1.0}/examples/redis_admission/README.md +0 -0
  36. {streamgate-3.0.0 → streamgate-3.1.0}/examples/redis_admission/models.py +0 -0
  37. {streamgate-3.0.0 → streamgate-3.1.0}/examples/sqlite_upsert/README.md +0 -0
  38. {streamgate-3.0.0 → streamgate-3.1.0}/examples/sqlite_upsert/models.py +0 -0
  39. {streamgate-3.0.0 → streamgate-3.1.0}/pyproject.toml +0 -0
  40. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/config.py +0 -0
  41. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/consumer/__init__.py +0 -0
  42. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/consumer/classifier.py +0 -0
  43. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/consumer/dlq.py +0 -0
  44. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/consumer/loop.py +0 -0
  45. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/consumer/options.py +0 -0
  46. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/consumer/runner.py +0 -0
  47. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/__init__.py +0 -0
  48. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/_deps.py +0 -0
  49. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/http_probe/__init__.py +0 -0
  50. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/http_probe/probe_signal.py +0 -0
  51. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/mssql_upsert/__init__.py +0 -0
  52. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/redis_dedup/__init__.py +0 -0
  53. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/redis_dedup/cache.py +0 -0
  54. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/redis_dedup/carrier.py +0 -0
  55. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/redis_dedup/config.py +0 -0
  56. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/redis_dedup/group_cache.py +0 -0
  57. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/redis_dedup/group_carrier.py +0 -0
  58. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/sql_upsert/__init__.py +0 -0
  59. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/sql_upsert/_dialects/__init__.py +0 -0
  60. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/sql_upsert/_dialects/mssql.py +0 -0
  61. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/sql_upsert/_dialects/sqlite.py +0 -0
  62. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/sql_upsert/backfill.py +0 -0
  63. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/sql_upsert/classifier.py +0 -0
  64. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/sql_upsert/config.py +0 -0
  65. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/sql_upsert/engines.py +0 -0
  66. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/sql_upsert/factory.py +0 -0
  67. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/sql_upsert/upsert.py +0 -0
  68. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/contrib/sqlite_upsert/__init__.py +0 -0
  69. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/ingest/__init__.py +0 -0
  70. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/ingest/dedup/__init__.py +0 -0
  71. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/ingest/dedup/in_memory.py +0 -0
  72. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/ingest/producer.py +0 -0
  73. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/obs/metrics.py +0 -0
  74. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/protocols.py +0 -0
  75. {streamgate-3.0.0/src/streamgate/obs → streamgate-3.1.0/src/streamgate/resilience}/__init__.py +0 -0
  76. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/resilience/backpressure.py +0 -0
  77. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/resilience/health.py +0 -0
  78. {streamgate-3.0.0/src/streamgate/resilience → streamgate-3.1.0/src/streamgate/transport}/__init__.py +0 -0
  79. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/transport/codec.py +0 -0
  80. {streamgate-3.0.0 → streamgate-3.1.0}/src/streamgate/transport/kafka.py +0 -0
  81. {streamgate-3.0.0 → streamgate-3.1.0}/uv.lock +0 -0
@@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.1.0] - 2026-09-14
9
+
10
+ ### Added
11
+
12
+ - **`JsonFormatter`** — a built-in, out-of-the-box stdlib `logging.Formatter`
13
+ that renders every record as one JSON line, restoring the structured-field
14
+ visibility 2.x hosts got for free from loguru's auto-configured JSON sink.
15
+ 3.0.0 kept emitting all structured fields (as flat `LogRecord` extras) but
16
+ stdlib's default formatter never renders them — a batch consumer logged just
17
+ the bare `batch_handle_success` event name, hiding `batch_size` /
18
+ `duration_ms`. Non-breaking; import from `streamgate` or `streamgate.obs`
19
+ and wire it in three lines:
20
+
21
+ ```python
22
+ import logging
23
+ from streamgate import JsonFormatter
24
+
25
+ handler = logging.StreamHandler() # stderr, same destination as 2.x
26
+ handler.setFormatter(JsonFormatter())
27
+ logging.basicConfig(level=logging.INFO, handlers=[handler])
28
+ ```
29
+
30
+ Output layout per record: `timestamp` (UTC ISO-8601 with `Z` suffix, taken
31
+ from the `LogRecord` clock, not format time), `level`, `logger` (hierarchical
32
+ namespace — new versus 2.x), `event` (the stable event-name contract; host
33
+ `%s`-style messages render identically), then every flat extra field
34
+ (`batch_size`, `duration_ms`, `partition`, `offset`, ...), plus `error`
35
+ (formatted traceback) when a record carries `exc_info`. Serialization: one
36
+ `json.dumps` per record, `ensure_ascii=False`, `default=str` fallback.
37
+ The formatter is a generic component — any logger's records render correctly
38
+ through it (no extras ⇒ base fields only), and it never raises on
39
+ `LogRecord` reserved-attribute collisions (host-side extras are skipped;
40
+ rendering-side tolerance, the emission-side `emit()` fail-fast is unchanged).
41
+ Attach the handler to the root logger for unified host + streamgate JSON
42
+ logs, or to `logging.getLogger("streamgate")` for streamgate-only.
43
+ - `streamgate.obs.logging.RESERVED_RECORD_ATTRS` (also re-exported from
44
+ `streamgate.obs`) — the reserved-attribute set as a single shared constant,
45
+ used by `emit()` for fail-fast and by `JsonFormatter` for skip-tolerance
46
+ (previously privately defined per side).
47
+ - All `examples/` now configure logging through the built-in `JsonFormatter`,
48
+ so copy-pasting an example yields field-visible JSON logs out of the box
49
+ (previously `basicConfig(format=...)` rendered no extras).
50
+
8
51
  ## [3.0.0] - 2026-09-14
9
52
 
10
53
  ### BREAKING — loguru removed, logging switched to stdlib `logging`
@@ -29,6 +29,27 @@ streamgate emits logs through the standard-library `logging` module and ships
29
29
  `streamgate.*` logger tree; level, handlers and formats are configured by the
30
30
  host application (e.g. `logging.getLogger("streamgate")`).
31
31
 
32
+ For structured output, attach the built-in `JsonFormatter`
33
+ (`from streamgate import JsonFormatter`, also exported from
34
+ `streamgate.obs`): one JSON line per record with `timestamp` (UTC
35
+ ISO-8601, `Z` suffix) / `level` / `logger` / `event` base fields, every flat
36
+ extra field, and `error` when the record carries `exc_info`:
37
+
38
+ ```python
39
+ import logging
40
+ from streamgate import JsonFormatter
41
+
42
+ handler = logging.StreamHandler() # stderr
43
+ handler.setFormatter(JsonFormatter())
44
+ logging.basicConfig(level=logging.INFO, handlers=[handler])
45
+ ```
46
+
47
+ Rendering-side notes: the formatter is generic (any logger's records render
48
+ correctly — records without extras produce just the base fields) and never
49
+ raises on `LogRecord` reserved-attribute collisions (host-side extras are
50
+ skipped; only the library's own `emit()` fails fast on them). The reserved
51
+ attribute set is shared as `streamgate.obs.logging.RESERVED_RECORD_ATTRS`.
52
+
32
53
  ---
33
54
 
34
55
  ## Consumer (flat arguments)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: streamgate
3
- Version: 3.0.0
3
+ Version: 3.1.0
4
4
  Summary: A pure-core Kafka data pipeline framework: conditional admission, reliable delivery, and one obvious consume-loop outlet via a typed handler.
5
5
  Project-URL: Homepage, https://github.com/pwg-code/streamgate
6
6
  Project-URL: Repository, https://github.com/pwg-code/streamgate
@@ -67,7 +67,7 @@ aiokafka gives you **transport**; streamgate gives you the **operating semantics
67
67
 
68
68
  A complete produce → Kafka → consume → SQLite round trip that runs on a **bare install** (stdlib outlet, zero extra packages) — [`examples/pure_pipeline/`](examples/pure_pipeline/):
69
69
 
70
- > Logging note: streamgate emits via stdlib `logging` and never configures it. Without host configuration only WARNING+ shows (stdlib `lastResort`); add one line `logging.basicConfig(level=logging.INFO, ...)` at your entry point to see INFO logs (the examples all do this — see [Logging](#logging)).
70
+ > Logging note: streamgate emits via stdlib `logging` and never configures it. Without host configuration only WARNING+ shows (stdlib `lastResort`); wire the built-in [`JsonFormatter`](#logging) at your entry point (3 lines, above) to see INFO logs with every structured field rendered as JSON (the examples all do this — see [Logging](#logging)).
71
71
 
72
72
  ```python
73
73
  import asyncio
@@ -233,16 +233,25 @@ There are exactly two sources of truth. **Required settings are true required co
233
233
 
234
234
  ## Logging
235
235
 
236
- streamgate logs through the standard-library `logging` module — **the library emits records and never configures logging** (no handlers added, no levels changed at import). Every logger lives under the `streamgate` namespace (`streamgate.ingest.producer`, `streamgate.consumer.loop`, ...), so the whole tree is controlled by the host:
236
+ streamgate logs through the standard-library `logging` module — **the library emits records and never configures logging** (no handlers added, no levels changed at import). Every logger lives under the `streamgate` namespace (`streamgate.ingest.producer`, `streamgate.consumer.loop`, ...), so the whole tree is controlled by the host. Event names (`batch_handle_start`, `poison_message_skipped`, ...) are a stable public contract; structured fields travel as flat `LogRecord` extras.
237
+
238
+ ### JSON output: built-in `JsonFormatter`
239
+
240
+ stdlib's default formatter does **not** render `LogRecord` extras — with `basicConfig(format="%(message)s")` a batch consumer logs just `batch_handle_success`, hiding `batch_size` / `duration_ms`. Use the built-in `JsonFormatter` to make every structured field visible (this is what all examples do):
237
241
 
238
242
  ```python
239
243
  import logging
240
244
 
241
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
242
- logging.getLogger("streamgate").setLevel(logging.WARNING) # or any subtree: silence INFO noise
245
+ from streamgate import JsonFormatter # equivalently: from streamgate.obs import JsonFormatter
246
+
247
+ handler = logging.StreamHandler() # stderr — same destination as 2.x
248
+ handler.setFormatter(JsonFormatter())
249
+ logging.basicConfig(level=logging.INFO, handlers=[handler])
243
250
  ```
244
251
 
245
- Unconfigured hosts see WARNING and above only (stdlib `lastResort` on stderr). Event names (`batch_handle_start`, `poison_message_skipped`, ...) are a stable public contract; structured fields travel as flat `LogRecord` extras. For JSON output, attach any stdlib-compatible JSON formatter of your choice.
252
+ Each record renders as one JSON line: `timestamp` (UTC ISO-8601 with `Z` suffix, taken from the `LogRecord` clock), `level`, `logger` (the hierarchical namespace — a 3.0+ extra versus 2.x), `event` (the stable event name; host `%s`-style messages render the same way), then every flat extra field (`batch_size`, `duration_ms`, `partition`, `offset`, ...), plus `error` (formatted traceback) when a record carries `exc_info`. The formatter is a generic component — any logger renders correctly through it: records without extras produce just the base fields, and reserved-attribute collisions are skipped, never raised.
253
+
254
+ Attach the handler to the root logger as above and host + streamgate logs share one JSON format; attach the same way to `logging.getLogger("streamgate")` instead and only the streamgate tree is formatted. Level control is orthogonal: `logging.getLogger("streamgate").setLevel(logging.WARNING)` silences INFO noise regardless of formatter. Unconfigured hosts still see WARNING+ only (stdlib `lastResort` on stderr).
246
255
 
247
256
  ## Examples
248
257
 
@@ -32,7 +32,7 @@ aiokafka gives you **transport**; streamgate gives you the **operating semantics
32
32
 
33
33
  A complete produce → Kafka → consume → SQLite round trip that runs on a **bare install** (stdlib outlet, zero extra packages) — [`examples/pure_pipeline/`](examples/pure_pipeline/):
34
34
 
35
- > Logging note: streamgate emits via stdlib `logging` and never configures it. Without host configuration only WARNING+ shows (stdlib `lastResort`); add one line `logging.basicConfig(level=logging.INFO, ...)` at your entry point to see INFO logs (the examples all do this — see [Logging](#logging)).
35
+ > Logging note: streamgate emits via stdlib `logging` and never configures it. Without host configuration only WARNING+ shows (stdlib `lastResort`); wire the built-in [`JsonFormatter`](#logging) at your entry point (3 lines, above) to see INFO logs with every structured field rendered as JSON (the examples all do this — see [Logging](#logging)).
36
36
 
37
37
  ```python
38
38
  import asyncio
@@ -198,16 +198,25 @@ There are exactly two sources of truth. **Required settings are true required co
198
198
 
199
199
  ## Logging
200
200
 
201
- streamgate logs through the standard-library `logging` module — **the library emits records and never configures logging** (no handlers added, no levels changed at import). Every logger lives under the `streamgate` namespace (`streamgate.ingest.producer`, `streamgate.consumer.loop`, ...), so the whole tree is controlled by the host:
201
+ streamgate logs through the standard-library `logging` module — **the library emits records and never configures logging** (no handlers added, no levels changed at import). Every logger lives under the `streamgate` namespace (`streamgate.ingest.producer`, `streamgate.consumer.loop`, ...), so the whole tree is controlled by the host. Event names (`batch_handle_start`, `poison_message_skipped`, ...) are a stable public contract; structured fields travel as flat `LogRecord` extras.
202
+
203
+ ### JSON output: built-in `JsonFormatter`
204
+
205
+ stdlib's default formatter does **not** render `LogRecord` extras — with `basicConfig(format="%(message)s")` a batch consumer logs just `batch_handle_success`, hiding `batch_size` / `duration_ms`. Use the built-in `JsonFormatter` to make every structured field visible (this is what all examples do):
202
206
 
203
207
  ```python
204
208
  import logging
205
209
 
206
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
207
- logging.getLogger("streamgate").setLevel(logging.WARNING) # or any subtree: silence INFO noise
210
+ from streamgate import JsonFormatter # equivalently: from streamgate.obs import JsonFormatter
211
+
212
+ handler = logging.StreamHandler() # stderr — same destination as 2.x
213
+ handler.setFormatter(JsonFormatter())
214
+ logging.basicConfig(level=logging.INFO, handlers=[handler])
208
215
  ```
209
216
 
210
- Unconfigured hosts see WARNING and above only (stdlib `lastResort` on stderr). Event names (`batch_handle_start`, `poison_message_skipped`, ...) are a stable public contract; structured fields travel as flat `LogRecord` extras. For JSON output, attach any stdlib-compatible JSON formatter of your choice.
217
+ Each record renders as one JSON line: `timestamp` (UTC ISO-8601 with `Z` suffix, taken from the `LogRecord` clock), `level`, `logger` (the hierarchical namespace — a 3.0+ extra versus 2.x), `event` (the stable event name; host `%s`-style messages render the same way), then every flat extra field (`batch_size`, `duration_ms`, `partition`, `offset`, ...), plus `error` (formatted traceback) when a record carries `exc_info`. The formatter is a generic component — any logger renders correctly through it: records without extras produce just the base fields, and reserved-attribute collisions are skipped, never raised.
218
+
219
+ Attach the handler to the root logger as above and host + streamgate logs share one JSON format; attach the same way to `logging.getLogger("streamgate")` instead and only the streamgate tree is formatted. Level control is orthogonal: `logging.getLogger("streamgate").setLevel(logging.WARNING)` silences INFO noise regardless of formatter. Unconfigured hosts still see WARNING+ only (stdlib `lastResort` on stderr).
211
220
 
212
221
  ## Examples
213
222
 
@@ -372,16 +372,25 @@ pip install "streamgate[http]" # + HTTP 探活背压
372
372
 
373
373
  ## 日志
374
374
 
375
- streamgate 走标准库 `logging`——**库只发日志记录、永不配置日志**(import 时不增删任何 handler、不改任何级别)。所有 logger 挂在 `streamgate` 命名空间下(`streamgate.ingest.producer`、`streamgate.consumer.loop` 等),宿主一行整树控制:
375
+ streamgate 走标准库 `logging`——**库只发日志记录、永不配置日志**(import 时不增删任何 handler、不改任何级别)。所有 logger 挂在 `streamgate` 命名空间下(`streamgate.ingest.producer`、`streamgate.consumer.loop` 等),宿主整树控制。事件名(`batch_handle_start`、`poison_message_skipped` 等)是稳定的对外契约;结构化字段以 `LogRecord` extra 平铺。
376
+
377
+ ### JSON 输出:内置 `JsonFormatter`
378
+
379
+ stdlib 默认 Formatter **不渲染** `LogRecord` extra——用 `basicConfig(format="%(message)s")` 时,批量消费只会打出裸事件名 `batch_handle_success`,看不到 `batch_size` / `duration_ms`。接内置 `JsonFormatter` 即可让全部结构化字段可见(所有示例均如此):
376
380
 
377
381
  ```python
378
382
  import logging
379
383
 
380
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
381
- logging.getLogger("streamgate").setLevel(logging.WARNING) # 或任意子树:关掉 INFO 噪音
384
+ from streamgate import JsonFormatter # 等价:from streamgate.obs import JsonFormatter
385
+
386
+ handler = logging.StreamHandler() # stderr,与 2.x 输出目标一致
387
+ handler.setFormatter(JsonFormatter())
388
+ logging.basicConfig(level=logging.INFO, handlers=[handler])
382
389
  ```
383
390
 
384
- 未配置日志的宿主只能看到 WARNING 及以上(stdlib `lastResort` 输出到 stderr)。事件名(`batch_handle_start`、`poison_message_skipped` 等)是稳定的对外契约;结构化字段以 `LogRecord` extra 平铺。需要 JSON 输出时,接任意 stdlib 兼容的 JSON Formatter 即可。
391
+ 每条日志渲染为一行 JSON:`timestamp`(UTC ISO8601,`Z` 后缀,取自 LogRecord 时钟)、`level`、`logger`(层级命名空间,2.x 没有的新字段)、`event`(稳定事件名;宿主 `%s` 风格日志同样适用),其余键为全部平铺的结构化字段(`batch_size`、`duration_ms`、`partition`、`offset` 等),记录携带 `exc_info` 时附 `error`(格式化堆栈)。Formatter 是通用组件——任何 logger 经它渲染都正确:无 extra 的记录只输出基础字段,与 LogRecord 保留属性撞键时直接跳过、不抛错。
392
+
393
+ 按上面方式挂 root logger,宿主与 streamgate 日志统一为 JSON 格式;同样三行挂 `logging.getLogger("streamgate")` 则仅 streamgate 树生效。级别控制与 Formatter 正交:`logging.getLogger("streamgate").setLevel(logging.WARNING)` 可关掉 INFO 噪音。未配置日志的宿主仍只能看到 WARNING 及以上(stdlib `lastResort` 输出到 stderr)。
385
394
 
386
395
  ## 架构
387
396
 
@@ -13,7 +13,7 @@ import os
13
13
 
14
14
  from health_server import serve_consumer_health
15
15
 
16
- from streamgate import ConsumeContext, Consumer, JsonObject
16
+ from streamgate import ConsumeContext, Consumer, JsonFormatter, JsonObject
17
17
 
18
18
 
19
19
  async def print_orders(
@@ -24,9 +24,9 @@ async def print_orders(
24
24
 
25
25
 
26
26
  async def main() -> None:
27
- logging.basicConfig(
28
- level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
29
- )
27
+ handler = logging.StreamHandler()
28
+ handler.setFormatter(JsonFormatter())
29
+ logging.basicConfig(level=logging.INFO, handlers=[handler])
30
30
  consumer = Consumer(
31
31
  bootstrap_servers=os.environ.get("KAFKA__BOOTSTRAP_SERVERS", "localhost:29092"),
32
32
  topic=os.environ.get("KAFKA__TOPIC", "orders"),
@@ -15,7 +15,7 @@ import os
15
15
 
16
16
  from models import OrderIn
17
17
 
18
- from streamgate import BackpressureConfig, Producer, ProducerOptions
18
+ from streamgate import BackpressureConfig, JsonFormatter, Producer, ProducerOptions
19
19
  from streamgate.contrib.http_probe import HttpProbeSignal
20
20
 
21
21
 
@@ -38,9 +38,9 @@ def backpressure_config() -> BackpressureConfig:
38
38
 
39
39
 
40
40
  async def main() -> None:
41
- logging.basicConfig(
42
- level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
43
- )
41
+ handler = logging.StreamHandler()
42
+ handler.setFormatter(JsonFormatter())
43
+ logging.basicConfig(level=logging.INFO, handlers=[handler])
44
44
  producer = Producer(
45
45
  os.environ.get("KAFKA__BOOTSTRAP_SERVERS", "localhost:29092"),
46
46
  topic=os.environ.get("KAFKA__TOPIC", "orders"),
@@ -19,7 +19,7 @@ from models import Order
19
19
  from sqlalchemy.ext.asyncio import create_async_engine
20
20
  from sqlmodel import SQLModel
21
21
 
22
- from streamgate import ConsumerOptions
22
+ from streamgate import ConsumerOptions, JsonFormatter
23
23
  from streamgate.contrib.mssql_upsert import MssqlConsumer, Upsert
24
24
 
25
25
 
@@ -38,9 +38,9 @@ async def init_db() -> None:
38
38
 
39
39
 
40
40
  async def main() -> None:
41
- logging.basicConfig(
42
- level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
43
- )
41
+ handler = logging.StreamHandler()
42
+ handler.setFormatter(JsonFormatter())
43
+ logging.basicConfig(level=logging.INFO, handlers=[handler])
44
44
  await init_db()
45
45
  consumer = MssqlConsumer(
46
46
  db=db_conn(),
@@ -15,7 +15,13 @@ import os
15
15
 
16
16
  from models import OrderIn
17
17
 
18
- from streamgate import BackpressureConfig, DedupOptions, Producer, ProducerOptions
18
+ from streamgate import (
19
+ BackpressureConfig,
20
+ DedupOptions,
21
+ JsonFormatter,
22
+ Producer,
23
+ ProducerOptions,
24
+ )
19
25
  from streamgate.contrib.http_probe import HttpProbeSignal
20
26
  from streamgate.contrib.redis_dedup import (
21
27
  RedisConfig,
@@ -59,9 +65,9 @@ def backpressure_config() -> BackpressureConfig:
59
65
 
60
66
 
61
67
  async def main() -> None:
62
- logging.basicConfig(
63
- level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
64
- )
68
+ handler = logging.StreamHandler()
69
+ handler.setFormatter(JsonFormatter())
70
+ logging.basicConfig(level=logging.INFO, handlers=[handler])
65
71
  producer = Producer(
66
72
  os.environ.get("KAFKA__BOOTSTRAP_SERVERS", "localhost:9092"),
67
73
  topic=os.environ.get("KAFKA__TOPIC", "orders"),
@@ -18,6 +18,7 @@ from streamgate import (
18
18
  Consumer,
19
19
  ConsumerOptions,
20
20
  DlqOptions,
21
+ JsonFormatter,
21
22
  JsonObject,
22
23
  )
23
24
 
@@ -58,9 +59,9 @@ async def handle_orders(
58
59
 
59
60
 
60
61
  async def main() -> None:
61
- logging.basicConfig(
62
- level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
63
- )
62
+ handler = logging.StreamHandler()
63
+ handler.setFormatter(JsonFormatter())
64
+ logging.basicConfig(level=logging.INFO, handlers=[handler])
64
65
  consumer = Consumer(
65
66
  bootstrap_servers=os.environ.get("KAFKA__BOOTSTRAP_SERVERS", "localhost:29092"),
66
67
  topic=os.environ.get("KAFKA__TOPIC", "orders"),
@@ -14,7 +14,7 @@ import os
14
14
 
15
15
  from models import OrderIn
16
16
 
17
- from streamgate import DedupOptions, Producer, ProducerOptions
17
+ from streamgate import DedupOptions, JsonFormatter, Producer, ProducerOptions
18
18
 
19
19
 
20
20
  def bootstrap_servers() -> str:
@@ -23,9 +23,9 @@ def bootstrap_servers() -> str:
23
23
 
24
24
 
25
25
  async def main() -> None:
26
- logging.basicConfig(
27
- level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
28
- )
26
+ handler = logging.StreamHandler()
27
+ handler.setFormatter(JsonFormatter())
28
+ logging.basicConfig(level=logging.INFO, handlers=[handler])
29
29
  producer = Producer(
30
30
  bootstrap_servers(),
31
31
  topic=os.environ.get("KAFKA__TOPIC", "orders"),
@@ -15,7 +15,7 @@ import os
15
15
 
16
16
  from models import OrderIn
17
17
 
18
- from streamgate import DedupOptions, Producer, ProducerOptions
18
+ from streamgate import DedupOptions, JsonFormatter, Producer, ProducerOptions
19
19
  from streamgate.contrib.redis_dedup import (
20
20
  RedisConfig,
21
21
  RedisDedupCache,
@@ -41,9 +41,9 @@ def build_carrier() -> RedisDedupCarrier[OrderIn]:
41
41
 
42
42
 
43
43
  async def main() -> None:
44
- logging.basicConfig(
45
- level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
46
- )
44
+ handler = logging.StreamHandler()
45
+ handler.setFormatter(JsonFormatter())
46
+ logging.basicConfig(level=logging.INFO, handlers=[handler])
47
47
  carrier = build_carrier()
48
48
  producer = Producer(
49
49
  os.environ.get("KAFKA__BOOTSTRAP_SERVERS", "localhost:29092"),
@@ -13,14 +13,14 @@ import os
13
13
 
14
14
  from models import Order
15
15
 
16
- from streamgate import ConsumerOptions
16
+ from streamgate import ConsumerOptions, JsonFormatter
17
17
  from streamgate.contrib.sqlite_upsert import SqliteConsumer, Upsert
18
18
 
19
19
 
20
20
  async def main() -> None:
21
- logging.basicConfig(
22
- level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
23
- )
21
+ handler = logging.StreamHandler()
22
+ handler.setFormatter(JsonFormatter())
23
+ logging.basicConfig(level=logging.INFO, handlers=[handler])
24
24
  consumer = SqliteConsumer(
25
25
  db=os.environ.get("DB_CONN", "sqlite+aiosqlite:///./data/streamgate.db"),
26
26
  upserts=[Upsert(model=Order, keys=["order_id"])],
@@ -10,13 +10,13 @@ import os
10
10
 
11
11
  from models import OrderIn
12
12
 
13
- from streamgate import Producer, ProducerOptions
13
+ from streamgate import JsonFormatter, Producer, ProducerOptions
14
14
 
15
15
 
16
16
  async def main() -> None:
17
- logging.basicConfig(
18
- level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s"
19
- )
17
+ handler = logging.StreamHandler()
18
+ handler.setFormatter(JsonFormatter())
19
+ logging.basicConfig(level=logging.INFO, handlers=[handler])
20
20
  producer = Producer(
21
21
  os.environ.get("KAFKA__BOOTSTRAP_SERVERS", "localhost:29092"),
22
22
  topic=os.environ.get("KAFKA__TOPIC", "orders"),
@@ -52,6 +52,7 @@ from streamgate.ingest.producer import (
52
52
  Producer,
53
53
  ProducerOptions,
54
54
  )
55
+ from streamgate.obs import JsonFormatter
55
56
  from streamgate.obs.metrics import LoggingMetricsSink, MetricsSink
56
57
  from streamgate.protocols import (
57
58
  AllowAllSignal,
@@ -85,7 +86,7 @@ from streamgate.resilience.health import (
85
86
  from streamgate.transport.codec import JsonEnvelopeCodec
86
87
  from streamgate.transport.kafka import KafkaConsumerService
87
88
 
88
- __version__ = "3.0.0"
89
+ __version__ = "3.1.0"
89
90
 
90
91
  __all__ = [
91
92
  "AllowAllSignal",
@@ -115,6 +116,7 @@ __all__ = [
115
116
  "InMemoryDedupCarrier",
116
117
  "JsonObject",
117
118
  "JsonEnvelopeCodec",
119
+ "JsonFormatter",
118
120
  "KafkaConfig",
119
121
  "KafkaConsumerService",
120
122
  "LoggingMetricsSink",
@@ -0,0 +1,9 @@
1
+ """可观测性出口:结构化日志发射层(logging)与宿主侧 JSON 渲染(formatter)。"""
2
+
3
+ from streamgate.obs.formatter import JsonFormatter
4
+ from streamgate.obs.logging import RESERVED_RECORD_ATTRS
5
+
6
+ __all__ = [
7
+ "JsonFormatter",
8
+ "RESERVED_RECORD_ATTRS",
9
+ ]
@@ -0,0 +1,57 @@
1
+ """内置 JSON Formatter:宿主开箱即用的结构化日志渲染。
2
+
3
+ 3.0.0 移除 loguru 后,``emit()`` 经 ``extra=fields`` 发出的结构化字段仍在
4
+ LogRecord 上,但 stdlib 默认 Formatter 不渲染 extra——字段"看不见"。
5
+ 本模块补齐渲染侧:每条日志渲染为一行 JSON(基础字段 + extra 平铺 +
6
+ exc_info 兜底)。定位是通用组件:任何 logger(含宿主自身的)经它渲染
7
+ 都正确;本模块同样不做任何 logging 全局配置(挂载方式归宿主)。
8
+ """
9
+
10
+ import json
11
+ import logging
12
+ from datetime import datetime, timezone
13
+ from typing import Final
14
+
15
+ from streamgate.obs.logging import RESERVED_RECORD_ATTRS
16
+
17
+ _BASE_FIELDS: Final[frozenset[str]] = frozenset(
18
+ {"timestamp", "level", "logger", "event"}
19
+ )
20
+
21
+
22
+ def _iso_utc(epoch_seconds: float) -> str:
23
+ """LogRecord 时间戳 → UTC ISO8601(毫秒精度,Z 后缀)。
24
+
25
+ 必须取自 LogRecord 时钟(record.created)而非 format 时刻,避免漂移。
26
+ """
27
+ moment = datetime.fromtimestamp(epoch_seconds, tz=timezone.utc)
28
+ return moment.isoformat(timespec="milliseconds").replace("+00:00", "Z")
29
+
30
+
31
+ class JsonFormatter(logging.Formatter):
32
+ """stdlib ``logging.Formatter`` 子类:每条日志渲染为一行 JSON。
33
+
34
+ 字段布局:``timestamp``(UTC ISO8601,Z 后缀)/ ``level`` /
35
+ ``logger``(层级命名空间)/ ``event``(``record.getMessage()``,
36
+ 兼容宿主 ``%s`` 风格日志),其余键从 ``record.__dict__`` 剔除保留
37
+ 属性后平铺(即 ``emit(extra=...)`` 注入的全部结构化字段);
38
+ ``record.exc_info`` 存在时附 ``error``(formatException 输出)。
39
+ 序列化:``ensure_ascii=False``(中文原样)、``default=str`` 兜底。
40
+ 与发射侧 emit 的 fail-fast 不同层:遇保留属性撞键直接跳过、不抛错
41
+ (渲染侧必须容错——宿主自己的 extra 不受 emit 保护)。
42
+ """
43
+
44
+ def format(self, record: logging.LogRecord) -> str:
45
+ entry: dict[str, object] = {
46
+ "timestamp": _iso_utc(record.created),
47
+ "level": record.levelname,
48
+ "logger": record.name,
49
+ "event": record.getMessage(),
50
+ }
51
+ for key, value in record.__dict__.items():
52
+ if key in RESERVED_RECORD_ATTRS or key in _BASE_FIELDS:
53
+ continue
54
+ entry[key] = value
55
+ if record.exc_info is not None:
56
+ entry["error"] = self.formatException(record.exc_info)
57
+ return json.dumps(entry, ensure_ascii=False, default=str)
@@ -6,6 +6,8 @@
6
6
  ``emit`` 是内部发射辅助:保持调用点单行形态,并统一防御
7
7
  (结构化字段名与 LogRecord 保留属性冲突时 fail-fast)。
8
8
  本模块不触碰任何 logging 全局状态(无 handler 增删、无级别修改)。
9
+ ``RESERVED_RECORD_ATTRS`` 是保留属性集合的唯一来源:发射侧据此 fail-fast,
10
+ 渲染侧(formatter.JsonFormatter)据此做"跳过"容错,两处禁止各自定义。
9
11
  """
10
12
 
11
13
  import logging
@@ -20,14 +22,14 @@ _LEVELS: Final[dict[str, int]] = {
20
22
 
21
23
  # LogRecord 实例属性 + 格式化期追加属性(asctime/message/exc_text):
22
24
  # extra 携带这些键会在 stdlib 内部炸 KeyError,这里提前 fail-fast 并给出可读错误
23
- _RESERVED: Final[frozenset[str]] = frozenset(
25
+ RESERVED_RECORD_ATTRS: Final[frozenset[str]] = frozenset(
24
26
  logging.LogRecord("", 0, "", 0, "", (), None).__dict__
25
27
  ) | {"asctime", "exc_text", "message", "taskName"}
26
28
 
27
29
 
28
30
  def emit(logger: logging.Logger, level: str, event: str, /, **fields: object) -> None:
29
31
  """发射一条结构化日志记录:event 为事件名(对外契约,勿改语义),fields 平铺进 extra。"""
30
- conflicts = fields.keys() & _RESERVED
32
+ conflicts = fields.keys() & RESERVED_RECORD_ATTRS
31
33
  if conflicts:
32
34
  names = ", ".join(sorted(conflicts))
33
35
  raise ValueError(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes