legion-logging 1.5.6 → 1.5.19

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,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
+ require 'monitor'
5
+ require_relative 'output_logger'
4
6
 
5
7
  module Legion
6
8
  module Logging
7
9
  module Builder
10
+ LIFECYCLE_MUTEX = Mutex.new
11
+ private_constant :LIFECYCLE_MUTEX
12
+
8
13
  def log_format(format: :text, include_pid: false, **)
9
14
  @format = format.to_sym
10
15
  if @format == :json
@@ -20,11 +25,19 @@ module Legion
20
25
 
21
26
  def json_format(include_pid: false)
22
27
  log.formatter = proc do |severity, datetime, _progname, msg|
28
+ origin = Thread.current[:legion_log_origin]
29
+ if origin
30
+ timestamp = origin[:timestamp]
31
+ thread_id = origin[:thread]
32
+ else
33
+ timestamp = datetime.utc
34
+ thread_id = Thread.current.object_id
35
+ end
23
36
  entry = {
24
- timestamp: datetime.utc.iso8601(3),
37
+ timestamp: timestamp.iso8601(3),
25
38
  level: severity.downcase,
26
39
  message: msg.is_a?(String) ? msg.gsub(/\e\[[0-9;]*m/, '') : msg.to_s,
27
- thread: Thread.current.object_id
40
+ thread: thread_id
28
41
  }
29
42
  entry[:pid] = ::Process.pid if include_pid
30
43
  segments = Thread.current[:legion_log_segments]
@@ -48,6 +61,7 @@ module Legion
48
61
 
49
62
  def text_format(include_pid: false, **options)
50
63
  log.formatter = proc do |severity, datetime, _progname, msg|
64
+ datetime = Thread.current[:legion_log_origin]&.[](:timestamp)&.getlocal || datetime
51
65
  lex_name = resolve_lex_tag(options)
52
66
  runner_trace = Thread.current[:legion_log_caller] || build_runner_trace if lex_name
53
67
 
@@ -117,25 +131,31 @@ module Legion
117
131
  end
118
132
 
119
133
  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
134
+ with_lifecycle_lock do
135
+ return false if @async_writer && stop_async_writer == false
136
+
137
+ previous_log = @log
138
+
139
+ if logfile && log_stdout != false
140
+ path = prepare_log_path(logfile)
141
+ require_relative 'multi_io'
142
+ file = File.new(path, 'a')
143
+ file.sync = true
144
+ io = MultiIO.new($stdout, file)
145
+ @log = Legion::Logging::OutputLogger.new(io)
146
+ elsif logfile
147
+ file = File.new(prepare_log_path(logfile), 'a')
148
+ file.sync = true
149
+ @log = Legion::Logging::OutputLogger.new(file)
150
+ else
151
+ @log = Legion::Logging::OutputLogger.new($stdout)
152
+ end
136
153
 
137
- close_replaced_log(previous_log)
138
- @log
154
+ @log.async_writer = @async_writer
155
+
156
+ close_replaced_log(previous_log)
157
+ @log
158
+ end
139
159
  end
140
160
 
141
161
  def prepare_log_path(path)
@@ -175,31 +195,39 @@ module Legion
175
195
  (@async == true && @async_writer&.alive?) || false
176
196
  end
177
197
 
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
198
+ def start_async_writer(buffer_size: 10_000, timeout: 2)
199
+ with_lifecycle_lock do
200
+ require_relative 'async_writer'
201
+ return false if @async_writer && stop_async_writer(timeout: timeout) == false
182
202
 
183
- @async_writer = AsyncWriter.new(log, buffer_size: buffer_size)
184
- @async_writer.start
185
- @async = true
186
- true
203
+ @async_writer = AsyncWriter.new(log, buffer_size: buffer_size)
204
+ log.async_writer = @async_writer
205
+ @async_writer.start
206
+ @async = true
207
+ true
208
+ end
187
209
  end
188
210
 
189
- def stop_async_writer
190
- writer = @async_writer
191
- stopped = writer&.stop
192
- return false if stopped == false
211
+ def stop_async_writer(timeout: 2)
212
+ with_lifecycle_lock do
213
+ return true unless @async_writer
193
214
 
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
215
+ @async_writer.stop(timeout: timeout)
216
+ end
198
217
  end
199
- # rubocop:enable Naming/PredicateMethod
200
218
 
201
219
  private
202
220
 
221
+ def with_lifecycle_lock
222
+ lock = LIFECYCLE_MUTEX.synchronize { @lifecycle_lock ||= Monitor.new }
223
+ entered = lock.try_enter
224
+ return false unless entered
225
+
226
+ yield
227
+ ensure
228
+ lock.exit if entered
229
+ end
230
+
203
231
  def close_replaced_log(logger)
204
232
  return unless logger
205
233
  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)
@@ -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
@@ -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
- log.unknown(message)
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 = %i[warn error fatal].include?(level) ? build_writer_context(level, raw) : nil
155
+ ctx = build_writer_context(level, raw)
114
156
  writer = @async_writer
115
157
  caller_trace = capture_runner_trace_for_async
116
- if writer&.alive?
117
- writer.push(AsyncWriter::LogEntry.new(
118
- level: level, message: formatted, writer_context: ctx,
119
- segments: Thread.current[:legion_log_segments],
120
- method_ctx: Thread.current[:legion_log_method],
121
- caller_trace: caller_trace,
122
- conv_id: Thread.current[:legion_log_conv_id],
123
- request_id: Thread.current[:legion_log_request_id],
124
- exchange_id: Thread.current[:legion_log_exchange_id],
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
- publish_exception_event(event, level)
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[:logging][:backtrace_limit]
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&.alive?
295
- queued = writer.push(AsyncWriter::LogEntry.new(
296
- level: level,
297
- message: formatted_message,
298
- writer_context: writer_context,
299
- segments: Thread.current[:legion_log_segments],
300
- method_ctx: Thread.current[:legion_log_method],
301
- caller_trace: caller_trace,
302
- conv_id: Thread.current[:legion_log_conv_id],
303
- request_id: Thread.current[:legion_log_request_id],
304
- exchange_id: Thread.current[:legion_log_exchange_id],
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
- build_runner_trace(caller_locations(5, 1)&.first)
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 publish_exception_event(event, level)
354
- lex_name = event[:lex] || 'core'
355
- comp = event[:component_type] || :unknown
356
- routing_key = "legion.logging.exception.#{level}.#{lex_name}.#{comp}"
357
- headers = build_exception_headers(event, comp, level)
358
- properties = build_exception_properties(event, level)
359
- Legion::Logging.exception_writer.call(event, routing_key: routing_key, headers: headers, properties: properties)
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}"