legion-logging 1.3.5 → 1.4.2

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: a197403f8e3949ef6680892655b47cdfec6052dffcf32732c8d9d277c5eecc93
4
- data.tar.gz: 3baa080b906527dc521b1f326f014469d2802cac0e16682cc3eb3b2354079894
3
+ metadata.gz: 31ec07f7a93713fb708102528d4e61525a4c3b4fa39de510d29a454726b49436
4
+ data.tar.gz: 62a67fc723d1e865ef3e632d1987b6ce73c2fe9aa033686df5fe3b2fb89bc14c
5
5
  SHA512:
6
- metadata.gz: c63d0b3a21a3822f0f6e8226455b6d1f65a743094acd6d8669f4f8cda2a63e988503026b53c25084f9cd4c1bda0b87fcc94a6bc9afbe057736a713e7d4f1a31c
7
- data.tar.gz: 1e76100161836d8e258fcb11a266f542bfc4e2df323a2830b3a54b649b2b0ea128613282f2fbebe8a0d5668200411fbfadcc92a152cd0fe1e3b0fea675a8de7d
6
+ metadata.gz: 5e0b2171b6ca1b1acbb3ad0f40cbd23a77bf5824a3b2f37a5c9b3484f7edf2cf9cf1a8dc23a9f0ed0acd426e139fb0d7ce13ac0a3ae2a77b3113dd0569cc6d53
7
+ data.tar.gz: e4e599809e26c6b7a0f9c1a81f05e390175cad0568b6b99f26888b675590972ff9148e27bc53f36461e1d6f09f83fcf7a818b01bd793098c5809910affabb0a2
@@ -0,0 +1,7 @@
1
+ # Auto-generated from team-config.yml
2
+ # Team: core
3
+ #
4
+ # To apply: scripts/apply-codeowners.sh legion-logging
5
+
6
+ * @LegionIO/maintainers
7
+ * @LegionIO/core
@@ -0,0 +1,18 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: bundler
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ day: monday
8
+ open-pull-requests-limit: 5
9
+ labels:
10
+ - "type:dependencies"
11
+ - package-ecosystem: github-actions
12
+ directory: /
13
+ schedule:
14
+ interval: weekly
15
+ day: monday
16
+ open-pull-requests-limit: 5
17
+ labels:
18
+ - "type:dependencies"
data/.rubocop.yml CHANGED
@@ -16,6 +16,9 @@ Layout/HashAlignment:
16
16
  Metrics/MethodLength:
17
17
  Max: 50
18
18
 
19
+ Metrics/ParameterLists:
20
+ CountKeywordArgs: false
21
+
19
22
  Metrics/ClassLength:
20
23
  Max: 1500
21
24
 
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Legion::Logging Changelog
2
2
 
3
+ ## [1.4.2] - 2026-03-28
4
+
5
+ ### Added
6
+ - `Legion::Logging::CategoryRegistry` module: extension-defined event category registration with `register_category`, `registered_categories`, `category_registered?`, and `category_info` methods
7
+ - `Legion::Logging.register_category` and `Legion::Logging.registered_categories` delegate methods on the top-level module
8
+ - `category:` keyword argument on `EventBuilder.build` — emits `category` field in structured log events when provided
9
+ - `SIEMExporter.format_for_elk` now includes `category` field when the event hash carries `:category` or `"category"`
10
+
11
+ ## [1.4.1] - 2026-03-27
12
+
13
+ ### Fixed
14
+ - `require 'time'` added to `event_builder.rb` so `Time#iso8601` is always available in minimal Ruby environments
15
+ - `log_writer` / `exception_writer` accessors no longer memoize the no-op default via `||=`; `@log_writer` stays `nil` until a real writer is assigned, which allows `build_writer_context` to correctly short-circuit event building when no writer is configured
16
+ - README writer lambda examples updated to show correct keyword argument signatures matching actual call sites
17
+
18
+ ## [1.4.0] - 2026-03-27
19
+
20
+ ### Added
21
+ - `log_exception` method in `Methods` — single call for complete structured exception events
22
+ - `EventBuilder.build_exception` — builds rich exception payloads with fingerprint, versions, flat caller keys
23
+ - `EventBuilder.fingerprint` — MD5 fingerprint of stable error fields for dedup
24
+ - `log_writer` / `exception_writer` pluggable lambda slots on `Legion::Logging`
25
+ - Size enforcement: 4KB message cap, 8KB payload_summary cap, 64KB total cap
26
+ - Vault token, JWT, lease ID, and URI patterns added to Redactor
27
+
28
+ ### Removed
29
+ - `Legion::Logging::Hooks` module (`on_fatal`, `on_error`, `on_warn`, `enable_hooks!`, `disable_hooks!`, `clear_hooks!`)
30
+ - Hooks replaced by `log_writer` and `exception_writer` lambdas
31
+
32
+ ### Changed
33
+ - `AsyncWriter::LogEntry` uses `writer_context` field instead of `hook_context`
34
+ - `runner_exception` now delegates to `log_exception` internally
35
+
3
36
  ## [1.3.5] - 2026-03-24
4
37
 
5
38
  ### Added
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.3.2
5
+ **Version**: 1.4.1
6
6
 
7
7
  ## Installation
8
8
 
@@ -94,6 +94,35 @@ class MyRunner
94
94
  end
95
95
  ```
96
96
 
97
+ ### Exception Logging
98
+
99
+ `log_exception` provides a single call for complete structured exception events with component context:
100
+
101
+ ```ruby
102
+ Legion::Logging.log_exception(exception,
103
+ handled: true,
104
+ component_type: :runner,
105
+ lex: 'my_extension',
106
+ task_id: 'abc-123')
107
+ ```
108
+
109
+ ### Writer Lambdas
110
+
111
+ `log_writer` and `exception_writer` are pluggable lambda slots that replace the old Hooks system. Assign them to forward events to external systems:
112
+
113
+ ```ruby
114
+ Legion::Logging.exception_writer = ->(payload, routing_key:, headers:, properties:) { publish_to_amqp(payload) }
115
+ Legion::Logging.log_writer = ->(context, routing_key:) { publish_log(context) }
116
+ ```
117
+
118
+ ### EventBuilder
119
+
120
+ `EventBuilder.build_exception` constructs rich structured exception payloads including caller location, lex identity, and gem metadata. `EventBuilder.fingerprint` produces an MD5 fingerprint of stable error fields for deduplication in log aggregation pipelines.
121
+
122
+ ### Redactor
123
+
124
+ `Legion::Logging::Redactor` redacts PII/PHI patterns (SSN, phone, MRN, DOB) plus Vault tokens, JWTs, bearer tokens, and lease IDs from log messages. Redaction is opt-in: load the module (for example via `require 'legion/logging/redactor'`) and enable it with `logging.redaction.enabled: true`. When loaded and enabled, it is wired into all log methods in the write path.
125
+
97
126
  ## Requirements
98
127
 
99
128
  - Ruby >= 3.4
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Logging
5
5
  class AsyncWriter
6
- LogEntry = ::Data.define(:level, :message, :hook_context)
6
+ LogEntry = ::Data.define(:level, :message, :writer_context)
7
7
  SHUTDOWN = :shutdown
8
8
 
9
9
  def initialize(logger, buffer_size: 10_000)
@@ -55,7 +55,7 @@ module Legion
55
55
 
56
56
  def write_entry(entry)
57
57
  @logger.send(entry.level, entry.message)
58
- fire_hooks(entry) if entry.hook_context
58
+ fire_writer(entry) if entry.writer_context
59
59
  rescue StandardError => e
60
60
  warn("legion-log-writer error: #{e.message} (#{e.backtrace&.first})")
61
61
  end
@@ -69,11 +69,16 @@ module Legion
69
69
  nil
70
70
  end
71
71
 
72
- def fire_hooks(entry)
73
- ctx = entry.hook_context
74
- Legion::Logging::Hooks.fire(ctx[:level], ctx[:event])
72
+ def fire_writer(entry)
73
+ ctx = entry.writer_context
74
+ event = ctx[:event]
75
+ level = ctx[:level]
76
+ lex_name = event[:lex] || 'core'
77
+ component = event.dig(:caller, :file).to_s[%r{/(runners|actors|transport|helpers|builders)/}, 1] || 'unknown'
78
+ routing_key = "legion.logging.log.#{level}.#{lex_name}.#{component}"
79
+ Legion::Logging.log_writer.call(event, routing_key: routing_key)
75
80
  rescue StandardError => e
76
- warn("legion-log-writer hook error: #{e.message}")
81
+ warn("legion-log-writer writer error: #{e.message}")
77
82
  end
78
83
  end
79
84
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Logging
5
+ module CategoryRegistry
6
+ VALID_NAME_PATTERN = /\A[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*\z/
7
+
8
+ class << self
9
+ def register_category(name, description: nil, expected_fields: [])
10
+ name = name.to_s
11
+ raise ArgumentError, "invalid category name: #{name.inspect}" unless name.match?(VALID_NAME_PATTERN)
12
+
13
+ registry[name] = {
14
+ name: name,
15
+ description: description,
16
+ expected_fields: Array(expected_fields)
17
+ }.freeze
18
+ name
19
+ end
20
+
21
+ def registered_categories
22
+ registry.dup.freeze
23
+ end
24
+
25
+ def category_registered?(name)
26
+ registry.key?(name.to_s)
27
+ end
28
+
29
+ def category_info(name)
30
+ registry[name.to_s]
31
+ end
32
+
33
+ def clear!
34
+ registry.clear
35
+ end
36
+
37
+ private
38
+
39
+ def registry
40
+ @registry ||= {}
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -1,10 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+ require 'json'
5
+ require 'time'
6
+
3
7
  module Legion
4
8
  module Logging
5
9
  module EventBuilder
10
+ MAX_MESSAGE_BYTES = 4096
11
+ MAX_PAYLOAD_BYTES = 8192
12
+ MAX_TOTAL_BYTES = 65_536
13
+ BACKTRACE_FALLBACK_FRAMES = 20
14
+
6
15
  class << self
7
- def build(level:, message:, lex: nil, lex_segments: nil, context: nil, caller_offset: 2) # rubocop:disable Metrics/ParameterLists
16
+ def build(level:, message:, lex: nil, lex_segments: nil, context: nil, category: nil, caller_offset: 2)
8
17
  event = base_fields(level, message)
9
18
  event[:lex] = derive_lex_source(lex, lex_segments)
10
19
  add_node(event)
@@ -12,9 +21,103 @@ module Legion
12
21
  add_exception_info(event, message)
13
22
  add_gem_info(event, event[:lex])
14
23
  event[:context] = context if context
24
+ event[:category] = category.to_s if category
25
+ event.compact
26
+ end
27
+
28
+ def build_exception(
29
+ exception:,
30
+ level:,
31
+ lex: nil,
32
+ component_type: nil,
33
+ gem_name: nil,
34
+ lex_version: nil,
35
+ gem_path: nil,
36
+ source_code_uri: nil,
37
+ handled: false,
38
+ payload_summary: nil,
39
+ task_id: nil,
40
+ caller_offset: 2,
41
+ **extra
42
+ )
43
+ bt = Array(exception.backtrace)
44
+ cf_file, cf_line, cf_func = parse_backtrace_location(bt.first) ||
45
+ caller_location(caller_offset)
46
+
47
+ event = {
48
+ timestamp: Time.now.utc.iso8601(3),
49
+ level: level,
50
+ exception_class: exception.class.name,
51
+ message: truncate_bytes(exception.message.to_s, MAX_MESSAGE_BYTES),
52
+ backtrace: bt,
53
+ caller_file: cf_file,
54
+ caller_line: cf_line,
55
+ caller_function: cf_func,
56
+ lex: lex,
57
+ component_type: component_type,
58
+ gem_name: gem_name,
59
+ lex_version: lex_version,
60
+ gem_path: gem_path,
61
+ source_code_uri: source_code_uri,
62
+ legion_versions: legion_versions,
63
+ ruby_version: "#{RUBY_VERSION} #{RUBY_PLATFORM}",
64
+ handled: handled,
65
+ pid: ::Process.pid,
66
+ thread: Thread.current.object_id
67
+ }
68
+
69
+ event[:task_id] = task_id if task_id
70
+ event[:payload_summary] = truncate_payload(payload_summary) if payload_summary
71
+
72
+ add_node(event)
73
+ add_user(event)
74
+ add_session_context(event)
75
+
76
+ event[:error_fingerprint] = fingerprint(
77
+ exception_class: exception.class.name,
78
+ message: event[:message],
79
+ caller_file: cf_file.to_s,
80
+ caller_line: cf_line.to_i,
81
+ caller_function: cf_func.to_s,
82
+ gem_name: gem_name.to_s,
83
+ component_type: component_type.to_s,
84
+ backtrace: bt
85
+ )
86
+
87
+ extra.each { |k, v| event[k] = v unless event.key?(k) }
88
+
89
+ enforce_total_size!(event)
15
90
  event.compact
16
91
  end
17
92
 
93
+ def fingerprint(
94
+ exception_class:,
95
+ message:,
96
+ caller_file:,
97
+ caller_line:,
98
+ caller_function:,
99
+ gem_name:,
100
+ component_type:,
101
+ backtrace:
102
+ )
103
+ norm_msg = normalize_message(message.to_s)
104
+ norm_file = normalize_path(caller_file.to_s)
105
+ norm_bt = Array(backtrace).first(5).map { |l| normalize_path(l.to_s) }.join('|')
106
+
107
+ raw = [
108
+ exception_class.to_s,
109
+ norm_msg,
110
+ norm_file,
111
+ caller_line.to_s,
112
+ caller_function.to_s,
113
+ gem_name.to_s,
114
+ component_type.to_s,
115
+ norm_bt
116
+ ].join(':')
117
+
118
+ Digest::MD5.hexdigest(raw)
119
+ end
120
+
18
121
  private
19
122
 
20
123
  def base_fields(level, message)
@@ -95,6 +198,106 @@ module Legion
95
198
  def strip_ansi(str)
96
199
  str.gsub(/\e\[[0-9;]*m/, '')
97
200
  end
201
+
202
+ # New private helpers for build_exception
203
+
204
+ def add_user(event)
205
+ identity = if defined?(Legion::Extensions::Helpers::Secret) &&
206
+ Legion::Extensions::Helpers::Secret.respond_to?(:resolved_identity)
207
+ Legion::Extensions::Helpers::Secret.resolved_identity
208
+ else
209
+ ENV.fetch('USER', nil)
210
+ end
211
+ event[:user] = identity
212
+ end
213
+
214
+ def add_session_context(event)
215
+ return unless defined?(Legion::Context)
216
+
217
+ session = begin
218
+ Legion::Context.current_session
219
+ rescue StandardError
220
+ nil
221
+ end
222
+ return unless session
223
+
224
+ event[:conversation_id] = session.session_id
225
+ end
226
+
227
+ def parse_backtrace_location(frame)
228
+ return nil unless frame.is_a?(String)
229
+
230
+ # Format: /path/to/file.rb:42:in `method_name`
231
+ if (m = frame.match(/\A(.+):(\d+):in `([^`]+)`\z/))
232
+ [m[1], m[2].to_i, m[3]]
233
+ elsif (m = frame.match(/\A(.+):(\d+)\z/))
234
+ [m[1], m[2].to_i, nil]
235
+ end
236
+ end
237
+
238
+ def caller_location(offset)
239
+ loc = caller_locations(offset + 2, 1)&.first
240
+ return [nil, nil, nil] unless loc
241
+
242
+ [loc.absolute_path || loc.path, loc.lineno, loc.base_label]
243
+ end
244
+
245
+ def normalize_message(msg)
246
+ msg
247
+ .gsub(/0x[0-9a-f]+/i, '0xXXX')
248
+ .gsub(/#<[A-Z][A-Za-z:]*:0xXXX>/, '#<Class:0xXXX>')
249
+ .gsub(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/, 'X.X.X.X')
250
+ end
251
+
252
+ def normalize_path(path)
253
+ path.gsub(/-\d+\.\d+[\d.]*/, '')
254
+ end
255
+
256
+ 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] }
260
+ end
261
+
262
+ def truncate_bytes(str, max)
263
+ return str if str.bytesize <= max
264
+
265
+ str.byteslice(0, max).scrub
266
+ end
267
+
268
+ def safe_json_bytesize(object)
269
+ ::JSON.generate(object).bytesize
270
+ rescue ::JSON::GeneratorError, TypeError
271
+ object.to_s.bytesize
272
+ end
273
+
274
+ def truncate_payload(payload)
275
+ return nil unless payload
276
+
277
+ str = if payload.is_a?(String)
278
+ payload
279
+ else
280
+ begin
281
+ ::JSON.generate(payload)
282
+ rescue ::JSON::GeneratorError, TypeError
283
+ payload.to_s
284
+ end
285
+ end
286
+ truncate_bytes(str, MAX_PAYLOAD_BYTES)
287
+ end
288
+
289
+ def enforce_total_size!(event)
290
+ return if safe_json_bytesize(event) <= MAX_TOTAL_BYTES
291
+
292
+ event.delete(:payload_summary)
293
+ return if safe_json_bytesize(event) <= MAX_TOTAL_BYTES
294
+
295
+ bt = event[:backtrace]
296
+ event[:backtrace] = bt.first(BACKTRACE_FALLBACK_FRAMES) if bt.is_a?(Array)
297
+ return if safe_json_bytesize(event) <= MAX_TOTAL_BYTES
298
+
299
+ event[:message] = truncate_bytes(event[:message].to_s, 1024)
300
+ end
98
301
  end
99
302
  end
100
303
  end
@@ -11,7 +11,7 @@ module Legion
11
11
  include Legion::Logging::Methods
12
12
  include Legion::Logging::Builder
13
13
 
14
- def initialize(level: 'info', log_file: nil, log_stdout: nil, lex: nil, trace: false, extended: false, trace_size: 4, format: :text, async: false, **opts) # rubocop:disable Metrics/ParameterLists
14
+ def initialize(level: 'info', log_file: nil, log_stdout: nil, lex: nil, trace: false, extended: false, trace_size: 4, format: :text, async: false, **opts)
15
15
  @lex = lex
16
16
  set_log(logfile: log_file, log_stdout: log_stdout)
17
17
  log_level(level)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+
3
5
  module Legion
4
6
  module Logging
5
7
  module Methods
@@ -25,7 +27,7 @@ module Legion
25
27
  message = Rainbow(message).blue if @color
26
28
  writer = @async_writer
27
29
  if writer&.alive?
28
- writer.push(AsyncWriter::LogEntry.new(level: :debug, message: message, hook_context: nil))
30
+ writer.push(AsyncWriter::LogEntry.new(level: :debug, message: message, writer_context: nil))
29
31
  else
30
32
  log.debug(message)
31
33
  end
@@ -39,7 +41,7 @@ module Legion
39
41
  message = Rainbow(message).green if @color
40
42
  writer = @async_writer
41
43
  if writer&.alive?
42
- writer.push(AsyncWriter::LogEntry.new(level: :info, message: message, hook_context: nil))
44
+ writer.push(AsyncWriter::LogEntry.new(level: :info, message: message, writer_context: nil))
43
45
  else
44
46
  log.info(message)
45
47
  end
@@ -54,11 +56,11 @@ module Legion
54
56
  message = Rainbow(message).yellow if @color
55
57
  writer = @async_writer
56
58
  if writer&.alive?
57
- ctx = build_hook_context(:warn, raw)
58
- writer.push(AsyncWriter::LogEntry.new(level: :warn, message: message, hook_context: ctx))
59
+ ctx = build_writer_context(:warn, raw)
60
+ writer.push(AsyncWriter::LogEntry.new(level: :warn, message: message, writer_context: ctx))
59
61
  else
60
62
  log.warn(message)
61
- fire_hooks(:warn, raw)
63
+ fire_log_writer(:warn, raw)
62
64
  end
63
65
  end
64
66
 
@@ -71,11 +73,11 @@ module Legion
71
73
  message = Rainbow(message).red if @color
72
74
  writer = @async_writer
73
75
  if writer&.alive?
74
- ctx = build_hook_context(:error, raw)
75
- writer.push(AsyncWriter::LogEntry.new(level: :error, message: message, hook_context: ctx))
76
+ ctx = build_writer_context(:error, raw)
77
+ writer.push(AsyncWriter::LogEntry.new(level: :error, message: message, writer_context: ctx))
76
78
  else
77
79
  log.error(message)
78
- fire_hooks(:error, raw)
80
+ fire_log_writer(:error, raw)
79
81
  end
80
82
  end
81
83
 
@@ -87,7 +89,7 @@ module Legion
87
89
  raw = message
88
90
  message = Rainbow(message).darkred if @color
89
91
  log.fatal(message)
90
- fire_hooks(:fatal, raw)
92
+ fire_log_writer(:fatal, raw)
91
93
  end
92
94
 
93
95
  def unknown(message = nil)
@@ -96,19 +98,56 @@ module Legion
96
98
  message = Rainbow(message).purple if @color
97
99
  writer = @async_writer
98
100
  if writer&.alive?
99
- writer.push(AsyncWriter::LogEntry.new(level: :unknown, message: message, hook_context: nil))
101
+ writer.push(AsyncWriter::LogEntry.new(level: :unknown, message: message, writer_context: nil))
100
102
  else
101
103
  log.unknown(message)
102
104
  end
103
105
  end
104
106
 
105
107
  def runner_exception(exc, **opts)
106
- Legion::Logging.error exc.message
107
- Legion::Logging.error exc.backtrace
108
- Legion::Logging.error opts
108
+ log_exception(exc, handled: true, **opts)
109
109
  { success: false, message: exc.message, backtrace: exc.backtrace }.merge(opts)
110
110
  end
111
111
 
112
+ def log_exception(exception, level: :error, lex: nil, component_type: nil,
113
+ gem_name: nil, lex_version: nil, gem_path: nil,
114
+ source_code_uri: nil, handled: false, payload_summary: nil,
115
+ task_id: nil, **extra)
116
+ level = level.to_sym if level.respond_to?(:to_sym)
117
+ # 1. Log human-readable line to stdout/file (bypass writer callbacks)
118
+ msg = exception.respond_to?(:message) ? exception.message : exception.to_s
119
+ log.public_send(level, msg) if respond_to?(:log) && log.respond_to?(level)
120
+
121
+ # 2. Build rich exception event
122
+ event = Legion::Logging::EventBuilder.build_exception(
123
+ exception: exception,
124
+ level: level,
125
+ lex: lex,
126
+ component_type: component_type,
127
+ gem_name: gem_name,
128
+ lex_version: lex_version,
129
+ gem_path: gem_path,
130
+ source_code_uri: source_code_uri,
131
+ handled: handled,
132
+ payload_summary: payload_summary,
133
+ task_id: task_id,
134
+ caller_offset: 3,
135
+ **extra
136
+ )
137
+
138
+ # 3. Redact secrets before publishing
139
+ event = Legion::Logging::Redactor.redact(event) if defined?(Legion::Logging::Redactor)
140
+
141
+ # 4. Publish rich event via exception_writer
142
+ publish_exception_event(event, level)
143
+ rescue StandardError => e
144
+ if respond_to?(:log) && log.respond_to?(:warn)
145
+ log.warn("Failed to publish structured exception event: #{e.class}: #{e.message}")
146
+ else
147
+ warn("Failed to publish structured exception event: #{e.class}: #{e.message}")
148
+ end
149
+ end
150
+
112
151
  def thread(kvl: false, **_opts)
113
152
  if kvl
114
153
  "thread=#{Thread.current.object_id}"
@@ -140,9 +179,53 @@ module Legion
140
179
  false
141
180
  end
142
181
 
143
- def build_hook_context(level, message)
144
- return nil unless Legion::Logging::Hooks.enabled?
145
- return nil if Legion::Logging::Hooks.hooks[level].empty?
182
+ def publish_exception_event(event, level)
183
+ lex_name = event[:lex] || 'core'
184
+ comp = event[:component_type] || :unknown
185
+ routing_key = "legion.logging.exception.#{level}.#{lex_name}.#{comp}"
186
+ headers = build_exception_headers(event, comp, level)
187
+ properties = build_exception_properties(event, level)
188
+ Legion::Logging.exception_writer.call(event, routing_key: routing_key, headers: headers, properties: properties)
189
+ end
190
+
191
+ def build_exception_headers(event, comp, level)
192
+ headers = {
193
+ 'x-error-fingerprint' => event[:error_fingerprint],
194
+ 'x-exception-class' => event[:exception_class],
195
+ 'x-handled' => event[:handled].to_s,
196
+ 'x-gem-name' => event[:gem_name].to_s,
197
+ 'x-lex-version' => event[:lex_version].to_s,
198
+ 'x-component-type' => comp.to_s,
199
+ 'x-level' => level.to_s
200
+ }
201
+ append_optional_header(headers, 'x-task-id', event[:task_id])
202
+ append_optional_header(headers, 'x-conversation-id', event[:conversation_id])
203
+ append_optional_header(headers, 'x-user', event[:user])
204
+ headers
205
+ end
206
+
207
+ def append_optional_header(headers, key, value)
208
+ return if value.nil?
209
+ return if value.respond_to?(:empty?) && value.empty?
210
+
211
+ headers[key] = value.to_s
212
+ end
213
+
214
+ def build_exception_properties(event, level)
215
+ {
216
+ content_type: 'application/json',
217
+ message_id: SecureRandom.uuid,
218
+ correlation_id: event[:error_fingerprint],
219
+ timestamp: Time.now.to_i,
220
+ app_id: 'legionio',
221
+ type: 'exception_event',
222
+ priority: { warn: 0, error: 5, fatal: 9 }[level] || 5,
223
+ delivery_mode: 2
224
+ }
225
+ end
226
+
227
+ def build_writer_context(level, message)
228
+ return nil if Legion::Logging.instance_variable_get(:@log_writer).nil?
146
229
 
147
230
  lex_val = instance_variable_defined?(:@lex) ? @lex : nil
148
231
  lex_segs = instance_variable_defined?(:@lex_segments) ? @lex_segments : nil
@@ -157,10 +240,7 @@ module Legion
157
240
  { level: level, event: event }
158
241
  end
159
242
 
160
- def fire_hooks(level, message)
161
- return unless Legion::Logging::Hooks.enabled?
162
- return if Legion::Logging::Hooks.hooks[level].empty?
163
-
243
+ def fire_log_writer(level, message)
164
244
  lex_val = instance_variable_defined?(:@lex) ? @lex : nil
165
245
  lex_segs = instance_variable_defined?(:@lex_segments) ? @lex_segments : nil
166
246
 
@@ -171,7 +251,14 @@ module Legion
171
251
  lex_segments: lex_segs,
172
252
  caller_offset: 4
173
253
  )
174
- Legion::Logging::Hooks.fire(level, event)
254
+ lex_name = event[:lex] || 'core'
255
+ component = event.dig(:caller, :file).to_s[%r{/(runners|actors|transport|helpers|builders)/}, 1] || 'unknown'
256
+ routing_key = "legion.logging.log.#{level}.#{lex_name}.#{component}"
257
+ Legion::Logging.log_writer.call(event, routing_key: routing_key)
258
+ 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
175
262
  end
176
263
  end
177
264
  end
@@ -4,12 +4,18 @@ module Legion
4
4
  module Logging
5
5
  module Redactor
6
6
  PATTERNS = {
7
- ssn: /\b\d{3}-\d{2}-\d{4}\b/,
8
- email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/,
9
- phone: /\b(?:\+1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/,
10
- mrn: /\bMRN[:\s]*\d{6,10}\b/i,
11
- dob: %r{\bDOB[:\s]*\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b}i,
12
- credit_card: /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/
7
+ ssn: /\b\d{3}-\d{2}-\d{4}\b/,
8
+ email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/,
9
+ phone: /\b(?:\+1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b/,
10
+ mrn: /\bMRN[:\s]*\d{6,10}\b/i,
11
+ dob: %r{\bDOB[:\s]*\d{1,2}[/-]\d{1,2}[/-]\d{2,4}\b}i,
12
+ credit_card: /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/,
13
+ vault_token: /\bhvs\.[A-Za-z0-9_-]{20,}\b/,
14
+ vault_lease_id: %r{\b[a-z_-]+/creds/[a-z_-]+/[A-Za-z0-9-]{36}\b},
15
+ jwt: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/,
16
+ vault_uri: %r{vault://[^"',\]\x7d\s]+},
17
+ lease_uri: %r{lease://[^"',\]\x7d\s]+},
18
+ bearer_token: %r{Bearer\s+[A-Za-z0-9._~+/=-]{20,}}i
13
19
  }.freeze
14
20
 
15
21
  SENSITIVE_FIELDS = %w[password secret token api_key authorization].freeze
@@ -37,12 +37,17 @@ module Legion
37
37
  end
38
38
 
39
39
  def format_for_elk(event, index: 'legion')
40
- {
40
+ result = {
41
41
  '@timestamp' => Time.now.utc.iso8601,
42
42
  'index' => index,
43
43
  'message' => redact_phi(event.to_s),
44
44
  'source' => 'legion'
45
45
  }
46
+ if event.is_a?(Hash)
47
+ category = event[:category] || event['category']
48
+ result['category'] = category.to_s if category
49
+ end
50
+ result
46
51
  end
47
52
  end
48
53
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Logging
5
- VERSION = '1.3.5'
5
+ VERSION = '1.4.2'
6
6
  end
7
7
  end
@@ -4,10 +4,10 @@ require 'legion/logging/version'
4
4
  require 'legion/logging/logger'
5
5
  require 'legion/logging/methods'
6
6
  require 'legion/logging/builder'
7
- require 'legion/logging/hooks'
8
7
  require 'legion/logging/event_builder'
9
8
  require 'legion/logging/async_writer'
10
9
  require 'legion/logging/helper'
10
+ require 'legion/logging/category_registry'
11
11
 
12
12
  require 'json'
13
13
  require 'logger'
@@ -19,14 +19,27 @@ module Legion
19
19
  include Legion::Logging::Methods
20
20
  include Legion::Logging::Builder
21
21
 
22
- def on_fatal(&) = Hooks.register(:fatal, &)
23
- def on_error(&) = Hooks.register(:error, &)
24
- def on_warn(&) = Hooks.register(:warn, &)
25
- def enable_hooks! = Hooks.enable!
26
- def disable_hooks! = Hooks.disable!
27
- def clear_hooks! = Hooks.clear!
28
-
29
22
  attr_reader :color
23
+ attr_writer :log_writer, :exception_writer
24
+
25
+ DEFAULT_LOG_WRITER = ->(_event, routing_key:) {}
26
+ DEFAULT_EXCEPTION_WRITER = ->(_event, routing_key:, headers:, properties:) {}
27
+
28
+ def log_writer
29
+ @log_writer || DEFAULT_LOG_WRITER
30
+ end
31
+
32
+ def exception_writer
33
+ @exception_writer || DEFAULT_EXCEPTION_WRITER
34
+ end
35
+
36
+ def register_category(name, description: nil, expected_fields: [])
37
+ CategoryRegistry.register_category(name, description: description, expected_fields: expected_fields)
38
+ end
39
+
40
+ def registered_categories
41
+ CategoryRegistry.registered_categories
42
+ end
30
43
 
31
44
  def setup(level: 'info', format: :text, async: true, **options)
32
45
  output(**options)
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.3.5
4
+ version: 1.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -47,6 +47,8 @@ extra_rdoc_files:
47
47
  - LICENSE
48
48
  - README.md
49
49
  files:
50
+ - ".github/CODEOWNERS"
51
+ - ".github/dependabot.yml"
50
52
  - ".github/workflows/ci.yml"
51
53
  - ".gitignore"
52
54
  - ".rubocop.yml"
@@ -59,9 +61,9 @@ files:
59
61
  - lib/legion/logging.rb
60
62
  - lib/legion/logging/async_writer.rb
61
63
  - lib/legion/logging/builder.rb
64
+ - lib/legion/logging/category_registry.rb
62
65
  - lib/legion/logging/event_builder.rb
63
66
  - lib/legion/logging/helper.rb
64
- - lib/legion/logging/hooks.rb
65
67
  - lib/legion/logging/logger.rb
66
68
  - lib/legion/logging/methods.rb
67
69
  - lib/legion/logging/multi_io.rb
@@ -1,46 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Legion
4
- module Logging
5
- module Hooks
6
- @hooks = { fatal: [], error: [], warn: [] }
7
- @enabled = false
8
-
9
- class << self
10
- attr_reader :hooks
11
-
12
- def enabled?
13
- @enabled
14
- end
15
-
16
- def enable!
17
- @enabled = true
18
- end
19
-
20
- def disable!
21
- @enabled = false
22
- end
23
-
24
- def clear!
25
- @hooks.each_value(&:clear)
26
- end
27
-
28
- def register(level, &block)
29
- @hooks[level] << block
30
- end
31
-
32
- def fire(level, event)
33
- return unless @enabled
34
- return if @hooks[level].empty?
35
-
36
- @hooks[level].each do |hook|
37
- hook.call(event)
38
- rescue StandardError => e
39
- warn("Legion::Logging::Hooks#fire hook failed at level=#{level}: #{e.message}")
40
- nil
41
- end
42
- end
43
- end
44
- end
45
- end
46
- end