legion-logging 1.4.2 → 1.5.0

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.
@@ -1,47 +1,576 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+ require_relative 'tagged_logger'
5
+
3
6
  module Legion
4
7
  module Logging
5
8
  module Helper
6
- def log
7
- return @log unless @log.nil?
9
+ SEGMENT_CACHE = {} # rubocop:disable Style/MutableConstant
10
+ SEGMENT_CACHE_MUTEX = Mutex.new
11
+ private_constant :SEGMENT_CACHE_MUTEX
12
+ COMPONENT_MAP = {
13
+ 'runners' => :runner,
14
+ 'actors' => :actor,
15
+ 'actor' => :actor,
16
+ 'helpers' => :helper,
17
+ 'hooks' => :hook,
18
+ 'absorbers' => :absorber,
19
+ 'matchers' => :matcher,
20
+ 'transport' => :transport,
21
+ 'exchanges' => :exchange,
22
+ 'queues' => :queue,
23
+ 'messages' => :message,
24
+ 'data' => :data,
25
+ 'builders' => :builder,
26
+ 'tools' => :tool,
27
+ 'adapters' => :adapter,
28
+ 'engines' => :engine,
29
+ 'formatters' => :formatter,
30
+ 'parsers' => :parser,
31
+ 'middleware' => :middleware
32
+ }.freeze
33
+
34
+ EXCEPTION_BACKTRACE_LIMIT = 10
35
+ EXCEPTION_PRIORITY = { warn: 0, error: 5, fatal: 9 }.freeze
36
+ EXCEPTION_COLORS = {
37
+ fatal: :darkred,
38
+ error: :red,
39
+ warn: :yellow,
40
+ debug: :aqua,
41
+ unknown: :magenta
42
+ }.freeze
43
+
44
+ def self.current_log_method
45
+ Thread.current[:legion_log_method]
46
+ end
47
+
48
+ def self.current_log_segments
49
+ Thread.current[:legion_log_segments]
50
+ end
51
+
52
+ def self.current_context
53
+ Thread.current[:legion_context]
54
+ end
8
55
 
9
- logger_hash = if respond_to?(:segments)
10
- { lex_segments: Array(segments) }
11
- else
12
- { lex: derive_log_tag }
13
- end
56
+ def log
57
+ current_generation =
58
+ if defined?(Legion::Logging) && Legion::Logging.respond_to?(:configuration_generation)
59
+ Legion::Logging.configuration_generation
60
+ else
61
+ 0
62
+ end
14
63
 
15
- if respond_to?(:settings) && settings.is_a?(Hash) && settings.key?(:logger)
16
- ls = settings[:logger]
17
- logger_hash[:level] = ls[:level] if ls.key?(:level)
18
- logger_hash[:log_file] = ls[:log_file] if ls.key?(:log_file)
19
- logger_hash[:trace] = ls[:trace] if ls.key?(:trace)
20
- logger_hash[:extended] = ls[:extended] if ls.key?(:extended)
64
+ if !defined?(@log) || @log.nil? || @log_generation != current_generation
65
+ @log = Legion::Logging::TaggedLogger.new(segments: derive_log_segments, **tagged_logger_settings)
66
+ @log_generation = current_generation
21
67
  end
22
68
 
23
- @log = Legion::Logging::Logger.new(**logger_hash)
69
+ @log
70
+ end
71
+
72
+ def with_log_context(method_name)
73
+ prev = Thread.current[:legion_log_method]
74
+ Thread.current[:legion_log_method] = method_name.to_s
75
+ yield
76
+ ensure
77
+ Thread.current[:legion_log_method] = prev
78
+ end
79
+
80
+ def handle_exception(exception, task_id: nil, level: :error, handled: true, **opts)
81
+ segments = derive_log_segments
82
+ spec = gem_spec
83
+ ctx = Thread.current[:legion_context] || {}
84
+
85
+ event = build_exception_event(
86
+ exception: exception,
87
+ level: level,
88
+ spec: spec,
89
+ handled: handled,
90
+ task_id: task_id || ctx[:task_id],
91
+ payload_summary: opts.empty? ? nil : opts
92
+ )
93
+
94
+ event[:conversation_id] ||= ctx[:conversation_id]
95
+ event[:chain_id] ||= ctx[:chain_id]
96
+ event[:log_segments] = segments
97
+ event[:method] = Thread.current[:legion_log_method]
98
+
99
+ event = Legion::Logging::Redactor.redact(event) if defined?(Legion::Logging::Redactor)
100
+
101
+ write_exception_to_log(exception, event, level, segments)
102
+ publish_exception(event, level) if structured_exception_support?
24
103
  end
25
104
 
26
105
  private
27
106
 
28
- def derive_log_tag
107
+ def build_exception_event(exception:, level:, spec:, handled:, task_id:, payload_summary:)
108
+ unless structured_exception_support?
109
+ return fallback_exception_event(
110
+ exception: exception,
111
+ level: level,
112
+ spec: spec,
113
+ handled: handled,
114
+ task_id: task_id,
115
+ payload_summary: payload_summary
116
+ )
117
+ end
118
+
119
+ Legion::Logging::EventBuilder.build_exception(
120
+ exception: exception,
121
+ level: level,
122
+ lex: log_name,
123
+ component_type: derive_component_type,
124
+ gem_name: gem_name,
125
+ lex_version: spec&.version&.to_s,
126
+ gem_path: spec&.full_gem_path,
127
+ source_code_uri: spec&.metadata&.[]('source_code_uri'),
128
+ handled: handled,
129
+ task_id: task_id,
130
+ payload_summary: payload_summary,
131
+ caller_offset: 3
132
+ )
133
+ end
134
+
135
+ def fallback_exception_event(exception:, level:, spec:, handled:, task_id:, payload_summary:)
136
+ {
137
+ exception_class: exception.class.to_s,
138
+ message: exception.message,
139
+ level: level,
140
+ lex: log_name,
141
+ component_type: derive_component_type,
142
+ gem_name: gem_name,
143
+ lex_version: spec&.version&.to_s,
144
+ gem_path: spec&.full_gem_path,
145
+ source_code_uri: spec&.metadata&.[]('source_code_uri'),
146
+ handled: handled,
147
+ task_id: task_id,
148
+ payload_summary: payload_summary,
149
+ error_fingerprint: SecureRandom.uuid
150
+ }
151
+ end
152
+
153
+ def derive_log_segments
154
+ key = respond_to?(:ancestors) ? ancestors.first : self.class
155
+ return SEGMENT_CACHE[key] if SEGMENT_CACHE.key?(key)
156
+
157
+ segments = begin
158
+ parts = key.to_s.split('::')
159
+ parts.shift if parts.first == 'Legion'
160
+ parts.shift if parts.first == 'Extensions'
161
+ parts.map! do |p|
162
+ p.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
163
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
164
+ .downcase
165
+ end
166
+ parts.freeze
167
+ end
168
+
169
+ SEGMENT_CACHE_MUTEX.synchronize { SEGMENT_CACHE[key] ||= segments }
170
+ end
171
+
172
+ def derive_component_type
173
+ segments = derive_log_segments
174
+ match = segments.find { |s| COMPONENT_MAP.key?(s) }
175
+ return COMPONENT_MAP[match] if match
176
+
177
+ segments.last&.to_sym || :unknown
178
+ end
179
+
180
+ def log_name
29
181
  if respond_to?(:lex_filename)
30
182
  fname = lex_filename
31
183
  return fname.is_a?(Array) ? fname.first : fname
32
184
  end
33
185
 
34
- name = respond_to?(:ancestors) ? ancestors.first.to_s : self.class.to_s
35
- parts = name.split('::')
36
- ext_idx = parts.index('Extensions')
37
- target = if ext_idx && parts[ext_idx + 1]
38
- parts[ext_idx + 1]
39
- else
40
- parts.last
41
- end
42
- target.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
43
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
44
- .downcase
186
+ derive_log_segments.first
187
+ rescue StandardError
188
+ nil
189
+ end
190
+
191
+ def gem_name
192
+ @gem_name_resolved ? @gem_name_value : resolve_gem_name
193
+ end
194
+
195
+ def gem_spec
196
+ @gem_spec_resolved ? @gem_spec_value : resolve_gem_spec
197
+ end
198
+
199
+ def resolve_gem_name
200
+ @gem_name_resolved = true
201
+ base = log_name
202
+ @gem_name_value = if base
203
+ %W[lex-#{base} legion-#{base} #{base}].find do |candidate|
204
+ Gem::Specification.find_by_name(candidate)
205
+ candidate
206
+ rescue Gem::MissingSpecError
207
+ nil
208
+ end
209
+ end
210
+ rescue StandardError
211
+ @gem_name_value = nil
212
+ end
213
+
214
+ def resolve_gem_spec
215
+ @gem_spec_resolved = true
216
+ name = gem_name
217
+ @gem_spec_value = name ? Gem::Specification.find_by_name(name) : nil
218
+ rescue Gem::MissingSpecError
219
+ @gem_spec_value = nil
220
+ end
221
+
222
+ def instance_log_level(default = Legion::Logging::Settings.default[:level] || :info)
223
+ component_level = component_log_level
224
+ return component_level if present_log_level?(component_level)
225
+
226
+ global_level = global_log_level
227
+ return global_level if present_log_level?(global_level)
228
+
229
+ Legion::Logging::Settings.default[:level] || default
230
+ rescue StandardError => e
231
+ Legion::Logging.warn("Legion::Logging::Helper.instance_log_level(#{default}) failed: #{e.class}: #{e.message}")
232
+ Legion::Logging::Settings.default[:level] || default
233
+ end
234
+
235
+ def global_logger_settings
236
+ defaults = defined?(Legion::Logging::Settings) ? Legion::Logging::Settings.default.dup : {}
237
+ settings_logging = if defined?(Legion::Settings) &&
238
+ Legion::Settings.respond_to?(:loaded?) &&
239
+ Legion::Settings.loaded?
240
+ raw = Legion::Settings[:logging]
241
+ raw.is_a?(Hash) ? raw : {}
242
+ else
243
+ {}
244
+ end
245
+ runtime_logging = if defined?(Legion::Logging) &&
246
+ Legion::Logging.respond_to?(:current_settings)
247
+ current = Legion::Logging.current_settings
248
+ current.is_a?(Hash) ? current : {}
249
+ else
250
+ {}
251
+ end
252
+
253
+ defaults.merge(settings_logging).merge(runtime_logging)
254
+ end
255
+
256
+ def resolve_logger_settings
257
+ base = global_logger_settings
258
+ override = component_logger_settings
259
+ merged = override ? base.merge(override) : base
260
+ merged.merge(
261
+ level: instance_log_level(merged[:level]),
262
+ trace: instance_trace(merged[:trace]),
263
+ trace_size: instance_trace_size(merged[:trace_size]),
264
+ extended: instance_extended(merged[:extended])
265
+ )
266
+ rescue StandardError
267
+ defined?(Legion::Logging::Settings) ? Legion::Logging::Settings.default : {}
268
+ end
269
+
270
+ def tagged_logger_settings
271
+ settings = resolve_logger_settings
272
+ {
273
+ level: settings[:level],
274
+ trace: settings[:trace],
275
+ trace_size: settings[:trace_size],
276
+ extended: settings[:extended]
277
+ }
278
+ end
279
+
280
+ def component_logger_settings
281
+ source = component_settings
282
+ raw = settings_value(source, :logger)
283
+ raw.is_a?(Hash) ? raw : nil
284
+ end
285
+
286
+ def component_log_level
287
+ source = component_settings
288
+ return unless source.is_a?(Hash)
289
+
290
+ settings_value(source, :log_level) ||
291
+ settings_value(source, :logger_level) ||
292
+ settings_value(source, :logger, :level)
293
+ end
294
+
295
+ def instance_trace(default = Legion::Logging::Settings.default[:trace])
296
+ component_trace = component_logger_option(:trace)
297
+ return component_trace unless component_trace.nil?
298
+
299
+ global_trace = global_logger_option(:trace)
300
+ return global_trace unless global_trace.nil?
301
+
302
+ Legion::Logging::Settings.default[:trace].nil? ? default : Legion::Logging::Settings.default[:trace]
303
+ rescue StandardError => e
304
+ Legion::Logging.warn("Legion::Logging::Helper.instance_trace(#{default}) failed: #{e.class}: #{e.message}")
305
+ Legion::Logging::Settings.default[:trace].nil? ? default : Legion::Logging::Settings.default[:trace]
306
+ end
307
+
308
+ def instance_trace_size(default = Legion::Logging::Settings.default[:trace_size] || 4)
309
+ component_trace_size = component_logger_option(:trace_size)
310
+ return component_trace_size unless component_trace_size.nil?
311
+
312
+ global_trace_size = global_logger_option(:trace_size)
313
+ return global_trace_size unless global_trace_size.nil?
314
+
315
+ Legion::Logging::Settings.default[:trace_size] || default
316
+ rescue StandardError => e
317
+ Legion::Logging.warn("Legion::Logging::Helper.instance_trace_size(#{default}) failed: #{e.class}: #{e.message}")
318
+ Legion::Logging::Settings.default[:trace_size] || default
319
+ end
320
+
321
+ def instance_extended(default = Legion::Logging::Settings.default[:extended])
322
+ component_extended = component_logger_option(:extended)
323
+ return component_extended unless component_extended.nil?
324
+
325
+ global_extended = global_logger_option(:extended)
326
+ return global_extended unless global_extended.nil?
327
+
328
+ Legion::Logging::Settings.default[:extended].nil? ? default : Legion::Logging::Settings.default[:extended]
329
+ rescue StandardError => e
330
+ Legion::Logging.warn("Legion::Logging::Helper.instance_extended(#{default}) failed: #{e.class}: #{e.message}")
331
+ Legion::Logging::Settings.default[:extended].nil? ? default : Legion::Logging::Settings.default[:extended]
332
+ end
333
+
334
+ def component_settings
335
+ local = local_settings_hash
336
+ return local if local.is_a?(Hash)
337
+
338
+ legion_component_settings
339
+ end
340
+
341
+ def local_settings_hash
342
+ return unless respond_to?(:settings, true)
343
+
344
+ source = settings
345
+ source if source.is_a?(Hash)
346
+ rescue StandardError
347
+ nil
348
+ end
349
+
350
+ def legion_component_settings
351
+ return unless defined?(Legion::Settings)
352
+ return unless Legion::Settings.respond_to?(:loaded?) ? Legion::Settings.loaded? : true
353
+
354
+ key = derive_component_settings_key
355
+ return unless key
356
+
357
+ top_level = Legion::Settings[key]
358
+ return top_level if top_level.is_a?(Hash)
359
+
360
+ extension_settings = Legion::Settings.dig(:extensions, key)
361
+ extension_settings if extension_settings.is_a?(Hash)
362
+ rescue StandardError
363
+ nil
364
+ end
365
+
366
+ def derive_component_settings_key
367
+ base = log_name
368
+ return unless base
369
+
370
+ base.to_s.tr('-', '_').to_sym
371
+ rescue StandardError
372
+ nil
373
+ end
374
+
375
+ def global_log_level
376
+ runtime_level = if defined?(Legion::Logging) &&
377
+ Legion::Logging.respond_to?(:current_settings)
378
+ settings_value(Legion::Logging.current_settings, :level)
379
+ end
380
+ return runtime_level if present_log_level?(runtime_level)
381
+
382
+ return unless defined?(Legion::Settings)
383
+ return unless Legion::Settings.respond_to?(:loaded?) ? Legion::Settings.loaded? : true
384
+
385
+ settings_value(Legion::Settings[:logging], :level) || Legion::Settings[:level]
386
+ rescue StandardError
387
+ nil
388
+ end
389
+
390
+ def component_logger_option(key)
391
+ source = component_settings
392
+ return unless source.is_a?(Hash)
393
+
394
+ return settings_value(source, key) if settings_key?(source, key)
395
+ return settings_value(source, :logger, key) if settings_key?(source, :logger, key)
396
+
397
+ nil
398
+ end
399
+
400
+ def global_logger_option(key)
401
+ runtime_value = if defined?(Legion::Logging) &&
402
+ Legion::Logging.respond_to?(:current_settings)
403
+ settings_value(Legion::Logging.current_settings, key)
404
+ end
405
+ return runtime_value unless runtime_value.nil?
406
+
407
+ return unless defined?(Legion::Settings)
408
+ return unless Legion::Settings.respond_to?(:loaded?) ? Legion::Settings.loaded? : true
409
+
410
+ settings_value(Legion::Settings[:logging], key)
411
+ rescue StandardError
412
+ nil
413
+ end
414
+
415
+ def settings_value(source, *keys)
416
+ missing = Object.new
417
+ current = source
418
+ keys.each do |key|
419
+ current =
420
+ if current.is_a?(Hash) && current.key?(key)
421
+ current[key]
422
+ elsif current.is_a?(Hash) && current.key?(key.to_s)
423
+ current[key.to_s]
424
+ else
425
+ missing
426
+ end
427
+
428
+ break if current.equal?(missing)
429
+ end
430
+
431
+ current.equal?(missing) ? nil : current
432
+ end
433
+
434
+ def settings_key?(source, *keys)
435
+ current = source
436
+ keys.each do |key|
437
+ return false unless current.is_a?(Hash)
438
+
439
+ next_key = if current.key?(key)
440
+ key
441
+ elsif current.key?(key.to_s)
442
+ key.to_s
443
+ else
444
+ return false
445
+ end
446
+ current = current[next_key]
447
+ end
448
+
449
+ true
450
+ end
451
+
452
+ def present_log_level?(value)
453
+ !value.nil? && !(value.respond_to?(:empty?) && value.empty?)
454
+ end
455
+
456
+ # -- Exception stdout/file output --
457
+
458
+ def write_exception_to_log(exception, event, level, segments)
459
+ prev_segs = Thread.current[:legion_log_segments]
460
+ Thread.current[:legion_log_segments] = segments
461
+
462
+ message = format_exception_output(exception, event)
463
+ message = Legion::Logging::Redactor.redact_string(message) if defined?(Legion::Logging::Redactor) && redaction_enabled?
464
+ message = colorize_exception(message, level) if Legion::Logging.respond_to?(:color) && Legion::Logging.color
465
+
466
+ logger = Legion::Logging.respond_to?(:log) ? Legion::Logging.log : nil
467
+ if logger.respond_to?(level)
468
+ logger.public_send(level, message)
469
+ elsif Legion::Logging.respond_to?(level)
470
+ Legion::Logging.public_send(level, message)
471
+ end
472
+ ensure
473
+ Thread.current[:legion_log_segments] = prev_segs
474
+ end
475
+
476
+ def format_exception_output(exception, event)
477
+ lines = ["#{exception.class}: #{exception.message}"]
478
+
479
+ context_line = build_context_line(event)
480
+ lines << " #{context_line}" unless context_line.empty?
481
+
482
+ bt = exception.backtrace
483
+ if bt&.any?
484
+ bt.first(EXCEPTION_BACKTRACE_LIMIT).each { |frame| lines << " #{frame}" }
485
+ remaining = bt.length - EXCEPTION_BACKTRACE_LIMIT
486
+ lines << " ... #{remaining} more" if remaining.positive?
487
+ end
488
+
489
+ lines.join("\n")
490
+ end
491
+
492
+ def colorize_exception(message, level)
493
+ color = EXCEPTION_COLORS[level] || :red
494
+ lines = message.split("\n")
495
+ lines[0] = Rainbow(lines[0]).color(color).bright
496
+ lines[1..].each_with_index do |line, i|
497
+ lines[i + 1] = Rainbow(line).color(color).faint
498
+ end
499
+ lines.join("\n")
500
+ end
501
+
502
+ def build_context_line(event)
503
+ parts = []
504
+ gn = event[:gem_name]
505
+ gv = event[:lex_version]
506
+ parts << (gv ? "#{gn}@#{gv}" : gn.to_s) if gn
507
+ parts << "task:#{event[:task_id]}" if event[:task_id]
508
+ parts << "conversation:#{event[:conversation_id]}" if event[:conversation_id]
509
+ parts << "chain:#{event[:chain_id]}" if event[:chain_id]
510
+ parts.join(' | ')
511
+ end
512
+
513
+ def redaction_enabled?
514
+ return false unless defined?(Legion::Settings)
515
+
516
+ loader = Legion::Settings.instance_variable_get(:@loader)
517
+ return false unless loader
518
+
519
+ loader.dig(:logging, :redaction, :enabled) == true
520
+ rescue StandardError
521
+ false
522
+ end
523
+
524
+ # -- Exception structured publish --
525
+
526
+ def publish_exception(event, level)
527
+ return unless structured_exception_support?
528
+
529
+ lex_name = event[:lex] || 'core'
530
+ comp = event[:component_type] || :unknown
531
+ routing_key = "legion.logging.exception.#{level}.#{lex_name}.#{comp}"
532
+
533
+ headers = build_exception_headers(event, comp, level)
534
+ properties = build_exception_properties(event, level)
535
+
536
+ Legion::Logging.exception_writer.call(event, routing_key: routing_key, headers: headers, properties: properties)
537
+ rescue StandardError => e
538
+ Legion::Logging.warn("Failed to publish exception event: #{e.class}: #{e.message}") if Legion::Logging.respond_to?(:warn)
539
+ end
540
+
541
+ def structured_exception_support?
542
+ defined?(Legion::Logging::EventBuilder) &&
543
+ Legion::Logging.respond_to?(:exception_writer)
544
+ end
545
+
546
+ def build_exception_headers(event, comp, level)
547
+ headers = {
548
+ 'x-error-fingerprint' => event[:error_fingerprint],
549
+ 'x-exception-class' => event[:exception_class],
550
+ 'x-handled' => event[:handled].to_s,
551
+ 'x-gem-name' => event[:gem_name].to_s,
552
+ 'x-lex-version' => event[:lex_version].to_s,
553
+ 'x-component-type' => comp.to_s,
554
+ 'x-level' => level.to_s
555
+ }
556
+ headers['x-task-id'] = event[:task_id].to_s if event[:task_id]
557
+ headers['x-conversation-id'] = event[:conversation_id].to_s if event[:conversation_id]
558
+ headers['x-chain-id'] = event[:chain_id].to_s if event[:chain_id]
559
+ headers['x-user'] = event[:user].to_s if event[:user]
560
+ headers
561
+ end
562
+
563
+ def build_exception_properties(event, level)
564
+ {
565
+ content_type: 'application/json',
566
+ message_id: SecureRandom.uuid,
567
+ correlation_id: event[:error_fingerprint],
568
+ timestamp: Time.now.to_i,
569
+ app_id: 'legionio',
570
+ type: 'exception_event',
571
+ priority: EXCEPTION_PRIORITY[level] || 5,
572
+ delivery_mode: 2
573
+ }
45
574
  end
46
575
  end
47
576
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Logging
5
+ module Hooks
6
+ class << self
7
+ def on_fatal(&block)
8
+ fatal_hooks << block
9
+ end
10
+
11
+ def on_error(&block)
12
+ error_hooks << block
13
+ end
14
+
15
+ def on_warn(&block)
16
+ warn_hooks << block
17
+ end
18
+
19
+ def fire(level, message, event)
20
+ return unless @enabled
21
+
22
+ hooks_for(level).each do |hook|
23
+ hook.call(message, event)
24
+ rescue StandardError => e
25
+ warn("Legion::Logging::Hooks#fire callback failed: #{e.message}")
26
+ end
27
+ end
28
+
29
+ def enable_hooks!
30
+ @enabled = true
31
+ end
32
+
33
+ def disable_hooks!
34
+ @enabled = false
35
+ end
36
+
37
+ def enabled?
38
+ @enabled || false
39
+ end
40
+
41
+ def clear_hooks!
42
+ @fatal_hooks = []
43
+ @error_hooks = []
44
+ @warn_hooks = []
45
+ end
46
+
47
+ private
48
+
49
+ def hooks_for(level)
50
+ case level.to_sym
51
+ when :fatal then fatal_hooks
52
+ when :error then error_hooks
53
+ when :warn then warn_hooks
54
+ else []
55
+ end
56
+ end
57
+
58
+ def fatal_hooks
59
+ @fatal_hooks ||= []
60
+ end
61
+
62
+ def error_hooks
63
+ @error_hooks ||= []
64
+ end
65
+
66
+ def warn_hooks
67
+ @warn_hooks ||= []
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end