legion-logging 1.3.5 → 1.4.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: a197403f8e3949ef6680892655b47cdfec6052dffcf32732c8d9d277c5eecc93
4
- data.tar.gz: 3baa080b906527dc521b1f326f014469d2802cac0e16682cc3eb3b2354079894
3
+ metadata.gz: 87467334f127d64997e116d784a68d875c8180a6633dd068e4e095eb7e8bc22f
4
+ data.tar.gz: a934447ca2a89f43d3f4993a1df8e879b15945b966b4ade4f29b4d6b2272762d
5
5
  SHA512:
6
- metadata.gz: c63d0b3a21a3822f0f6e8226455b6d1f65a743094acd6d8669f4f8cda2a63e988503026b53c25084f9cd4c1bda0b87fcc94a6bc9afbe057736a713e7d4f1a31c
7
- data.tar.gz: 1e76100161836d8e258fcb11a266f542bfc4e2df323a2830b3a54b649b2b0ea128613282f2fbebe8a0d5668200411fbfadcc92a152cd0fe1e3b0fea675a8de7d
6
+ metadata.gz: bb9ece1de3b070fb58417992daf22fdaf3e0021f8a45e486a6d5fa4bdca8536c9ed6bdaec8b867f8aef854b52a1496d462260c7cf31d1faae4b0769a91995ef2
7
+ data.tar.gz: 00f6510d46e38bb7a638aace8818cdccdefe9bfb8c857ecc2d6b11a43f55461ea25bd56a37b0b325749971808491679160930ba11014a453d7fbd5b096d50368
@@ -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,30 @@
1
1
  # Legion::Logging Changelog
2
2
 
3
+ ## [1.4.1] - 2026-03-27
4
+
5
+ ### Fixed
6
+ - `require 'time'` added to `event_builder.rb` so `Time#iso8601` is always available in minimal Ruby environments
7
+ - `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
8
+ - README writer lambda examples updated to show correct keyword argument signatures matching actual call sites
9
+
10
+ ## [1.4.0] - 2026-03-27
11
+
12
+ ### Added
13
+ - `log_exception` method in `Methods` — single call for complete structured exception events
14
+ - `EventBuilder.build_exception` — builds rich exception payloads with fingerprint, versions, flat caller keys
15
+ - `EventBuilder.fingerprint` — MD5 fingerprint of stable error fields for dedup
16
+ - `log_writer` / `exception_writer` pluggable lambda slots on `Legion::Logging`
17
+ - Size enforcement: 4KB message cap, 8KB payload_summary cap, 64KB total cap
18
+ - Vault token, JWT, lease ID, and URI patterns added to Redactor
19
+
20
+ ### Removed
21
+ - `Legion::Logging::Hooks` module (`on_fatal`, `on_error`, `on_warn`, `enable_hooks!`, `disable_hooks!`, `clear_hooks!`)
22
+ - Hooks replaced by `log_writer` and `exception_writer` lambdas
23
+
24
+ ### Changed
25
+ - `AsyncWriter::LogEntry` uses `writer_context` field instead of `hook_context`
26
+ - `runner_exception` now delegates to `log_exception` internally
27
+
3
28
  ## [1.3.5] - 2026-03-24
4
29
 
5
30
  ### 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
@@ -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, 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)
@@ -15,6 +24,99 @@ module Legion
15
24
  event.compact
16
25
  end
17
26
 
27
+ def build_exception(
28
+ exception:,
29
+ level:,
30
+ lex: nil,
31
+ component_type: nil,
32
+ gem_name: nil,
33
+ lex_version: nil,
34
+ gem_path: nil,
35
+ source_code_uri: nil,
36
+ handled: false,
37
+ payload_summary: nil,
38
+ task_id: nil,
39
+ caller_offset: 2,
40
+ **extra
41
+ )
42
+ bt = Array(exception.backtrace)
43
+ cf_file, cf_line, cf_func = parse_backtrace_location(bt.first) ||
44
+ caller_location(caller_offset)
45
+
46
+ event = {
47
+ timestamp: Time.now.utc.iso8601(3),
48
+ level: level,
49
+ exception_class: exception.class.name,
50
+ message: truncate_bytes(exception.message.to_s, MAX_MESSAGE_BYTES),
51
+ backtrace: bt,
52
+ caller_file: cf_file,
53
+ caller_line: cf_line,
54
+ caller_function: cf_func,
55
+ lex: lex,
56
+ component_type: component_type,
57
+ gem_name: gem_name,
58
+ lex_version: lex_version,
59
+ gem_path: gem_path,
60
+ source_code_uri: source_code_uri,
61
+ legion_versions: legion_versions,
62
+ ruby_version: "#{RUBY_VERSION} #{RUBY_PLATFORM}",
63
+ handled: handled,
64
+ pid: ::Process.pid,
65
+ thread: Thread.current.object_id
66
+ }
67
+
68
+ event[:task_id] = task_id if task_id
69
+ event[:payload_summary] = truncate_payload(payload_summary) if payload_summary
70
+
71
+ add_node(event)
72
+ add_user(event)
73
+ add_session_context(event)
74
+
75
+ event[:error_fingerprint] = fingerprint(
76
+ exception_class: exception.class.name,
77
+ message: event[:message],
78
+ caller_file: cf_file.to_s,
79
+ caller_line: cf_line.to_i,
80
+ caller_function: cf_func.to_s,
81
+ gem_name: gem_name.to_s,
82
+ component_type: component_type.to_s,
83
+ backtrace: bt
84
+ )
85
+
86
+ extra.each { |k, v| event[k] = v unless event.key?(k) }
87
+
88
+ enforce_total_size!(event)
89
+ event.compact
90
+ end
91
+
92
+ def fingerprint(
93
+ exception_class:,
94
+ message:,
95
+ caller_file:,
96
+ caller_line:,
97
+ caller_function:,
98
+ gem_name:,
99
+ component_type:,
100
+ backtrace:
101
+ )
102
+ norm_msg = normalize_message(message.to_s)
103
+ norm_file = normalize_path(caller_file.to_s)
104
+ norm_bt = Array(backtrace).first(5).map { |l| normalize_path(l.to_s) }.join('|')
105
+
106
+ raw = [
107
+ exception_class.to_s,
108
+ norm_msg,
109
+ norm_file,
110
+ caller_line.to_s,
111
+ caller_function.to_s,
112
+ gem_name.to_s,
113
+ component_type.to_s,
114
+ norm_bt
115
+ ].join(':')
116
+
117
+ Digest::MD5.hexdigest(raw)
118
+ end
119
+
18
120
  private
19
121
 
20
122
  def base_fields(level, message)
@@ -95,6 +197,106 @@ module Legion
95
197
  def strip_ansi(str)
96
198
  str.gsub(/\e\[[0-9;]*m/, '')
97
199
  end
200
+
201
+ # New private helpers for build_exception
202
+
203
+ def add_user(event)
204
+ identity = if defined?(Legion::Extensions::Helpers::Secret) &&
205
+ Legion::Extensions::Helpers::Secret.respond_to?(:resolved_identity)
206
+ Legion::Extensions::Helpers::Secret.resolved_identity
207
+ else
208
+ ENV.fetch('USER', nil)
209
+ end
210
+ event[:user] = identity
211
+ end
212
+
213
+ def add_session_context(event)
214
+ return unless defined?(Legion::Context)
215
+
216
+ session = begin
217
+ Legion::Context.current_session
218
+ rescue StandardError
219
+ nil
220
+ end
221
+ return unless session
222
+
223
+ event[:conversation_id] = session.session_id
224
+ end
225
+
226
+ def parse_backtrace_location(frame)
227
+ return nil unless frame.is_a?(String)
228
+
229
+ # Format: /path/to/file.rb:42:in `method_name`
230
+ if (m = frame.match(/\A(.+):(\d+):in `([^`]+)`\z/))
231
+ [m[1], m[2].to_i, m[3]]
232
+ elsif (m = frame.match(/\A(.+):(\d+)\z/))
233
+ [m[1], m[2].to_i, nil]
234
+ end
235
+ end
236
+
237
+ def caller_location(offset)
238
+ loc = caller_locations(offset + 2, 1)&.first
239
+ return [nil, nil, nil] unless loc
240
+
241
+ [loc.absolute_path || loc.path, loc.lineno, loc.base_label]
242
+ end
243
+
244
+ def normalize_message(msg)
245
+ msg
246
+ .gsub(/0x[0-9a-f]+/i, '0xXXX')
247
+ .gsub(/#<[A-Z][A-Za-z:]*:0xXXX>/, '#<Class:0xXXX>')
248
+ .gsub(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/, 'X.X.X.X')
249
+ end
250
+
251
+ def normalize_path(path)
252
+ path.gsub(/-\d+\.\d+[\d.]*/, '')
253
+ end
254
+
255
+ def legion_versions
256
+ Gem::Specification
257
+ .select { |s| s.name.start_with?('legion-', 'lex-') }
258
+ .to_h { |s| [s.name, s.version.to_s] }
259
+ end
260
+
261
+ def truncate_bytes(str, max)
262
+ return str if str.bytesize <= max
263
+
264
+ str.byteslice(0, max).scrub
265
+ end
266
+
267
+ def safe_json_bytesize(object)
268
+ ::JSON.generate(object).bytesize
269
+ rescue ::JSON::GeneratorError, TypeError
270
+ object.to_s.bytesize
271
+ end
272
+
273
+ def truncate_payload(payload)
274
+ return nil unless payload
275
+
276
+ str = if payload.is_a?(String)
277
+ payload
278
+ else
279
+ begin
280
+ ::JSON.generate(payload)
281
+ rescue ::JSON::GeneratorError, TypeError
282
+ payload.to_s
283
+ end
284
+ end
285
+ truncate_bytes(str, MAX_PAYLOAD_BYTES)
286
+ end
287
+
288
+ def enforce_total_size!(event)
289
+ return if safe_json_bytesize(event) <= MAX_TOTAL_BYTES
290
+
291
+ event.delete(:payload_summary)
292
+ return if safe_json_bytesize(event) <= MAX_TOTAL_BYTES
293
+
294
+ bt = event[:backtrace]
295
+ event[:backtrace] = bt.first(BACKTRACE_FALLBACK_FRAMES) if bt.is_a?(Array)
296
+ return if safe_json_bytesize(event) <= MAX_TOTAL_BYTES
297
+
298
+ event[:message] = truncate_bytes(event[:message].to_s, 1024)
299
+ end
98
300
  end
99
301
  end
100
302
  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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Logging
5
- VERSION = '1.3.5'
5
+ VERSION = '1.4.1'
6
6
  end
7
7
  end
@@ -4,7 +4,6 @@ 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'
@@ -19,14 +18,19 @@ module Legion
19
18
  include Legion::Logging::Methods
20
19
  include Legion::Logging::Builder
21
20
 
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
21
  attr_reader :color
22
+ attr_writer :log_writer, :exception_writer
23
+
24
+ DEFAULT_LOG_WRITER = ->(_event, routing_key:) {}
25
+ DEFAULT_EXCEPTION_WRITER = ->(_event, routing_key:, headers:, properties:) {}
26
+
27
+ def log_writer
28
+ @log_writer || DEFAULT_LOG_WRITER
29
+ end
30
+
31
+ def exception_writer
32
+ @exception_writer || DEFAULT_EXCEPTION_WRITER
33
+ end
30
34
 
31
35
  def setup(level: 'info', format: :text, async: true, **options)
32
36
  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.1
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"
@@ -61,7 +63,6 @@ files:
61
63
  - lib/legion/logging/builder.rb
62
64
  - lib/legion/logging/event_builder.rb
63
65
  - lib/legion/logging/helper.rb
64
- - lib/legion/logging/hooks.rb
65
66
  - lib/legion/logging/logger.rb
66
67
  - lib/legion/logging/methods.rb
67
68
  - 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