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.
@@ -1,10 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
+ require 'monitor'
5
+ require_relative 'output_logger'
6
+ require_relative 'rotation'
4
7
 
5
8
  module Legion
6
9
  module Logging
7
10
  module Builder
11
+ LIFECYCLE_MUTEX = Mutex.new
12
+ private_constant :LIFECYCLE_MUTEX
13
+
8
14
  def log_format(format: :text, include_pid: false, **)
9
15
  @format = format.to_sym
10
16
  if @format == :json
@@ -20,11 +26,19 @@ module Legion
20
26
 
21
27
  def json_format(include_pid: false)
22
28
  log.formatter = proc do |severity, datetime, _progname, msg|
29
+ origin = Thread.current[:legion_log_origin]
30
+ if origin
31
+ timestamp = origin[:timestamp]
32
+ thread_id = origin[:thread]
33
+ else
34
+ timestamp = datetime.utc
35
+ thread_id = Thread.current.object_id
36
+ end
23
37
  entry = {
24
- timestamp: datetime.utc.iso8601(3),
38
+ timestamp: timestamp.iso8601(3),
25
39
  level: severity.downcase,
26
40
  message: msg.is_a?(String) ? msg.gsub(/\e\[[0-9;]*m/, '') : msg.to_s,
27
- thread: Thread.current.object_id
41
+ thread: thread_id
28
42
  }
29
43
  entry[:pid] = ::Process.pid if include_pid
30
44
  segments = Thread.current[:legion_log_segments]
@@ -48,6 +62,7 @@ module Legion
48
62
 
49
63
  def text_format(include_pid: false, **options)
50
64
  log.formatter = proc do |severity, datetime, _progname, msg|
65
+ datetime = Thread.current[:legion_log_origin]&.[](:timestamp)&.getlocal || datetime
51
66
  lex_name = resolve_lex_tag(options)
52
67
  runner_trace = Thread.current[:legion_log_caller] || build_runner_trace if lex_name
53
68
 
@@ -109,33 +124,40 @@ module Legion
109
124
  end
110
125
 
111
126
  def output(**options)
112
- 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))
113
129
  end
114
130
 
115
131
  def log
116
132
  @log ||= set_log
117
133
  end
118
134
 
119
- def set_log(logfile: nil, log_stdout: nil, **)
120
- previous_log = @log
121
-
122
- if logfile && log_stdout != false
123
- path = prepare_log_path(logfile)
124
- require_relative 'multi_io'
125
- file = File.new(path, 'a')
126
- file.sync = true
127
- io = MultiIO.new($stdout, file)
128
- @log = ::Logger.new(io)
129
- elsif logfile
130
- file = File.new(prepare_log_path(logfile), 'a')
131
- file.sync = true
132
- @log = ::Logger.new(file)
133
- else
134
- @log = ::Logger.new($stdout)
135
- end
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, **)
137
+ with_lifecycle_lock do
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
140
+
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
146
+
147
+ if logfile && log_stdout != false
148
+ require_relative 'multi_io'
149
+ io = Legion::Logging::MultiIO.new(stdout, open_log_file(path: prepare_log_path(logfile), rotation_opts: rotation_opts))
150
+ @log = Legion::Logging::OutputLogger.new(io)
151
+ elsif logfile
152
+ @log = Legion::Logging::OutputLogger.new(open_log_file(path: prepare_log_path(logfile), rotation_opts: rotation_opts))
153
+ else
154
+ @log = Legion::Logging::OutputLogger.new(stdout)
155
+ end
136
156
 
137
- close_replaced_log(previous_log)
138
- @log
157
+ @log.async_writer = @async_writer
158
+ close_replaced_log(previous_log)
159
+ @log
160
+ end
139
161
  end
140
162
 
141
163
  def prepare_log_path(path)
@@ -175,31 +197,77 @@ module Legion
175
197
  (@async == true && @async_writer&.alive?) || false
176
198
  end
177
199
 
178
- # rubocop:disable Naming/PredicateMethod
179
- def start_async_writer(buffer_size: 10_000)
180
- require_relative 'async_writer'
181
- return false if @async_writer&.alive? && stop_async_writer == false
200
+ def start_async_writer(buffer_size: 10_000, timeout: 2)
201
+ with_lifecycle_lock do
202
+ require_relative 'async_writer'
203
+ return false if @async_writer && stop_async_writer(timeout: timeout) == false
182
204
 
183
- @async_writer = AsyncWriter.new(log, buffer_size: buffer_size)
184
- @async_writer.start
185
- @async = true
186
- true
205
+ @async_writer = AsyncWriter.new(log, buffer_size: buffer_size)
206
+ log.async_writer = @async_writer
207
+ @async_writer.start
208
+ @async = true
209
+ true
210
+ end
187
211
  end
188
212
 
189
- def stop_async_writer
190
- writer = @async_writer
191
- stopped = writer&.stop
192
- return false if stopped == false
213
+ def stop_async_writer(timeout: 2)
214
+ with_lifecycle_lock do
215
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout if timeout
216
+ return false if @async_writer && @async_writer.stop(timeout: timeout) == false
193
217
 
194
- close_replaced_log(writer.logger) if writer.respond_to?(:logger)
195
- @async_writer = nil if @async_writer.equal?(writer)
196
- @async = false
197
- true
218
+ remaining = [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), 0].max if deadline
219
+ flush_stdio_output!(timeout: remaining)
220
+ end
198
221
  end
199
- # rubocop:enable Naming/PredicateMethod
200
222
 
201
223
  private
202
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
+
261
+ def with_lifecycle_lock
262
+ lock = LIFECYCLE_MUTEX.synchronize { @lifecycle_lock ||= Monitor.new }
263
+ entered = lock.try_enter
264
+ return false unless entered
265
+
266
+ yield
267
+ ensure
268
+ lock.exit if entered
269
+ end
270
+
203
271
  def close_replaced_log(logger)
204
272
  return unless logger
205
273
  return if logger.equal?(@log)
@@ -69,9 +69,10 @@ module Legion
69
69
  bt = Array(exception.backtrace)
70
70
  cf_file, cf_line, cf_func = parse_backtrace_location(bt.first) ||
71
71
  caller_location(caller_offset)
72
+ origin = Thread.current[:legion_log_origin]
72
73
 
73
74
  event = {
74
- timestamp: Time.now.utc.iso8601(3),
75
+ timestamp: (origin&.[](:timestamp) || Time.now.utc).iso8601(3),
75
76
  level: level,
76
77
  exception_class: exception.class.name,
77
78
  message: truncate_bytes(exception.message.to_s, MAX_MESSAGE_BYTES),
@@ -89,7 +90,7 @@ module Legion
89
90
  ruby_version: "#{RUBY_VERSION} #{RUBY_PLATFORM}",
90
91
  handled: handled,
91
92
  pid: ::Process.pid,
92
- thread: Thread.current.object_id
93
+ thread: origin&.[](:thread) || Thread.current.object_id
93
94
  }
94
95
 
95
96
  event[:task_id] = task_id if task_id
@@ -147,12 +148,13 @@ module Legion
147
148
  private
148
149
 
149
150
  def base_fields(level, message)
151
+ origin = Thread.current[:legion_log_origin]
150
152
  {
151
- timestamp: Time.now.utc.iso8601(3),
153
+ timestamp: (origin&.[](:timestamp) || Time.now.utc).iso8601(3),
152
154
  level: level,
153
155
  message: message.is_a?(Exception) ? message.message : strip_ansi(message.to_s),
154
156
  pid: ::Process.pid,
155
- thread: Thread.current.object_id
157
+ thread: origin&.[](:thread) || Thread.current.object_id
156
158
  }
157
159
  end
158
160
 
@@ -166,6 +168,7 @@ module Legion
166
168
 
167
169
  def add_node(event)
168
170
  return unless defined?(Legion::Settings)
171
+ return unless Legion::Settings.respond_to?(:loaded?) ? Legion::Settings.loaded? : true
169
172
 
170
173
  name = begin
171
174
  Legion::Settings[:client][:name]
@@ -177,7 +180,7 @@ module Legion
177
180
  end
178
181
 
179
182
  def add_caller_info(event, offset)
180
- loc = caller_locations(offset + 1, 1)&.first
183
+ loc = Thread.current[:legion_log_origin]&.[](:location) || caller_locations(offset + 1, 1)&.first
181
184
  return unless loc
182
185
 
183
186
  event[:caller] = {
@@ -268,7 +271,7 @@ module Legion
268
271
  end
269
272
 
270
273
  def caller_location(offset)
271
- loc = caller_locations(offset + 2, 1)&.first
274
+ loc = Thread.current[:legion_log_origin]&.[](:location) || caller_locations(offset + 2, 1)&.first
272
275
  return [nil, nil, nil] unless loc
273
276
 
274
277
  [loc.absolute_path || loc.path, loc.lineno, loc.base_label]
@@ -80,6 +80,13 @@ module Legion
80
80
  end
81
81
 
82
82
  def handle_exception(exception, task_id: nil, level: :error, handled: true, **opts)
83
+ if Legion::Logging.async_producer?
84
+ return Legion::Logging.defer_log_call(
85
+ receiver: self, method_name: :handle_exception, arguments: [exception],
86
+ keywords: { task_id: task_id, level: level, handled: handled, **opts }
87
+ )
88
+ end
89
+
83
90
  segments = derive_log_segments
84
91
  spec = gem_spec
85
92
  ctx = Thread.current[:legion_context] || {}
@@ -101,7 +108,7 @@ module Legion
101
108
  event = Legion::Logging::Redactor.redact(event) if defined?(Legion::Logging::Redactor)
102
109
 
103
110
  write_exception_to_log(exception, event, level, segments)
104
- publish_exception(event, level) if structured_exception_support?
111
+ Legion::Logging.publish_exception(event: event, level: level) if structured_exception_support?
105
112
  end
106
113
 
107
114
  def self.included(base)
@@ -519,7 +526,9 @@ module Legion
519
526
  lines << " #{context_line}" unless context_line.empty?
520
527
 
521
528
  max_frames = if event[:backtrace_limit].nil?
522
- defined?(Legion::Settings) ? Legion::Settings[:logging][:backtrace_limit] : nil
529
+ if defined?(Legion::Settings) && (!Legion::Settings.respond_to?(:loaded?) || Legion::Settings.loaded?)
530
+ Legion::Settings.dig(:logging, :backtrace_limit)
531
+ end
523
532
  else
524
533
  event[:backtrace_limit]
525
534
  end
@@ -555,23 +564,6 @@ module Legion
555
564
  parts.join(' | ')
556
565
  end
557
566
 
558
- # -- Exception structured publish --
559
-
560
- def publish_exception(event, level)
561
- return unless structured_exception_support?
562
-
563
- lex_name = event[:lex] || 'core'
564
- comp = event[:component_type] || :unknown
565
- routing_key = "legion.logging.exception.#{level}.#{lex_name}.#{comp}"
566
-
567
- headers = build_exception_headers(event, comp, level)
568
- properties = build_exception_properties(event, level)
569
-
570
- Legion::Logging.exception_writer.call(event, routing_key: routing_key, headers: headers, properties: properties)
571
- rescue StandardError => e
572
- Legion::Logging.warn("Failed to publish exception event: #{e.class}: #{e.message}") if Legion::Logging.respond_to?(:warn)
573
- end
574
-
575
567
  def structured_exception_support?
576
568
  defined?(Legion::Logging::EventBuilder) &&
577
569
  Legion::Logging.respond_to?(:exception_writer)
@@ -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]
@@ -16,6 +16,8 @@ module Legion
16
16
 
17
17
  base_name = base.to_s
18
18
  tp = TracePoint.new(:call, :return) do |trace|
19
+ next if Thread.current[:legion_trace_emitting]
20
+
19
21
  next unless trace.defined_class == base || (match_singleton && trace.defined_class == base.singleton_class)
20
22
 
21
23
  stack = (Thread.current[:_legion_trace_stack] ||= [])
@@ -25,12 +27,12 @@ module Legion
25
27
  params = format_params(trace)
26
28
  params_segment = params.empty? ? '' : ", #{params.join(', ')}"
27
29
  indent = ' ' * stack.size
28
- puts "#{indent}-> #{trace.method_id}, #{base_name}#{params_segment}"
30
+ emit_trace(message: "#{indent}-> #{trace.method_id}, #{base_name}#{params_segment}")
29
31
  stack.push(trace.method_id)
30
32
  when :return
31
33
  stack.pop
32
34
  indent = ' ' * stack.size
33
- puts "#{indent}<- #{trace.method_id}, #{base_name}"
35
+ emit_trace(message: "#{indent}<- #{trace.method_id}, #{base_name}")
34
36
  end
35
37
  end
36
38
  tp.enable
@@ -52,6 +54,15 @@ module Legion
52
54
  end
53
55
  end
54
56
 
57
+ def self.emit_trace(message:)
58
+ previous = Thread.current[:legion_trace_emitting]
59
+ Thread.current[:legion_trace_emitting] = true
60
+ Legion::Logging.unknown(message)
61
+ ensure
62
+ Thread.current[:legion_trace_emitting] = previous
63
+ end
64
+ private_class_method :emit_trace
65
+
55
66
  def self.format_params(trace_point)
56
67
  trace_point.parameters.filter_map do |type, name|
57
68
  next unless name