legion-logging 1.4.2 → 1.5.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 31ec07f7a93713fb708102528d4e61525a4c3b4fa39de510d29a454726b49436
4
- data.tar.gz: 62a67fc723d1e865ef3e632d1987b6ce73c2fe9aa033686df5fe3b2fb89bc14c
3
+ metadata.gz: 62c3c14ff8e6f67a11941a869f4f4ea2b682686c85c41bb1568368b448f23d0c
4
+ data.tar.gz: af9c1588f601a09f6b986d9dc47cc07b1c8e9b966e482b45d1a7242342e8d500
5
5
  SHA512:
6
- metadata.gz: 5e0b2171b6ca1b1acbb3ad0f40cbd23a77bf5824a3b2f37a5c9b3484f7edf2cf9cf1a8dc23a9f0ed0acd426e139fb0d7ce13ac0a3ae2a77b3113dd0569cc6d53
7
- data.tar.gz: e4e599809e26c6b7a0f9c1a81f05e390175cad0568b6b99f26888b675590972ff9148e27bc53f36461e1d6f09f83fcf7a818b01bd793098c5809910affabb0a2
6
+ metadata.gz: 66a26f844b44432886b1d703d93dbb9547134ede3542610861e123e240b8ec89746d7bdc522ae8e831c93e212f9e1a9bff5fc9b2fa19a78dfb47007373c5e0ab
7
+ data.tar.gz: add0a140cbe3c101dc83dcfb4b1c3eef385e4c778a4b6957be6d71b2ec95a9316c3b36d7b687906867046d482b2246ad0a44e8fd4cc98472ad1157994207146a
@@ -3,13 +3,31 @@ on:
3
3
  push:
4
4
  branches: [main]
5
5
  pull_request:
6
+ schedule:
7
+ - cron: '0 9 * * 1'
6
8
 
7
9
  jobs:
8
10
  ci:
9
11
  uses: LegionIO/.github/.github/workflows/ci.yml@main
10
12
 
13
+ lint:
14
+ uses: LegionIO/.github/.github/workflows/lint-patterns.yml@main
15
+
16
+ security:
17
+ uses: LegionIO/.github/.github/workflows/security-scan.yml@main
18
+
19
+ version-changelog:
20
+ uses: LegionIO/.github/.github/workflows/version-changelog.yml@main
21
+
22
+ dependency-review:
23
+ uses: LegionIO/.github/.github/workflows/dependency-review.yml@main
24
+
25
+ stale:
26
+ if: github.event_name == 'schedule'
27
+ uses: LegionIO/.github/.github/workflows/stale.yml@main
28
+
11
29
  release:
12
- needs: ci
30
+ needs: [ci, lint]
13
31
  if: github.event_name == 'push' && github.ref == 'refs/heads/main'
14
32
  uses: LegionIO/.github/.github/workflows/release.yml@main
15
33
  secrets:
data/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # Legion::Logging Changelog
2
2
 
3
+ ## [1.5.0] - 2026-04-02
4
+
5
+ ### Added
6
+ - `Legion::Logging.current_settings` and `.configuration_generation` so helper mixins can refresh memoized tagged loggers after runtime reconfiguration
7
+ - Component logger overrides from local `settings`, top-level `Legion::Settings[component]`, and `Legion::Settings.dig(:extensions, component)` for `log_level`, `trace`, `trace_size`, and `extended`
8
+ - `Methods#emit_tagged` / `TaggedLogger#dispatch` path so component-level loggers can emit with their own level while preserving tagged context
9
+ - Fallback exception event construction in `Helper#handle_exception` when structured exception support is unavailable
10
+
11
+ ### Changed
12
+ - `setup` and `Builder#log_level` now default to `debug`
13
+ - Default helper/tagged logger behavior enables trace and extended metadata
14
+ - `Helper#log` rebuilds memoized `TaggedLogger` instances when logging configuration changes
15
+ - Runtime logger settings take precedence over loaded global settings for helper-mixed components
16
+
17
+ ### Fixed
18
+ - `setup(async: true)` now tolerates boolean `logging.async` settings without probing for `buffer_size`
19
+ - Exception stdout/file output now falls back safely when singleton logger helpers are unavailable
20
+ - Structured exception publishing is skipped when the exception writer/EventBuilder path is unavailable
21
+ - `TaggedLogger#unknown` falls back to `debug` output when `Legion::Logging.unknown` is unavailable
22
+
23
+ ## [1.4.3] - 2026-04-01
24
+
25
+ ### Added
26
+ - `TaggedLogger` lightweight proxy: delegates to singleton for shared stdout/file/async output
27
+ - `Helper#derive_log_segments` with class-level `SEGMENT_CACHE` — auto-derives `[llm][router]` from namespace
28
+ - `Helper#with_log_context` for block-scoped method name thread-locals (`{dispatch}` in log output)
29
+ - `Helper#handle_exception` with direct EventBuilder calls, per-line Rainbow coloring, structured AMQP publish
30
+ - `Helper.current_log_method`, `.current_log_segments`, `.current_context` thread-local readers
31
+ - `Legion::Logging::Settings` module with logger defaults
32
+ - `COMPONENT_MAP` with 18 component types (runners, actors, hooks, absorbers, tools, adapters, middleware, etc.)
33
+ - `EXCEPTION_COLORS` map for per-level exception coloring (bold first line, faint backtrace)
34
+ - `Thread.current[:legion_context]` support for wire protocol fields (task_id, conversation_id, chain_id)
35
+ - Redaction applied to exception stdout output when redaction is enabled
36
+ - Method context (`legion_log_method`) included in structured exception events
37
+ - `AsyncWriter::LogEntry` carries `segments` and `method_ctx` for thread-local propagation to writer thread
38
+ - `Builder#resolve_lex_tag` and `#build_runner_trace` extracted from `text_format`
39
+
40
+ ### Changed
41
+ - `Helper#log` returns `TaggedLogger` instead of `Logger.new` (shared output, one async thread)
42
+ - `Helper#log_name`/`gem_name`/`gem_spec` replace `log_lex_name`/`lex_gem_name`/`gem_spec_for_lex` with multi-prefix resolution
43
+ - `gem_name` and `gem_spec` memoized per instance
44
+ - `COMPONENT_REGEX` in Methods expanded from 5 to 18 component types
45
+ - `build_writer_context` reads `Thread.current[:legion_log_segments]` instead of stale `@lex_segments` ivar
46
+ - `Builder#output` delegates to `set_log` (was parallel implementation)
47
+ - `Builder#caller_locations` allocates single frame instead of full stack
48
+ - Unknown log level strings default to INFO instead of DEBUG
49
+ - `EventBuilder#legion_versions` and `#resolve_gem_spec` memoized
50
+ - `EXCEPTION_PRIORITY` extracted to frozen constant in Methods (was inline hash allocation per call)
51
+ - `text_format` and `json_format` in Builder read thread-locals for segments and method context
52
+
53
+ ### Fixed
54
+ - `fire_log_writer` rescue no longer references undefined `routing_key` variable
55
+ - Splunk auth header in `http_transport` — `apply_auth` receives actual URI instead of always evaluating against `URI('/')`
56
+ - `TaggedLogger#initialize` accepts `**_opts` splat for unexpected settings keys
57
+ - `TaggedLogger#trace` guards nil `size` to prevent `TypeError` on `caller_locations`
58
+
59
+ ### Removed
60
+ - `TaggedLogger#runner_exception` (runner business logic, not logging concern)
61
+ - `TaggedLogger#log_exception` (use `Helper#handle_exception` instead)
62
+ - `Builder#log_level` no-op `@log = log` self-assignment
63
+
3
64
  ## [1.4.2] - 2026-03-28
4
65
 
5
66
  ### Added
@@ -135,4 +196,4 @@
135
196
  - `format_for_elk` produces ELK-compatible event hashes
136
197
 
137
198
  ## v1.2.0
138
- Moving from BitBucket to GitHub. All git history is reset from this point on
199
+ Moving from BitBucket to GitHub. All git history is reset from this point on
data/Gemfile CHANGED
@@ -11,5 +11,6 @@ group :test do
11
11
  gem 'rspec'
12
12
  gem 'rspec_junit_formatter'
13
13
  gem 'rubocop'
14
+ gem 'rubocop-legion'
14
15
  gem 'simplecov'
15
16
  end
@@ -1,42 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'methods'
4
+
3
5
  module Legion
4
6
  module Logging
5
7
  class AsyncWriter
6
- LogEntry = ::Data.define(:level, :message, :writer_context)
8
+ LogEntry = ::Data.define(:level, :message, :writer_context, :segments, :method_ctx, :caller_trace)
7
9
  SHUTDOWN = :shutdown
8
10
 
11
+ attr_reader :logger
12
+
9
13
  def initialize(logger, buffer_size: 10_000)
10
14
  @logger = logger
15
+ @buffer_size = buffer_size
11
16
  @queue = SizedQueue.new(buffer_size)
12
17
  @thread = nil
18
+ @state_mutex = Mutex.new
19
+ @accepting = true
13
20
  end
14
21
 
15
22
  def start
16
23
  return if @thread&.alive?
17
24
 
25
+ @state_mutex.synchronize { @accepting = true }
18
26
  drain
27
+ @queue = SizedQueue.new(@buffer_size)
19
28
  @thread = Thread.new { consume }
20
29
  @thread.name = 'legion-log-writer'
21
30
  @thread.abort_on_exception = false
22
31
  end
23
32
 
33
+ # rubocop:disable Naming/PredicateMethod
24
34
  def stop(timeout: 2)
25
- return unless @thread&.alive?
35
+ @state_mutex.synchronize { @accepting = false }
26
36
 
27
- begin
28
- @queue.push(SHUTDOWN, true)
29
- rescue ThreadError
30
- # Queue full — fall through to join/kill + drain
37
+ unless @thread&.alive?
38
+ drain
39
+ @thread = nil
40
+ return true
31
41
  end
32
- @thread.join(timeout)
33
- @thread.kill if @thread&.alive?
34
- drain
42
+
43
+ @queue.close
44
+ timeout ? @thread.join(timeout) : @thread.join
45
+ return false if @thread&.alive?
46
+
47
+ @thread = nil
48
+ true
35
49
  end
36
50
 
37
51
  def push(entry)
52
+ return false unless accepting?
53
+
38
54
  @queue.push(entry)
55
+ true
56
+ rescue ClosedQueueError
57
+ false
39
58
  end
59
+ # rubocop:enable Naming/PredicateMethod
40
60
 
41
61
  def alive?
42
62
  @thread&.alive? || false
@@ -47,17 +67,27 @@ module Legion
47
67
  def consume
48
68
  loop do
49
69
  entry = @queue.pop
50
- break if entry == SHUTDOWN
70
+ break if entry.nil? || entry == SHUTDOWN
51
71
 
52
72
  write_entry(entry)
53
73
  end
54
74
  end
55
75
 
56
76
  def write_entry(entry)
77
+ prev_segments = Thread.current[:legion_log_segments]
78
+ prev_method_ctx = Thread.current[:legion_log_method]
79
+ prev_caller = Thread.current[:legion_log_caller]
80
+ Thread.current[:legion_log_segments] = entry.segments
81
+ Thread.current[:legion_log_method] = entry.method_ctx
82
+ Thread.current[:legion_log_caller] = entry.caller_trace
57
83
  @logger.send(entry.level, entry.message)
58
84
  fire_writer(entry) if entry.writer_context
59
85
  rescue StandardError => e
60
86
  warn("legion-log-writer error: #{e.message} (#{e.backtrace&.first})")
87
+ ensure
88
+ Thread.current[:legion_log_segments] = prev_segments
89
+ Thread.current[:legion_log_method] = prev_method_ctx
90
+ Thread.current[:legion_log_caller] = prev_caller
61
91
  end
62
92
 
63
93
  def drain
@@ -69,14 +99,19 @@ module Legion
69
99
  nil
70
100
  end
71
101
 
102
+ def accepting?
103
+ @state_mutex.synchronize { @accepting }
104
+ end
105
+
72
106
  def fire_writer(entry)
73
107
  ctx = entry.writer_context
74
108
  event = ctx[:event]
75
109
  level = ctx[:level]
76
110
  lex_name = event[:lex] || 'core'
77
- component = event.dig(:caller, :file).to_s[%r{/(runners|actors|transport|helpers|builders)/}, 1] || 'unknown'
111
+ component = event.dig(:caller, :file).to_s[Legion::Logging::Methods::COMPONENT_REGEX, 1] || 'unknown'
78
112
  routing_key = "legion.logging.log.#{level}.#{lex_name}.#{component}"
79
113
  Legion::Logging.log_writer.call(event, routing_key: routing_key)
114
+ Legion::Logging::Hooks.fire(level, entry.message, event) if defined?(Legion::Logging::Hooks)
80
115
  rescue StandardError => e
81
116
  warn("legion-log-writer writer error: #{e.message}")
82
117
  end
@@ -27,6 +27,10 @@ module Legion
27
27
  thread: Thread.current.object_id
28
28
  }
29
29
  entry[:pid] = ::Process.pid if include_pid
30
+ segments = Thread.current[:legion_log_segments]
31
+ entry[:segments] = segments if segments
32
+ method_ctx = Thread.current[:legion_log_method]
33
+ entry[:method] = method_ctx if method_ctx
30
34
  "#{::JSON.generate(entry)}\n"
31
35
  rescue StandardError => e
32
36
  warn("Legion::Logging::Builder#json_format formatter failed: #{e.message}")
@@ -36,24 +40,12 @@ module Legion
36
40
 
37
41
  def text_format(include_pid: false, **options)
38
42
  log.formatter = proc do |severity, datetime, _progname, msg|
39
- options[:lex_name] = if options.key?(:lex_segments)
40
- options[:lex_segments].map { |s| "[#{s}]" }.join
41
- elsif options.key?(:lex) && !options[:lex].nil?
42
- "[#{options[:lex]}]"
43
- end
44
- unless options[:lex_name].nil?
45
- loc = caller_locations[4]
46
- path = loc.to_s.split('/').last(2)
47
- runner_trace = {
48
- type: path[0],
49
- file: File.basename(loc.path, '.*'),
50
- function: loc.base_label,
51
- line_number: loc.lineno
52
- }
53
- end
43
+ lex_name = resolve_lex_tag(options)
44
+ runner_trace = Thread.current[:legion_log_caller] || build_runner_trace if lex_name
45
+
54
46
  string = "[#{datetime}]"
55
47
  string.concat("[#{::Process.pid}]") if include_pid
56
- string.concat(options[:lex_name]) unless options[:lex_name].nil?
48
+ string.concat(lex_name) if lex_name
57
49
  if runner_trace.is_a?(Hash) && (options[:extended] || severity == 'debug')
58
50
  string.concat("[#{runner_trace[:type]}:#{runner_trace[:file]}:#{runner_trace[:function]}:#{runner_trace[:line_number]}]")
59
51
  end
@@ -62,17 +54,35 @@ module Legion
62
54
  end
63
55
  end
64
56
 
57
+ def resolve_lex_tag(options)
58
+ segments = Thread.current[:legion_log_segments]
59
+ tag = if segments
60
+ segments.map { |s| "[#{s}]" }.join
61
+ elsif options.key?(:lex_segments)
62
+ options[:lex_segments].map { |s| "[#{s}]" }.join
63
+ elsif options.key?(:lex) && !options[:lex].nil?
64
+ "[#{options[:lex]}]"
65
+ end
66
+
67
+ method_ctx = Thread.current[:legion_log_method]
68
+ tag = "#{tag}{#{method_ctx}}" if tag && method_ctx
69
+ tag
70
+ end
71
+
72
+ def build_runner_trace(loc = caller_locations(6, 1)&.first)
73
+ return unless loc
74
+
75
+ path = loc.to_s.split('/').last(2)
76
+ {
77
+ type: path[0],
78
+ file: File.basename(loc.path, '.*'),
79
+ function: loc.base_label,
80
+ line_number: loc.lineno
81
+ }
82
+ end
83
+
65
84
  def output(**options)
66
- if options[:log_file] && options[:log_stdout] != false
67
- path = prepare_log_path(options[:log_file])
68
- require_relative 'multi_io'
69
- io = MultiIO.new($stdout, File.open(path, 'a'))
70
- @log = ::Logger.new(io)
71
- elsif options[:log_file]
72
- @log = ::Logger.new(prepare_log_path(options[:log_file]))
73
- else
74
- @log = ::Logger.new($stdout)
75
- end
85
+ set_log(logfile: options[:log_file], log_stdout: options[:log_stdout])
76
86
  end
77
87
 
78
88
  def log
@@ -80,16 +90,25 @@ module Legion
80
90
  end
81
91
 
82
92
  def set_log(logfile: nil, log_stdout: nil, **)
93
+ previous_log = @log
94
+
83
95
  if logfile && log_stdout != false
84
96
  path = prepare_log_path(logfile)
85
97
  require_relative 'multi_io'
86
- io = MultiIO.new($stdout, File.open(path, 'a'))
98
+ file = File.new(path, 'a')
99
+ file.sync = true
100
+ io = MultiIO.new($stdout, file)
87
101
  @log = ::Logger.new(io)
88
102
  elsif logfile
89
- @log = ::Logger.new(prepare_log_path(logfile))
103
+ file = File.new(prepare_log_path(logfile), 'a')
104
+ file.sync = true
105
+ @log = ::Logger.new(file)
90
106
  else
91
107
  @log = ::Logger.new($stdout)
92
108
  end
109
+
110
+ close_replaced_log(previous_log)
111
+ @log
93
112
  end
94
113
 
95
114
  def prepare_log_path(path)
@@ -102,7 +121,7 @@ module Legion
102
121
  log.level
103
122
  end
104
123
 
105
- def log_level(level = 'info')
124
+ def log_level(level = 'debug')
106
125
  log.level = case level
107
126
  when 'trace', 'debug'
108
127
  ::Logger::DEBUG
@@ -120,29 +139,50 @@ module Legion
120
139
  if level.is_a? Integer
121
140
  level
122
141
  else
123
- 0
142
+ 1
124
143
  end
125
144
  end
126
- @log = log
127
145
  end
128
146
 
129
147
  def async?
130
148
  (@async == true && @async_writer&.alive?) || false
131
149
  end
132
150
 
151
+ # rubocop:disable Naming/PredicateMethod
133
152
  def start_async_writer(buffer_size: 10_000)
134
153
  require_relative 'async_writer'
135
- stop_async_writer if @async_writer&.alive?
154
+ return false if @async_writer&.alive? && stop_async_writer == false
155
+
136
156
  @async_writer = AsyncWriter.new(log, buffer_size: buffer_size)
137
157
  @async_writer.start
138
158
  @async = true
159
+ true
139
160
  end
140
161
 
141
162
  def stop_async_writer
142
163
  writer = @async_writer
143
- @async_writer = nil
164
+ stopped = writer&.stop
165
+ return false if stopped == false
166
+
167
+ close_replaced_log(writer.logger) if writer.respond_to?(:logger)
168
+ @async_writer = nil if @async_writer.equal?(writer)
144
169
  @async = false
145
- writer&.stop
170
+ true
171
+ end
172
+ # rubocop:enable Naming/PredicateMethod
173
+
174
+ private
175
+
176
+ def close_replaced_log(logger)
177
+ return unless logger
178
+ return if logger.equal?(@log)
179
+ return if @async_writer&.alive? && @async_writer.respond_to?(:logger) && @async_writer.logger.equal?(logger)
180
+
181
+ log_device = logger.instance_variable_get(:@logdev)
182
+ dev = log_device&.dev
183
+ return if dev.nil? || [$stdout, $stderr].include?(dev)
184
+
185
+ dev.close if dev.respond_to?(:close)
146
186
  end
147
187
  end
148
188
  end
@@ -11,6 +11,32 @@ module Legion
11
11
  MAX_PAYLOAD_BYTES = 8192
12
12
  MAX_TOTAL_BYTES = 65_536
13
13
  BACKTRACE_FALLBACK_FRAMES = 20
14
+ MIN_TRUNCATED_FIELD_BYTES = 256
15
+
16
+ CORE_EXCEPTION_FIELDS = %i[
17
+ timestamp
18
+ level
19
+ exception_class
20
+ message
21
+ caller_file
22
+ caller_line
23
+ caller_function
24
+ lex
25
+ component_type
26
+ gem_name
27
+ lex_version
28
+ handled
29
+ pid
30
+ thread
31
+ task_id
32
+ conversation_id
33
+ user
34
+ error_fingerprint
35
+ node
36
+ ].freeze
37
+
38
+ GEM_SPEC_CACHE_MUTEX = Mutex.new
39
+ private_constant :GEM_SPEC_CACHE_MUTEX
14
40
 
15
41
  class << self
16
42
  def build(level:, message:, lex: nil, lex_segments: nil, context: nil, category: nil, caller_offset: 2)
@@ -187,12 +213,18 @@ module Legion
187
213
  end
188
214
 
189
215
  def resolve_gem_spec(name)
190
- [name, "lex-#{name}", "legion-#{name}"].each do |candidate|
191
- return Gem::Specification.find_by_name(candidate)
216
+ cache = (@gem_spec_cache ||= {})
217
+ return cache[name] if cache.key?(name)
218
+
219
+ spec = nil
220
+ ["lex-#{name}", "legion-#{name}", name].each do |candidate|
221
+ spec = Gem::Specification.find_by_name(candidate)
222
+ break
192
223
  rescue Gem::MissingSpecError
193
224
  next
194
225
  end
195
- nil
226
+
227
+ GEM_SPEC_CACHE_MUTEX.synchronize { cache[name] = spec }
196
228
  end
197
229
 
198
230
  def strip_ansi(str)
@@ -254,9 +286,13 @@ module Legion
254
286
  end
255
287
 
256
288
  def legion_versions
257
- Gem::Specification
258
- .select { |s| s.name.start_with?('legion-', 'lex-') }
259
- .to_h { |s| [s.name, s.version.to_s] }
289
+ @legion_versions ||= Gem::Specification
290
+ .select { |s| s.name.start_with?('legion-', 'lex-') }
291
+ .to_h do |s|
292
+ [s.name,
293
+ s.version.to_s]
294
+ end
295
+ .freeze
260
296
  end
261
297
 
262
298
  def truncate_bytes(str, max)
@@ -297,6 +333,56 @@ module Legion
297
333
  return if safe_json_bytesize(event) <= MAX_TOTAL_BYTES
298
334
 
299
335
  event[:message] = truncate_bytes(event[:message].to_s, 1024)
336
+ trim_optional_fields!(event)
337
+ hard_cap_message!(event)
338
+ end
339
+
340
+ def trim_optional_fields!(event)
341
+ while safe_json_bytesize(event) > MAX_TOTAL_BYTES
342
+ key = largest_optional_field(event)
343
+ break unless key
344
+
345
+ reduced = reduce_field(event[key])
346
+ if reduced.nil?
347
+ event.delete(key)
348
+ else
349
+ event[key] = reduced
350
+ end
351
+ end
352
+ end
353
+
354
+ def largest_optional_field(event)
355
+ event.each_key
356
+ .reject { |key| CORE_EXCEPTION_FIELDS.include?(key) }
357
+ .max_by { |key| safe_json_bytesize(event[key]) }
358
+ end
359
+
360
+ def reduce_field(value)
361
+ case value
362
+ when String
363
+ return nil if value.bytesize <= MIN_TRUNCATED_FIELD_BYTES
364
+
365
+ truncate_bytes(value, [value.bytesize / 2, MIN_TRUNCATED_FIELD_BYTES].max)
366
+ when Array
367
+ return nil if value.size <= 1
368
+
369
+ value.first([value.size / 2, 1].max)
370
+ when Hash
371
+ return nil if value.size <= 1
372
+
373
+ value.first([value.size / 2, 1].max).to_h
374
+ end
375
+ end
376
+
377
+ def hard_cap_message!(event)
378
+ return if safe_json_bytesize(event) <= MAX_TOTAL_BYTES
379
+
380
+ event[:message] = truncate_bytes(event[:message].to_s, MIN_TRUNCATED_FIELD_BYTES)
381
+ return if safe_json_bytesize(event) <= MAX_TOTAL_BYTES
382
+
383
+ message_overhead = safe_json_bytesize(event.merge(message: ''))
384
+ available = MAX_TOTAL_BYTES - message_overhead
385
+ event[:message] = truncate_bytes(event[:message].to_s, [available, 0].max)
300
386
  end
301
387
  end
302
388
  end