legion-logging 1.4.3 → 1.5.1

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: 707d2460ca4e016ca62cc8ef83bf2d646819439ed67ef5549a164518ba2d3bf5
4
- data.tar.gz: a013c59d975f901f25b153cbbfe4ed288dbedb262098627aed6d71baeb70031a
3
+ metadata.gz: 46129a3dbab733493155c3883562e0b8f867959f9749b0ead2c55096eaaa91f0
4
+ data.tar.gz: e5de12a36bdaa85ceca97cf8936312257ad607393b56fa3663ac0f827d850315
5
5
  SHA512:
6
- metadata.gz: f5f3d87c609bca88c501e32d241fabc796e48b8aae8912fed39ac3615e0565f2a7d4672b66f12b0fdf606c6d3e0b40abb2b04b9b38d554edca4e9fa4b063a40a
7
- data.tar.gz: 741697e72078cb1cbede684a8729d8a9a0b79c25a8beb4ed465aa53ae89711e11f482b9e93f434f51dae0778dfdf3bf49855282e80a7df17f98054d980d3e037
6
+ metadata.gz: cbedead38cab56af14b780484b110f6f9a8bb6fbc28934478f203d02dea738208fe9fbc63e3c7bdb8d8cc90a33b3328ba39e5059cff85a0ab8be7e83a7068c7f
7
+ data.tar.gz: 337d49c5e424fc9cf487cec98295e4fd80a71da2882a5892c2b478d896cf05e52f6f10632719737df4072abbc555193c1475ae7b01d0e9f51d88ac5df3cc62fc
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Legion::Logging Changelog
2
2
 
3
+ ## [1.5.1] - 2026-04-08
4
+
5
+ ### Added
6
+ - `Legion::Logging::MethodTracer` module: opt-in TracePoint-based method call tracing with indent-aware call/return output and parameter formatting
7
+ - `Helper.included` / `Helper.extended` hooks auto-attach `MethodTracer` when `MethodTracer::ENABLED` is true
8
+ - `log_exception` now formats backtrace (up to 10 frames + overflow count) inline in the stdout/file log line
9
+ - `Legion::Logging.current_settings` and `.configuration_generation` so helper mixins can refresh memoized tagged loggers after runtime reconfiguration
10
+ - 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`
11
+ - `Methods#emit_tagged` / `TaggedLogger#dispatch` path so component-level loggers can emit with their own level while preserving tagged context
12
+ - Fallback exception event construction in `Helper#handle_exception` when structured exception support is unavailable
13
+
14
+ ### Changed
15
+ - `setup` and `Builder#log_level` now default to `debug`
16
+ - Default helper/tagged logger behavior enables trace and extended metadata
17
+ - `Helper#log` rebuilds memoized `TaggedLogger` instances when logging configuration changes
18
+ - Runtime logger settings take precedence over loaded global settings for helper-mixed components
19
+
20
+ ### Fixed
21
+ - `setup(async: true)` now tolerates boolean `logging.async` settings without probing for `buffer_size`
22
+ - Exception stdout/file output now falls back safely when singleton logger helpers are unavailable
23
+ - Structured exception publishing is skipped when the exception writer/EventBuilder path is unavailable
24
+ - `TaggedLogger#unknown` falls back to `debug` output when `Legion::Logging.unknown` is unavailable
25
+
3
26
  ## [1.4.3] - 2026-04-01
4
27
 
5
28
  ### Added
@@ -176,4 +199,4 @@
176
199
  - `format_for_elk` produces ELK-compatible event hashes
177
200
 
178
201
  ## v1.2.0
179
- Moving from BitBucket to GitHub. All git history is reset from this point on
202
+ Moving from BitBucket to GitHub. All git history is reset from this point on
data/CLAUDE.md CHANGED
@@ -8,40 +8,50 @@
8
8
  Ruby logging class for the LegionIO framework. Provides colorized console output via Rainbow, structured JSON logging (`format: :json`), and a consistent logging interface across all Legion gems and extensions.
9
9
 
10
10
  **GitHub**: https://github.com/LegionIO/legion-logging
11
- **Version**: 1.3.2
11
+ **Version**: 1.5.0
12
12
  **License**: Apache-2.0
13
13
 
14
14
  ## Architecture
15
15
 
16
16
  ```
17
17
  Legion::Logging (singleton module)
18
- ├── Methods # Log level methods: debug, info, warn, error, fatal, unknown
19
- ├── Builder # Output destination (stdout/file), log level, formatter, async: keyword
20
- ├── AsyncWriter # Non-blocking SizedQueue-backed writer thread; fatal calls bypass queue
21
- ├── Hooks # Callback registry for fatal/error/warn events (on_fatal, on_error, on_warn)
22
- ├── EventBuilder # Structured event payload builder (caller, exception, lex, gem metadata)
23
- ├── Helper # Injectable log mixin for LEX extensions (derives logger tags from segments/class)
24
- ├── Logger # Core logger configuration and setup
25
- ├── MultiIO # Write to multiple destinations simultaneously
26
- ├── SIEMExporter # PHI-redacting SIEM export (Splunk HEC, ELK/OpenSearch)
27
- ├── Shipper # Buffered log event forwarding (file/http transports)
28
- ├── Redactor # PII/PHI pattern redaction
29
- └── Version # VERSION constant
18
+ ├── Methods # Log level methods: debug, info, warn, error, fatal, unknown; log_exception helper
19
+ ├── Builder # Output destination (stdout/file), log level, formatter, async: keyword
20
+ ├── AsyncWriter # Non-blocking SizedQueue-backed writer thread; fatal calls bypass queue
21
+ ├── Hooks # Callback registry for fatal/error/warn events (on_fatal, on_error, on_warn)
22
+ ├── EventBuilder # Structured event payload builder (caller, exception, lex, gem metadata); fingerprint for dedup
23
+ ├── Helper # Injectable log mixin for LEX extensions (derives logger tags from segments/class)
24
+ ├── Logger # Core logger configuration and setup
25
+ ├── MultiIO # Write to multiple destinations simultaneously
26
+ ├── TaggedLogger # Logger wrapper that prepends structured tags to each message
27
+ ├── CategoryRegistry # Registry of named log categories with description and expected_fields
28
+ ├── MethodTracer # Tracing module for instrumenting method calls (call/return, formatted args)
29
+ ├── SIEMExporter # PHI-redacting SIEM export (Splunk HEC, ELK/OpenSearch)
30
+ ├── Shipper # Buffered log event forwarding; sub-transports: FileTransport, HttpTransport
31
+ ├── Redactor # PII/PHI + secret pattern redaction; opt-in via logging.redaction.enabled
32
+ └── Version # VERSION constant
33
+
34
+ # Module-level writers (pluggable lambda slots replacing old Hooks for AMQP forwarding)
35
+ Legion::Logging.log_writer # -> lambda(->(event, routing_key:) {})
36
+ Legion::Logging.exception_writer # -> lambda(->(event, routing_key:, headers:, properties:) {})
30
37
  ```
31
38
 
32
39
  ### Key Design Patterns
33
40
 
34
- - **Singleton Module**: `Legion::Logging` uses `class << self` - called directly: `Legion::Logging.info("msg")`
35
- - **Rainbow Colorization**: Console output uses Rainbow gem for colored terminal output
36
- - **Setup Method**: `Legion::Logging.setup(log_file:, level:, async: true)` configures output destination, level, and async mode
37
- - **Async by Default**: `setup` enables async logging — calls return immediately. Fatal calls always bypass the queue. `stop_async_writer` flushes and stops on shutdown. Buffer size configurable via `Legion::Settings.dig(:logging, :async, :buffer_size)` (default 10,000). Back-pressure: callers block when buffer is full.
38
- - **Structured JSON**: `format: :json` in settings outputs machine-parseable JSON log lines
41
+ - **Singleton Module**: `Legion::Logging` uses `class << self` called directly: `Legion::Logging.info("msg")`
42
+ - **Rainbow Colorization**: Console output uses Rainbow gem for colored terminal output. Color auto-disabled in JSON format and when writing to a log file.
43
+ - **Setup Method**: `Legion::Logging.setup(level:, format:, async:, **options)` configures output, level, format, and async mode. Increments `configuration_generation` on each call.
44
+ - **Async by Default**: `setup` enables async logging — calls return immediately. Fatal calls always bypass the queue. `stop_async_writer` flushes and stops on shutdown. Buffer size configurable via `Legion::Settings[:logging][:async][:buffer_size]` (default 10,000). Back-pressure: callers block when buffer is full.
45
+ - **Structured JSON**: `format: :json` in settings outputs machine-parseable JSON log lines (disables color)
39
46
  - **Shared Interface**: Same method signature (`info`, `warn`, `error`, etc.) across all Legion components
40
47
  - **MultiIO**: Splits writes to stdout and a log file simultaneously (used by Builder when `log_file` is set)
41
48
  - **SIEMExporter**: PHI redaction (SSN, phone, MRN, DOB patterns), `export_to_splunk` (HEC), `format_for_elk`
42
- - **Hook Callbacks**: `on_fatal`, `on_error`, `on_warn` register procs called after each log at those levels. Hooks are gated by `enable_hooks!`/`disable_hooks!`. Hook failures are silently rescued — never impact the logger. Hooks fire on the async writer thread; event context captured on caller thread.
43
- - **EventBuilder**: Builds structured event hashes from log context (caller location, exception info, lex identity, gem metadata). All from in-memory data, zero IO.
49
+ - **Hook Callbacks**: `on_fatal`, `on_error`, `on_warn` register procs called after each log at those levels. Hooks are gated by `enable_hooks!`/`disable_hooks!`. Hook failures are silently rescued. Hooks fire on the async writer thread; event context captured on caller thread.
50
+ - **EventBuilder**: Builds structured event hashes from log context (caller location, exception info, lex identity, gem metadata). All from in-memory data, zero IO. `fingerprint` produces MD5 for dedup in log aggregation.
44
51
  - **Helper mixin**: `Legion::Logging::Helper` is injectable into LEX extensions. Derives logger tags from `segments`, `lex_filename`, or class name. Passes through `settings[:logger]` config when available.
52
+ - **Writer Lambdas**: `log_writer` and `exception_writer` are module-level lambda slots for forwarding to external systems (AMQP, etc.). Default implementations are no-ops. Set via `Legion::Logging.log_writer = lambda`.
53
+ - **CategoryRegistry**: Named log categories with description and expected_fields. Register via `Legion::Logging.register_category`. Used for structured log validation.
54
+ - **Redactor**: Opt-in PII/PHI redaction (`logging.redaction.enabled: true`). Guards against Settings recursive init via `@loader` ivar check. Patterns: SSN, phone, MRN, DOB, Vault tokens, JWTs, bearer tokens, lease IDs.
45
55
 
46
56
  ## Dependencies
47
57
 
@@ -62,7 +72,15 @@ Legion::Logging (singleton module)
62
72
  | `lib/legion/logging/multi_io.rb` | Multi-output IO (write to multiple destinations simultaneously) |
63
73
  | `lib/legion/logging/siem_exporter.rb` | PHI-redacting SIEM export helpers (Splunk HEC, ELK format) |
64
74
  | `lib/legion/logging/hooks.rb` | Callback registry (fatal/error/warn hook arrays, enable/disable/clear) |
65
- | `lib/legion/logging/event_builder.rb` | Structured event payload builder |
75
+ | `lib/legion/logging/event_builder.rb` | Structured event payload builder; `fingerprint` for MD5 dedup |
76
+ | `lib/legion/logging/tagged_logger.rb` | Logger wrapper that prepends structured tags to each message |
77
+ | `lib/legion/logging/category_registry.rb` | Named log category registration and lookup |
78
+ | `lib/legion/logging/method_tracer.rb` | Method call tracing instrumentation (call/return, formatted args) |
79
+ | `lib/legion/logging/shipper.rb` | Buffered log event forwarding to external systems |
80
+ | `lib/legion/logging/shipper/file_transport.rb` | File-based log shipper transport |
81
+ | `lib/legion/logging/shipper/http_transport.rb` | HTTP-based log shipper transport |
82
+ | `lib/legion/logging/siem_exporter.rb` | PHI-redacting SIEM export (Splunk HEC, ELK format) |
83
+ | `lib/legion/logging/redactor.rb` | PII/PHI + secret pattern redaction (opt-in) |
66
84
  | `lib/legion/logging/version.rb` | VERSION constant |
67
85
 
68
86
  ## Role in LegionIO
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Logging module for the [LegionIO](https://github.com/LegionIO/LegionIO) framework. Provides colorized console output via Rainbow, structured JSON logging, multi-output IO, and a consistent logging interface across all Legion gems and extensions.
4
4
 
5
- **Version**: 1.4.1
5
+ **Version**: 1.5.0
6
6
 
7
7
  ## Installation
8
8
 
@@ -5,40 +5,58 @@ require_relative 'methods'
5
5
  module Legion
6
6
  module Logging
7
7
  class AsyncWriter
8
- LogEntry = ::Data.define(:level, :message, :writer_context, :segments, :method_ctx)
8
+ LogEntry = ::Data.define(:level, :message, :writer_context, :segments, :method_ctx, :caller_trace)
9
9
  SHUTDOWN = :shutdown
10
10
 
11
+ attr_reader :logger
12
+
11
13
  def initialize(logger, buffer_size: 10_000)
12
14
  @logger = logger
15
+ @buffer_size = buffer_size
13
16
  @queue = SizedQueue.new(buffer_size)
14
17
  @thread = nil
18
+ @state_mutex = Mutex.new
19
+ @accepting = true
15
20
  end
16
21
 
17
22
  def start
18
23
  return if @thread&.alive?
19
24
 
25
+ @state_mutex.synchronize { @accepting = true }
20
26
  drain
27
+ @queue = SizedQueue.new(@buffer_size)
21
28
  @thread = Thread.new { consume }
22
29
  @thread.name = 'legion-log-writer'
23
30
  @thread.abort_on_exception = false
24
31
  end
25
32
 
33
+ # rubocop:disable Naming/PredicateMethod
26
34
  def stop(timeout: 2)
27
- return unless @thread&.alive?
35
+ @state_mutex.synchronize { @accepting = false }
28
36
 
29
- begin
30
- @queue.push(SHUTDOWN, true)
31
- rescue ThreadError
32
- # Queue full — fall through to join/kill + drain
37
+ unless @thread&.alive?
38
+ drain
39
+ @thread = nil
40
+ return true
33
41
  end
34
- @thread.join(timeout)
35
- @thread.kill if @thread&.alive?
36
- drain
42
+
43
+ @queue.close
44
+ timeout ? @thread.join(timeout) : @thread.join
45
+ return false if @thread&.alive?
46
+
47
+ @thread = nil
48
+ true
37
49
  end
38
50
 
39
51
  def push(entry)
52
+ return false unless accepting?
53
+
40
54
  @queue.push(entry)
55
+ true
56
+ rescue ClosedQueueError
57
+ false
41
58
  end
59
+ # rubocop:enable Naming/PredicateMethod
42
60
 
43
61
  def alive?
44
62
  @thread&.alive? || false
@@ -49,7 +67,7 @@ module Legion
49
67
  def consume
50
68
  loop do
51
69
  entry = @queue.pop
52
- break if entry == SHUTDOWN
70
+ break if entry.nil? || entry == SHUTDOWN
53
71
 
54
72
  write_entry(entry)
55
73
  end
@@ -58,8 +76,10 @@ module Legion
58
76
  def write_entry(entry)
59
77
  prev_segments = Thread.current[:legion_log_segments]
60
78
  prev_method_ctx = Thread.current[:legion_log_method]
79
+ prev_caller = Thread.current[:legion_log_caller]
61
80
  Thread.current[:legion_log_segments] = entry.segments
62
81
  Thread.current[:legion_log_method] = entry.method_ctx
82
+ Thread.current[:legion_log_caller] = entry.caller_trace
63
83
  @logger.send(entry.level, entry.message)
64
84
  fire_writer(entry) if entry.writer_context
65
85
  rescue StandardError => e
@@ -67,6 +87,7 @@ module Legion
67
87
  ensure
68
88
  Thread.current[:legion_log_segments] = prev_segments
69
89
  Thread.current[:legion_log_method] = prev_method_ctx
90
+ Thread.current[:legion_log_caller] = prev_caller
70
91
  end
71
92
 
72
93
  def drain
@@ -78,6 +99,10 @@ module Legion
78
99
  nil
79
100
  end
80
101
 
102
+ def accepting?
103
+ @state_mutex.synchronize { @accepting }
104
+ end
105
+
81
106
  def fire_writer(entry)
82
107
  ctx = entry.writer_context
83
108
  event = ctx[:event]
@@ -41,7 +41,7 @@ module Legion
41
41
  def text_format(include_pid: false, **options)
42
42
  log.formatter = proc do |severity, datetime, _progname, msg|
43
43
  lex_name = resolve_lex_tag(options)
44
- runner_trace = build_runner_trace if lex_name
44
+ runner_trace = Thread.current[:legion_log_caller] || build_runner_trace if lex_name
45
45
 
46
46
  string = "[#{datetime}]"
47
47
  string.concat("[#{::Process.pid}]") if include_pid
@@ -69,8 +69,7 @@ module Legion
69
69
  tag
70
70
  end
71
71
 
72
- def build_runner_trace
73
- loc = caller_locations(6, 1)&.first
72
+ def build_runner_trace(loc = caller_locations(6, 1)&.first)
74
73
  return unless loc
75
74
 
76
75
  path = loc.to_s.split('/').last(2)
@@ -91,16 +90,25 @@ module Legion
91
90
  end
92
91
 
93
92
  def set_log(logfile: nil, log_stdout: nil, **)
93
+ previous_log = @log
94
+
94
95
  if logfile && log_stdout != false
95
96
  path = prepare_log_path(logfile)
96
97
  require_relative 'multi_io'
97
- io = MultiIO.new($stdout, File.open(path, 'a'))
98
+ file = File.new(path, 'a')
99
+ file.sync = true
100
+ io = MultiIO.new($stdout, file)
98
101
  @log = ::Logger.new(io)
99
102
  elsif logfile
100
- @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)
101
106
  else
102
107
  @log = ::Logger.new($stdout)
103
108
  end
109
+
110
+ close_replaced_log(previous_log)
111
+ @log
104
112
  end
105
113
 
106
114
  def prepare_log_path(path)
@@ -113,7 +121,7 @@ module Legion
113
121
  log.level
114
122
  end
115
123
 
116
- def log_level(level = 'info')
124
+ def log_level(level = 'debug')
117
125
  log.level = case level
118
126
  when 'trace', 'debug'
119
127
  ::Logger::DEBUG
@@ -140,19 +148,41 @@ module Legion
140
148
  (@async == true && @async_writer&.alive?) || false
141
149
  end
142
150
 
151
+ # rubocop:disable Naming/PredicateMethod
143
152
  def start_async_writer(buffer_size: 10_000)
144
153
  require_relative 'async_writer'
145
- stop_async_writer if @async_writer&.alive?
154
+ return false if @async_writer&.alive? && stop_async_writer == false
155
+
146
156
  @async_writer = AsyncWriter.new(log, buffer_size: buffer_size)
147
157
  @async_writer.start
148
158
  @async = true
159
+ true
149
160
  end
150
161
 
151
162
  def stop_async_writer
152
163
  writer = @async_writer
153
- @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)
154
169
  @async = false
155
- 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)
156
186
  end
157
187
  end
158
188
  end
@@ -11,6 +11,29 @@ 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
14
37
 
15
38
  GEM_SPEC_CACHE_MUTEX = Mutex.new
16
39
  private_constant :GEM_SPEC_CACHE_MUTEX
@@ -310,6 +333,56 @@ module Legion
310
333
  return if safe_json_bytesize(event) <= MAX_TOTAL_BYTES
311
334
 
312
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)
313
386
  end
314
387
  end
315
388
  end