legion-logging 1.3.4 → 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 +35 -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 +135 -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,40 @@
|
|
|
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
|
+
|
|
28
|
+
## [1.3.5] - 2026-03-24
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- Automatic PII/PHI redaction in log write path: all log methods (`debug`, `info`, `warn`, `error`, `fatal`, `unknown`) pass string messages through `Legion::Logging::Redactor.redact_string` when `logging.redaction.enabled` is `true` (default: `false`)
|
|
32
|
+
- `maybe_redact(message)` private helper on `Legion::Logging::Methods` — no-ops when redaction is disabled, `Redactor` is not defined, or message is not a string
|
|
33
|
+
- Hook callbacks (`on_warn`, `on_error`, `on_fatal`) receive already-redacted message so no PHI leaks through hook dispatch
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- `redaction_enabled?` guards against recursive `Legion::Settings::Loader` initialization by checking `@loader` ivar directly before calling `dig`; prevents infinite recursion when settings bootstrap calls `Legion::Logging.warn`
|
|
37
|
+
|
|
3
38
|
## [1.3.4] - 2026-03-24
|
|
4
39
|
|
|
5
40
|
### Fixed
|
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
|
|
@@ -21,10 +23,11 @@ module Legion
|
|
|
21
23
|
return unless log.level < 1
|
|
22
24
|
|
|
23
25
|
message = yield if message.nil? && block_given?
|
|
26
|
+
message = maybe_redact(message)
|
|
24
27
|
message = Rainbow(message).blue if @color
|
|
25
28
|
writer = @async_writer
|
|
26
29
|
if writer&.alive?
|
|
27
|
-
writer.push(AsyncWriter::LogEntry.new(level: :debug, message: message,
|
|
30
|
+
writer.push(AsyncWriter::LogEntry.new(level: :debug, message: message, writer_context: nil))
|
|
28
31
|
else
|
|
29
32
|
log.debug(message)
|
|
30
33
|
end
|
|
@@ -34,10 +37,11 @@ module Legion
|
|
|
34
37
|
return unless log.level < 2
|
|
35
38
|
|
|
36
39
|
message = yield if message.nil? && block_given?
|
|
40
|
+
message = maybe_redact(message)
|
|
37
41
|
message = Rainbow(message).green if @color
|
|
38
42
|
writer = @async_writer
|
|
39
43
|
if writer&.alive?
|
|
40
|
-
writer.push(AsyncWriter::LogEntry.new(level: :info, message: message,
|
|
44
|
+
writer.push(AsyncWriter::LogEntry.new(level: :info, message: message, writer_context: nil))
|
|
41
45
|
else
|
|
42
46
|
log.info(message)
|
|
43
47
|
end
|
|
@@ -47,15 +51,16 @@ module Legion
|
|
|
47
51
|
return unless log.level < 3
|
|
48
52
|
|
|
49
53
|
message = yield if message.nil? && block_given?
|
|
54
|
+
message = maybe_redact(message)
|
|
50
55
|
raw = message
|
|
51
56
|
message = Rainbow(message).yellow if @color
|
|
52
57
|
writer = @async_writer
|
|
53
58
|
if writer&.alive?
|
|
54
|
-
ctx =
|
|
55
|
-
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))
|
|
56
61
|
else
|
|
57
62
|
log.warn(message)
|
|
58
|
-
|
|
63
|
+
fire_log_writer(:warn, raw)
|
|
59
64
|
end
|
|
60
65
|
end
|
|
61
66
|
|
|
@@ -63,15 +68,16 @@ module Legion
|
|
|
63
68
|
return unless log.level < 4
|
|
64
69
|
|
|
65
70
|
message = yield if message.nil? && block_given?
|
|
71
|
+
message = maybe_redact(message)
|
|
66
72
|
raw = message
|
|
67
73
|
message = Rainbow(message).red if @color
|
|
68
74
|
writer = @async_writer
|
|
69
75
|
if writer&.alive?
|
|
70
|
-
ctx =
|
|
71
|
-
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))
|
|
72
78
|
else
|
|
73
79
|
log.error(message)
|
|
74
|
-
|
|
80
|
+
fire_log_writer(:error, raw)
|
|
75
81
|
end
|
|
76
82
|
end
|
|
77
83
|
|
|
@@ -79,30 +85,69 @@ module Legion
|
|
|
79
85
|
return unless log.level < 5
|
|
80
86
|
|
|
81
87
|
message = yield if message.nil? && block_given?
|
|
88
|
+
message = maybe_redact(message)
|
|
82
89
|
raw = message
|
|
83
90
|
message = Rainbow(message).darkred if @color
|
|
84
91
|
log.fatal(message)
|
|
85
|
-
|
|
92
|
+
fire_log_writer(:fatal, raw)
|
|
86
93
|
end
|
|
87
94
|
|
|
88
95
|
def unknown(message = nil)
|
|
89
96
|
message = yield if message.nil? && block_given?
|
|
97
|
+
message = maybe_redact(message)
|
|
90
98
|
message = Rainbow(message).purple if @color
|
|
91
99
|
writer = @async_writer
|
|
92
100
|
if writer&.alive?
|
|
93
|
-
writer.push(AsyncWriter::LogEntry.new(level: :unknown, message: message,
|
|
101
|
+
writer.push(AsyncWriter::LogEntry.new(level: :unknown, message: message, writer_context: nil))
|
|
94
102
|
else
|
|
95
103
|
log.unknown(message)
|
|
96
104
|
end
|
|
97
105
|
end
|
|
98
106
|
|
|
99
107
|
def runner_exception(exc, **opts)
|
|
100
|
-
|
|
101
|
-
Legion::Logging.error exc.backtrace
|
|
102
|
-
Legion::Logging.error opts
|
|
108
|
+
log_exception(exc, handled: true, **opts)
|
|
103
109
|
{ success: false, message: exc.message, backtrace: exc.backtrace }.merge(opts)
|
|
104
110
|
end
|
|
105
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
|
+
|
|
106
151
|
def thread(kvl: false, **_opts)
|
|
107
152
|
if kvl
|
|
108
153
|
"thread=#{Thread.current.object_id}"
|
|
@@ -113,9 +158,74 @@ module Legion
|
|
|
113
158
|
|
|
114
159
|
private
|
|
115
160
|
|
|
116
|
-
def
|
|
117
|
-
return
|
|
118
|
-
return
|
|
161
|
+
def maybe_redact(message)
|
|
162
|
+
return message unless message.is_a?(String)
|
|
163
|
+
return message unless redaction_enabled?
|
|
164
|
+
return message unless defined?(Legion::Logging::Redactor)
|
|
165
|
+
|
|
166
|
+
Legion::Logging::Redactor.redact_string(message)
|
|
167
|
+
rescue StandardError
|
|
168
|
+
message
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def redaction_enabled?
|
|
172
|
+
return false unless defined?(Legion::Settings)
|
|
173
|
+
|
|
174
|
+
loader = Legion::Settings.instance_variable_get(:@loader)
|
|
175
|
+
return false unless loader
|
|
176
|
+
|
|
177
|
+
loader.dig(:logging, :redaction, :enabled) == true
|
|
178
|
+
rescue StandardError
|
|
179
|
+
false
|
|
180
|
+
end
|
|
181
|
+
|
|
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?
|
|
119
229
|
|
|
120
230
|
lex_val = instance_variable_defined?(:@lex) ? @lex : nil
|
|
121
231
|
lex_segs = instance_variable_defined?(:@lex_segments) ? @lex_segments : nil
|
|
@@ -130,10 +240,7 @@ module Legion
|
|
|
130
240
|
{ level: level, event: event }
|
|
131
241
|
end
|
|
132
242
|
|
|
133
|
-
def
|
|
134
|
-
return unless Legion::Logging::Hooks.enabled?
|
|
135
|
-
return if Legion::Logging::Hooks.hooks[level].empty?
|
|
136
|
-
|
|
243
|
+
def fire_log_writer(level, message)
|
|
137
244
|
lex_val = instance_variable_defined?(:@lex) ? @lex : nil
|
|
138
245
|
lex_segs = instance_variable_defined?(:@lex_segments) ? @lex_segments : nil
|
|
139
246
|
|
|
@@ -144,7 +251,14 @@ module Legion
|
|
|
144
251
|
lex_segments: lex_segs,
|
|
145
252
|
caller_offset: 4
|
|
146
253
|
)
|
|
147
|
-
|
|
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
|
|
148
262
|
end
|
|
149
263
|
end
|
|
150
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
|