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 +4 -4
- data/.github/CODEOWNERS +7 -0
- data/.github/dependabot.yml +18 -0
- data/.rubocop.yml +3 -0
- data/CHANGELOG.md +25 -0
- data/README.md +30 -1
- data/lib/legion/logging/async_writer.rb +11 -6
- data/lib/legion/logging/event_builder.rb +203 -1
- data/lib/legion/logging/logger.rb +1 -1
- data/lib/legion/logging/methods.rb +108 -21
- data/lib/legion/logging/redactor.rb +12 -6
- data/lib/legion/logging/version.rb +1 -1
- data/lib/legion/logging.rb +12 -8
- metadata +3 -2
- data/lib/legion/logging/hooks.rb +0 -46
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 87467334f127d64997e116d784a68d875c8180a6633dd068e4e095eb7e8bc22f
|
|
4
|
+
data.tar.gz: a934447ca2a89f43d3f4993a1df8e879b15945b966b4ade4f29b4d6b2272762d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb9ece1de3b070fb58417992daf22fdaf3e0021f8a45e486a6d5fa4bdca8536c9ed6bdaec8b867f8aef854b52a1496d462260c7cf31d1faae4b0769a91995ef2
|
|
7
|
+
data.tar.gz: 00f6510d46e38bb7a638aace8818cdccdefe9bfb8c857ecc2d6b11a43f55461ea25bd56a37b0b325749971808491679160930ba11014a453d7fbd5b096d50368
|
data/.github/CODEOWNERS
ADDED
|
@@ -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
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.
|
|
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, :
|
|
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
|
-
|
|
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
|
|
73
|
-
ctx = entry.
|
|
74
|
-
|
|
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
|
|
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)
|
|
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)
|
|
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,
|
|
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,
|
|
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 =
|
|
58
|
-
writer.push(AsyncWriter::LogEntry.new(level: :warn, message: message,
|
|
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
|
-
|
|
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 =
|
|
75
|
-
writer.push(AsyncWriter::LogEntry.new(level: :error, message: message,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
8
|
-
email:
|
|
9
|
-
phone:
|
|
10
|
-
mrn:
|
|
11
|
-
dob:
|
|
12
|
-
credit_card:
|
|
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
|
data/lib/legion/logging.rb
CHANGED
|
@@ -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.
|
|
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
|
data/lib/legion/logging/hooks.rb
DELETED
|
@@ -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
|