legionio 1.6.18 → 1.6.21

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: 5796d0724836fec4f8426fa337d5c6de420fbdf81c94fa9d070ca2a1a6abd9c7
4
- data.tar.gz: bbaa73b5ee1d36c1c8b7cb9536d1c7e4144464cacfe68c1b4fee55b9bfea08df
3
+ metadata.gz: 4d50e1efc9398c0e2d380c7a921e2ccc8b6e6db3a142d4f4a700ff536555a2e4
4
+ data.tar.gz: e44d47e0c4d04362d9a35c6ad6953d0b4a42a5f0f2225dd0420e7f2d1c544ad4
5
5
  SHA512:
6
- metadata.gz: 2c1a641e20d067fddcf0d949c3d2f11bbbcca141783b20e581f361bb916821c07e3538037e00c21f7866a12399d3a2c3ce56ad040f8af5713ff2969ea4ee1463
7
- data.tar.gz: 568b3efc39291f9e2593b373eb3009db4d708fc7030b38237924d52acf2933c5a64e0aafb4e39265fe8997a7fd7a66aded647d969a4dcb3a8af1e3207afe0c29
6
+ metadata.gz: f4cc63f869d21abc423f7836a25b91f62a815d01d1972004b85728be0b969e332a5f95c9172ab6177f3759e837b6e40bbb30afba1ecd3a817cd5088fff4c675b
7
+ data.tar.gz: 719f9a8e3d316937be26ed610b7ba86c653317b476b271d821591fecf5ff8157c02788b03df1468c7fe3af19d76ec1a55cf48312d911a502e30873c51d378bb0
data/CHANGELOG.md CHANGED
@@ -2,8 +2,41 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.6.21] - 2026-03-27
6
+
7
+ ### Added
8
+ - `legionio knowledge capture transcript` CLI command: ingests Claude Code session transcripts into Apollo knowledge store
9
+ - Stop hook for automatic transcript capture at session end (installed via `legion setup claude-code`)
10
+
11
+ ## [1.6.20] - 2026-03-27
12
+
13
+ ### Changed
14
+ - Bump `legion-logging` dependency to `>= 1.4.0` (required for `log_exception`, writer lambdas)
15
+
16
+ ### Fixed
17
+ - `subscription.rb` (both `on_delivery` and `subscribe` blocks): initialize `fn = nil` before `process_message` so the rescue interpolation never raises `NameError` if message processing fails before `fn` is assigned
18
+ - `Helpers::Logger#lex_name` removed to avoid overriding `Helpers::Base#lex_name` (underscore contract used by settings/routing); renamed to private `log_lex_name` used only within this module for gem name derivation
19
+ - `Helpers::Logger#handle_exception`: use `spec&.version&.to_s` so nil spec version produces `nil` rather than `""` in structured log output
20
+ - README: update version badge from `v1.6.18` to `v1.6.20`
21
+
22
+ ## [1.6.19] - 2026-03-27
23
+
24
+ ### Fixed
25
+ - `teardown_logging_transport`: rescue block in `setup_logging_transport` now calls `teardown_logging_transport` to clean up any partially-created `@log_session` on failure
26
+ - `teardown_logging_transport`: guard `open?` call with `respond_to?(:open?)` check to avoid `NoMethodError` on session objects that do not implement the method
27
+ - `service_logging_transport_spec`: early-return specs now assert `create_dedicated_session` was not called and `@log_session` remains nil, rather than the vacuous `respond_to(:call)` check
28
+ - `service_logging_transport_spec`: replaced vacuous `not_to eq(owner)` assertion with `have_received(:create_dedicated_session)` to verify the dedicated session was actually created
29
+
5
30
  ## [1.6.18] - 2026-03-27
6
31
 
32
+ ### Added
33
+ - `setup_logging_transport`: dedicated AMQP session for log and exception forwarding, replacing the previous `register_logging_hooks` approach; writer lambda wiring is gated by `Settings[:logging][:transport]` feature flags
34
+ - `teardown_logging_transport`: cleanly shuts down the dedicated logging AMQP session during the shutdown sequence
35
+
36
+ ### Changed
37
+ - Split `log.error(e.message); log.error(e.backtrace)` patterns replaced with `log.log_exception` across 14 files for structured, single-call exception logging
38
+ - `Extensions::Helpers::Logger#handle_exception` rewritten to use `log.log_exception` with full lex context
39
+
7
40
  ### Fixed
8
41
  - `legionio pipeline image analyze`: `call_llm` no longer passes unsupported `messages:` keyword to `Legion::LLM.chat`; now creates a chat object and sends multimodal content via `chat.ask`, returning a plain hash with `:content` and `:usage` keys
9
42
  - `legionio ai trace search/summarize`: both commands now call `setup_connection` before invoking `TraceSearch`, ensuring `Legion::LLM` is booted so `TraceSearch.generate_filter` can use structured LLM output instead of returning "no filter generated"; added `class_option :config_dir` and `class_option :verbose` to `TraceCommand`
data/README.md CHANGED
@@ -14,7 +14,7 @@ Schedule tasks, chain services into dependency graphs, run them concurrently via
14
14
  ╰──────────────────────────────────────╯
15
15
  ```
16
16
 
17
- **Ruby >= 3.4** | **v1.5.20** | **Apache-2.0** | [@Esity](https://github.com/Esity)
17
+ **Ruby >= 3.4** | **v1.6.20** | **Apache-2.0** | [@Esity](https://github.com/Esity)
18
18
 
19
19
  ---
20
20
 
data/legionio.gemspec CHANGED
@@ -56,7 +56,7 @@ Gem::Specification.new do |spec|
56
56
  spec.add_dependency 'legion-crypt', '>= 1.4.17'
57
57
  spec.add_dependency 'legion-data', '>= 1.6.7'
58
58
  spec.add_dependency 'legion-json', '>= 1.2.1'
59
- spec.add_dependency 'legion-logging', '>= 1.3.2'
59
+ spec.add_dependency 'legion-logging', '>= 1.4.0'
60
60
  spec.add_dependency 'legion-settings', '>= 1.3.19'
61
61
  spec.add_dependency 'legion-transport', '>= 1.4.4'
62
62
 
@@ -66,8 +66,7 @@ module Legion
66
66
 
67
67
  dispatch_hook(context, payload: payload, runner: runner, function: function)
68
68
  rescue StandardError => e
69
- Legion::Logging.error "API #{request.request_method} #{request.path_info}: #{e.class} — #{e.message}"
70
- Legion::Logging.error e.backtrace&.first(5)
69
+ Legion::Logging.log_exception(e, payload_summary: "API #{request.request_method} #{request.path_info}", component_type: :api)
71
70
  context.json_error('internal_error', e.message, status_code: 500)
72
71
  end
73
72
 
@@ -53,8 +53,7 @@ module Legion
53
53
  context.json_response({ task_id: result[:task_id], status: result[:status],
54
54
  result: result[:result] }.compact)
55
55
  rescue StandardError => e
56
- Legion::Logging.error "API POST /api/lex/#{request.path_info.sub(%r{^/api/lex/}, '')}: #{e.class} — #{e.message}"
57
- Legion::Logging.error e.backtrace&.first(5)
56
+ Legion::Logging.log_exception(e, payload_summary: "API POST /api/lex/#{request.path_info.sub(%r{^/api/lex/}, '')}", component_type: :api)
58
57
  context.json_error('internal_error', e.message, status_code: 500)
59
58
  end
60
59
 
data/lib/legion/api.rb CHANGED
@@ -95,8 +95,7 @@ module Legion
95
95
  error do
96
96
  content_type :json
97
97
  err = env['sinatra.error']
98
- Legion::Logging.error "API #{request.request_method} #{request.path_info} returned 500: #{err.class} — #{err.message}"
99
- Legion::Logging.error err.backtrace&.first(10)
98
+ Legion::Logging.log_exception(err, payload_summary: "API #{request.request_method} #{request.path_info} returned 500", component_type: :api)
100
99
  Legion::JSON.dump({
101
100
  error: { code: 'internal_error', message: err.message },
102
101
  meta: { timestamp: Time.now.utc.iso8601, node: Legion::Settings[:client][:name] }
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'shellwords'
4
+
3
5
  module Legion
4
6
  module CLI
5
7
  class MonitorCommand < Thor
@@ -173,10 +175,130 @@ module Legion
173
175
  end
174
176
  end
175
177
 
176
- no_commands do
178
+ desc 'transcript', 'Capture a Claude Code session transcript as knowledge'
179
+ option :session_id, type: :string, desc: 'Session ID (defaults to CLAUDE_SESSION_ID env)'
180
+ option :cwd, type: :string, desc: 'Working directory (defaults to CLAUDE_CWD env)'
181
+ option :max_chunks, type: :numeric, default: 50, desc: 'Max conversation turn chunks to ingest'
182
+ def transcript
183
+ session_id = options[:session_id] || ENV.fetch('CLAUDE_SESSION_ID', nil)
184
+ cwd = options[:cwd] || ENV.fetch('CLAUDE_CWD', nil) || ::Dir.pwd
185
+
186
+ unless session_id
187
+ formatter.warn('No session ID provided (set CLAUDE_SESSION_ID or --session-id)')
188
+ return
189
+ end
190
+
191
+ jsonl_path = resolve_transcript_path(session_id, cwd)
192
+ unless jsonl_path && ::File.exist?(jsonl_path)
193
+ formatter.warn("Transcript not found: #{jsonl_path || 'could not resolve path'}")
194
+ return
195
+ end
196
+
197
+ turns = extract_turns(jsonl_path)
198
+ if turns.empty?
199
+ formatter.warn('No conversation turns found in transcript')
200
+ return
201
+ end
202
+
203
+ repo = `git -C #{Shellwords.escape(cwd)} rev-parse --show-toplevel 2>/dev/null`.strip.split('/').last
204
+ base_tags = ['claude-code', 'transcript', "session:#{session_id}", ::Time.now.strftime('%Y-%m-%d')]
205
+ base_tags << "repo:#{repo}" unless repo.to_s.empty?
206
+
207
+ ingested = 0
208
+ turns.first(options[:max_chunks]).each_with_index do |turn, idx|
209
+ content = format_turn(turn, idx + 1)
210
+ next if content.strip.empty?
211
+
212
+ result = ingest_content(
213
+ content: content,
214
+ tags: base_tags + ["turn:#{idx + 1}"],
215
+ source: "claude-code:#{session_id}:turn-#{idx + 1}"
216
+ )
217
+ ingested += 1 if result[:success]
218
+ end
219
+
220
+ out = formatter
221
+ if options[:json]
222
+ out.json(success: true, session_id: session_id, turns: turns.size, ingested: ingested)
223
+ else
224
+ out.success("Captured #{ingested}/#{[turns.size, options[:max_chunks]].min} turns from session #{session_id[0, 8]}")
225
+ end
226
+ end
227
+
228
+ no_commands do # rubocop:disable Metrics/BlockLength
177
229
  def formatter
178
230
  @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color])
179
231
  end
232
+
233
+ def resolve_transcript_path(session_id, cwd)
234
+ project_dir = cwd.gsub('/', '-')
235
+ ::File.expand_path("~/.claude/projects/#{project_dir}/#{session_id}.jsonl")
236
+ end
237
+
238
+ def extract_turns(path)
239
+ turns = []
240
+ current_turn = nil
241
+
242
+ ::File.foreach(path) do |line|
243
+ entry = ::JSON.parse(line, symbolize_names: true)
244
+ type = entry[:type]
245
+
246
+ case type
247
+ when 'user'
248
+ turns << current_turn if current_turn
249
+ current_turn = { user: extract_message_text(entry), assistant: +'', timestamp: entry[:timestamp] }
250
+ when 'assistant'
251
+ next unless current_turn
252
+
253
+ text = extract_message_text(entry)
254
+ current_turn[:assistant] << text unless text.empty?
255
+ end
256
+ rescue ::JSON::ParserError
257
+ next
258
+ end
259
+
260
+ turns << current_turn if current_turn
261
+ turns
262
+ end
263
+
264
+ def extract_message_text(entry)
265
+ msg = entry[:message]
266
+ return '' unless msg
267
+
268
+ content = msg[:content]
269
+ case content
270
+ when String then content
271
+ when Array
272
+ content.filter_map { |block| block[:text] if block[:type] == 'text' }.join("\n")
273
+ else ''
274
+ end
275
+ end
276
+
277
+ def format_turn(turn, number)
278
+ text = "## Turn #{number}\n"
279
+ text << "Timestamp: #{turn[:timestamp]}\n\n" if turn[:timestamp]
280
+ text << "### User\n#{truncate_text(turn[:user], 4096)}\n\n"
281
+ text << "### Assistant\n#{truncate_text(turn[:assistant], 4096)}\n"
282
+ text
283
+ end
284
+
285
+ def truncate_text(text, max_bytes)
286
+ return text if text.bytesize <= max_bytes
287
+
288
+ "#{text.byteslice(0, max_bytes - 20)}\n\n[truncated]"
289
+ end
290
+
291
+ def ingest_content(content:, tags:, source:)
292
+ if defined?(Legion::Extensions::Knowledge::Runners::Ingest)
293
+ Legion::Extensions::Knowledge::Runners::Ingest.ingest_file(
294
+ content: content, tags: tags, source: source
295
+ )
296
+ elsif defined?(Legion::Apollo)
297
+ Legion::Apollo.ingest(content: content, tags: tags, source: source)
298
+ else
299
+ { success: false, error: 'neither lex-knowledge nor legion-apollo available' }
300
+ end
301
+ end
180
302
  end
181
303
  end
182
304
 
@@ -350,9 +350,9 @@ module Legion
350
350
 
351
351
  hooks = existing['hooks'] || {}
352
352
 
353
- has_commit = Array(hooks['PostToolUse']).any? { |h| h['command']&.include?('knowledge capture commit') }
354
- has_session = Array(hooks['Stop']).any? { |h| h['command']&.include?('knowledge capture session') }
355
- if has_commit && has_session && !options[:force]
353
+ has_commit = Array(hooks['PostToolUse']).any? { |h| h['command']&.include?('knowledge capture commit') }
354
+ has_transcript = Array(hooks['Stop']).any? { |h| h['command']&.include?('knowledge capture transcript') }
355
+ if has_commit && has_transcript && !options[:force]
356
356
  puts ' Write-back hooks already present (use --force to overwrite)' unless options[:json]
357
357
  return
358
358
  end
@@ -368,10 +368,10 @@ module Legion
368
368
  }
369
369
  end
370
370
 
371
- unless has_session
371
+ unless has_transcript
372
372
  hooks['Stop'] << {
373
- 'command' => 'legionio knowledge capture session',
374
- 'timeout' => 15_000
373
+ 'command' => 'legionio knowledge capture transcript',
374
+ 'timeout' => 30_000
375
375
  }
376
376
  end
377
377
 
data/lib/legion/events.rb CHANGED
@@ -31,8 +31,7 @@ module Legion
31
31
  listeners[event_name.to_s].each do |listener|
32
32
  listener.call(event)
33
33
  rescue StandardError => e
34
- Legion::Logging.warn "[Events] listener error on #{event_name}: #{e.message}"
35
- Legion::Logging.error e.backtrace&.first(5)
34
+ Legion::Logging.log_exception(e, payload_summary: "[Events] listener error on #{event_name}", component_type: :event)
36
35
  end
37
36
 
38
37
  # Also fire wildcard listeners
@@ -9,8 +9,7 @@ module Legion
9
9
  def runner
10
10
  Legion::Runner.run(runner_class: runner_class, function: function, check_subtask: check_subtask?, generate_task: generate_task?)
11
11
  rescue StandardError => e
12
- Legion::Logging.error e.message
13
- Legion::Logging.error e.backtrace
12
+ Legion::Logging.log_exception(e, component_type: :actor)
14
13
  end
15
14
 
16
15
  def manual
@@ -23,8 +22,7 @@ module Legion
23
22
  klass.send(func, **args)
24
23
  end
25
24
  rescue StandardError => e
26
- Legion::Logging.error e.message
27
- Legion::Logging.error e.backtrace
25
+ Legion::Logging.log_exception(e, component_type: :actor)
28
26
  end
29
27
 
30
28
  def function
@@ -16,15 +16,13 @@ module Legion
16
16
  begin
17
17
  skip_or_run { use_runner? ? runner : manual }
18
18
  rescue StandardError => e
19
- log.error "[Every] tick failed for #{self.class}: #{e.message}" if defined?(log)
20
- log.error e.backtrace if defined?(log)
19
+ log.log_exception(e, payload_summary: "[Every] tick failed for #{self.class}", component_type: :actor) if defined?(log)
21
20
  end
22
21
  end
23
22
 
24
23
  @timer.execute
25
24
  rescue StandardError => e
26
- log.error e.message
27
- log.error e.backtrace
25
+ log.log_exception(e, component_type: :actor)
28
26
  end
29
27
 
30
28
  def time
@@ -49,8 +47,7 @@ module Legion
49
47
 
50
48
  @timer.shutdown
51
49
  rescue StandardError => e
52
- log.error e.message
53
- log.error e.backtrace
50
+ log.log_exception(e, component_type: :actor)
54
51
  end
55
52
  end
56
53
  end
@@ -13,8 +13,7 @@ module Legion
13
13
  @loop = true
14
14
  async.run
15
15
  rescue StandardError => e
16
- Legion::Logging.error e
17
- Legion::Logging.error e.backtrace
16
+ Legion::Logging.log_exception(e, component_type: :actor)
18
17
  end
19
18
 
20
19
  def run
@@ -17,13 +17,11 @@ check_subtask: check_subtask? }}"
17
17
  @timer = Concurrent::TimerTask.new(execution_interval: time, run_now: run_now?) do
18
18
  skip_or_run { poll_cycle }
19
19
  rescue StandardError => e
20
- Legion::Logging.fatal e.message
21
- Legion::Logging.fatal e.backtrace
20
+ Legion::Logging.log_exception(e, level: :fatal, component_type: :actor)
22
21
  end
23
22
  @timer.execute
24
23
  rescue StandardError => e
25
- Legion::Logging.error e.message
26
- Legion::Logging.error e.backtrace
24
+ Legion::Logging.log_exception(e, component_type: :actor)
27
25
  end
28
26
 
29
27
  def poll_cycle
@@ -55,8 +53,7 @@ check_subtask: check_subtask? }}"
55
53
  log.debug("#{self.class} result: #{results}")
56
54
  results
57
55
  rescue StandardError => e
58
- Legion::Logging.fatal e.message
59
- Legion::Logging.fatal e.backtrace
56
+ Legion::Logging.log_exception(e, level: :fatal, component_type: :actor)
60
57
  end
61
58
 
62
59
  def cache_name
@@ -91,7 +88,7 @@ check_subtask: check_subtask? }}"
91
88
  Legion::Logging.debug 'Cancelling Legion Poller'
92
89
  @timer.shutdown
93
90
  rescue StandardError => e
94
- Legion::Logging.error e.message
91
+ Legion::Logging.log_exception(e, component_type: :actor)
95
92
  end
96
93
  end
97
94
  end
@@ -17,8 +17,7 @@ module Legion
17
17
  @queue = queue.new
18
18
  @queue.channel.prefetch(prefetch) if defined? prefetch
19
19
  rescue StandardError => e
20
- log.fatal e.message
21
- log.fatal e.backtrace
20
+ log.log_exception(e, level: :fatal, component_type: :actor)
22
21
  end
23
22
 
24
23
  def create_queue
@@ -48,12 +47,13 @@ module Legion
48
47
  true
49
48
  end
50
49
 
51
- def prepare # rubocop:disable Metrics/AbcSize
50
+ def prepare
52
51
  @queue = queue.new
53
52
  @queue.channel.prefetch(prefetch) if defined? prefetch
54
53
  consumer_tag = "#{Legion::Settings[:client][:name]}_#{lex_name}_#{runner_name}_#{SecureRandom.uuid}"
55
54
  @consumer = Bunny::Consumer.new(@queue.channel, @queue, consumer_tag, false, false)
56
55
  @consumer.on_delivery do |delivery_info, metadata, payload|
56
+ fn = nil
57
57
  message = process_message(payload, metadata, delivery_info)
58
58
  fn = find_function(message)
59
59
  log.debug "[Subscription] message received: #{lex_name}/#{fn}" if defined?(log)
@@ -76,14 +76,12 @@ module Legion
76
76
 
77
77
  cancel if Legion::Settings[:client][:shutting_down]
78
78
  rescue StandardError => e
79
- log.error "[Subscription] message processing failed: #{lex_name}/#{fn}: #{e.message}"
80
- log.error e.backtrace
79
+ log.log_exception(e, payload_summary: "[Subscription] message processing failed: #{lex_name}/#{fn}", component_type: :actor)
81
80
  @queue.reject(delivery_info.delivery_tag) if manual_ack
82
81
  end
83
82
  log.info "[Subscription] prepared: #{lex_name}/#{runner_name}"
84
83
  rescue StandardError => e
85
- log.fatal "Subscription#prepare failed: #{e.message}"
86
- log.fatal e.backtrace
84
+ log.log_exception(e, level: :fatal, payload_summary: 'Subscription#prepare failed', component_type: :actor)
87
85
  end
88
86
 
89
87
  def activate
@@ -159,6 +157,7 @@ module Legion
159
157
  metadata = rmq_message.last
160
158
  delivery_info = rmq_message.first
161
159
 
160
+ fn = nil
162
161
  message = process_message(payload, metadata, delivery_info)
163
162
  fn = find_function(message)
164
163
  log.debug "[Subscription] message received: #{lex_name}/#{fn}" if defined?(log)
@@ -185,8 +184,7 @@ module Legion
185
184
 
186
185
  cancel if Legion::Settings[:client][:shutting_down]
187
186
  rescue StandardError => e
188
- log.error "[Subscription] message processing failed: #{lex_name}/#{fn}: #{e.message}"
189
- log.error e.backtrace
187
+ log.log_exception(e, payload_summary: "[Subscription] message processing failed: #{lex_name}/#{fn}", component_type: :actor)
190
188
  log.warn "[Subscription] nacking message for #{lex_name}/#{fn}"
191
189
  @queue.reject(delivery_info.delivery_tag) if manual_ack
192
190
  end
@@ -220,9 +220,7 @@ module Legion
220
220
  lex_class.const_set(:Data, Module.new { extend Legion::Extensions::Data })
221
221
  end
222
222
  rescue StandardError => e
223
- Legion::Logging.error "[Core] auto_generate_data failed for #{name}: #{e.message}" if defined?(Legion::Logging)
224
- log.error e.message
225
- log.error e.backtrace
223
+ log.log_exception(e, payload_summary: "[Core] auto_generate_data failed for #{name}", component_type: :builder)
226
224
  end
227
225
  end
228
226
  end
@@ -7,9 +7,17 @@ module Legion
7
7
  include Legion::Logging::Helper
8
8
 
9
9
  def handle_exception(exception, task_id: nil, **opts)
10
- log.error exception.message + " for task_id: #{task_id} but was logged "
11
- log.error exception.backtrace[0..10]
12
- log.error opts
10
+ spec = gem_spec_for_lex
11
+ log.log_exception(exception,
12
+ lex: log_lex_name,
13
+ component_type: derive_component_type,
14
+ gem_name: lex_gem_name,
15
+ lex_version: spec&.version&.to_s,
16
+ gem_path: spec&.full_gem_path,
17
+ source_code_uri: spec&.metadata&.[]('source_code_uri'),
18
+ handled: true,
19
+ payload_summary: opts.empty? ? nil : opts,
20
+ task_id: task_id)
13
21
 
14
22
  unless task_id.nil?
15
23
  Legion::Transport::Messages::TaskLog.new(
@@ -25,6 +33,51 @@ module Legion
25
33
 
26
34
  raise Legion::Exception::HandledTask
27
35
  end
36
+
37
+ private
38
+
39
+ def derive_component_type
40
+ parts = respond_to?(:calling_class_array) ? calling_class_array : self.class.to_s.split('::')
41
+ match = parts.find { |p| Legion::Extensions::Helpers::Base::NAMESPACE_BOUNDARIES.include?(p) }
42
+ case match
43
+ when 'Runners' then :runner
44
+ when 'Actor', 'Actors' then :actor
45
+ when 'Transport' then :transport
46
+ when 'Helpers' then :helper
47
+ when 'Data' then :data
48
+ else :unknown
49
+ end
50
+ rescue StandardError
51
+ :unknown
52
+ end
53
+
54
+ def lex_gem_name
55
+ base_name = log_lex_name
56
+ return nil unless base_name
57
+
58
+ "lex-#{base_name}"
59
+ rescue StandardError
60
+ nil
61
+ end
62
+
63
+ def gem_spec_for_lex
64
+ name = lex_gem_name
65
+ return nil unless name
66
+
67
+ Gem::Specification.find_by_name(name)
68
+ rescue Gem::MissingSpecError
69
+ nil
70
+ end
71
+
72
+ def log_lex_name
73
+ if respond_to?(:segments)
74
+ segments.join('-')
75
+ else
76
+ derive_log_tag
77
+ end
78
+ rescue StandardError
79
+ nil
80
+ end
28
81
  end
29
82
  end
30
83
  end
@@ -16,8 +16,7 @@ module Legion
16
16
  return true if Legion::Data::Model::TaskLog.insert(task_id: task_id, function_id: function_id, entry: Legion::JSON.dump(payload))
17
17
  end
18
18
  rescue StandardError => e
19
- log.warn e.backtrace
20
- log.warn("generate_task_log failed, reverting to rmq message, e: #{e.message}")
19
+ log.log_exception(e, level: :warn, payload_summary: 'generate_task_log failed, reverting to rmq message', component_type: :helper)
21
20
  end
22
21
  Legion::Transport::Messages::TaskLog.new(task_id: task_id, runner_class: runner_class, function: function, entry: payload).publish
23
22
  end
@@ -41,8 +40,7 @@ module Legion
41
40
  end
42
41
  Legion::Transport::Messages::TaskUpdate.new(**update_hash).publish
43
42
  rescue StandardError => e
44
- log.fatal e.message
45
- log.fatal e.backtrace
43
+ log.log_exception(e, level: :fatal, component_type: :helper)
46
44
  raise e
47
45
  end
48
46
 
@@ -24,8 +24,7 @@ module Legion
24
24
  auto_create_dlx_queue
25
25
  log.info "[Transport] built exchanges=#{@exchanges.count} queues=#{@queues.count} for #{lex_name}"
26
26
  rescue StandardError => e
27
- log.error "[Transport] build failed for #{lex_name}: #{e.message}"
28
- log.error e.backtrace
27
+ log.log_exception(e, payload_summary: "[Transport] build failed for #{lex_name}", component_type: :transport)
29
28
  end
30
29
 
31
30
  def generate_base_modules
@@ -140,9 +139,7 @@ module Legion
140
139
  to = to.is_a?(String) ? Kernel.const_get(to).new : to.new
141
140
  to.bind(from, routing_key: routing_key)
142
141
  rescue StandardError => e
143
- log.fatal e.message
144
- log.fatal e.backtrace
145
- log.fatal({ from: from, to: to, routing_key: routing_key })
142
+ log.log_exception(e, level: :fatal, payload_summary: { from: from, to: to, routing_key: routing_key }, component_type: :transport)
146
143
  end
147
144
 
148
145
  def e_to_q
@@ -266,8 +266,7 @@ module Legion
266
266
  end
267
267
  true
268
268
  rescue StandardError => e
269
- Legion::Logging.error e.message
270
- Legion::Logging.error e.backtrace
269
+ Legion::Logging.log_exception(e, lex: entry[:gem_name], component_type: :boot)
271
270
  false
272
271
  end
273
272
 
@@ -21,8 +21,7 @@ module Legion
21
21
  Legion::Transport::Messages::TaskUpdate.new(task_id: task_id, status: status, **).publish
22
22
  rescue StandardError => e
23
23
  retries += 1
24
- Legion::Logging.warn "[Status] update_rmq failed (attempt #{retries}/3): #{e.message}"
25
- Legion::Logging.fatal e.backtrace
24
+ Legion::Logging.log_exception(e, level: :fatal, payload_summary: "[Status] update_rmq failed (attempt #{retries}/3)", component_type: :runner)
26
25
  retry if retries < 3
27
26
  end
28
27
 
@@ -32,9 +31,9 @@ module Legion
32
31
  task = Legion::Data::Model::Task[task_id]
33
32
  task.update(status: status)
34
33
  rescue StandardError => e
35
- Legion::Logging.warn "[Status] update_db failed for task_id=#{task_id}: #{e.message}"
36
- Legion::Logging.warn '[Status] falling back to RabbitMQ update'
37
- Legion::Logging.warn e.backtrace
34
+ Legion::Logging.log_exception(e, level: :warn,
35
+ payload_summary: "[Status] update_db failed for task_id=#{task_id}, falling back to RabbitMQ update",
36
+ component_type: :runner)
38
37
  update_rmq(task_id: task_id, status: status, **)
39
38
  end
40
39
 
@@ -61,8 +60,7 @@ module Legion
61
60
 
62
61
  { success: true, task_id: Legion::Data::Model::Task.insert(insert), **insert }
63
62
  rescue StandardError => e
64
- Legion::Logging.error e.message
65
- Legion::Logging.error e.backtrace
63
+ Legion::Logging.log_exception(e, component_type: :runner)
66
64
  raise(e)
67
65
  end
68
66
  end
@@ -48,7 +48,7 @@ module Legion
48
48
  if transport
49
49
  setup_transport
50
50
  Legion::Readiness.mark_ready(:transport)
51
- register_logging_hooks
51
+ setup_logging_transport
52
52
  end
53
53
 
54
54
  setup_dispatch
@@ -248,14 +248,18 @@ module Legion
248
248
  Legion::Logging.setup(log_level: log_level, level: log_level, trace: true)
249
249
  end
250
250
 
251
- def reconfigure_logging(cli_level)
252
- logging_settings = Legion::Settings[:logging] || {}
253
- level = cli_level || logging_settings[:level] || 'info'
251
+ def reconfigure_logging(cli_level = nil)
252
+ ls = Legion::Settings[:logging] || {}
253
+ level = cli_level || ls[:level] || 'info'
254
+
254
255
  Legion::Logging.setup(
255
- level: level,
256
- log_file: logging_settings[:log_file],
257
- log_stdout: logging_settings[:log_stdout],
258
- trace: logging_settings.fetch(:trace, true)
256
+ level: level,
257
+ format: (ls[:format] || 'text').to_sym,
258
+ log_file: ls[:log_file],
259
+ log_stdout: ls.fetch(:log_stdout, true),
260
+ trace: ls.fetch(:trace, true),
261
+ async: ls.fetch(:async, true),
262
+ include_pid: ls.fetch(:include_pid, false)
259
263
  )
260
264
  end
261
265
 
@@ -365,34 +369,73 @@ module Legion
365
369
  Legion::Logging.info 'Legion::Transport connected'
366
370
  end
367
371
 
368
- def register_logging_hooks
372
+ def setup_logging_transport # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
369
373
  return unless defined?(Legion::Transport::Connection)
370
374
  return unless Legion::Transport::Connection.session_open?
371
- return unless Legion::Transport::Connection.respond_to?(:log_channel)
372
375
 
373
- log_ch = Legion::Transport::Connection.log_channel
374
- unless log_ch
375
- Legion::Logging.debug 'No dedicated log channel available, log forwarding disabled'
376
- return
376
+ lt_settings = begin
377
+ Legion::Settings.dig(:logging, :transport) || {}
378
+ rescue StandardError
379
+ {}
377
380
  end
381
+ return unless lt_settings[:enabled] == true
378
382
 
379
- require 'legion/transport/exchanges/logging' unless defined?(Legion::Transport::Exchanges::Logging)
380
- exchange = Legion::Transport::Exchanges::Logging.new('legion.logging', channel: log_ch)
383
+ forward_logs = lt_settings.fetch(:forward_logs, true)
384
+ forward_exceptions = lt_settings.fetch(:forward_exceptions, true)
385
+ return unless forward_logs || forward_exceptions
381
386
 
382
- %i[fatal error warn].each do |level|
383
- Legion::Logging.send(:"on_#{level}") do |event|
384
- next unless log_ch&.open?
387
+ log_session = Legion::Transport::Connection.create_dedicated_session(name: 'legion-logging')
388
+ @log_session = log_session
389
+ log_channel = log_session.create_channel
390
+ log_channel.prefetch(1)
391
+ exchange = log_channel.topic('legion.logging', durable: true)
385
392
 
386
- source = event[:lex] || 'core'
387
- routing_key = "legion.#{source}.#{level}"
388
- exchange.publish(Legion::JSON.dump(event), routing_key: routing_key)
389
- rescue StandardError
390
- nil
391
- end
393
+ if forward_logs
394
+ Legion::Logging.log_writer = lambda { |event, routing_key:|
395
+ begin
396
+ next unless log_channel&.open?
397
+
398
+ exchange.publish(Legion::JSON.dump(event), routing_key: routing_key)
399
+ rescue StandardError
400
+ nil
401
+ end
402
+ }
392
403
  end
393
404
 
394
- Legion::Logging.enable_hooks!
395
- Legion::Logging.info('Logging hooks registered (dedicated channel)')
405
+ if forward_exceptions
406
+ Legion::Logging.exception_writer = lambda { |event, routing_key:, headers:, properties:|
407
+ begin
408
+ next unless log_channel&.open?
409
+
410
+ exchange.publish(
411
+ Legion::JSON.dump(event),
412
+ routing_key: routing_key,
413
+ headers: headers,
414
+ **properties
415
+ )
416
+ rescue StandardError
417
+ nil
418
+ end
419
+ }
420
+ end
421
+
422
+ modes = []
423
+ modes << 'logs' if forward_logs
424
+ modes << 'exceptions' if forward_exceptions
425
+ Legion::Logging.info("Logging transport wired: #{modes.join(' + ')} (dedicated session)")
426
+ rescue StandardError => e
427
+ Legion::Logging.warn "Logging transport setup failed: #{e.message}"
428
+ teardown_logging_transport
429
+ end
430
+
431
+ def teardown_logging_transport
432
+ Legion::Logging.log_writer = nil
433
+ Legion::Logging.exception_writer = nil
434
+ @log_session&.close if @log_session.respond_to?(:close) &&
435
+ (!@log_session.respond_to?(:open?) || @log_session.open?)
436
+ @log_session = nil
437
+ rescue StandardError
438
+ nil
396
439
  end
397
440
 
398
441
  def setup_alerts
@@ -547,6 +590,7 @@ module Legion
547
590
  shutdown_component('Cache') { Legion::Cache.shutdown }
548
591
  Legion::Readiness.mark_not_ready(:cache)
549
592
 
593
+ teardown_logging_transport
550
594
  shutdown_component('Transport') { Legion::Transport::Connection.shutdown }
551
595
  Legion::Readiness.mark_not_ready(:transport)
552
596
 
@@ -558,7 +602,7 @@ module Legion
558
602
  Legion::Events.emit('service.shutdown')
559
603
  end
560
604
 
561
- def reload
605
+ def reload # rubocop:disable Metrics/MethodLength
562
606
  return if @reloading
563
607
 
564
608
  @reloading = true
@@ -583,6 +627,7 @@ module Legion
583
627
  shutdown_component('Cache') { Legion::Cache.shutdown }
584
628
  Legion::Readiness.mark_not_ready(:cache)
585
629
 
630
+ teardown_logging_transport
586
631
  shutdown_component('Transport') { Legion::Transport::Connection.shutdown }
587
632
  Legion::Readiness.mark_not_ready(:transport)
588
633
 
@@ -599,7 +644,8 @@ module Legion
599
644
 
600
645
  setup_transport
601
646
  Legion::Readiness.mark_ready(:transport)
602
- register_logging_hooks
647
+ teardown_logging_transport
648
+ setup_logging_transport
603
649
 
604
650
  require 'legion/cache' unless defined?(Legion::Cache)
605
651
  Legion::Cache.setup
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.6.18'
4
+ VERSION = '1.6.21'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.18
4
+ version: 1.6.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -281,14 +281,14 @@ dependencies:
281
281
  requirements:
282
282
  - - ">="
283
283
  - !ruby/object:Gem::Version
284
- version: 1.3.2
284
+ version: 1.4.0
285
285
  type: :runtime
286
286
  prerelease: false
287
287
  version_requirements: !ruby/object:Gem::Requirement
288
288
  requirements:
289
289
  - - ">="
290
290
  - !ruby/object:Gem::Version
291
- version: 1.3.2
291
+ version: 1.4.0
292
292
  - !ruby/object:Gem::Dependency
293
293
  name: legion-settings
294
294
  requirement: !ruby/object:Gem::Requirement