legion-logging 1.5.6 → 1.5.21

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff7f461a7f614eb2480b2ef960992d463c7e26ee8cae0283d9f381e859e28c8d
4
- data.tar.gz: a5bc8f5e7fd81d72ef8cc4ae98225e677645581340611103ac99807bb2f6c691
3
+ metadata.gz: c94ba448ab05d8309fd27afcd2672364c2d843ed277036f640cf798715fe97af
4
+ data.tar.gz: 146fa61ef555986daadc1e94996e53e936b34c47a71b8647fdad3c240808e107
5
5
  SHA512:
6
- metadata.gz: 006d30ed434deec409edd42ca5b5ac9245a4657dc8fc6280fc8b982c704d9ef1b1aee299bf6f905698a382f84cbc5ca3205adfd4691f2010d6aec0dea818a986
7
- data.tar.gz: f6288442156789fd8bbb9858910a3deb01fffea277341c2c0e5b8288ee3cab7150b07b4c348faad78b3645f808823f536a856140fc1c5ce4935b4b805d13caea
6
+ metadata.gz: 4391e8e771c4d747469af379d33b1b3f397f60426e6b750dbdc1a6de16e3fa88085abefe9a36760d058aed2ffef559f473e94a37f5bf1a1eb006f058fc3ac1db
7
+ data.tar.gz: e2d141523b19fe6f0c300d97b62fc28d6aeced3e9db8a8c276564cfd0fef5acdb0398aa1eac1542803df34ca12de171326103224739b1c3f500fda7e8dfe5a53
data/CHANGELOG.md CHANGED
@@ -1,5 +1,119 @@
1
1
  # Legion::Logging Changelog
2
2
 
3
+ ## [1.5.21] - 2026-10-30
4
+
5
+ ### Changed
6
+ - Enforce a hard retained-byte cap for managed log files: split oversized writes before the per-file limit, trim existing oversized files to their newest bytes, and immediately prune excess archives.
7
+ - Capture eligible inherited stdout/stderr through bounded kernel pipes, including raw descriptor and child-process writes; full pipes backpressure producers instead of allocating an unbounded byte queue.
8
+ - Coordinate same-path file writers with persistent zero-byte lock sidecars and close regular data descriptors between operations so idle devices cannot pin deleted archives.
9
+ - Fail closed on rotation or drain errors rather than append beyond the cap. The bound covers managed file contents, not filesystem metadata, snapshots, external writers, hardlink aliases, or externally retained descriptors.
10
+
11
+ ### Fixed
12
+ - Include downstream raw-stdio drains in `stop_async_writer` deadlines, including synchronous and file-only module logging.
13
+ - Preserve capture after pre-drain timeouts; wait for worker cleanup before reporting successful disable, and prevent inherited children from controlling parent workers or silently changing their retention policy.
14
+ - Preserve existing instance loggers across process-capture disable/re-enable and close destinations if fail-closed descriptor reattachment itself fails.
15
+
16
+ ## [1.5.20] - 2026-10-29
17
+
18
+ ### Added
19
+ - Size-based log rotation, on by default (100 MiB x 5 archives), configurable via `logging.rotation`
20
+ (`enabled`, `max_size_mb`, `max_files`, `stdio`) or `setup(rotation: {...})`; `rotation: false` disables it
21
+ - `Legion::Logging::RotatingFile`: self-rotating append-mode device used for `log_file:`; reopens if another
22
+ process rotated the path
23
+ - `Legion::Logging::StdioRotator`: rotates the file behind stdout/stderr when a supervisor (launchd
24
+ `StandardOutPath`, systemd `append:`) redirected them to an append-mode regular file, using `IO#reopen` so
25
+ raw stderr output follows the new file. Checks are logger-write-driven, not a hard disk quota.
26
+
27
+ ### Fixed
28
+ - Preserve current async OutputLogger routing, lifecycle locks, shutdown deadlines, and forwarding settings while integrating rotation (PR #21).
29
+ - Honor instance logger rotation overrides and opt-out; rotate distinct stderr files independently, including when stdout is a pipe.
30
+ - Preserve nested Settings lookup guards, normalize invalid limits, and reopen remembered paths after external rotation.
31
+ - Keep non-regular devices untouched and report rotation failures without discarding the incoming message.
32
+
33
+ ## [1.5.19] - 2026-10-29
34
+
35
+ ### Changed
36
+ - Document the complete async contract: bounded backpressure, producer snapshots, worker-side preparation, lifecycle deadlines, reentrant logging, and explicit synchronous boundaries (#22).
37
+ - Correct writer callback signatures and describe delivery acknowledgement limits, retained Shipper retries, and raw Logger compatibility (#22).
38
+
39
+ ## [1.5.18] - 2026-10-29
40
+
41
+ ### Fixed
42
+ - Preserve producer-side raw Logger admission and message/progname normalization: accepted `with_level` records are not filtered again, and a block's nil result reaches the formatter unchanged (#22).
43
+ - Pass captured producer time to raw Logger formatters instead of the later worker dispatch time (#22).
44
+
45
+ ## [1.5.17] - 2026-10-29
46
+
47
+ ### Fixed
48
+ - Make async regression cleanup nil-safe so setup failures are not replaced by secondary errors while releasing worker gates (#22; PR #23 review feedback).
49
+
50
+ ## [1.5.16] - 2026-10-29
51
+
52
+ ### Fixed
53
+ - Preserve producer timestamps in text/JSON output and producer thread IDs in JSON, including queued standard Logger calls (#22).
54
+ - Snapshot mutable raw Logger messages and context before enqueueing so later caller mutation cannot change accepted output.
55
+
56
+ ## [1.5.15] - 2026-10-29
57
+
58
+ ### Changed
59
+ - Move normal log and both exception APIs' formatting, redaction, event enrichment, fingerprinting, and envelope preparation onto the async worker (#22).
60
+ - Capture producer call sites, timestamps, thread/session context, and shallow input snapshots before enqueueing; preserve component-level overrides during worker output.
61
+ - Honor configurable forwarding levels at `logging.transport.log_levels`, retaining warn/error/fatal by default and avoiding duplicate synchronous event construction.
62
+
63
+ ### Fixed
64
+ - Avoid implicitly loading Settings from logging and shipping; early redaction defaults no longer hide custom patterns loaded later.
65
+
66
+ ## [1.5.14] - 2026-10-29
67
+
68
+ ### Fixed
69
+ - Serialize logger-owned lifecycle transitions so concurrent startup cannot orphan a writer and discard its accepted backlog (#22).
70
+ - Reject contending lifecycle calls without blocking callbacks behind their own shutdown join.
71
+ - Retry retained shipping batches on a subsequent shutdown call after a previous delivery failure.
72
+
73
+ ## [1.5.13] - 2026-10-29
74
+
75
+ ### Fixed
76
+ - Keep startup and shutdown draining on the writer, including accepted work without a running consumer; apply shutdown deadlines without synchronous fallback (#22).
77
+ - Retain timed-out workers and queues, reject writes after shutdown, and avoid joining the writer from its own callback.
78
+ - Stop the existing writer before reconfiguring outputs; a failed shutdown leaves the configuration intact instead of opening a synchronous bypass.
79
+
80
+ ## [1.5.12] - 2026-10-29
81
+
82
+ ### Fixed
83
+ - Route opt-in MethodTracer output through configured async logging instead of direct producer-thread stdout writes (#22).
84
+ - Keep failed shipping diagnostics out of the forwarding pipeline to avoid logging-to-shipper feedback loops, and add explicit HTTP write timeouts.
85
+ - Clarify that direct transport/export APIs, MultiIO, and hook dispatch are synchronous primitives; their async callers own scheduling.
86
+
87
+ ## [1.5.11] - 2026-10-29
88
+
89
+ ### Fixed
90
+ - Move threshold-triggered and explicit Shipper delivery onto one background worker; `flush(timeout:)` waits for delivery without performing sink IO on its caller (#22).
91
+ - Bound accepted and in-flight shipping events with `logging.shipper.buffer_size` (default 10,000), preserve failed batches for retry, and wake the worker for flush/shutdown instead of sleeping through requests.
92
+
93
+ ## [1.5.10] - 2026-10-29
94
+
95
+ ### Fixed
96
+ - Prevent writer callbacks and hooks from waiting on their own full queue: nested messages are written locally on the writer without recursively forwarding them (#22).
97
+ - Preserve nested exception events as local JSON lines and bypass recursive formatting when necessary, without growing an overflow queue or dropping nested messages.
98
+
99
+ ## [1.5.9] - 2026-10-29
100
+
101
+ ### Fixed
102
+ - Keep the standard Ruby Logger interface returned by `log`, including `add`, `log`, and raw append, on the configured async writer; preserve producer context for Sinatra/APM consumers (#22).
103
+ - Route instance tracing through async logging and raise `ClosedQueueError` for rejected async writes instead of silently performing producer-thread IO.
104
+
105
+ ## [1.5.8] - 2026-10-29
106
+
107
+ ### Fixed
108
+ - Queue structured exception publication for both `log_exception` and Helper `handle_exception`, keeping exception-writer callbacks off producer threads when async logging is enabled (#22).
109
+ - Preserve captured exception metadata and callback envelopes while retaining synchronous publication for explicit synchronous logging.
110
+
111
+ ## [1.5.7] - 2026-10-29
112
+
113
+ ### Changed
114
+ - Document bounded, lossless-in-process queue backpressure as the intentional async saturation policy: producers wait rather than silently dropping logs or growing the queue without a bound (#22).
115
+ - Correct async fatal routing and the buffer-size configuration example.
116
+
3
117
  ## [1.5.6] - 2026-06-01
4
118
 
5
119
  ### Added
data/CLAUDE.md CHANGED
@@ -2,45 +2,55 @@
2
2
 
3
3
  Structured logging framework for LegionIO. Provides colorized console output (Rainbow), structured JSON logging, and a consistent interface across all Legion gems and extensions.
4
4
 
5
- **GitHub**: https://github.com/LegionIO/legion-logging
6
- **Version**: 1.5.3
7
-
8
5
  ## Architecture
9
6
 
10
7
  ```
11
8
  Legion::Logging (singleton module)
12
9
  ├── Methods # debug, info, warn, error, fatal, unknown; log_exception
13
10
  ├── Builder # Output destination, log level, formatter, async: keyword
14
- ├── AsyncWriter # Non-blocking SizedQueue-backed writer thread
11
+ ├── AsyncWriter # Bounded, backpressured SizedQueue-backed writer thread
12
+ ├── OutputLogger # Queues standard Logger add/log/<< calls
15
13
  ├── Hooks # Callback registry for fatal/error/warn events
16
14
  ├── EventBuilder # Structured event payload + fingerprint for dedup
17
15
  ├── Helper # Injectable log mixin for LEX extensions
18
16
  ├── TaggedLogger # Prepends structured tags to each message
19
17
  ├── CategoryRegistry # Named log categories with expected_fields
20
18
  ├── SIEMExporter # PHI-redacting SIEM export (Splunk HEC, ELK)
21
- ├── Shipper # Buffered forwarding (FileTransport, HttpTransport)
19
+ ├── Shipper # Bounded batch-delivery worker (file/HTTP transports)
22
20
  ├── Redactor # PII/PHI + secret pattern redaction (opt-in)
23
- └── MultiIO # Write to multiple destinations simultaneously
21
+ ├── MultiIO # Synchronous, sequential writes to multiple destinations
22
+ └── Rotation # RotatingFile (log_file) + shared StdioRotator (redirected stdout/stderr)
24
23
 
25
24
  # Module-level writer lambdas (pluggable forwarding slots)
26
- Legion::Logging.log_writer # ->(event, routing_key:) {}
25
+ Legion::Logging.log_writer # ->(event, routing_key:, headers:, properties:) {}
27
26
  Legion::Logging.exception_writer # ->(event, routing_key:, headers:, properties:) {}
28
27
  ```
29
28
 
30
29
  ## Key Patterns
31
30
 
32
31
  - **Singleton module** — `class << self`; called directly: `Legion::Logging.info("msg")`
33
- - **Async by default** — `setup` enables async logging; fatal bypasses queue. Buffer size via `Settings[:logging][:async][:buffer_size]` (default 10,000). Back-pressure blocks callers when full.
32
+ - **Async by default** — `setup` queues all levels, including fatal, plus structured exception publication and raw Logger calls. Buffer size via `Settings[:logging][:async][:buffer_size]` (default 10,000 entries, not bytes). Saturation blocks producers rather than dropping logs or growing the queue without bound.
34
33
  - **Structured JSON** — `format: :json` outputs machine-parseable JSON lines (disables color)
34
+ - **Producer/worker boundary** — Producers evaluate message blocks and capture caller/context plus shallow snapshots. Formatting, event enrichment, fingerprinting, and publication run on the worker. Nested payload objects are not deep-copied. Built-in output retains producer time/thread; raw Logger output retains admission, normalized payload, and formatter time.
35
+ - **Lifecycle** — `stop_async_writer(timeout: 2)` closes admission and drains on the worker; timeout or lifecycle contention returns false. No caller-thread drain or synchronous rejection fallback. Stopped async loggers raise `ClosedQueueError` until restart or explicit `setup(async: false)`. Setup file operations remain synchronous.
36
+ - **Delivery semantics** — Successful shutdown means processing finished, not durable delivery or external acknowledgement. AsyncWriter diagnoses failures locally without retry; Shipper retains failed batches for retry.
35
37
  - **Helper mixin** — `Legion::Logging::Helper` injects into LEX extensions; derives tags from `segments`, `lex_filename`, or class name
36
38
  - **Writer lambdas** — `log_writer` and `exception_writer` are module-level lambda slots for forwarding to external systems (AMQP, etc.). Default no-ops.
37
39
  - **Redactor** — Opt-in (`logging.redaction.enabled: true`). Patterns: SSN, phone, MRN, DOB, Vault tokens, JWTs, bearer tokens, lease IDs. Guards against Settings recursive init.
38
40
  - **Hook callbacks** — `on_fatal`, `on_error`, `on_warn` register procs; gated by `enable_hooks!`/`disable_hooks!`; fire on async writer thread
41
+ - **Reentrant logging** — Callback/hook logs stay local on the worker without recursively forwarding or waiting on its own queue. Nested exception events and recursive formatter diagnostics use local JSON lines.
42
+ - **Forwarding levels** — `logging.transport.log_levels` accepts strings/symbols; absent or empty lists default to warn/error/fatal. Settings guards use defaults while Settings is unloaded rather than initializing it.
43
+ - **Synchronous APIs** — `MultiIO`, direct transport delivery, SIEM export, and direct hook dispatch remain synchronous. Shipper filtering/redaction run on its caller; bounded batch delivery runs on its worker.
39
44
  - **EventBuilder** — Structured event hashes from log context (caller, exception, lex identity). `fingerprint` produces MD5 for dedup.
45
+ - **Rotation** — Hard managed-content cap: 100 MiB per file plus five archives (600 MiB/path). `RotatingFile` splits writes, trims oversized existing files, prunes excess archives, and coordinates same-path/same-policy writers through a persistent zero-byte `.lock`. Regular data FDs close between operations. Eligible append-mode inherited stdout/stderr become bounded pipes drained by dedicated capped sinks, including raw-only and child writes; saturation blocks, errors fail closed. `stop_async_writer` drains both layers under one deadline, including sync/file-only module logging; call it after final raw writes. Capture is process-wide; disable only with quiescent producers. Pre-drain timeout keeps capture active; final cleanup failure/timeout requires restart. Instance loggers survive disable/re-enable. Forked children cannot control parent workers or change their policy. File contents are bounded, not filesystem metadata/snapshots, external writers/aliases, or externally held/inherited descriptors; a volume-level guarantee needs an OS quota.
40
46
 
41
47
  ## Role in LegionIO
42
48
 
43
49
  Foundational gem — dependency of `legion-cache`, `legion-data`, and `LegionIO`. First module initialized during `Legion::Service` startup.
44
50
 
45
- ---
46
- **Maintained By**: Matthew Iverson (@Esity)
51
+ ## Backtrace Limit
52
+
53
+ - `settings[:logging][:backtrace_limit]` — `nil` = full trace, `0` = suppress entirely, `N` = cap at N frames
54
+ - Per-call override: `log_exception(err, backtrace_limit: 5)`
55
+ - Read at runtime via `Legion::Settings.dig(:logging, :backtrace_limit)` after checking Settings is loaded; otherwise use the default without triggering initialization.
56
+
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Logging module for the [LegionIO](https://github.com/LegionIO/LegionIO) framework. Provides colorized console output via Rainbow, structured JSON logging, multi-output IO, and a consistent logging interface across all Legion gems and extensions.
4
4
 
5
- **Version**: 1.5.2
5
+ **Version**: 1.5.21
6
6
 
7
7
  ## Installation
8
8
 
@@ -31,9 +31,74 @@ Legion::Logging.error('something went wrong')
31
31
  Legion::Logging.fatal('critical failure')
32
32
  ```
33
33
 
34
+ ### Log Rotation
35
+
36
+ Managed file contents have a hard byte bound: **100 MiB per file with five archives** by
37
+ default (`legion.log`, `legion.log.1` through `legion.log.5`), or **600 MiB per managed path**.
38
+ The bound is `max_bytes * (max_files + 1)`, where `max_bytes` is `max_size_mb * 1024 * 1024`,
39
+ rounded down to a positive byte count. Separate stdout, stderr, and configured log paths
40
+ each have their own retention budget.
41
+
42
+ This covers `log_file:` and eligible stdout/stderr files inherited from launchd, systemd
43
+ `append:`, or shell `>>` redirects. Only append-mode regular inherited files qualify;
44
+ pipes, terminals, and non-append redirects (`>`) remain unchanged. Eligible stderr is captured
45
+ independently even when stdout is a pipe. The actual descriptors become bounded kernel pipes,
46
+ so raw writes and inherited child-process writes also pass through the capped sinks without
47
+ needing a logger call. Full pipes block producers; there is no unbounded Ruby byte queue.
48
+ Raw syscall callers must handle short writes, as with any pipe.
49
+
50
+ Writes are split before a file exceeds its limit. Oversized records can cross archive,
51
+ line, and UTF-8 character boundaries; concatenate archives oldest-first followed by the active
52
+ file to read the retained byte stream. Enabling or tightening rotation trims each oversized
53
+ existing file in place to its newest bytes and deletes archives beyond `max_files`.
54
+ Rotation/drain failures do not fall back to uncapped appends; drain/control APIs surface errors.
55
+
56
+ File writes use a stable, zero-byte `<path>.lock` sidecar to coordinate devices and processes.
57
+ All cooperating writers must use the same canonical path and limits; do not delete the lock
58
+ while writers are alive. Regular data descriptors close between operations so idle devices
59
+ cannot retain deleted archives. External replacement is followed on the next write.
60
+
61
+ This is a bound on **Legion-managed file contents**, not an OS/filesystem quota. Filesystem
62
+ allocation overhead, snapshots, external writers, hardlink aliases, and descriptors retained
63
+ outside these devices are outside the bound. A fork without exec during an in-flight file
64
+ operation can inherit such a descriptor; close inherited descriptors or exec before leaving
65
+ the child idle. A strict volume-level disk-space guarantee requires a filesystem quota.
66
+
67
+ ```json
68
+ {
69
+ "logging": {
70
+ "rotation": { "enabled": true, "max_size_mb": 100, "max_files": 5, "stdio": true }
71
+ }
72
+ }
73
+ ```
74
+
75
+ ```ruby
76
+ Legion::Logging.setup(log_file: './legion.log', rotation: { max_size_mb: 50, max_files: 3 })
77
+ Legion::Logging.setup(log_file: './legion.log', rotation: false) # disable
78
+ Legion::Logging.setup(rotation: { stdio: false }) # leave inherited stdout/stderr alone
79
+ logger = Legion::Logging::Logger.new(log_file: './extension.log', rotation: { max_size_mb: 25 })
80
+ ```
81
+
82
+ Omitting `rotation:` reads `logging.rotation` from already-loaded Settings, otherwise uses the
83
+ defaults above. Instance loggers honor the same overrides, including `rotation: false`.
84
+
85
+ Inherited-stream capture is process-wide. Instance opt-out does not detach capture owned by
86
+ another logger; module-level `setup(rotation: false)` or `setup(rotation: { stdio: false })`
87
+ explicitly disables it. Quiesce raw producers, including children, before disabling capture.
88
+ Pre-drain timeout leaves capture active; a failure or timeout during final handle cleanup
89
+ leaves it fail-closed rather than restoring an uncapped fallback and requires process restart.
90
+ Forked children may write through the inherited pipes but cannot drain/reset parent workers
91
+ or request a different capture policy. Fork-without-exec children also retain pipe read ends,
92
+ which can delay `EPIPE` after a parent drain failure until those children close them or exit.
93
+
94
+ Captured stdio is asynchronous even with `async: false`: call `stop_async_writer(timeout: 2)`
95
+ **after the final raw stdout/stderr writes** to drain both layers before exit. This does not
96
+ detach capture, and abrupt process termination is not a delivery guarantee. Setup, explicit
97
+ rotation, and retention changes remain synchronous control operations.
98
+
34
99
  ### Async Logging
35
100
 
36
- By default, `setup` enables async logging — log calls push to a background writer thread and return immediately. Fatal calls always bypass the queue and write synchronously.
101
+ By default, `setup` enables async logging: log calls enqueue output for a background writer, including fatal messages. Async does not mean wait-free. The queue holds at most 10,000 entries by default, and producers wait for capacity when it fills.
37
102
 
38
103
  ```ruby
39
104
  # Async is on by default
@@ -43,10 +108,30 @@ Legion::Logging.setup(level: 'info')
43
108
  Legion::Logging.setup(level: 'info', async: false)
44
109
 
45
110
  # Configure buffer size via Legion::Settings
46
- # Legion::Settings[:logging, :async, :buffer_size] = 20_000
111
+ # Legion::Settings[:logging][:async] = { buffer_size: 20_000 }
47
112
  ```
48
113
 
49
- When the buffer is full, callers block until the writer drains — this preserves log completeness. Use `Legion::Logging.stop_async_writer` during shutdown to flush and stop the writer thread.
114
+ Bounded backpressure is intentional: saturation blocks callers rather than dropping logs or allowing unbounded memory growth. A stalled destination can therefore stall producers once the queue fills. Buffer capacity limits queued entries, not their total byte size, and does not guarantee delivery across process termination.
115
+
116
+ Use `Legion::Logging.stop_async_writer(timeout: 2)` during shutdown, after the final raw stdio writes. It closes async admission and returns `true` only after the writer exits and relevant captured-stdio sinks finish their drain, within one shared deadline. It also drains captured stdio when async logging is disabled. A drain deadline returns `false` without killing workers or draining on the caller; release the blocked destination and retry. Calling shutdown from a writer callback requests closure but returns `false` rather than joining itself.
117
+
118
+ A successful stop means queued entries have been processed, not that a sink durably stored them or an external service acknowledged them. AsyncWriter reports sink/callback failures locally and does not automatically retry them. The Shipper's retained-batch retry behavior is separate.
119
+
120
+ Stopped async loggers reject new writes until explicitly restarted or configured with `setup(async: false)`. Reconfiguration stops the previous worker before replacing its output. `setup(shutdown_timeout: 2, ...)` returns `false` if that drain times out and does not change the existing output; it does not silently enter synchronous mode.
121
+
122
+ Lifecycle transitions are serialized per logger. A competing setup/start/stop returns `false` instead of creating overlapping workers or blocking a callback behind its own shutdown. Retry the control operation after the active transition completes.
123
+
124
+ Configuration is a synchronous control path: opening or closing owned log files can still block in `setup`. Shutdown deadlines bound worker drain/join, not every filesystem operation.
125
+
126
+ Producers evaluate message blocks and capture caller/context plus shallow snapshots of strings, arrays, hashes, and exceptions. The worker performs formatting, redaction, structured-event enrichment, fingerprinting, and envelope construction. Nested payload objects are not deep-copied. Explicit caller tracing and application-provided message/settings methods can still perform producer-side work.
127
+
128
+ Text and JSON timestamps represent enqueue time, not time spent waiting for a destination. JSON thread IDs identify the producer. Standard Logger calls use the same shallow snapshot policy for messages and context; custom raw Logger formatters receive the producer timestamp. The worker preserves producer-side `with_level` admission and already-resolved message/progname values, including a block's nil result.
129
+
130
+ Forwarding defaults to warn/error/fatal. Set `logging.transport.log_levels` to an array of strings or symbols to change which locally emitted levels reach `log_writer` and Hooks; absent or empty lists retain the defaults. Library-owned Settings lookups use defaults while Settings is unloaded rather than triggering initialization.
131
+
132
+ The standard Ruby Logger interface returned by `Legion::Logging.log` also queues output in async mode, including `add`, `log`, and `<<`. Instance tracing uses the same async path. If an async writer is unavailable or rejects an entry during shutdown, logging raises `ClosedQueueError` rather than falling back to synchronous IO. Explicit synchronous mode retains direct output.
133
+
134
+ Logs generated by a writer callback or hook are written locally on that worker rather than queued behind themselves or forwarded recursively. Nested exception events and recursive formatter diagnostics use local JSON lines. This keeps bounded backpressure for producers without allowing the sole consumer to deadlock on its own queue.
50
135
 
51
136
  ### Structured JSON Output
52
137
 
@@ -60,12 +145,26 @@ This is useful for log aggregation pipelines (Elasticsearch, Splunk, etc.).
60
145
 
61
146
  ### Multi-Output IO
62
147
 
63
- `Legion::Logging::MultiIO` writes to multiple destinations simultaneously — for example, stdout and a file at the same time. Used internally by the Builder when `log_file` is set alongside console output.
148
+ `Legion::Logging::MultiIO` writes to destinations sequentially—for example, stdout followed by a file. It is a synchronous IO primitive used by the async writer, not a parallel executor. A slow destination delays that worker and can eventually cause queue backpressure.
149
+
150
+ Opt-in `MethodTracer` output uses the configured logging queue rather than writing directly to the traced application's stdout. Hook dispatch is synchronous on its invoking thread; normal async logging invokes hooks on the writer, not on producers.
151
+
152
+ ### Buffered Shipping
153
+
154
+ Explicitly require `legion/logging/shipper` and enable `logging.shipper.enabled` to use the file or HTTP batch shipper. `Shipper.ship(event)` starts its worker automatically and never performs threshold-triggered delivery on the producer. Filtering and redaction still run on the calling thread; batch delivery runs on the worker. Set `logging.shipper.transport` to `file` or `http`.
155
+
156
+ Defaults: `batch_size: 100`, `flush_interval: 5` seconds, and `buffer_size: 10_000`. The capacity includes in-flight events; producers wait when it is full. Failed batches remain queued in order for retry.
157
+
158
+ `Shipper.flush(timeout: 5)` requests worker delivery and waits up to the supplied deadline; it returns `false` on timeout or delivery failure. `Shipper.stop(timeout: 5)` wakes and joins the worker without moving delivery onto the caller. A timeout retains the worker and accepted events; check the return value before assuming delivery completed. If shutdown exits after a delivery failure, retrying `stop` starts a drainer for the retained batch.
159
+
160
+ `FileTransport.ship_batch` and `HttpTransport.ship_batch` are intentionally synchronous delivery primitives; use `Shipper.ship` for producer-side asynchronous shipping. Sink failures are reported locally to stderr instead of being forwarded back into the failing shipper.
64
161
 
65
162
  ### SIEM Export
66
163
 
67
164
  `Legion::Logging::SIEMExporter` provides PHI-redacting export helpers for security event pipelines:
68
165
 
166
+ `export_to_splunk` is an explicitly synchronous request/response API. Use the HTTP Shipper for asynchronous delivery. Both HTTP implementations configure 5-second connection and 10-second read/write timeouts; these are phase timeouts, not a total request deadline.
167
+
69
168
  ```ruby
70
169
  # Redact PHI patterns (SSN, phone, MRN, DOB) from a string
71
170
  clean = Legion::Logging::SIEMExporter.redact_phi(raw_message)
@@ -96,7 +195,7 @@ end
96
195
 
97
196
  ### Exception Logging
98
197
 
99
- `log_exception` provides a single call for complete exception logging with component context. It writes a human-readable exception line through the configured logger and publishes a structured exception event when an `exception_writer` is configured.
198
+ `log_exception` provides a single call for complete exception logging with component context. It writes a human-readable exception line and publishes a structured exception event when an `exception_writer` is configured. With async logging enabled, both output and exception publication run on the writer thread; Helper `handle_exception` uses the same publication path. Bounded queue backpressure still applies.
100
199
 
101
200
  ```ruby
102
201
  begin
@@ -112,7 +211,7 @@ rescue StandardError => e
112
211
  end
113
212
  ```
114
213
 
115
- The synchronous log line includes the full Ruby backtrace. Legion does not truncate it to a fixed frame count or replace the tail with `... N more`, because the missing frames are often the useful part of production failures.
214
+ The human-readable log line includes the full Ruby backtrace unless `backtrace_limit` is configured. Legion does not replace the tail with `... N more`.
116
215
 
117
216
  Structured exception events include:
118
217
 
@@ -126,15 +225,15 @@ Structured exception events include:
126
225
 
127
226
  ### Writer Lambdas
128
227
 
129
- `log_writer` and `exception_writer` are pluggable lambda slots that replace the old Hooks system. Assign them to forward events to external systems:
228
+ `log_writer` and `exception_writer` are pluggable lambda slots for forwarding events to external systems. They coexist with Hooks; normal async logging invokes both on the writer thread:
130
229
 
131
230
  ```ruby
132
231
  Legion::Logging.exception_writer = lambda do |payload, routing_key:, headers:, properties:|
133
232
  publish_to_amqp(payload, routing_key:, headers:, properties:)
134
233
  end
135
234
 
136
- Legion::Logging.log_writer = lambda do |context, routing_key:|
137
- publish_log(context, routing_key:)
235
+ Legion::Logging.log_writer = lambda do |context, routing_key:, headers:, properties:|
236
+ publish_log(context, routing_key:, headers:, properties:)
138
237
  end
139
238
  ```
140
239
 
@@ -1,20 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
3
4
  require_relative 'methods'
4
5
 
5
6
  module Legion
6
7
  module Logging
7
8
  class AsyncWriter
8
9
  LogEntry = ::Data.define(:level, :message, :writer_context, :segments, :method_ctx, :caller_trace,
9
- :conv_id, :request_id, :exchange_id, :chain_id)
10
- SHUTDOWN = :shutdown
10
+ :conv_id, :request_id, :exchange_id, :chain_id, :force)
11
+ EventEntry = ::Data.define(:callback, :event, :options)
12
+ CallEntry = ::Data.define(:receiver, :method_name, :arguments, :keywords, :context)
13
+ OutputEntry = ::Data.define(:severity, :message, :progname, :segments, :method_ctx, :caller_trace,
14
+ :conv_id, :request_id, :exchange_id, :chain_id, :origin)
11
15
  THREAD_KEYS = %i[
12
16
  legion_log_segments legion_log_method legion_log_caller
13
17
  legion_log_conv_id legion_log_request_id legion_log_exchange_id legion_log_chain_id
14
18
  ].freeze
19
+ CALL_THREAD_KEYS = (THREAD_KEYS + %i[legion_context legion_session_context legion_log_origin]).freeze
15
20
 
16
21
  attr_reader :logger
17
22
 
23
+ def self.snapshot_value(value:)
24
+ return value if value.nil? || value.frozen?
25
+ return value unless value.is_a?(String) || value.is_a?(Array) || value.is_a?(Hash) || value.is_a?(Exception)
26
+
27
+ value.dup.freeze
28
+ end
29
+
18
30
  def initialize(logger, buffer_size: 10_000)
19
31
  @logger = logger
20
32
  @buffer_size = buffer_size
@@ -24,36 +36,55 @@ module Legion
24
36
  @accepting = true
25
37
  end
26
38
 
39
+ # rubocop:disable Naming/PredicateMethod
27
40
  def start
28
- return if @thread&.alive?
29
-
30
- @state_mutex.synchronize { @accepting = true }
31
- drain
32
- @queue = SizedQueue.new(@buffer_size)
33
- @thread = Thread.new { consume }
34
- @thread.name = 'legion-log-writer'
35
- @thread.abort_on_exception = false
41
+ @state_mutex.synchronize do
42
+ return @accepting if @thread&.alive?
43
+
44
+ if @queue.closed?
45
+ queue = SizedQueue.new(@buffer_size)
46
+ queue << @queue.pop(true) until @queue.empty?
47
+ @queue = queue
48
+ end
49
+ @accepting = true
50
+ @thread = Thread.new { consume(queue: @state_mutex.synchronize { @queue }) }
51
+ @thread.name = 'legion-log-writer'
52
+ @thread.abort_on_exception = false
53
+ end
54
+ true
36
55
  end
37
56
 
38
- # rubocop:disable Naming/PredicateMethod
39
57
  def stop(timeout: 2)
40
- @state_mutex.synchronize { @accepting = false }
41
-
42
- unless @thread&.alive?
43
- drain
44
- @thread = nil
45
- return true
58
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout if timeout
59
+ thread = @state_mutex.synchronize do
60
+ @accepting = false
61
+ @queue.close
62
+ if !@thread&.alive? && !@queue.empty?
63
+ @thread = Thread.new { consume(queue: @state_mutex.synchronize { @queue }) }
64
+ @thread.name = 'legion-log-writer'
65
+ @thread.abort_on_exception = false
66
+ end
67
+ @thread
46
68
  end
69
+ return true unless thread
70
+ return false if Thread.current.equal?(thread)
47
71
 
48
- @queue.close
49
- timeout ? @thread.join(timeout) : @thread.join
50
- return false if @thread&.alive?
72
+ remaining = [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 0].max if deadline
73
+ thread.join(remaining)
74
+ return false if thread.alive?
51
75
 
52
- @thread = nil
76
+ @state_mutex.synchronize { @thread = nil if @thread.equal?(thread) }
53
77
  true
54
78
  end
55
79
 
56
80
  def push(entry)
81
+ if current_thread?
82
+ return write_reentrant_entry(entry) unless @preparing
83
+
84
+ write_entry(entry)
85
+ return true
86
+ end
87
+
57
88
  return false unless accepting?
58
89
 
59
90
  @queue.push(entry)
@@ -67,28 +98,90 @@ module Legion
67
98
  @thread&.alive? || false
68
99
  end
69
100
 
101
+ def current_thread?
102
+ Thread.current.equal?(@thread)
103
+ end
104
+
70
105
  private
71
106
 
72
- def consume
107
+ def consume(queue:)
73
108
  loop do
74
- entry = @queue.pop
75
- break if entry.nil? || entry == SHUTDOWN
109
+ entry = queue.pop
110
+ break if entry.nil?
76
111
 
77
112
  write_entry(entry)
78
113
  end
79
114
  end
80
115
 
81
- def write_entry(entry)
116
+ def write_entry(entry, forward: true)
117
+ previous_preparing = @preparing
118
+ @preparing = entry.is_a?(CallEntry)
119
+ if @preparing
120
+ write_call_entry(entry)
121
+ return
122
+ end
123
+
124
+ if entry.is_a?(EventEntry)
125
+ callback_origin = Thread.current[:legion_log_origin]
126
+ Thread.current[:legion_log_origin] = nil
127
+ entry.callback.call(entry.event, **entry.options)
128
+ return
129
+ end
130
+
82
131
  with_entry_context(entry) do
83
- @logger.send(entry.level, entry.message)
84
- fire_writer(entry) if entry.writer_context
132
+ if entry.is_a?(OutputEntry)
133
+ write_output_entry(entry)
134
+ else
135
+ if entry.force
136
+ formatter = @logger.formatter || ::Logger::Formatter.new
137
+ severity = entry.level == :unknown ? 'ANY' : entry.level.to_s.upcase
138
+ @logger << formatter.call(severity, Time.now, nil, entry.message)
139
+ else
140
+ @logger.send(entry.level, entry.message)
141
+ end
142
+ fire_writer(entry) if forward && entry.writer_context
143
+ end
85
144
  end
86
145
  rescue StandardError => e
87
146
  warn("legion-log-writer error: #{e.message} (#{e.backtrace&.first})")
147
+ ensure
148
+ @preparing = previous_preparing
149
+ Thread.current[:legion_log_origin] = callback_origin if entry.is_a?(EventEntry)
150
+ end
151
+
152
+ def write_output_entry(entry)
153
+ return @logger << entry.message if entry.severity.nil?
154
+
155
+ severity = @logger.send(:format_severity, entry.severity)
156
+ @logger << @logger.send(:format_message, severity, entry.origin[:timestamp].getlocal, entry.progname, entry.message)
157
+ end
158
+
159
+ def write_reentrant_entry(entry)
160
+ previous = @reentrant_write
161
+ @reentrant_write = true
162
+ if previous || entry.is_a?(EventEntry)
163
+ event = entry.is_a?(EventEntry) ? entry.event : { message: entry.message.to_s }
164
+ @logger << "#{::JSON.generate(event)}\n"
165
+ else
166
+ write_entry(entry, forward: false)
167
+ end
168
+ true
169
+ ensure
170
+ @reentrant_write = previous
171
+ end
172
+
173
+ def write_call_entry(entry)
174
+ previous = entry.context.map { |key, _| [key, Thread.current[key]] }
175
+ entry.context.each { |key, value| Thread.current[key] = value }
176
+ entry.receiver.public_send(entry.method_name, *entry.arguments, **entry.keywords)
177
+ ensure
178
+ previous&.each { |key, value| Thread.current[key] = value }
88
179
  end
89
180
 
90
181
  def with_entry_context(entry)
91
182
  prev = THREAD_KEYS.map { |k| [k, Thread.current[k]] }
183
+ previous_origin = Thread.current[:legion_log_origin]
184
+ Thread.current[:legion_log_origin] = entry.origin if entry.is_a?(OutputEntry)
92
185
  Thread.current[:legion_log_segments] = entry.segments
93
186
  Thread.current[:legion_log_method] = entry.method_ctx
94
187
  Thread.current[:legion_log_caller] = entry.caller_trace
@@ -99,15 +192,7 @@ module Legion
99
192
  yield
100
193
  ensure
101
194
  prev&.each { |k, v| Thread.current[k] = v }
102
- end
103
-
104
- def drain
105
- until @queue.empty?
106
- entry = @queue.pop(true)
107
- write_entry(entry) unless entry == SHUTDOWN
108
- end
109
- rescue ThreadError
110
- nil
195
+ Thread.current[:legion_log_origin] = previous_origin
111
196
  end
112
197
 
113
198
  def accepting?
@@ -115,6 +200,7 @@ module Legion
115
200
  end
116
201
 
117
202
  def fire_writer(entry)
203
+ previous_origin = Thread.current[:legion_log_origin]
118
204
  ctx = entry.writer_context
119
205
  event = ctx[:event]
120
206
  level = ctx[:level]
@@ -123,10 +209,13 @@ module Legion
123
209
  routing_key = "legion.logging.log.#{level}.#{lex_name}.#{component}"
124
210
  headers = Legion::Logging.send(:build_log_headers, event, component, level)
125
211
  properties = Legion::Logging.send(:build_log_properties, level)
212
+ Thread.current[:legion_log_origin] = nil
126
213
  Legion::Logging.log_writer.call(event, routing_key: routing_key, headers: headers, properties: properties)
127
214
  Legion::Logging::Hooks.fire(level, entry.message, event) if defined?(Legion::Logging::Hooks)
128
215
  rescue StandardError => e
129
216
  warn("legion-log-writer writer error: #{e.message}")
217
+ ensure
218
+ Thread.current[:legion_log_origin] = previous_origin
130
219
  end
131
220
  end
132
221
  end