legion-logging 1.5.19 → 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 +30 -0
- data/CLAUDE.md +3 -1
- data/README.md +67 -2
- data/lib/legion/logging/builder.rb +54 -14
- data/lib/legion/logging/logger.rb +1 -1
- data/lib/legion/logging/multi_io.rb +3 -1
- data/lib/legion/logging/rotating_file.rb +239 -0
- data/lib/legion/logging/rotation.rb +143 -0
- data/lib/legion/logging/settings.rb +7 -1
- data/lib/legion/logging/stdio_rotator.rb +492 -0
- data/lib/legion/logging/version.rb +1 -1
- data/lib/legion/logging.rb +5 -1
- metadata +4 -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,35 @@
|
|
|
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
|
+
|
|
3
33
|
## [1.5.19] - 2026-10-29
|
|
4
34
|
|
|
5
35
|
### Changed
|
data/CLAUDE.md
CHANGED
|
@@ -18,7 +18,8 @@ Legion::Logging (singleton module)
|
|
|
18
18
|
├── SIEMExporter # PHI-redacting SIEM export (Splunk HEC, ELK)
|
|
19
19
|
├── Shipper # Bounded batch-delivery worker (file/HTTP transports)
|
|
20
20
|
├── Redactor # PII/PHI + secret pattern redaction (opt-in)
|
|
21
|
-
|
|
21
|
+
├── MultiIO # Synchronous, sequential writes to multiple destinations
|
|
22
|
+
└── Rotation # RotatingFile (log_file) + shared StdioRotator (redirected stdout/stderr)
|
|
22
23
|
|
|
23
24
|
# Module-level writer lambdas (pluggable forwarding slots)
|
|
24
25
|
Legion::Logging.log_writer # ->(event, routing_key:, headers:, properties:) {}
|
|
@@ -41,6 +42,7 @@ Legion::Logging.exception_writer # ->(event, routing_key:, headers:, properties:
|
|
|
41
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.
|
|
42
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.
|
|
43
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.
|
|
44
46
|
|
|
45
47
|
## Role in LegionIO
|
|
46
48
|
|
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,6 +31,71 @@ 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
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.
|
|
@@ -48,7 +113,7 @@ Legion::Logging.setup(level: 'info', async: false)
|
|
|
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.
|
|
50
115
|
|
|
51
|
-
Use `Legion::Logging.stop_async_writer(timeout: 2)` during shutdown. It closes admission and
|
|
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.
|
|
52
117
|
|
|
53
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.
|
|
54
119
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
require 'monitor'
|
|
5
5
|
require_relative 'output_logger'
|
|
6
|
+
require_relative 'rotation'
|
|
6
7
|
|
|
7
8
|
module Legion
|
|
8
9
|
module Logging
|
|
@@ -123,36 +124,37 @@ module Legion
|
|
|
123
124
|
end
|
|
124
125
|
|
|
125
126
|
def output(**options)
|
|
126
|
-
set_log(logfile: options[:log_file], log_stdout: options[:log_stdout]
|
|
127
|
+
set_log(logfile: options[:log_file], log_stdout: options[:log_stdout], rotation: options[:rotation],
|
|
128
|
+
shutdown_timeout: options.fetch(:shutdown_timeout, 2))
|
|
127
129
|
end
|
|
128
130
|
|
|
129
131
|
def log
|
|
130
132
|
@log ||= set_log
|
|
131
133
|
end
|
|
132
134
|
|
|
133
|
-
|
|
135
|
+
# A nil rotation option reads loaded Settings; false disables rotation.
|
|
136
|
+
def set_log(logfile: nil, log_stdout: nil, rotation: nil, shutdown_timeout: 2, **)
|
|
134
137
|
with_lifecycle_lock do
|
|
135
|
-
|
|
138
|
+
deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + shutdown_timeout if shutdown_timeout
|
|
139
|
+
return false if @async_writer && stop_async_writer(timeout: shutdown_timeout) == false
|
|
136
140
|
|
|
137
141
|
previous_log = @log
|
|
142
|
+
rotation_opts = Legion::Logging::Rotation.resolve(raw: rotation.nil? ? Legion::Logging::Rotation.from_settings : rotation)
|
|
143
|
+
remaining = [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 0].max if deadline
|
|
144
|
+
stdout = stdout_target(rotation_opts: rotation_opts, uses_stdout: !logfile || log_stdout != false, timeout: remaining)
|
|
145
|
+
return false if stdout == false
|
|
138
146
|
|
|
139
147
|
if logfile && log_stdout != false
|
|
140
|
-
path = prepare_log_path(logfile)
|
|
141
148
|
require_relative 'multi_io'
|
|
142
|
-
|
|
143
|
-
file.sync = true
|
|
144
|
-
io = MultiIO.new($stdout, file)
|
|
149
|
+
io = Legion::Logging::MultiIO.new(stdout, open_log_file(path: prepare_log_path(logfile), rotation_opts: rotation_opts))
|
|
145
150
|
@log = Legion::Logging::OutputLogger.new(io)
|
|
146
151
|
elsif logfile
|
|
147
|
-
|
|
148
|
-
file.sync = true
|
|
149
|
-
@log = Legion::Logging::OutputLogger.new(file)
|
|
152
|
+
@log = Legion::Logging::OutputLogger.new(open_log_file(path: prepare_log_path(logfile), rotation_opts: rotation_opts))
|
|
150
153
|
else
|
|
151
|
-
@log = Legion::Logging::OutputLogger.new(
|
|
154
|
+
@log = Legion::Logging::OutputLogger.new(stdout)
|
|
152
155
|
end
|
|
153
156
|
|
|
154
157
|
@log.async_writer = @async_writer
|
|
155
|
-
|
|
156
158
|
close_replaced_log(previous_log)
|
|
157
159
|
@log
|
|
158
160
|
end
|
|
@@ -210,14 +212,52 @@ module Legion
|
|
|
210
212
|
|
|
211
213
|
def stop_async_writer(timeout: 2)
|
|
212
214
|
with_lifecycle_lock do
|
|
213
|
-
|
|
215
|
+
deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout if timeout
|
|
216
|
+
return false if @async_writer && @async_writer.stop(timeout: timeout) == false
|
|
214
217
|
|
|
215
|
-
|
|
218
|
+
remaining = [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 0].max if deadline
|
|
219
|
+
flush_stdio_output!(timeout: remaining)
|
|
216
220
|
end
|
|
217
221
|
end
|
|
218
222
|
|
|
219
223
|
private
|
|
220
224
|
|
|
225
|
+
def open_log_file(path:, rotation_opts:)
|
|
226
|
+
if rotation_opts[:enabled]
|
|
227
|
+
return Legion::Logging::RotatingFile.new(path: path, max_bytes: rotation_opts[:max_bytes], max_files: rotation_opts[:max_files])
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
file = File.new(path, 'a')
|
|
231
|
+
file.sync = true
|
|
232
|
+
file
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def stdout_target(rotation_opts:, uses_stdout:, timeout:)
|
|
236
|
+
if rotation_opts[:enabled] && rotation_opts[:stdio]
|
|
237
|
+
return $stdout unless uses_stdout || equal?(Legion::Logging)
|
|
238
|
+
|
|
239
|
+
return Legion::Logging::StdioRotator.shared(max_bytes: rotation_opts[:max_bytes], max_files: rotation_opts[:max_files]) || $stdout
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
return false if equal?(Legion::Logging) && !Legion::Logging::StdioRotator.reset!(timeout: timeout)
|
|
243
|
+
|
|
244
|
+
$stdout
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def flush_stdio_output!(timeout:) # rubocop:disable Naming/PredicateMethod -- Performs a drain; false signals its deadline.
|
|
248
|
+
deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout if timeout
|
|
249
|
+
device = @log&.instance_variable_get(:@logdev)&.dev
|
|
250
|
+
targets = defined?(Legion::Logging::MultiIO) && device.is_a?(Legion::Logging::MultiIO) ? device.targets : [device]
|
|
251
|
+
captures = targets.grep(Legion::Logging::StdioRotator)
|
|
252
|
+
current = Legion::Logging::StdioRotator.current
|
|
253
|
+
captures << current if equal?(Legion::Logging) && current && !captures.include?(current)
|
|
254
|
+
captures.each do |capture|
|
|
255
|
+
remaining = [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 0].max if deadline
|
|
256
|
+
return false if capture.flush(timeout: remaining) == false
|
|
257
|
+
end
|
|
258
|
+
true
|
|
259
|
+
end
|
|
260
|
+
|
|
221
261
|
def with_lifecycle_lock
|
|
222
262
|
lock = LIFECYCLE_MUTEX.synchronize { @lifecycle_lock ||= Monitor.new }
|
|
223
263
|
entered = lock.try_enter
|
|
@@ -13,7 +13,7 @@ module Legion
|
|
|
13
13
|
|
|
14
14
|
def initialize(level: 'info', log_file: nil, log_stdout: nil, lex: nil, trace: false, extended: false, trace_size: 4, format: :text, async: true, **opts)
|
|
15
15
|
@lex = lex
|
|
16
|
-
set_log(logfile: log_file, log_stdout: log_stdout)
|
|
16
|
+
set_log(logfile: log_file, log_stdout: log_stdout, rotation: opts[:rotation])
|
|
17
17
|
log_level(level)
|
|
18
18
|
log_format(format: format, lex: lex, extended: extended, **opts)
|
|
19
19
|
@color = opts[:color]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Logging
|
|
5
|
+
# Every cooperating writer for a path must use the same limits. The stable
|
|
6
|
+
# lock file coordinates separate devices/processes and must not be removed.
|
|
7
|
+
class RotatingFile
|
|
8
|
+
BUFFER_BYTES = 64 * 1024
|
|
9
|
+
|
|
10
|
+
attr_reader :file_path, :max_bytes, :max_files
|
|
11
|
+
|
|
12
|
+
def initialize(path:, max_bytes:, max_files:)
|
|
13
|
+
unless max_bytes.is_a?(Integer) && max_bytes.positive? && max_files.is_a?(Integer) && max_files.positive?
|
|
14
|
+
raise ArgumentError, 'max_bytes and max_files must be positive integers'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
@file_path = File.expand_path(path)
|
|
18
|
+
@max_bytes = max_bytes
|
|
19
|
+
@max_files = max_files
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@archive_pattern = /\A#{Regexp.escape(File.basename(@file_path))}\.([1-9]\d*)\z/
|
|
22
|
+
@needs_normalization = true
|
|
23
|
+
@closed = false
|
|
24
|
+
synchronize_path { normalize_locked }
|
|
25
|
+
rescue StandardError
|
|
26
|
+
begin
|
|
27
|
+
@io&.close
|
|
28
|
+
ensure
|
|
29
|
+
@lock_io&.close
|
|
30
|
+
end
|
|
31
|
+
raise
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def write(*messages)
|
|
35
|
+
raise ArgumentError, 'wrong number of arguments (given 0, expected 1+)' if messages.empty?
|
|
36
|
+
|
|
37
|
+
synchronize_path do
|
|
38
|
+
normalize_locked if @needs_normalization
|
|
39
|
+
next @io.write(*messages) unless @regular
|
|
40
|
+
|
|
41
|
+
trim_file(path: @file_path) if @io.size > @max_bytes
|
|
42
|
+
messages.sum { |message| write_message(message: message.to_s) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def flush
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
raise IOError, 'closed stream' if @closed
|
|
49
|
+
|
|
50
|
+
@io&.flush
|
|
51
|
+
end
|
|
52
|
+
self
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def sync = true # rubocop:disable Naming/PredicateMethod -- IO#sync interface
|
|
56
|
+
|
|
57
|
+
def sync=(_value); end
|
|
58
|
+
|
|
59
|
+
def close
|
|
60
|
+
@mutex.synchronize do
|
|
61
|
+
return if @closed
|
|
62
|
+
|
|
63
|
+
@closed = true
|
|
64
|
+
begin
|
|
65
|
+
@io&.close
|
|
66
|
+
ensure
|
|
67
|
+
@lock_io.close if @lock_io && !@lock_io.closed?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def closed?
|
|
73
|
+
@mutex.synchronize { @closed }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def size
|
|
77
|
+
synchronize_path { @io.size }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def rotate!
|
|
81
|
+
synchronize_path do
|
|
82
|
+
normalize_locked if @needs_normalization
|
|
83
|
+
rotate_locked!
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def configure(max_bytes:, max_files:)
|
|
88
|
+
unless max_bytes.is_a?(Integer) && max_bytes.positive? && max_files.is_a?(Integer) && max_files.positive?
|
|
89
|
+
raise ArgumentError, 'max_bytes and max_files must be positive integers'
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
synchronize_path do
|
|
93
|
+
@max_bytes = max_bytes
|
|
94
|
+
@max_files = max_files
|
|
95
|
+
@needs_normalization = true
|
|
96
|
+
normalize_locked
|
|
97
|
+
end
|
|
98
|
+
self
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def open_file
|
|
104
|
+
file = File.new(@file_path, 'ab')
|
|
105
|
+
file.sync = true
|
|
106
|
+
file
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def synchronize_path
|
|
110
|
+
@mutex.synchronize do
|
|
111
|
+
raise IOError, 'closed stream' if @closed
|
|
112
|
+
|
|
113
|
+
lock_path if @lock_io || File.file?(@file_path) || !File.exist?(@file_path)
|
|
114
|
+
begin
|
|
115
|
+
reopen_if_replaced
|
|
116
|
+
if @regular && !@lock_io
|
|
117
|
+
@io.close
|
|
118
|
+
@io = nil
|
|
119
|
+
lock_path
|
|
120
|
+
reopen_if_replaced
|
|
121
|
+
end
|
|
122
|
+
yield
|
|
123
|
+
ensure
|
|
124
|
+
begin
|
|
125
|
+
# Idle devices must not pin archives after another writer unlinks
|
|
126
|
+
# them. Release every regular data-file FD before the path lock.
|
|
127
|
+
if @regular && @io
|
|
128
|
+
@io.close
|
|
129
|
+
@io = nil
|
|
130
|
+
end
|
|
131
|
+
ensure
|
|
132
|
+
@lock_io&.flock(File::LOCK_UN)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def lock_path
|
|
139
|
+
# flock locks are inherited across fork with the open file description.
|
|
140
|
+
# Reopen in the child so its lock competes with its parent's lock.
|
|
141
|
+
if @lock_io && @lock_pid != ::Process.pid
|
|
142
|
+
@lock_io.close
|
|
143
|
+
@lock_io = nil
|
|
144
|
+
end
|
|
145
|
+
@lock_io ||= File.open("#{@file_path}.lock", File::RDWR | File::CREAT, 0o600)
|
|
146
|
+
@lock_pid = ::Process.pid
|
|
147
|
+
raise IOError, 'could not acquire log rotation lock' unless @lock_io.flock(File::LOCK_EX)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def reopen_if_replaced
|
|
151
|
+
if @io
|
|
152
|
+
current = @io.stat
|
|
153
|
+
target = File.stat(@file_path) if File.exist?(@file_path)
|
|
154
|
+
return if target && current.dev == target.dev && current.ino == target.ino
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
replace_io
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def replace_io
|
|
161
|
+
previous = @io
|
|
162
|
+
@io = open_file
|
|
163
|
+
current = @io.stat
|
|
164
|
+
@regular = current.file?
|
|
165
|
+
@needs_normalization ||= @file_dev != current.dev || @file_ino != current.ino
|
|
166
|
+
@file_dev = current.dev
|
|
167
|
+
@file_ino = current.ino
|
|
168
|
+
previous&.close
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def normalize_locked
|
|
172
|
+
if @regular
|
|
173
|
+
trim_file(path: @file_path)
|
|
174
|
+
directory = File.dirname(@file_path)
|
|
175
|
+
Dir.each_child(directory) do |name|
|
|
176
|
+
match = @archive_pattern.match(name)
|
|
177
|
+
next unless match
|
|
178
|
+
|
|
179
|
+
archive = File.join(directory, name)
|
|
180
|
+
if match[1].to_i > @max_files
|
|
181
|
+
File.unlink(archive)
|
|
182
|
+
else
|
|
183
|
+
trim_file(path: archive)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
@needs_normalization = false
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def trim_file(path:)
|
|
191
|
+
stat = File.stat(path)
|
|
192
|
+
return unless stat.file? && stat.size > @max_bytes
|
|
193
|
+
|
|
194
|
+
# Move the retained tail toward the beginning without a temporary file
|
|
195
|
+
# or a full-file allocation. Forward copies cannot overwrite unread data.
|
|
196
|
+
File.open(path, 'r+b') do |file|
|
|
197
|
+
source_offset = file.size - @max_bytes
|
|
198
|
+
offset = 0
|
|
199
|
+
buffer = +''.b
|
|
200
|
+
while offset < @max_bytes
|
|
201
|
+
file.seek(source_offset + offset)
|
|
202
|
+
chunk = file.read([BUFFER_BYTES, @max_bytes - offset].min, buffer)
|
|
203
|
+
raise IOError, 'log file changed while trimming its retained tail' if chunk.nil? || chunk.empty?
|
|
204
|
+
|
|
205
|
+
file.seek(offset)
|
|
206
|
+
offset += file.write(chunk)
|
|
207
|
+
end
|
|
208
|
+
file.truncate(@max_bytes)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def write_message(message:)
|
|
213
|
+
offset = 0
|
|
214
|
+
available = @max_bytes - @io.size
|
|
215
|
+
while offset < message.bytesize
|
|
216
|
+
unless available.positive?
|
|
217
|
+
rotate_locked!
|
|
218
|
+
available = @max_bytes
|
|
219
|
+
end
|
|
220
|
+
length = [message.bytesize - offset, available, BUFFER_BYTES].min
|
|
221
|
+
chunk = offset.zero? && length == message.bytesize ? message : message.byteslice(offset, length)
|
|
222
|
+
written = @io.write(chunk)
|
|
223
|
+
offset += written
|
|
224
|
+
available -= written
|
|
225
|
+
end
|
|
226
|
+
offset
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def rotate_locked! # rubocop:disable Naming/PredicateMethod -- Renames files; false means a nonregular destination.
|
|
230
|
+
return false unless @regular
|
|
231
|
+
|
|
232
|
+
Rotation.shift_files(path: @file_path, max_files: @max_files)
|
|
233
|
+
replace_io
|
|
234
|
+
@needs_normalization = false
|
|
235
|
+
true
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Logging
|
|
7
|
+
# Settings and descriptor detection shared by the byte-capped file and stdio sinks.
|
|
8
|
+
module Rotation
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
enabled: true,
|
|
11
|
+
max_size_mb: 100,
|
|
12
|
+
max_files: 5,
|
|
13
|
+
stdio: true
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
F_GETPATH = 50 # macOS fcntl(2) command; not exposed by the Fcntl module
|
|
17
|
+
MAXPATHLEN = 1024
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Normalizes rotation settings. Accepts nil, false (disabled), or a Hash
|
|
21
|
+
# with symbol or string keys. Missing keys fall back to DEFAULTS.
|
|
22
|
+
def resolve(raw:)
|
|
23
|
+
overrides = if raw == false
|
|
24
|
+
{ enabled: false }
|
|
25
|
+
elsif raw.is_a?(Hash)
|
|
26
|
+
raw.transform_keys(&:to_sym)
|
|
27
|
+
else
|
|
28
|
+
{}
|
|
29
|
+
end
|
|
30
|
+
merged = DEFAULTS.merge(overrides.slice(*DEFAULTS.keys))
|
|
31
|
+
merged[:enabled] = merged[:enabled] != false
|
|
32
|
+
merged[:stdio] = merged[:stdio] != false
|
|
33
|
+
merged[:max_size_mb] = positive_number(merged[:max_size_mb], DEFAULTS[:max_size_mb])
|
|
34
|
+
merged[:max_files] = positive_number(merged[:max_files], DEFAULTS[:max_files]).to_i
|
|
35
|
+
merged[:max_files] = DEFAULTS[:max_files] unless merged[:max_files].positive?
|
|
36
|
+
bytes = merged[:max_size_mb] * 1024 * 1024
|
|
37
|
+
unless bytes.finite?
|
|
38
|
+
merged[:max_size_mb] = DEFAULTS[:max_size_mb]
|
|
39
|
+
bytes = merged[:max_size_mb] * 1024 * 1024
|
|
40
|
+
end
|
|
41
|
+
merged[:max_bytes] = [bytes.to_i, 1].max
|
|
42
|
+
merged
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Reads rotation settings from Legion::Settings once it has loaded.
|
|
46
|
+
# Guarded against re-entry because Settings may log while loading.
|
|
47
|
+
def from_settings
|
|
48
|
+
return nil unless defined?(Legion::Settings) && Legion::Settings.respond_to?(:[])
|
|
49
|
+
return nil if Thread.current[:legion_logging_rotation_lookup]
|
|
50
|
+
|
|
51
|
+
lookup_started = true
|
|
52
|
+
Thread.current[:legion_logging_rotation_lookup] = true
|
|
53
|
+
return nil if Legion::Settings.respond_to?(:loaded?) && !Legion::Settings.loaded?
|
|
54
|
+
|
|
55
|
+
logging = Legion::Settings[:logging]
|
|
56
|
+
logging[:rotation] if logging.is_a?(Hash)
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
warn("Legion::Logging::Rotation.from_settings failed: #{e.class}: #{e.message}")
|
|
59
|
+
nil
|
|
60
|
+
ensure
|
|
61
|
+
Thread.current[:legion_logging_rotation_lookup] = nil if lookup_started
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Shifts path.(n-1) -> path.n ... path -> path.1, dropping the oldest.
|
|
65
|
+
def shift_files(path:, max_files:)
|
|
66
|
+
oldest = "#{path}.#{max_files}"
|
|
67
|
+
FileUtils.rm(oldest, force: false) if File.exist?(oldest)
|
|
68
|
+
(max_files - 1).downto(1) do |i|
|
|
69
|
+
src = "#{path}.#{i}"
|
|
70
|
+
File.rename(src, "#{path}.#{i + 1}") if File.exist?(src)
|
|
71
|
+
end
|
|
72
|
+
File.rename(path, "#{path}.1") if File.exist?(path)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Returns the filesystem path behind an IO if it is a regular file opened
|
|
76
|
+
# in append mode (launchd StandardOutPath, systemd append:, shell >>), or nil.
|
|
77
|
+
# Non-append redirects (shell >) are left alone.
|
|
78
|
+
def regular_file_path(io:)
|
|
79
|
+
return nil unless io.respond_to?(:stat) && io.stat.file?
|
|
80
|
+
return nil unless append_mode?(io: io)
|
|
81
|
+
|
|
82
|
+
path = fd_path(io)
|
|
83
|
+
return nil unless path && File.file?(path)
|
|
84
|
+
return nil unless same_file?(io: io, path: path)
|
|
85
|
+
|
|
86
|
+
path
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
warn("Legion::Logging::Rotation.regular_file_path failed: #{e.class}: #{e.message}")
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def same_file?(io:, path:)
|
|
93
|
+
return false unless File.exist?(path)
|
|
94
|
+
|
|
95
|
+
io_stat = io.stat
|
|
96
|
+
path_stat = File.stat(path)
|
|
97
|
+
io_stat.ino == path_stat.ino && io_stat.dev == path_stat.dev
|
|
98
|
+
rescue SystemCallError, IOError => e
|
|
99
|
+
warn("Legion::Logging::Rotation.same_file? path=#{path} failed: #{e.class}: #{e.message}")
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def append_mode?(io:)
|
|
104
|
+
require 'fcntl'
|
|
105
|
+
io.fcntl(Fcntl::F_GETFL).anybits?(File::APPEND)
|
|
106
|
+
rescue NotImplementedError, SystemCallError, IOError => e
|
|
107
|
+
warn("Legion::Logging::Rotation.append_mode? failed: #{e.class}: #{e.message}")
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def fd_path(io)
|
|
114
|
+
if io.respond_to?(:path)
|
|
115
|
+
path = File.expand_path(io.path)
|
|
116
|
+
return path if same_file?(io: io, path: path)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
if RUBY_PLATFORM.include?('darwin')
|
|
120
|
+
require 'fcntl'
|
|
121
|
+
buf = "\0" * MAXPATHLEN
|
|
122
|
+
io.fcntl(F_GETPATH, buf)
|
|
123
|
+
buf.unpack1('Z*')
|
|
124
|
+
else
|
|
125
|
+
proc_link = "/proc/self/fd/#{io.fileno}"
|
|
126
|
+
File.readlink(proc_link) if File.symlink?(proc_link)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def positive_number(value, default)
|
|
131
|
+
number = Float(value)
|
|
132
|
+
number.positive? && number.finite? ? number : default
|
|
133
|
+
rescue ArgumentError, TypeError => e
|
|
134
|
+
warn("Legion::Logging::Rotation invalid numeric limit: #{e.class}: #{e.message}")
|
|
135
|
+
default
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
require_relative 'rotating_file'
|
|
143
|
+
require_relative 'stdio_rotator'
|
|
@@ -16,7 +16,13 @@ module Legion
|
|
|
16
16
|
trace_size: 4,
|
|
17
17
|
extended: true,
|
|
18
18
|
backtrace_limit: nil,
|
|
19
|
-
forward_log_levels: DEFAULT_FORWARD_LOG_LEVELS
|
|
19
|
+
forward_log_levels: DEFAULT_FORWARD_LOG_LEVELS,
|
|
20
|
+
rotation: {
|
|
21
|
+
enabled: true,
|
|
22
|
+
max_size_mb: 100,
|
|
23
|
+
max_files: 5,
|
|
24
|
+
stdio: true
|
|
25
|
+
}
|
|
20
26
|
}
|
|
21
27
|
end
|
|
22
28
|
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Logging
|
|
5
|
+
# The descriptors themselves become pipe writers, including writes which do
|
|
6
|
+
# not pass through Ruby's Logger. Only the drain thread owns the file sink.
|
|
7
|
+
class StdioRotator
|
|
8
|
+
class << self
|
|
9
|
+
attr_reader :current
|
|
10
|
+
|
|
11
|
+
def shared(max_bytes:, max_files:)
|
|
12
|
+
inherited = @current
|
|
13
|
+
if inherited && !inherited.owned?
|
|
14
|
+
raise IOError, 'cannot change the policy of an inherited stdio capture' unless inherited.max_bytes == max_bytes && inherited.max_files == max_files
|
|
15
|
+
|
|
16
|
+
return inherited
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@shared_mutex.synchronize do
|
|
20
|
+
return @current.configure(max_bytes: max_bytes, max_files: max_files) if @current
|
|
21
|
+
|
|
22
|
+
capture = new(max_bytes: max_bytes, max_files: max_files)
|
|
23
|
+
@current = capture if capture.capturing?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def reset!(timeout: 2)
|
|
28
|
+
return false unless timeout.nil? ? @shared_mutex.lock : @shared_mutex.try_lock
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
return true unless @current
|
|
32
|
+
return false unless @current.shutdown(timeout: timeout)
|
|
33
|
+
|
|
34
|
+
@current = nil
|
|
35
|
+
true
|
|
36
|
+
ensure
|
|
37
|
+
@shared_mutex.unlock
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
@shared_mutex = Mutex.new
|
|
42
|
+
|
|
43
|
+
attr_reader :max_bytes, :max_files
|
|
44
|
+
|
|
45
|
+
def initialize(max_bytes:, max_files:, stdout: $stdout, stderr: $stderr)
|
|
46
|
+
@stdout = stdout
|
|
47
|
+
@streams = [stdout, stderr].uniq(&:object_id)
|
|
48
|
+
@lifecycle = Mutex.new
|
|
49
|
+
@captures = []
|
|
50
|
+
@closed = false
|
|
51
|
+
@owner_pid = ::Process.pid
|
|
52
|
+
@max_bytes = max_bytes
|
|
53
|
+
@max_files = max_files
|
|
54
|
+
eligible = @streams.filter_map do |stream|
|
|
55
|
+
path = Rotation.regular_file_path(io: stream)
|
|
56
|
+
stream.flush if path
|
|
57
|
+
[stream, path, stream.stat] if path
|
|
58
|
+
end
|
|
59
|
+
eligible.group_by { |_, _, stat| [stat.dev, stat.ino] }.each_value do |group|
|
|
60
|
+
@captures << StdioCapture.new(path: group.first[1], streams: group.map(&:first), max_bytes: max_bytes, max_files: max_files)
|
|
61
|
+
end
|
|
62
|
+
@captures.each(&:attach)
|
|
63
|
+
@captures.each(&:release_backups)
|
|
64
|
+
rescue StandardError
|
|
65
|
+
@captures.reverse_each(&:rollback)
|
|
66
|
+
raise
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def capturing? = !@captures.empty? && !@closed
|
|
70
|
+
|
|
71
|
+
def owned? = @owner_pid == ::Process.pid
|
|
72
|
+
|
|
73
|
+
def configure(max_bytes:, max_files:)
|
|
74
|
+
raise IOError, 'cannot configure an inherited stdio capture' unless owned?
|
|
75
|
+
|
|
76
|
+
@lifecycle.synchronize do
|
|
77
|
+
raise IOError, 'closed stdio capture' if @closed
|
|
78
|
+
|
|
79
|
+
@captures.each { |capture| capture.command(type: :configure, arguments: { max_bytes: max_bytes, max_files: max_files }) }
|
|
80
|
+
@max_bytes = max_bytes
|
|
81
|
+
@max_files = max_files
|
|
82
|
+
end
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def write(*messages)
|
|
87
|
+
if @closed
|
|
88
|
+
current = self.class.current
|
|
89
|
+
return current.write(*messages) if current && !current.equal?(self) && current.stdout.equal?(@stdout)
|
|
90
|
+
|
|
91
|
+
return @stdout.write(*messages)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
@captures.each(&:check_failure!) if owned?
|
|
95
|
+
@stdout.write(*messages)
|
|
96
|
+
rescue IOError, SystemCallError
|
|
97
|
+
@captures.each(&:check_failure!) if owned? && !@closed
|
|
98
|
+
raise
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def flush(timeout: 2)
|
|
102
|
+
return false unless owned?
|
|
103
|
+
|
|
104
|
+
if @closed
|
|
105
|
+
current = self.class.current
|
|
106
|
+
return current.flush(timeout: timeout) if current && !current.equal?(self) && current.stdout.equal?(@stdout)
|
|
107
|
+
|
|
108
|
+
@stdout.flush
|
|
109
|
+
return self
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
deadline = monotonic_time + timeout if timeout
|
|
113
|
+
@captures.each { |capture| return false unless capture.flush(deadline: deadline) }
|
|
114
|
+
@stdout.flush unless @captures.any? { |capture| capture.includes?(stream: @stdout) }
|
|
115
|
+
self
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Logger borrows process stdio. Only explicit shutdown/reset! detaches it.
|
|
119
|
+
def close; end
|
|
120
|
+
|
|
121
|
+
def closed? = @closed
|
|
122
|
+
|
|
123
|
+
def sync = true # rubocop:disable Naming/PredicateMethod -- IO#sync interface
|
|
124
|
+
|
|
125
|
+
def sync=(_value); end
|
|
126
|
+
|
|
127
|
+
def rotate!
|
|
128
|
+
raise IOError, 'cannot rotate an inherited stdio capture' unless owned?
|
|
129
|
+
|
|
130
|
+
@lifecycle.synchronize do
|
|
131
|
+
raise IOError, 'closed stdio capture' if @closed
|
|
132
|
+
|
|
133
|
+
@captures.each { |capture| capture.command(type: :rotate!, arguments: {}) }
|
|
134
|
+
!@captures.empty?
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def shutdown(timeout: 2)
|
|
139
|
+
return false unless owned?
|
|
140
|
+
return false unless timeout.nil? ? @lifecycle.lock : @lifecycle.try_lock
|
|
141
|
+
|
|
142
|
+
deadline = monotonic_time + timeout if timeout
|
|
143
|
+
detached = false
|
|
144
|
+
begin
|
|
145
|
+
return true if @closed
|
|
146
|
+
return false unless @captures.all? { |capture| capture.pause(deadline: deadline) }
|
|
147
|
+
|
|
148
|
+
detached = true
|
|
149
|
+
@closed = drain_and_detach!(deadline: deadline)
|
|
150
|
+
ensure
|
|
151
|
+
begin
|
|
152
|
+
unless @closed
|
|
153
|
+
@captures.each(&:attach) if detached
|
|
154
|
+
@captures.each(&:resume)
|
|
155
|
+
end
|
|
156
|
+
ensure
|
|
157
|
+
@lifecycle.unlock
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
protected
|
|
163
|
+
|
|
164
|
+
attr_reader :stdout
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def drain_and_detach!(deadline:)
|
|
169
|
+
@captures.each(&:restore)
|
|
170
|
+
@captures.each(&:resume)
|
|
171
|
+
# Include bytes written to the old descriptors during restoration.
|
|
172
|
+
return false unless @captures.all? { |capture| capture.pause(deadline: deadline) }
|
|
173
|
+
|
|
174
|
+
# A final drain may itself rotate: restore the current active inode.
|
|
175
|
+
@captures.each(&:restore)
|
|
176
|
+
@captures.all? { |capture| capture.finish!(deadline: deadline) }
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def monotonic_time
|
|
180
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# One reader and one bounded control pipe per distinct inherited file.
|
|
185
|
+
# Commands occupy a single slot; bytes are never queued in Ruby.
|
|
186
|
+
class StdioCapture
|
|
187
|
+
READ_BYTES = 16 * 1024
|
|
188
|
+
|
|
189
|
+
def initialize(path:, streams:, max_bytes:, max_files:)
|
|
190
|
+
@path = path
|
|
191
|
+
@streams = streams
|
|
192
|
+
@state = Mutex.new
|
|
193
|
+
@changed = ConditionVariable.new
|
|
194
|
+
@requested_flush = 0
|
|
195
|
+
@completed_flush = 0
|
|
196
|
+
@backups = []
|
|
197
|
+
@properties = streams.map { |stream| [stream.sync, stream.close_on_exec?, stream.external_encoding, stream.internal_encoding] }
|
|
198
|
+
streams.each { |stream| @backups << stream.dup }
|
|
199
|
+
@sink = RotatingFile.new(path: path, max_bytes: max_bytes, max_files: max_files)
|
|
200
|
+
@reader, @writer = IO.pipe
|
|
201
|
+
@wake_reader, @wake_writer = IO.pipe
|
|
202
|
+
@writer.binmode
|
|
203
|
+
@writer.sync = true
|
|
204
|
+
@worker = Thread.new do
|
|
205
|
+
Thread.current.report_on_exception = false
|
|
206
|
+
drain
|
|
207
|
+
end
|
|
208
|
+
rescue StandardError
|
|
209
|
+
begin
|
|
210
|
+
close_handles
|
|
211
|
+
ensure
|
|
212
|
+
release_backups
|
|
213
|
+
end
|
|
214
|
+
raise
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def includes?(stream:)
|
|
218
|
+
@streams.any? { |candidate| candidate.equal?(stream) }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def attach
|
|
222
|
+
writer = @state.synchronize { @writer.dup unless @writer.closed? }
|
|
223
|
+
unless writer
|
|
224
|
+
# A failure during shutdown must not leave restored regular-file
|
|
225
|
+
# descriptors as an uncapped fallback. A readerless pipe fails closed.
|
|
226
|
+
reader, writer = IO.pipe
|
|
227
|
+
reader.close
|
|
228
|
+
end
|
|
229
|
+
@streams.each_with_index do |stream, index|
|
|
230
|
+
next if stream.closed?
|
|
231
|
+
|
|
232
|
+
stream.reopen(writer)
|
|
233
|
+
apply_properties(stream: stream, index: index, captured: true)
|
|
234
|
+
end
|
|
235
|
+
rescue StandardError
|
|
236
|
+
# Construction still has original descriptors available for rollback.
|
|
237
|
+
raise unless @backups.empty?
|
|
238
|
+
|
|
239
|
+
# If even dup/reopen fails, close the destinations rather than leave a
|
|
240
|
+
# restored regular-file descriptor able to bypass the retention cap.
|
|
241
|
+
failure = nil
|
|
242
|
+
@streams.each do |stream|
|
|
243
|
+
stream.close unless stream.closed?
|
|
244
|
+
rescue StandardError => e
|
|
245
|
+
failure ||= e
|
|
246
|
+
end
|
|
247
|
+
raise failure if failure
|
|
248
|
+
|
|
249
|
+
raise
|
|
250
|
+
ensure
|
|
251
|
+
writer.close if writer && !writer.closed?
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def restore
|
|
255
|
+
file = @state.synchronize do
|
|
256
|
+
prepared = @restore_file
|
|
257
|
+
@restore_file = nil
|
|
258
|
+
prepared
|
|
259
|
+
end
|
|
260
|
+
raise IOError, 'stdio restoration was not prepared' unless file
|
|
261
|
+
|
|
262
|
+
@streams.each_with_index do |stream, index|
|
|
263
|
+
next if stream.closed?
|
|
264
|
+
|
|
265
|
+
stream.reopen(file)
|
|
266
|
+
apply_properties(stream: stream, index: index, captured: false)
|
|
267
|
+
end
|
|
268
|
+
ensure
|
|
269
|
+
file&.close
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def release_backups
|
|
273
|
+
failure = nil
|
|
274
|
+
@backups.each do |backup|
|
|
275
|
+
backup.close unless backup.closed?
|
|
276
|
+
rescue StandardError => e
|
|
277
|
+
failure ||= e
|
|
278
|
+
end
|
|
279
|
+
@backups.clear
|
|
280
|
+
raise failure if failure
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def rollback
|
|
284
|
+
@streams.each_with_index do |stream, index|
|
|
285
|
+
stream.reopen(@backups[index])
|
|
286
|
+
apply_properties(stream: stream, index: index, captured: false)
|
|
287
|
+
end
|
|
288
|
+
ensure
|
|
289
|
+
begin
|
|
290
|
+
finish!
|
|
291
|
+
ensure
|
|
292
|
+
release_backups
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def check_failure!
|
|
297
|
+
@state.synchronize do
|
|
298
|
+
raise @failure if @failure
|
|
299
|
+
raise IOError, 'stdio capture is stopping' if @stopping
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def flush(deadline:)
|
|
304
|
+
wait_for_flush(deadline: deadline, pause: false)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def pause(deadline:)
|
|
308
|
+
wait_for_flush(deadline: deadline, pause: true)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def resume
|
|
312
|
+
file = @state.synchronize do
|
|
313
|
+
@pause_target = nil
|
|
314
|
+
prepared = @restore_file
|
|
315
|
+
@restore_file = nil
|
|
316
|
+
prepared
|
|
317
|
+
end
|
|
318
|
+
file&.close
|
|
319
|
+
@state.synchronize do
|
|
320
|
+
@paused = false
|
|
321
|
+
wake unless @failure || @stopping
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def finish!(deadline: nil) # rubocop:disable Naming/PredicateMethod -- Stops and joins the worker; false signals its deadline.
|
|
326
|
+
@state.synchronize do
|
|
327
|
+
unless @stopping
|
|
328
|
+
@stopping = true
|
|
329
|
+
wake unless @failure
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
remaining = [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 0].max if deadline
|
|
333
|
+
!@worker.join(remaining).nil?
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def command(type:, arguments:)
|
|
337
|
+
@state.synchronize do
|
|
338
|
+
raise @failure if @failure
|
|
339
|
+
raise IOError, 'stdio capture is stopping' if @stopping
|
|
340
|
+
|
|
341
|
+
wait_until { !@command }
|
|
342
|
+
|
|
343
|
+
command = { type: type, arguments: arguments, complete: false }
|
|
344
|
+
@command = command
|
|
345
|
+
wake
|
|
346
|
+
wait_until { command[:complete] }
|
|
347
|
+
command[:result]
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
private
|
|
352
|
+
|
|
353
|
+
def apply_properties(stream:, index:, captured:)
|
|
354
|
+
sync, close_on_exec, external, internal = @properties[index]
|
|
355
|
+
stream.sync = captured || sync
|
|
356
|
+
stream.close_on_exec = close_on_exec
|
|
357
|
+
stream.set_encoding(external, internal) if external
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def wait_for_flush(deadline:, pause:)
|
|
361
|
+
@state.synchronize do
|
|
362
|
+
raise @failure if @failure
|
|
363
|
+
raise IOError, 'stdio capture is stopping' if @stopping
|
|
364
|
+
|
|
365
|
+
target = @requested_flush += 1
|
|
366
|
+
@pause_target = target if pause
|
|
367
|
+
wake
|
|
368
|
+
wait_until(deadline: deadline) { @completed_flush >= target && (!pause || @paused) }
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def wait_until(deadline: nil)
|
|
373
|
+
loop do
|
|
374
|
+
raise @failure if @failure
|
|
375
|
+
return true if yield
|
|
376
|
+
|
|
377
|
+
if deadline
|
|
378
|
+
remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
379
|
+
return false unless remaining.positive?
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
@changed.wait(@state, remaining)
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def wake
|
|
387
|
+
return if @wake_pending
|
|
388
|
+
|
|
389
|
+
@wake_writer.write_nonblock('.')
|
|
390
|
+
@wake_pending = true
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def next_action
|
|
394
|
+
@state.synchronize do
|
|
395
|
+
return [:stop] if @stopping
|
|
396
|
+
return [:wait] if @paused
|
|
397
|
+
return [:command, @command] if @command
|
|
398
|
+
|
|
399
|
+
# Only this thread completes flushes, after both the read and its
|
|
400
|
+
# corresponding sink write. A consumed chunk cannot hide from flush.
|
|
401
|
+
bytes = @reader.read_nonblock(READ_BYTES, exception: false)
|
|
402
|
+
return [:write, bytes] if bytes.is_a?(String)
|
|
403
|
+
return [:flush, @requested_flush] if @requested_flush > @completed_flush
|
|
404
|
+
|
|
405
|
+
[:wait]
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def drain
|
|
410
|
+
loop do
|
|
411
|
+
action, payload = next_action
|
|
412
|
+
case action
|
|
413
|
+
when :stop then break
|
|
414
|
+
when :wait then wait_for_input
|
|
415
|
+
when :write then @sink.write(payload)
|
|
416
|
+
when :command then execute_command(command: payload)
|
|
417
|
+
when :flush then complete_flush(target: payload)
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
rescue StandardError => e
|
|
421
|
+
# Closing the read end wakes blocked producers with EPIPE. Never log a
|
|
422
|
+
# drain failure to stderr: stderr may be the pipe which just failed.
|
|
423
|
+
begin
|
|
424
|
+
@reader.close unless @reader.closed?
|
|
425
|
+
ensure
|
|
426
|
+
@state.synchronize do
|
|
427
|
+
@failure = e
|
|
428
|
+
@changed.broadcast
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
raise
|
|
432
|
+
ensure
|
|
433
|
+
close_handles
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def wait_for_input
|
|
437
|
+
IO.select(@paused ? [@wake_reader] : [@reader, @wake_reader])
|
|
438
|
+
@state.synchronize do
|
|
439
|
+
@wake_reader.read_nonblock(READ_BYTES, exception: false)
|
|
440
|
+
@wake_pending = false
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def execute_command(command:)
|
|
445
|
+
result = @sink.public_send(command[:type], **command[:arguments])
|
|
446
|
+
@state.synchronize do
|
|
447
|
+
command[:result] = result
|
|
448
|
+
command[:complete] = true
|
|
449
|
+
@command = nil
|
|
450
|
+
@changed.broadcast
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def complete_flush(target:)
|
|
455
|
+
@sink.flush
|
|
456
|
+
prepare = @state.synchronize { @pause_target && target >= @pause_target }
|
|
457
|
+
file = File.new(@path, 'ab') if prepare
|
|
458
|
+
retained = false
|
|
459
|
+
@state.synchronize do
|
|
460
|
+
@completed_flush = target
|
|
461
|
+
if file && @pause_target && target >= @pause_target
|
|
462
|
+
@restore_file = file
|
|
463
|
+
@paused = true
|
|
464
|
+
retained = true
|
|
465
|
+
end
|
|
466
|
+
@changed.broadcast
|
|
467
|
+
end
|
|
468
|
+
ensure
|
|
469
|
+
file.close if file && !retained
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def close_handles
|
|
473
|
+
failure = nil
|
|
474
|
+
[@reader, @writer, @wake_reader, @wake_writer, @restore_file, @sink].compact.each do |handle|
|
|
475
|
+
if handle.equal?(@writer)
|
|
476
|
+
@state.synchronize { handle.close unless handle.closed? }
|
|
477
|
+
else
|
|
478
|
+
handle.close unless handle.closed?
|
|
479
|
+
end
|
|
480
|
+
rescue StandardError => e
|
|
481
|
+
failure ||= e
|
|
482
|
+
@state.synchronize do
|
|
483
|
+
@failure ||= e
|
|
484
|
+
@changed.broadcast
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
raise failure if failure
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
private_constant :StdioCapture
|
|
491
|
+
end
|
|
492
|
+
end
|
data/lib/legion/logging.rb
CHANGED
|
@@ -78,8 +78,11 @@ module Legion
|
|
|
78
78
|
|
|
79
79
|
def setup(level: 'debug', format: :text, async: true, shutdown_timeout: 2, **options)
|
|
80
80
|
with_lifecycle_lock do
|
|
81
|
+
deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + shutdown_timeout if shutdown_timeout
|
|
81
82
|
return false if @async_writer && stop_async_writer(timeout: shutdown_timeout) == false
|
|
82
|
-
|
|
83
|
+
|
|
84
|
+
remaining = [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 0].max if deadline
|
|
85
|
+
return false if output(shutdown_timeout: remaining, **options) == false
|
|
83
86
|
|
|
84
87
|
log_level(level)
|
|
85
88
|
log_format(format: format, **options)
|
|
@@ -94,6 +97,7 @@ module Legion
|
|
|
94
97
|
extended: options.fetch(:extended, true),
|
|
95
98
|
log_file: options[:log_file],
|
|
96
99
|
log_stdout: options[:log_stdout],
|
|
100
|
+
rotation: options[:rotation],
|
|
97
101
|
include_pid: options.fetch(:include_pid, false),
|
|
98
102
|
color: @color
|
|
99
103
|
}.freeze
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legion-logging
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.5.
|
|
4
|
+
version: 1.5.21
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -72,11 +72,14 @@ files:
|
|
|
72
72
|
- lib/legion/logging/multi_io.rb
|
|
73
73
|
- lib/legion/logging/output_logger.rb
|
|
74
74
|
- lib/legion/logging/redactor.rb
|
|
75
|
+
- lib/legion/logging/rotating_file.rb
|
|
76
|
+
- lib/legion/logging/rotation.rb
|
|
75
77
|
- lib/legion/logging/settings.rb
|
|
76
78
|
- lib/legion/logging/shipper.rb
|
|
77
79
|
- lib/legion/logging/shipper/file_transport.rb
|
|
78
80
|
- lib/legion/logging/shipper/http_transport.rb
|
|
79
81
|
- lib/legion/logging/siem_exporter.rb
|
|
82
|
+
- lib/legion/logging/stdio_rotator.rb
|
|
80
83
|
- lib/legion/logging/tagged_logger.rb
|
|
81
84
|
- lib/legion/logging/version.rb
|
|
82
85
|
- lib/legion/service.rb
|