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 +4 -4
- data/CHANGELOG.md +114 -0
- data/CLAUDE.md +20 -10
- data/README.md +109 -10
- data/lib/legion/logging/async_writer.rb +125 -36
- data/lib/legion/logging/builder.rb +107 -39
- data/lib/legion/logging/event_builder.rb +9 -6
- data/lib/legion/logging/helper.rb +11 -19
- data/lib/legion/logging/logger.rb +1 -1
- data/lib/legion/logging/method_tracer.rb +13 -2
- data/lib/legion/logging/methods.rb +152 -53
- data/lib/legion/logging/multi_io.rb +3 -1
- data/lib/legion/logging/output_logger.rb +60 -0
- data/lib/legion/logging/redactor.rb +3 -2
- data/lib/legion/logging/rotating_file.rb +239 -0
- data/lib/legion/logging/rotation.rb +143 -0
- data/lib/legion/logging/settings.rb +27 -5
- data/lib/legion/logging/shipper/file_transport.rb +2 -1
- data/lib/legion/logging/shipper/http_transport.rb +4 -2
- data/lib/legion/logging/shipper.rb +118 -69
- data/lib/legion/logging/siem_exporter.rb +1 -1
- data/lib/legion/logging/stdio_rotator.rb +492 -0
- data/lib/legion/logging/version.rb +1 -1
- data/lib/legion/logging.rb +35 -30
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c94ba448ab05d8309fd27afcd2672364c2d843ed277036f640cf798715fe97af
|
|
4
|
+
data.tar.gz: 146fa61ef555986daadc1e94996e53e936b34c47a71b8647fdad3c240808e107
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 #
|
|
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 #
|
|
19
|
+
├── Shipper # Bounded batch-delivery worker (file/HTTP transports)
|
|
22
20
|
├── Redactor # PII/PHI + secret pattern redaction (opt-in)
|
|
23
|
-
|
|
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`
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
111
|
+
# Legion::Settings[:logging][:async] = { buffer_size: 20_000 }
|
|
47
112
|
```
|
|
48
113
|
|
|
49
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
return false if
|
|
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 =
|
|
75
|
-
break if entry.nil?
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|