legion-logging 1.4.2 → 1.4.3

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: 707d2460ca4e016ca62cc8ef83bf2d646819439ed67ef5549a164518ba2d3bf5
4
+ data.tar.gz: a013c59d975f901f25b153cbbfe4ed288dbedb262098627aed6d71baeb70031a
5
5
  SHA512:
6
- metadata.gz: 5e0b2171b6ca1b1acbb3ad0f40cbd23a77bf5824a3b2f37a5c9b3484f7edf2cf9cf1a8dc23a9f0ed0acd426e139fb0d7ce13ac0a3ae2a77b3113dd0569cc6d53
7
- data.tar.gz: e4e599809e26c6b7a0f9c1a81f05e390175cad0568b6b99f26888b675590972ff9148e27bc53f36461e1d6f09f83fcf7a818b01bd793098c5809910affabb0a2
6
+ metadata.gz: f5f3d87c609bca88c501e32d241fabc796e48b8aae8912fed39ac3615e0565f2a7d4672b66f12b0fdf606c6d3e0b40abb2b04b9b38d554edca4e9fa4b063a40a
7
+ data.tar.gz: 741697e72078cb1cbede684a8729d8a9a0b79c25a8beb4ed465aa53ae89711e11f482b9e93f434f51dae0778dfdf3bf49855282e80a7df17f98054d980d3e037
@@ -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,46 @@
1
1
  # Legion::Logging Changelog
2
2
 
3
+ ## [1.4.3] - 2026-04-01
4
+
5
+ ### Added
6
+ - `TaggedLogger` lightweight proxy: delegates to singleton for shared stdout/file/async output
7
+ - `Helper#derive_log_segments` with class-level `SEGMENT_CACHE` — auto-derives `[llm][router]` from namespace
8
+ - `Helper#with_log_context` for block-scoped method name thread-locals (`{dispatch}` in log output)
9
+ - `Helper#handle_exception` with direct EventBuilder calls, per-line Rainbow coloring, structured AMQP publish
10
+ - `Helper.current_log_method`, `.current_log_segments`, `.current_context` thread-local readers
11
+ - `Legion::Logging::Settings` module with logger defaults
12
+ - `COMPONENT_MAP` with 18 component types (runners, actors, hooks, absorbers, tools, adapters, middleware, etc.)
13
+ - `EXCEPTION_COLORS` map for per-level exception coloring (bold first line, faint backtrace)
14
+ - `Thread.current[:legion_context]` support for wire protocol fields (task_id, conversation_id, chain_id)
15
+ - Redaction applied to exception stdout output when redaction is enabled
16
+ - Method context (`legion_log_method`) included in structured exception events
17
+ - `AsyncWriter::LogEntry` carries `segments` and `method_ctx` for thread-local propagation to writer thread
18
+ - `Builder#resolve_lex_tag` and `#build_runner_trace` extracted from `text_format`
19
+
20
+ ### Changed
21
+ - `Helper#log` returns `TaggedLogger` instead of `Logger.new` (shared output, one async thread)
22
+ - `Helper#log_name`/`gem_name`/`gem_spec` replace `log_lex_name`/`lex_gem_name`/`gem_spec_for_lex` with multi-prefix resolution
23
+ - `gem_name` and `gem_spec` memoized per instance
24
+ - `COMPONENT_REGEX` in Methods expanded from 5 to 18 component types
25
+ - `build_writer_context` reads `Thread.current[:legion_log_segments]` instead of stale `@lex_segments` ivar
26
+ - `Builder#output` delegates to `set_log` (was parallel implementation)
27
+ - `Builder#caller_locations` allocates single frame instead of full stack
28
+ - Unknown log level strings default to INFO instead of DEBUG
29
+ - `EventBuilder#legion_versions` and `#resolve_gem_spec` memoized
30
+ - `EXCEPTION_PRIORITY` extracted to frozen constant in Methods (was inline hash allocation per call)
31
+ - `text_format` and `json_format` in Builder read thread-locals for segments and method context
32
+
33
+ ### Fixed
34
+ - `fire_log_writer` rescue no longer references undefined `routing_key` variable
35
+ - Splunk auth header in `http_transport` — `apply_auth` receives actual URI instead of always evaluating against `URI('/')`
36
+ - `TaggedLogger#initialize` accepts `**_opts` splat for unexpected settings keys
37
+ - `TaggedLogger#trace` guards nil `size` to prevent `TypeError` on `caller_locations`
38
+
39
+ ### Removed
40
+ - `TaggedLogger#runner_exception` (runner business logic, not logging concern)
41
+ - `TaggedLogger#log_exception` (use `Helper#handle_exception` instead)
42
+ - `Builder#log_level` no-op `@log = log` self-assignment
43
+
3
44
  ## [1.4.2] - 2026-03-28
4
45
 
5
46
  ### Added
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,9 +1,11 @@
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)
7
9
  SHUTDOWN = :shutdown
8
10
 
9
11
  def initialize(logger, buffer_size: 10_000)
@@ -54,10 +56,17 @@ module Legion
54
56
  end
55
57
 
56
58
  def write_entry(entry)
59
+ prev_segments = Thread.current[:legion_log_segments]
60
+ prev_method_ctx = Thread.current[:legion_log_method]
61
+ Thread.current[:legion_log_segments] = entry.segments
62
+ Thread.current[:legion_log_method] = entry.method_ctx
57
63
  @logger.send(entry.level, entry.message)
58
64
  fire_writer(entry) if entry.writer_context
59
65
  rescue StandardError => e
60
66
  warn("legion-log-writer error: #{e.message} (#{e.backtrace&.first})")
67
+ ensure
68
+ Thread.current[:legion_log_segments] = prev_segments
69
+ Thread.current[:legion_log_method] = prev_method_ctx
61
70
  end
62
71
 
63
72
  def drain
@@ -74,9 +83,10 @@ module Legion
74
83
  event = ctx[:event]
75
84
  level = ctx[:level]
76
85
  lex_name = event[:lex] || 'core'
77
- component = event.dig(:caller, :file).to_s[%r{/(runners|actors|transport|helpers|builders)/}, 1] || 'unknown'
86
+ component = event.dig(:caller, :file).to_s[Legion::Logging::Methods::COMPONENT_REGEX, 1] || 'unknown'
78
87
  routing_key = "legion.logging.log.#{level}.#{lex_name}.#{component}"
79
88
  Legion::Logging.log_writer.call(event, routing_key: routing_key)
89
+ Legion::Logging::Hooks.fire(level, entry.message, event) if defined?(Legion::Logging::Hooks)
80
90
  rescue StandardError => e
81
91
  warn("legion-log-writer writer error: #{e.message}")
82
92
  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 = 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,36 @@ 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
73
+ loc = caller_locations(6, 1)&.first
74
+ return unless loc
75
+
76
+ path = loc.to_s.split('/').last(2)
77
+ {
78
+ type: path[0],
79
+ file: File.basename(loc.path, '.*'),
80
+ function: loc.base_label,
81
+ line_number: loc.lineno
82
+ }
83
+ end
84
+
65
85
  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
86
+ set_log(logfile: options[:log_file], log_stdout: options[:log_stdout])
76
87
  end
77
88
 
78
89
  def log
@@ -120,10 +131,9 @@ module Legion
120
131
  if level.is_a? Integer
121
132
  level
122
133
  else
123
- 0
134
+ 1
124
135
  end
125
136
  end
126
- @log = log
127
137
  end
128
138
 
129
139
  def async?
@@ -12,6 +12,9 @@ module Legion
12
12
  MAX_TOTAL_BYTES = 65_536
13
13
  BACKTRACE_FALLBACK_FRAMES = 20
14
14
 
15
+ GEM_SPEC_CACHE_MUTEX = Mutex.new
16
+ private_constant :GEM_SPEC_CACHE_MUTEX
17
+
15
18
  class << self
16
19
  def build(level:, message:, lex: nil, lex_segments: nil, context: nil, category: nil, caller_offset: 2)
17
20
  event = base_fields(level, message)
@@ -187,12 +190,18 @@ module Legion
187
190
  end
188
191
 
189
192
  def resolve_gem_spec(name)
190
- [name, "lex-#{name}", "legion-#{name}"].each do |candidate|
191
- return Gem::Specification.find_by_name(candidate)
193
+ cache = (@gem_spec_cache ||= {})
194
+ return cache[name] if cache.key?(name)
195
+
196
+ spec = nil
197
+ ["lex-#{name}", "legion-#{name}", name].each do |candidate|
198
+ spec = Gem::Specification.find_by_name(candidate)
199
+ break
192
200
  rescue Gem::MissingSpecError
193
201
  next
194
202
  end
195
- nil
203
+
204
+ GEM_SPEC_CACHE_MUTEX.synchronize { cache[name] = spec }
196
205
  end
197
206
 
198
207
  def strip_ansi(str)
@@ -254,9 +263,13 @@ module Legion
254
263
  end
255
264
 
256
265
  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] }
266
+ @legion_versions ||= Gem::Specification
267
+ .select { |s| s.name.start_with?('legion-', 'lex-') }
268
+ .to_h do |s|
269
+ [s.name,
270
+ s.version.to_s]
271
+ end
272
+ .freeze
260
273
  end
261
274
 
262
275
  def truncate_bytes(str, max)
@@ -1,47 +1,296 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+ require_relative 'tagged_logger'
5
+
3
6
  module Legion
4
7
  module Logging
5
8
  module Helper
9
+ SEGMENT_CACHE = {} # rubocop:disable Style/MutableConstant
10
+ SEGMENT_CACHE_MUTEX = Mutex.new
11
+ private_constant :SEGMENT_CACHE_MUTEX
12
+ COMPONENT_MAP = {
13
+ 'runners' => :runner,
14
+ 'actors' => :actor,
15
+ 'actor' => :actor,
16
+ 'helpers' => :helper,
17
+ 'hooks' => :hook,
18
+ 'absorbers' => :absorber,
19
+ 'matchers' => :matcher,
20
+ 'transport' => :transport,
21
+ 'exchanges' => :exchange,
22
+ 'queues' => :queue,
23
+ 'messages' => :message,
24
+ 'data' => :data,
25
+ 'builders' => :builder,
26
+ 'tools' => :tool,
27
+ 'adapters' => :adapter,
28
+ 'engines' => :engine,
29
+ 'formatters' => :formatter,
30
+ 'parsers' => :parser,
31
+ 'middleware' => :middleware
32
+ }.freeze
33
+
34
+ EXCEPTION_BACKTRACE_LIMIT = 10
35
+ EXCEPTION_PRIORITY = { warn: 0, error: 5, fatal: 9 }.freeze
36
+ EXCEPTION_COLORS = {
37
+ fatal: :darkred,
38
+ error: :red,
39
+ warn: :yellow,
40
+ debug: :aqua,
41
+ unknown: :magenta
42
+ }.freeze
43
+
44
+ def self.current_log_method
45
+ Thread.current[:legion_log_method]
46
+ end
47
+
48
+ def self.current_log_segments
49
+ Thread.current[:legion_log_segments]
50
+ end
51
+
52
+ def self.current_context
53
+ Thread.current[:legion_context]
54
+ end
55
+
6
56
  def log
7
- return @log unless @log.nil?
8
-
9
- logger_hash = if respond_to?(:segments)
10
- { lex_segments: Array(segments) }
11
- else
12
- { lex: derive_log_tag }
13
- end
14
-
15
- if respond_to?(:settings) && settings.is_a?(Hash) && settings.key?(:logger)
16
- ls = settings[:logger]
17
- logger_hash[:level] = ls[:level] if ls.key?(:level)
18
- logger_hash[:log_file] = ls[:log_file] if ls.key?(:log_file)
19
- logger_hash[:trace] = ls[:trace] if ls.key?(:trace)
20
- logger_hash[:extended] = ls[:extended] if ls.key?(:extended)
21
- end
57
+ @log ||= Legion::Logging::TaggedLogger.new(segments: derive_log_segments, **resolve_logger_settings)
58
+ end
22
59
 
23
- @log = Legion::Logging::Logger.new(**logger_hash)
60
+ def with_log_context(method_name)
61
+ prev = Thread.current[:legion_log_method]
62
+ Thread.current[:legion_log_method] = method_name.to_s
63
+ yield
64
+ ensure
65
+ Thread.current[:legion_log_method] = prev
66
+ end
67
+
68
+ def handle_exception(exception, task_id: nil, level: :error, handled: true, **opts)
69
+ segments = derive_log_segments
70
+ spec = gem_spec
71
+ ctx = Thread.current[:legion_context] || {}
72
+
73
+ event = Legion::Logging::EventBuilder.build_exception(
74
+ exception: exception,
75
+ level: level,
76
+ lex: log_name,
77
+ component_type: derive_component_type,
78
+ gem_name: gem_name,
79
+ lex_version: spec&.version&.to_s,
80
+ gem_path: spec&.full_gem_path,
81
+ source_code_uri: spec&.metadata&.[]('source_code_uri'),
82
+ handled: handled,
83
+ task_id: task_id || ctx[:task_id],
84
+ payload_summary: opts.empty? ? nil : opts,
85
+ caller_offset: 3
86
+ )
87
+
88
+ event[:conversation_id] ||= ctx[:conversation_id]
89
+ event[:chain_id] ||= ctx[:chain_id]
90
+ event[:log_segments] = segments
91
+ event[:method] = Thread.current[:legion_log_method]
92
+
93
+ event = Legion::Logging::Redactor.redact(event) if defined?(Legion::Logging::Redactor)
94
+
95
+ write_exception_to_log(exception, event, level, segments)
96
+ publish_exception(event, level)
24
97
  end
25
98
 
26
99
  private
27
100
 
28
- def derive_log_tag
101
+ def derive_log_segments
102
+ key = respond_to?(:ancestors) ? ancestors.first : self.class
103
+ return SEGMENT_CACHE[key] if SEGMENT_CACHE.key?(key)
104
+
105
+ segments = begin
106
+ parts = key.to_s.split('::')
107
+ parts.shift if parts.first == 'Legion'
108
+ parts.shift if parts.first == 'Extensions'
109
+ parts.map! do |p|
110
+ p.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
111
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
112
+ .downcase
113
+ end
114
+ parts.freeze
115
+ end
116
+
117
+ SEGMENT_CACHE_MUTEX.synchronize { SEGMENT_CACHE[key] ||= segments }
118
+ end
119
+
120
+ def derive_component_type
121
+ segments = derive_log_segments
122
+ match = segments.find { |s| COMPONENT_MAP.key?(s) }
123
+ return COMPONENT_MAP[match] if match
124
+
125
+ segments.last&.to_sym || :unknown
126
+ end
127
+
128
+ def log_name
29
129
  if respond_to?(:lex_filename)
30
130
  fname = lex_filename
31
131
  return fname.is_a?(Array) ? fname.first : fname
32
132
  end
33
133
 
34
- name = respond_to?(:ancestors) ? ancestors.first.to_s : self.class.to_s
35
- parts = name.split('::')
36
- ext_idx = parts.index('Extensions')
37
- target = if ext_idx && parts[ext_idx + 1]
38
- parts[ext_idx + 1]
39
- else
40
- parts.last
41
- end
42
- target.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
43
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
44
- .downcase
134
+ derive_log_segments.first
135
+ rescue StandardError
136
+ nil
137
+ end
138
+
139
+ def gem_name
140
+ @gem_name_resolved ? @gem_name_value : resolve_gem_name
141
+ end
142
+
143
+ def gem_spec
144
+ @gem_spec_resolved ? @gem_spec_value : resolve_gem_spec
145
+ end
146
+
147
+ def resolve_gem_name
148
+ @gem_name_resolved = true
149
+ base = log_name
150
+ @gem_name_value = if base
151
+ %W[lex-#{base} legion-#{base} #{base}].find do |candidate|
152
+ Gem::Specification.find_by_name(candidate)
153
+ candidate
154
+ rescue Gem::MissingSpecError
155
+ nil
156
+ end
157
+ end
158
+ rescue StandardError
159
+ @gem_name_value = nil
160
+ end
161
+
162
+ def resolve_gem_spec
163
+ @gem_spec_resolved = true
164
+ name = gem_name
165
+ @gem_spec_value = name ? Gem::Specification.find_by_name(name) : nil
166
+ rescue Gem::MissingSpecError
167
+ @gem_spec_value = nil
168
+ end
169
+
170
+ def settings
171
+ { logger: logger_settings }
172
+ end
173
+
174
+ def logger_settings
175
+ return Legion::Settings[:logging] if defined?(Legion::Settings) && Legion::Settings[:logging].is_a?(Hash)
176
+
177
+ Legion::Logging::Settings.default
178
+ end
179
+
180
+ def resolve_logger_settings
181
+ s = settings
182
+ return Legion::Logging::Settings.default unless s.is_a?(Hash)
183
+
184
+ raw = s[:logger]
185
+ raw.is_a?(Hash) ? raw : Legion::Logging::Settings.default
186
+ end
187
+
188
+ # -- Exception stdout/file output --
189
+
190
+ def write_exception_to_log(exception, event, level, segments)
191
+ prev_segs = Thread.current[:legion_log_segments]
192
+ Thread.current[:legion_log_segments] = segments
193
+
194
+ message = format_exception_output(exception, event)
195
+ message = Legion::Logging::Redactor.redact_string(message) if defined?(Legion::Logging::Redactor) && redaction_enabled?
196
+ message = colorize_exception(message, level) if Legion::Logging.color
197
+
198
+ Legion::Logging.log.public_send(level, message)
199
+ ensure
200
+ Thread.current[:legion_log_segments] = prev_segs
201
+ end
202
+
203
+ def format_exception_output(exception, event)
204
+ lines = ["#{exception.class}: #{exception.message}"]
205
+
206
+ context_line = build_context_line(event)
207
+ lines << " #{context_line}" unless context_line.empty?
208
+
209
+ bt = exception.backtrace
210
+ if bt&.any?
211
+ bt.first(EXCEPTION_BACKTRACE_LIMIT).each { |frame| lines << " #{frame}" }
212
+ remaining = bt.length - EXCEPTION_BACKTRACE_LIMIT
213
+ lines << " ... #{remaining} more" if remaining.positive?
214
+ end
215
+
216
+ lines.join("\n")
217
+ end
218
+
219
+ def colorize_exception(message, level)
220
+ color = EXCEPTION_COLORS[level] || :red
221
+ lines = message.split("\n")
222
+ lines[0] = Rainbow(lines[0]).color(color).bright
223
+ lines[1..].each_with_index do |line, i|
224
+ lines[i + 1] = Rainbow(line).color(color).faint
225
+ end
226
+ lines.join("\n")
227
+ end
228
+
229
+ def build_context_line(event)
230
+ parts = []
231
+ gn = event[:gem_name]
232
+ gv = event[:lex_version]
233
+ parts << (gv ? "#{gn}@#{gv}" : gn.to_s) if gn
234
+ parts << "task:#{event[:task_id]}" if event[:task_id]
235
+ parts << "conversation:#{event[:conversation_id]}" if event[:conversation_id]
236
+ parts << "chain:#{event[:chain_id]}" if event[:chain_id]
237
+ parts.join(' | ')
238
+ end
239
+
240
+ def redaction_enabled?
241
+ return false unless defined?(Legion::Settings)
242
+
243
+ loader = Legion::Settings.instance_variable_get(:@loader)
244
+ return false unless loader
245
+
246
+ loader.dig(:logging, :redaction, :enabled) == true
247
+ rescue StandardError
248
+ false
249
+ end
250
+
251
+ # -- Exception structured publish --
252
+
253
+ def publish_exception(event, level)
254
+ lex_name = event[:lex] || 'core'
255
+ comp = event[:component_type] || :unknown
256
+ routing_key = "legion.logging.exception.#{level}.#{lex_name}.#{comp}"
257
+
258
+ headers = build_exception_headers(event, comp, level)
259
+ properties = build_exception_properties(event, level)
260
+
261
+ Legion::Logging.exception_writer.call(event, routing_key: routing_key, headers: headers, properties: properties)
262
+ rescue StandardError => e
263
+ Legion::Logging.warn("Failed to publish exception event: #{e.class}: #{e.message}")
264
+ end
265
+
266
+ def build_exception_headers(event, comp, level)
267
+ headers = {
268
+ 'x-error-fingerprint' => event[:error_fingerprint],
269
+ 'x-exception-class' => event[:exception_class],
270
+ 'x-handled' => event[:handled].to_s,
271
+ 'x-gem-name' => event[:gem_name].to_s,
272
+ 'x-lex-version' => event[:lex_version].to_s,
273
+ 'x-component-type' => comp.to_s,
274
+ 'x-level' => level.to_s
275
+ }
276
+ headers['x-task-id'] = event[:task_id].to_s if event[:task_id]
277
+ headers['x-conversation-id'] = event[:conversation_id].to_s if event[:conversation_id]
278
+ headers['x-chain-id'] = event[:chain_id].to_s if event[:chain_id]
279
+ headers['x-user'] = event[:user].to_s if event[:user]
280
+ headers
281
+ end
282
+
283
+ def build_exception_properties(event, level)
284
+ {
285
+ content_type: 'application/json',
286
+ message_id: SecureRandom.uuid,
287
+ correlation_id: event[:error_fingerprint],
288
+ timestamp: Time.now.to_i,
289
+ app_id: 'legionio',
290
+ type: 'exception_event',
291
+ priority: EXCEPTION_PRIORITY[level] || 5,
292
+ delivery_mode: 2
293
+ }
45
294
  end
46
295
  end
47
296
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Logging
5
+ module Hooks
6
+ class << self
7
+ def on_fatal(&block)
8
+ fatal_hooks << block
9
+ end
10
+
11
+ def on_error(&block)
12
+ error_hooks << block
13
+ end
14
+
15
+ def on_warn(&block)
16
+ warn_hooks << block
17
+ end
18
+
19
+ def fire(level, message, event)
20
+ return unless @enabled
21
+
22
+ hooks_for(level).each do |hook|
23
+ hook.call(message, event)
24
+ rescue StandardError => e
25
+ warn("Legion::Logging::Hooks#fire callback failed: #{e.message}")
26
+ end
27
+ end
28
+
29
+ def enable_hooks!
30
+ @enabled = true
31
+ end
32
+
33
+ def disable_hooks!
34
+ @enabled = false
35
+ end
36
+
37
+ def enabled?
38
+ @enabled || false
39
+ end
40
+
41
+ def clear_hooks!
42
+ @fatal_hooks = []
43
+ @error_hooks = []
44
+ @warn_hooks = []
45
+ end
46
+
47
+ private
48
+
49
+ def hooks_for(level)
50
+ case level.to_sym
51
+ when :fatal then fatal_hooks
52
+ when :error then error_hooks
53
+ when :warn then warn_hooks
54
+ else []
55
+ end
56
+ end
57
+
58
+ def fatal_hooks
59
+ @fatal_hooks ||= []
60
+ end
61
+
62
+ def error_hooks
63
+ @error_hooks ||= []
64
+ end
65
+
66
+ def warn_hooks
67
+ @warn_hooks ||= []
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -5,6 +5,13 @@ require 'securerandom'
5
5
  module Legion
6
6
  module Logging
7
7
  module Methods
8
+ COMPONENT_REGEX = %r{
9
+ /(runners|actors|actor|helpers|hooks|absorbers|matchers|transport|
10
+ exchanges|queues|messages|data|builders|tools|adapters|engines|
11
+ formatters|parsers|middleware)/
12
+ }x
13
+ EXCEPTION_PRIORITY = { warn: 0, error: 5, fatal: 9 }.freeze
14
+
8
15
  def trace(raw_message = nil, size: @trace_size, log_caller: true)
9
16
  return unless @trace_enabled
10
17
 
@@ -27,7 +34,11 @@ module Legion
27
34
  message = Rainbow(message).blue if @color
28
35
  writer = @async_writer
29
36
  if writer&.alive?
30
- writer.push(AsyncWriter::LogEntry.new(level: :debug, message: message, writer_context: nil))
37
+ writer.push(AsyncWriter::LogEntry.new(
38
+ level: :debug, message: message, writer_context: nil,
39
+ segments: Thread.current[:legion_log_segments],
40
+ method_ctx: Thread.current[:legion_log_method]
41
+ ))
31
42
  else
32
43
  log.debug(message)
33
44
  end
@@ -41,7 +52,11 @@ module Legion
41
52
  message = Rainbow(message).green if @color
42
53
  writer = @async_writer
43
54
  if writer&.alive?
44
- writer.push(AsyncWriter::LogEntry.new(level: :info, message: message, writer_context: nil))
55
+ writer.push(AsyncWriter::LogEntry.new(
56
+ level: :info, message: message, writer_context: nil,
57
+ segments: Thread.current[:legion_log_segments],
58
+ method_ctx: Thread.current[:legion_log_method]
59
+ ))
45
60
  else
46
61
  log.info(message)
47
62
  end
@@ -57,7 +72,11 @@ module Legion
57
72
  writer = @async_writer
58
73
  if writer&.alive?
59
74
  ctx = build_writer_context(:warn, raw)
60
- writer.push(AsyncWriter::LogEntry.new(level: :warn, message: message, writer_context: ctx))
75
+ writer.push(AsyncWriter::LogEntry.new(
76
+ level: :warn, message: message, writer_context: ctx,
77
+ segments: Thread.current[:legion_log_segments],
78
+ method_ctx: Thread.current[:legion_log_method]
79
+ ))
61
80
  else
62
81
  log.warn(message)
63
82
  fire_log_writer(:warn, raw)
@@ -74,7 +93,11 @@ module Legion
74
93
  writer = @async_writer
75
94
  if writer&.alive?
76
95
  ctx = build_writer_context(:error, raw)
77
- writer.push(AsyncWriter::LogEntry.new(level: :error, message: message, writer_context: ctx))
96
+ writer.push(AsyncWriter::LogEntry.new(
97
+ level: :error, message: message, writer_context: ctx,
98
+ segments: Thread.current[:legion_log_segments],
99
+ method_ctx: Thread.current[:legion_log_method]
100
+ ))
78
101
  else
79
102
  log.error(message)
80
103
  fire_log_writer(:error, raw)
@@ -98,7 +121,11 @@ module Legion
98
121
  message = Rainbow(message).purple if @color
99
122
  writer = @async_writer
100
123
  if writer&.alive?
101
- writer.push(AsyncWriter::LogEntry.new(level: :unknown, message: message, writer_context: nil))
124
+ writer.push(AsyncWriter::LogEntry.new(
125
+ level: :unknown, message: message, writer_context: nil,
126
+ segments: Thread.current[:legion_log_segments],
127
+ method_ctx: Thread.current[:legion_log_method]
128
+ ))
102
129
  else
103
130
  log.unknown(message)
104
131
  end
@@ -219,16 +246,18 @@ module Legion
219
246
  timestamp: Time.now.to_i,
220
247
  app_id: 'legionio',
221
248
  type: 'exception_event',
222
- priority: { warn: 0, error: 5, fatal: 9 }[level] || 5,
249
+ priority: EXCEPTION_PRIORITY[level] || 5,
223
250
  delivery_mode: 2
224
251
  }
225
252
  end
226
253
 
227
254
  def build_writer_context(level, message)
228
- return nil if Legion::Logging.instance_variable_get(:@log_writer).nil?
255
+ has_writer = !Legion::Logging.instance_variable_get(:@log_writer).nil?
256
+ has_hooks = defined?(Legion::Logging::Hooks) && Legion::Logging::Hooks.enabled?
257
+ return nil unless has_writer || has_hooks
229
258
 
230
259
  lex_val = instance_variable_defined?(:@lex) ? @lex : nil
231
- lex_segs = instance_variable_defined?(:@lex_segments) ? @lex_segments : nil
260
+ lex_segs = Thread.current[:legion_log_segments] || (instance_variable_defined?(:@lex_segments) ? @lex_segments : nil)
232
261
 
233
262
  event = Legion::Logging::EventBuilder.build(
234
263
  level: level,
@@ -242,7 +271,7 @@ module Legion
242
271
 
243
272
  def fire_log_writer(level, message)
244
273
  lex_val = instance_variable_defined?(:@lex) ? @lex : nil
245
- lex_segs = instance_variable_defined?(:@lex_segments) ? @lex_segments : nil
274
+ lex_segs = Thread.current[:legion_log_segments] || (instance_variable_defined?(:@lex_segments) ? @lex_segments : nil)
246
275
 
247
276
  event = Legion::Logging::EventBuilder.build(
248
277
  level: level,
@@ -252,13 +281,13 @@ module Legion
252
281
  caller_offset: 4
253
282
  )
254
283
  lex_name = event[:lex] || 'core'
255
- component = event.dig(:caller, :file).to_s[%r{/(runners|actors|transport|helpers|builders)/}, 1] || 'unknown'
284
+ component = event.dig(:caller, :file).to_s[COMPONENT_REGEX, 1] || 'unknown'
256
285
  routing_key = "legion.logging.log.#{level}.#{lex_name}.#{component}"
257
286
  Legion::Logging.log_writer.call(event, routing_key: routing_key)
287
+ Legion::Logging::Hooks.fire(level, message, event) if defined?(Legion::Logging::Hooks)
258
288
  rescue StandardError => e
259
- if respond_to?(:log) && log.respond_to?(:warn)
260
- log.warn("fire_log_writer failed for level=#{level}, routing_key=#{routing_key}: #{e.class}: #{e.message}")
261
- end
289
+ rk = defined?(routing_key) ? routing_key : 'unknown'
290
+ log.warn("fire_log_writer failed for level=#{level}, routing_key=#{rk}: #{e.class}: #{e.message}") if respond_to?(:log) && log.respond_to?(:warn)
262
291
  end
263
292
  end
264
293
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Logging
5
+ module Settings
6
+ def self.default
7
+ {
8
+ level: :info,
9
+ trace: false,
10
+ trace_size: 4,
11
+ extended: false
12
+ }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -29,7 +29,7 @@ module Legion
29
29
  def post(uri, body)
30
30
  req = Net::HTTP::Post.new(uri)
31
31
  req['Content-Type'] = 'application/json'
32
- apply_auth(req)
32
+ apply_auth(req, uri)
33
33
 
34
34
  Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https',
35
35
  open_timeout: 5, read_timeout: 10) do |http|
@@ -50,11 +50,11 @@ module Legion
50
50
  uri.path.include?('/services/collector')
51
51
  end
52
52
 
53
- def apply_auth(req)
53
+ def apply_auth(req, uri)
54
54
  token = auth_token
55
55
  return unless token
56
56
 
57
- req['Authorization'] = if splunk_hec?(URI(req.path.empty? ? '/' : req.uri&.to_s || '/'))
57
+ req['Authorization'] = if splunk_hec?(uri)
58
58
  "Splunk #{token}"
59
59
  else
60
60
  "Bearer #{token}"
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Logging
5
+ class TaggedLogger
6
+ LEVELS = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4, unknown: 5 }.freeze
7
+
8
+ attr_reader :segments, :trace_enabled, :extended
9
+
10
+ def initialize(segments:, level: :info, trace: false, trace_size: 4, extended: false, **_opts)
11
+ @segments = segments
12
+ @level_value =
13
+ if level.is_a?(Integer)
14
+ level
15
+ else
16
+ LEVELS.fetch(level.to_s.downcase.to_sym, LEVELS[:info])
17
+ end
18
+ @trace_enabled = trace
19
+ @trace_size = trace_size
20
+ @extended = extended
21
+ end
22
+
23
+ def level
24
+ @level_value
25
+ end
26
+
27
+ def debug(message = nil)
28
+ return unless @level_value < 1
29
+
30
+ message = yield if message.nil? && block_given?
31
+ with_segments { Legion::Logging.debug(message) }
32
+ end
33
+
34
+ def info(message = nil)
35
+ return unless @level_value < 2
36
+
37
+ message = yield if message.nil? && block_given?
38
+ with_segments { Legion::Logging.info(message) }
39
+ end
40
+
41
+ def warn(message = nil)
42
+ return unless @level_value < 3
43
+
44
+ message = yield if message.nil? && block_given?
45
+ with_segments { Legion::Logging.warn(message) }
46
+ end
47
+
48
+ def error(message = nil)
49
+ return unless @level_value < 4
50
+
51
+ message = yield if message.nil? && block_given?
52
+ with_segments { Legion::Logging.error(message) }
53
+ end
54
+
55
+ def fatal(message = nil)
56
+ return unless @level_value < 5
57
+
58
+ message = yield if message.nil? && block_given?
59
+ with_segments { Legion::Logging.fatal(message) }
60
+ end
61
+
62
+ def unknown(message = nil)
63
+ message = yield if message.nil? && block_given?
64
+ with_segments { Legion::Logging.unknown(message) }
65
+ end
66
+
67
+ def trace(raw_message = nil, size: @trace_size, log_caller: true)
68
+ return unless @trace_enabled
69
+
70
+ raw_message = yield if raw_message.nil? && block_given?
71
+ message = "Tracing: #{raw_message} "
72
+ if log_caller
73
+ frames = size ? caller_locations(2, size) : caller_locations(2)
74
+ message.concat(frames&.join(', ').to_s)
75
+ end
76
+ with_segments { Legion::Logging.unknown(message) }
77
+ end
78
+
79
+ def thread(kvl: false, **_opts)
80
+ if kvl
81
+ "thread=#{Thread.current.object_id}"
82
+ else
83
+ Thread.current.object_id.to_s
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def with_segments
90
+ prev = Thread.current[:legion_log_segments]
91
+ Thread.current[:legion_log_segments] = @segments
92
+ yield
93
+ ensure
94
+ Thread.current[:legion_log_segments] = prev
95
+ end
96
+ end
97
+ end
98
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Logging
5
- VERSION = '1.4.2'
5
+ VERSION = '1.4.3'
6
6
  end
7
7
  end
@@ -1,13 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/logging/version'
4
+ require 'legion/logging/settings'
4
5
  require 'legion/logging/logger'
5
6
  require 'legion/logging/methods'
6
7
  require 'legion/logging/builder'
7
8
  require 'legion/logging/event_builder'
8
9
  require 'legion/logging/async_writer'
10
+ require 'legion/logging/tagged_logger'
9
11
  require 'legion/logging/helper'
10
12
  require 'legion/logging/category_registry'
13
+ require 'legion/logging/hooks'
11
14
 
12
15
  require 'json'
13
16
  require 'logger'
@@ -22,7 +25,7 @@ module Legion
22
25
  attr_reader :color
23
26
  attr_writer :log_writer, :exception_writer
24
27
 
25
- DEFAULT_LOG_WRITER = ->(_event, routing_key:) {}
28
+ DEFAULT_LOG_WRITER = ->(_event, routing_key:) {}
26
29
  DEFAULT_EXCEPTION_WRITER = ->(_event, routing_key:, headers:, properties:) {}
27
30
 
28
31
  def log_writer
@@ -41,6 +44,30 @@ module Legion
41
44
  CategoryRegistry.registered_categories
42
45
  end
43
46
 
47
+ def on_fatal(&)
48
+ Hooks.on_fatal(&)
49
+ end
50
+
51
+ def on_error(&)
52
+ Hooks.on_error(&)
53
+ end
54
+
55
+ def on_warn(&)
56
+ Hooks.on_warn(&)
57
+ end
58
+
59
+ def enable_hooks!
60
+ Hooks.enable_hooks!
61
+ end
62
+
63
+ def disable_hooks!
64
+ Hooks.disable_hooks!
65
+ end
66
+
67
+ def clear_hooks!
68
+ Hooks.clear_hooks!
69
+ end
70
+
44
71
  def setup(level: 'info', format: :text, async: true, **options)
45
72
  output(**options)
46
73
  log_level(level)
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Service
5
+ def self.register_logging_hooks
6
+ return unless defined?(Legion::Logging::Hooks)
7
+ return unless defined?(Legion::Transport)
8
+
9
+ Legion::Logging::Hooks.on_warn do |message, event|
10
+ Legion::Transport::Exchanges::Logging.publish(event.merge(level: :warn, message: message))
11
+ rescue StandardError => e
12
+ Kernel.warn("register_logging_hooks on_warn publish failed: #{e.message}")
13
+ end
14
+
15
+ Legion::Logging::Hooks.on_error do |message, event|
16
+ Legion::Transport::Exchanges::Logging.publish(event.merge(level: :error, message: message))
17
+ rescue StandardError => e
18
+ Kernel.warn("register_logging_hooks on_error publish failed: #{e.message}")
19
+ end
20
+
21
+ Legion::Logging::Hooks.on_fatal do |message, event|
22
+ Legion::Transport::Exchanges::Logging.publish(event.merge(level: :fatal, message: message))
23
+ rescue StandardError => e
24
+ Kernel.warn("register_logging_hooks on_fatal publish failed: #{e.message}")
25
+ end
26
+ end
27
+ end
28
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-logging
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.2
4
+ version: 1.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -64,15 +64,19 @@ files:
64
64
  - lib/legion/logging/category_registry.rb
65
65
  - lib/legion/logging/event_builder.rb
66
66
  - lib/legion/logging/helper.rb
67
+ - lib/legion/logging/hooks.rb
67
68
  - lib/legion/logging/logger.rb
68
69
  - lib/legion/logging/methods.rb
69
70
  - lib/legion/logging/multi_io.rb
70
71
  - lib/legion/logging/redactor.rb
72
+ - lib/legion/logging/settings.rb
71
73
  - lib/legion/logging/shipper.rb
72
74
  - lib/legion/logging/shipper/file_transport.rb
73
75
  - lib/legion/logging/shipper/http_transport.rb
74
76
  - lib/legion/logging/siem_exporter.rb
77
+ - lib/legion/logging/tagged_logger.rb
75
78
  - lib/legion/logging/version.rb
79
+ - lib/legion/service.rb
76
80
  - sonar-project.properties
77
81
  homepage: https://github.com/LegionIO/legion-logging
78
82
  licenses: