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
|
@@ -13,6 +13,7 @@ module Legion
|
|
|
13
13
|
exchanges|queues|messages|data|builders|tools|adapters|engines|
|
|
14
14
|
formatters|parsers|middleware)/
|
|
15
15
|
}x
|
|
16
|
+
LOGGING_PATH = __dir__
|
|
16
17
|
|
|
17
18
|
def trace(raw_message = nil, size: @trace_size, log_caller: true)
|
|
18
19
|
return unless @trace_enabled
|
|
@@ -25,18 +26,24 @@ module Legion
|
|
|
25
26
|
elsif log_caller
|
|
26
27
|
message.concat Rainbow(caller_locations[0..size]).cyan.underline
|
|
27
28
|
end
|
|
28
|
-
|
|
29
|
+
unknown(message)
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
def debug(message = nil, task_id: nil, conv_id: nil, request_id: nil, exchange_id: nil, chain_id: nil, **_ctx)
|
|
32
33
|
return unless log.level < 1
|
|
33
34
|
|
|
34
35
|
message = yield if message.nil? && block_given?
|
|
36
|
+
if async_producer?
|
|
37
|
+
return defer_log_call(receiver: self, method_name: :debug, arguments: [message],
|
|
38
|
+
keywords: { task_id: task_id, conv_id: conv_id, request_id: request_id,
|
|
39
|
+
exchange_id: exchange_id, chain_id: chain_id })
|
|
40
|
+
end
|
|
41
|
+
|
|
35
42
|
raw = maybe_redact(message)
|
|
36
43
|
formatted = format_message_for_level(:debug, raw)
|
|
37
44
|
with_context_ids(task_id: task_id, conv_id: conv_id, request_id: request_id,
|
|
38
45
|
exchange_id: exchange_id, chain_id: chain_id) do
|
|
39
|
-
write_async_or_sync(:debug, formatted, raw)
|
|
46
|
+
write_async_or_sync(:debug, formatted, raw, writer_context: build_writer_context(:debug, raw))
|
|
40
47
|
end
|
|
41
48
|
end
|
|
42
49
|
|
|
@@ -44,11 +51,17 @@ module Legion
|
|
|
44
51
|
return unless log.level < 2
|
|
45
52
|
|
|
46
53
|
message = yield if message.nil? && block_given?
|
|
54
|
+
if async_producer?
|
|
55
|
+
return defer_log_call(receiver: self, method_name: :info, arguments: [message],
|
|
56
|
+
keywords: { task_id: task_id, conv_id: conv_id, request_id: request_id,
|
|
57
|
+
exchange_id: exchange_id, chain_id: chain_id })
|
|
58
|
+
end
|
|
59
|
+
|
|
47
60
|
raw = maybe_redact(message)
|
|
48
61
|
formatted = format_message_for_level(:info, raw)
|
|
49
62
|
with_context_ids(task_id: task_id, conv_id: conv_id, request_id: request_id,
|
|
50
63
|
exchange_id: exchange_id, chain_id: chain_id) do
|
|
51
|
-
write_async_or_sync(:info, formatted, raw)
|
|
64
|
+
write_async_or_sync(:info, formatted, raw, writer_context: build_writer_context(:info, raw))
|
|
52
65
|
end
|
|
53
66
|
end
|
|
54
67
|
|
|
@@ -56,6 +69,12 @@ module Legion
|
|
|
56
69
|
return unless log.level < 3
|
|
57
70
|
|
|
58
71
|
message = yield if message.nil? && block_given?
|
|
72
|
+
if async_producer?
|
|
73
|
+
return defer_log_call(receiver: self, method_name: :warn, arguments: [message],
|
|
74
|
+
keywords: { task_id: task_id, conv_id: conv_id, request_id: request_id,
|
|
75
|
+
exchange_id: exchange_id, chain_id: chain_id })
|
|
76
|
+
end
|
|
77
|
+
|
|
59
78
|
raw = maybe_redact(message)
|
|
60
79
|
formatted = format_message_for_level(:warn, raw)
|
|
61
80
|
with_context_ids(task_id: task_id, conv_id: conv_id, request_id: request_id,
|
|
@@ -68,6 +87,12 @@ module Legion
|
|
|
68
87
|
return unless log.level < 4
|
|
69
88
|
|
|
70
89
|
message = yield if message.nil? && block_given?
|
|
90
|
+
if async_producer?
|
|
91
|
+
return defer_log_call(receiver: self, method_name: :error, arguments: [message],
|
|
92
|
+
keywords: { task_id: task_id, conv_id: conv_id, request_id: request_id,
|
|
93
|
+
exchange_id: exchange_id, chain_id: chain_id })
|
|
94
|
+
end
|
|
95
|
+
|
|
71
96
|
raw = maybe_redact(message)
|
|
72
97
|
formatted = format_message_for_level(:error, raw)
|
|
73
98
|
with_context_ids(task_id: task_id, conv_id: conv_id, request_id: request_id,
|
|
@@ -80,6 +105,12 @@ module Legion
|
|
|
80
105
|
return unless log.level < 5
|
|
81
106
|
|
|
82
107
|
message = yield if message.nil? && block_given?
|
|
108
|
+
if async_producer?
|
|
109
|
+
return defer_log_call(receiver: self, method_name: :fatal, arguments: [message],
|
|
110
|
+
keywords: { task_id: task_id, conv_id: conv_id, request_id: request_id,
|
|
111
|
+
exchange_id: exchange_id, chain_id: chain_id })
|
|
112
|
+
end
|
|
113
|
+
|
|
83
114
|
raw = maybe_redact(message)
|
|
84
115
|
formatted = format_message_for_level(:fatal, raw)
|
|
85
116
|
with_context_ids(task_id: task_id, conv_id: conv_id, request_id: request_id,
|
|
@@ -90,11 +121,17 @@ module Legion
|
|
|
90
121
|
|
|
91
122
|
def unknown(message = nil, task_id: nil, conv_id: nil, request_id: nil, exchange_id: nil, chain_id: nil, **_ctx)
|
|
92
123
|
message = yield if message.nil? && block_given?
|
|
124
|
+
if async_producer?
|
|
125
|
+
return defer_log_call(receiver: self, method_name: :unknown, arguments: [message],
|
|
126
|
+
keywords: { task_id: task_id, conv_id: conv_id, request_id: request_id,
|
|
127
|
+
exchange_id: exchange_id, chain_id: chain_id })
|
|
128
|
+
end
|
|
129
|
+
|
|
93
130
|
raw = maybe_redact(message)
|
|
94
131
|
formatted = format_message_for_level(:unknown, raw)
|
|
95
132
|
with_context_ids(task_id: task_id, conv_id: conv_id, request_id: request_id,
|
|
96
133
|
exchange_id: exchange_id, chain_id: chain_id) do
|
|
97
|
-
write_async_or_sync(:unknown, formatted, raw)
|
|
134
|
+
write_async_or_sync(:unknown, formatted, raw, writer_context: build_writer_context(:unknown, raw))
|
|
98
135
|
end
|
|
99
136
|
end
|
|
100
137
|
|
|
@@ -103,6 +140,11 @@ module Legion
|
|
|
103
140
|
level = level.to_sym
|
|
104
141
|
message = yield if message.nil? && block_given?
|
|
105
142
|
return if message.nil?
|
|
143
|
+
if async_producer?
|
|
144
|
+
return defer_log_call(receiver: self, method_name: :emit_tagged, arguments: [level, message],
|
|
145
|
+
keywords: { segments: segments, method_ctx: method_ctx, task_id: task_id, conv_id: conv_id,
|
|
146
|
+
request_id: request_id, exchange_id: exchange_id, chain_id: chain_id })
|
|
147
|
+
end
|
|
106
148
|
|
|
107
149
|
raw = maybe_redact(message)
|
|
108
150
|
formatted = format_message_for_level(level, raw)
|
|
@@ -110,23 +152,21 @@ module Legion
|
|
|
110
152
|
with_tagged_context(segments, method_ctx) do
|
|
111
153
|
with_context_ids(task_id: task_id, conv_id: conv_id, request_id: request_id,
|
|
112
154
|
exchange_id: exchange_id, chain_id: chain_id) do
|
|
113
|
-
ctx =
|
|
155
|
+
ctx = build_writer_context(level, raw)
|
|
114
156
|
writer = @async_writer
|
|
115
157
|
caller_trace = capture_runner_trace_for_async
|
|
116
|
-
if writer
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
chain_id: Thread.current[:legion_log_chain_id]
|
|
126
|
-
))
|
|
158
|
+
if @async || writer
|
|
159
|
+
push_async_entry(entry: AsyncWriter::LogEntry.new(force: true, level: level, message: formatted, writer_context: ctx,
|
|
160
|
+
segments: Thread.current[:legion_log_segments],
|
|
161
|
+
method_ctx: Thread.current[:legion_log_method],
|
|
162
|
+
caller_trace: caller_trace,
|
|
163
|
+
conv_id: Thread.current[:legion_log_conv_id],
|
|
164
|
+
request_id: Thread.current[:legion_log_request_id],
|
|
165
|
+
exchange_id: Thread.current[:legion_log_exchange_id],
|
|
166
|
+
chain_id: Thread.current[:legion_log_chain_id]))
|
|
127
167
|
else
|
|
128
168
|
with_caller_trace(caller_trace) { write_forced(level, formatted) }
|
|
129
|
-
fire_log_writer(level, raw) if ctx
|
|
169
|
+
fire_log_writer(level, raw, event: ctx[:event]) if ctx
|
|
130
170
|
end
|
|
131
171
|
end
|
|
132
172
|
end
|
|
@@ -141,6 +181,14 @@ module Legion
|
|
|
141
181
|
gem_name: nil, lex_version: nil, gem_path: nil,
|
|
142
182
|
source_code_uri: nil, handled: false, payload_summary: nil,
|
|
143
183
|
task_id: nil, backtrace_limit: nil, **extra)
|
|
184
|
+
if async_producer?
|
|
185
|
+
return defer_log_call(receiver: self, method_name: :log_exception, arguments: [exception],
|
|
186
|
+
keywords: { level: level, lex: lex, component_type: component_type, gem_name: gem_name,
|
|
187
|
+
lex_version: lex_version, gem_path: gem_path, source_code_uri: source_code_uri,
|
|
188
|
+
handled: handled, payload_summary: payload_summary, task_id: task_id,
|
|
189
|
+
backtrace_limit: backtrace_limit, **extra })
|
|
190
|
+
end
|
|
191
|
+
|
|
144
192
|
level = level.to_sym if level.respond_to?(:to_sym)
|
|
145
193
|
# 1. Log human-readable line + backtrace via async writer
|
|
146
194
|
msg = exception.respond_to?(:message) ? exception.message : exception.to_s
|
|
@@ -170,7 +218,7 @@ module Legion
|
|
|
170
218
|
event = Legion::Logging::Redactor.redact(event) if defined?(Legion::Logging::Redactor)
|
|
171
219
|
|
|
172
220
|
# 4. Publish rich event via exception_writer
|
|
173
|
-
|
|
221
|
+
publish_exception(event: event, level: level)
|
|
174
222
|
rescue StandardError => e
|
|
175
223
|
if respond_to?(:log) && log.respond_to?(:warn)
|
|
176
224
|
log.warn("Failed to publish structured exception event: #{e.class}: #{e.message}")
|
|
@@ -179,6 +227,45 @@ module Legion
|
|
|
179
227
|
end
|
|
180
228
|
end
|
|
181
229
|
|
|
230
|
+
def publish_exception(event:, level:)
|
|
231
|
+
lex_name = event[:lex] || 'core'
|
|
232
|
+
component = event[:component_type] || :unknown
|
|
233
|
+
options = {
|
|
234
|
+
routing_key: "legion.logging.exception.#{level}.#{lex_name}.#{component}",
|
|
235
|
+
headers: build_exception_headers(event, component, level),
|
|
236
|
+
properties: build_exception_properties(event, level)
|
|
237
|
+
}
|
|
238
|
+
callback = Legion::Logging.exception_writer
|
|
239
|
+
writer = @async_writer
|
|
240
|
+
return push_async_entry(entry: AsyncWriter::EventEntry.new(callback: callback, event: event, options: options)) if @async || writer
|
|
241
|
+
|
|
242
|
+
callback.call(event, **options)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def async_producer?
|
|
246
|
+
(@async == true || !@async_writer.nil?) && !@async_writer&.current_thread?
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def defer_log_call(receiver:, method_name:, arguments:, keywords:)
|
|
250
|
+
return false unless async_producer?
|
|
251
|
+
raise ClosedQueueError, 'async logging writer is unavailable' unless @async_writer&.alive?
|
|
252
|
+
|
|
253
|
+
context = Legion::Logging::AsyncWriter::CALL_THREAD_KEYS.to_h do |key|
|
|
254
|
+
[key, Legion::Logging::AsyncWriter.snapshot_value(value: Thread.current[key])]
|
|
255
|
+
end
|
|
256
|
+
location = caller_locations(1, 16).find do |frame|
|
|
257
|
+
path = frame.absolute_path || frame.path
|
|
258
|
+
!path.start_with?(LOGGING_PATH)
|
|
259
|
+
end
|
|
260
|
+
context[:legion_log_origin] = { location: location, timestamp: Time.now.utc, thread: Thread.current.object_id }
|
|
261
|
+
push_async_entry(entry: Legion::Logging::AsyncWriter::CallEntry.new(
|
|
262
|
+
receiver: receiver, method_name: method_name,
|
|
263
|
+
arguments: arguments.map { |value| Legion::Logging::AsyncWriter.snapshot_value(value: value) },
|
|
264
|
+
keywords: keywords.transform_values { |value| Legion::Logging::AsyncWriter.snapshot_value(value: value) },
|
|
265
|
+
context: context
|
|
266
|
+
))
|
|
267
|
+
end
|
|
268
|
+
|
|
182
269
|
def thread(kvl: false, **_opts)
|
|
183
270
|
if kvl
|
|
184
271
|
"thread=#{Thread.current.object_id}"
|
|
@@ -192,8 +279,9 @@ module Legion
|
|
|
192
279
|
def resolve_backtrace_limit(explicit_limit)
|
|
193
280
|
return explicit_limit unless explicit_limit.nil?
|
|
194
281
|
return nil unless defined?(Legion::Settings)
|
|
282
|
+
return nil unless Legion::Settings.respond_to?(:loaded?) ? Legion::Settings.loaded? : true
|
|
195
283
|
|
|
196
|
-
Legion::Settings
|
|
284
|
+
Legion::Settings.dig(:logging, :backtrace_limit)
|
|
197
285
|
end
|
|
198
286
|
|
|
199
287
|
def build_exception_log_message(exception, msg, backtrace_limit)
|
|
@@ -291,30 +379,35 @@ module Legion
|
|
|
291
379
|
def write_async_or_sync(level, formatted_message, raw_message, writer_context: nil)
|
|
292
380
|
writer = @async_writer
|
|
293
381
|
caller_trace = capture_runner_trace_for_async
|
|
294
|
-
if writer
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
chain_id: Thread.current[:legion_log_chain_id]
|
|
306
|
-
))
|
|
307
|
-
return if queued
|
|
382
|
+
if @async || writer
|
|
383
|
+
return push_async_entry(entry: AsyncWriter::LogEntry.new(force: false, level: level,
|
|
384
|
+
message: formatted_message,
|
|
385
|
+
writer_context: writer_context,
|
|
386
|
+
segments: Thread.current[:legion_log_segments],
|
|
387
|
+
method_ctx: Thread.current[:legion_log_method],
|
|
388
|
+
caller_trace: caller_trace,
|
|
389
|
+
conv_id: Thread.current[:legion_log_conv_id],
|
|
390
|
+
request_id: Thread.current[:legion_log_request_id],
|
|
391
|
+
exchange_id: Thread.current[:legion_log_exchange_id],
|
|
392
|
+
chain_id: Thread.current[:legion_log_chain_id]))
|
|
308
393
|
end
|
|
309
394
|
|
|
310
395
|
with_caller_trace(caller_trace) do
|
|
311
396
|
log.public_send(level, formatted_message)
|
|
312
|
-
fire_log_writer(level, raw_message) if writer_context
|
|
397
|
+
fire_log_writer(level, raw_message, event: writer_context[:event]) if writer_context
|
|
313
398
|
end
|
|
314
399
|
end
|
|
315
400
|
|
|
401
|
+
def push_async_entry(entry:) # rubocop:disable Naming/PredicateMethod
|
|
402
|
+
writer = @async_writer
|
|
403
|
+
raise ClosedQueueError, 'async logging writer is unavailable' unless writer&.alive? && writer.push(entry)
|
|
404
|
+
|
|
405
|
+
true
|
|
406
|
+
end
|
|
407
|
+
|
|
316
408
|
def capture_runner_trace_for_async
|
|
317
|
-
|
|
409
|
+
location = Thread.current[:legion_log_origin]&.[](:location) || caller_locations(5, 1)&.first
|
|
410
|
+
build_runner_trace(location)
|
|
318
411
|
end
|
|
319
412
|
|
|
320
413
|
def with_caller_trace(caller_trace)
|
|
@@ -350,19 +443,35 @@ module Legion
|
|
|
350
443
|
}
|
|
351
444
|
end
|
|
352
445
|
|
|
353
|
-
def
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
446
|
+
def forward_level?(level)
|
|
447
|
+
forwardable_log_levels.include?(level.to_sym)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Levels whose logs build a structured event for the transport bus / Hooks.
|
|
451
|
+
# Runtime override: logging.transport.log_levels (Array of level names).
|
|
452
|
+
# Falls back to Settings.default[:forward_log_levels] (warn/error/fatal).
|
|
453
|
+
def forwardable_log_levels
|
|
454
|
+
default = Legion::Logging::Settings::DEFAULT_FORWARD_LOG_LEVELS
|
|
455
|
+
return default unless defined?(Legion::Settings)
|
|
456
|
+
return default unless Legion::Settings.respond_to?(:loaded?) ? Legion::Settings.loaded? : true
|
|
457
|
+
|
|
458
|
+
configured = Legion::Settings.dig(:logging, :transport, :log_levels)
|
|
459
|
+
return default unless configured.is_a?(Array) && !configured.empty?
|
|
460
|
+
|
|
461
|
+
configured.map { |lvl| lvl.to_s.to_sym }
|
|
462
|
+
rescue StandardError => e
|
|
463
|
+
Kernel.warn("Legion::Logging forwarding configuration failed: #{e.message}")
|
|
464
|
+
default
|
|
360
465
|
end
|
|
361
466
|
|
|
362
467
|
def build_writer_context(level, message)
|
|
468
|
+
# Cheap ivar/flag checks first: when nothing consumes forwarded events
|
|
469
|
+
# (the common case) we return before touching Settings, keeping the
|
|
470
|
+
# hot debug/info path allocation- and lookup-free.
|
|
363
471
|
has_writer = !Legion::Logging.instance_variable_get(:@log_writer).nil?
|
|
364
472
|
has_hooks = defined?(Legion::Logging::Hooks) && Legion::Logging::Hooks.enabled?
|
|
365
473
|
return nil unless has_writer || has_hooks
|
|
474
|
+
return nil unless forward_level?(level)
|
|
366
475
|
|
|
367
476
|
lex_val = instance_variable_defined?(:@lex) ? @lex : nil
|
|
368
477
|
lex_segs = Thread.current[:legion_log_segments] || (instance_variable_defined?(:@lex_segments) ? @lex_segments : nil)
|
|
@@ -377,17 +486,7 @@ module Legion
|
|
|
377
486
|
{ level: level, event: event }
|
|
378
487
|
end
|
|
379
488
|
|
|
380
|
-
def fire_log_writer(level, message)
|
|
381
|
-
lex_val = instance_variable_defined?(:@lex) ? @lex : nil
|
|
382
|
-
lex_segs = Thread.current[:legion_log_segments] || (instance_variable_defined?(:@lex_segments) ? @lex_segments : nil)
|
|
383
|
-
|
|
384
|
-
event = Legion::Logging::EventBuilder.build(
|
|
385
|
-
level: level,
|
|
386
|
-
message: message,
|
|
387
|
-
lex: lex_val,
|
|
388
|
-
lex_segments: lex_segs,
|
|
389
|
-
caller_offset: 4
|
|
390
|
-
)
|
|
489
|
+
def fire_log_writer(level, message, event:)
|
|
391
490
|
lex_name = event[:lex] || 'core'
|
|
392
491
|
component = event.dig(:caller, :file).to_s[COMPONENT_REGEX, 1] || 'unknown'
|
|
393
492
|
routing_key = "legion.logging.log.#{level}.#{lex_name}.#{component}"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Logging
|
|
7
|
+
# Keeps the standard Logger interface used by Sinatra/APM on the async queue.
|
|
8
|
+
class OutputLogger < ::Logger
|
|
9
|
+
attr_accessor :async_writer
|
|
10
|
+
|
|
11
|
+
def add(severity, message = nil, progname = nil)
|
|
12
|
+
writer = @async_writer
|
|
13
|
+
return super unless writer && !writer.current_thread?
|
|
14
|
+
|
|
15
|
+
severity ||= UNKNOWN
|
|
16
|
+
return true if @logdev.nil? || severity < level
|
|
17
|
+
|
|
18
|
+
progname ||= @progname
|
|
19
|
+
if message.nil?
|
|
20
|
+
if block_given?
|
|
21
|
+
message = yield
|
|
22
|
+
else
|
|
23
|
+
message = progname
|
|
24
|
+
progname = @progname
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
enqueue_output(severity: severity, message: message, progname: progname)
|
|
28
|
+
end
|
|
29
|
+
alias log add
|
|
30
|
+
|
|
31
|
+
def <<(message)
|
|
32
|
+
writer = @async_writer
|
|
33
|
+
return super unless writer && !writer.current_thread?
|
|
34
|
+
|
|
35
|
+
enqueue_output(severity: nil, message: message, progname: nil)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def enqueue_output(severity:, message:, progname:) # rubocop:disable Naming/PredicateMethod
|
|
41
|
+
entry = Legion::Logging::AsyncWriter::OutputEntry.new(
|
|
42
|
+
severity: severity,
|
|
43
|
+
message: Legion::Logging::AsyncWriter.snapshot_value(value: message),
|
|
44
|
+
progname: Legion::Logging::AsyncWriter.snapshot_value(value: progname),
|
|
45
|
+
segments: Legion::Logging::AsyncWriter.snapshot_value(value: Thread.current[:legion_log_segments]),
|
|
46
|
+
method_ctx: Legion::Logging::AsyncWriter.snapshot_value(value: Thread.current[:legion_log_method]),
|
|
47
|
+
caller_trace: Legion::Logging::AsyncWriter.snapshot_value(value: Thread.current[:legion_log_caller]),
|
|
48
|
+
conv_id: Legion::Logging::AsyncWriter.snapshot_value(value: Thread.current[:legion_log_conv_id]),
|
|
49
|
+
request_id: Legion::Logging::AsyncWriter.snapshot_value(value: Thread.current[:legion_log_request_id]),
|
|
50
|
+
exchange_id: Legion::Logging::AsyncWriter.snapshot_value(value: Thread.current[:legion_log_exchange_id]),
|
|
51
|
+
chain_id: Legion::Logging::AsyncWriter.snapshot_value(value: Thread.current[:legion_log_chain_id]),
|
|
52
|
+
origin: { timestamp: Time.now.utc, thread: Thread.current.object_id }
|
|
53
|
+
)
|
|
54
|
+
raise ClosedQueueError, 'async logging writer is unavailable' unless @async_writer.alive? && @async_writer.push(entry)
|
|
55
|
+
|
|
56
|
+
true
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -74,6 +74,9 @@ module Legion
|
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
def all_patterns
|
|
77
|
+
return PATTERNS unless defined?(Legion::Settings)
|
|
78
|
+
return PATTERNS unless Legion::Settings.respond_to?(:loaded?) ? Legion::Settings.loaded? : true
|
|
79
|
+
|
|
77
80
|
@all_patterns ||= build_patterns
|
|
78
81
|
end
|
|
79
82
|
|
|
@@ -85,8 +88,6 @@ module Legion
|
|
|
85
88
|
end
|
|
86
89
|
|
|
87
90
|
def custom_patterns
|
|
88
|
-
return {} unless defined?(Legion::Settings)
|
|
89
|
-
|
|
90
91
|
raw = Legion::Settings.dig(:logging, :redactor, :custom_patterns)
|
|
91
92
|
return {} unless raw.is_a?(Hash)
|
|
92
93
|
|
|
@@ -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
|