semantic_logger 3.2.1 → 3.3.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/semantic_logger.rb +22 -2
  4. data/lib/semantic_logger/appender/bugsnag.rb +2 -2
  5. data/lib/semantic_logger/appender/elasticsearch.rb +9 -1
  6. data/lib/semantic_logger/appender/file.rb +1 -1
  7. data/lib/semantic_logger/appender/graylog.rb +18 -21
  8. data/lib/semantic_logger/appender/honeybadger.rb +27 -12
  9. data/lib/semantic_logger/appender/http.rb +19 -11
  10. data/lib/semantic_logger/appender/mongodb.rb +23 -30
  11. data/lib/semantic_logger/appender/new_relic.rb +2 -2
  12. data/lib/semantic_logger/appender/splunk.rb +32 -21
  13. data/lib/semantic_logger/appender/splunk_http.rb +1 -3
  14. data/lib/semantic_logger/appender/syslog.rb +25 -10
  15. data/lib/semantic_logger/appender/tcp.rb +231 -0
  16. data/lib/semantic_logger/appender/udp.rb +106 -0
  17. data/lib/semantic_logger/appender/wrapper.rb +1 -1
  18. data/lib/semantic_logger/base.rb +9 -3
  19. data/lib/semantic_logger/formatters/base.rb +36 -0
  20. data/lib/semantic_logger/formatters/color.rb +13 -10
  21. data/lib/semantic_logger/formatters/default.rb +8 -5
  22. data/lib/semantic_logger/formatters/json.rb +10 -3
  23. data/lib/semantic_logger/formatters/raw.rb +13 -0
  24. data/lib/semantic_logger/formatters/syslog.rb +119 -0
  25. data/lib/semantic_logger/log.rb +7 -5
  26. data/lib/semantic_logger/loggable.rb +5 -0
  27. data/lib/semantic_logger/logger.rb +45 -10
  28. data/lib/semantic_logger/metrics/new_relic.rb +1 -1
  29. data/lib/semantic_logger/metrics/statsd.rb +5 -1
  30. data/lib/semantic_logger/metrics/udp.rb +80 -0
  31. data/lib/semantic_logger/semantic_logger.rb +23 -27
  32. data/lib/semantic_logger/subscriber.rb +127 -0
  33. data/lib/semantic_logger/version.rb +1 -1
  34. data/test/appender/elasticsearch_test.rb +6 -4
  35. data/test/appender/file_test.rb +12 -12
  36. data/test/appender/honeybadger_test.rb +7 -1
  37. data/test/appender/http_test.rb +4 -2
  38. data/test/appender/mongodb_test.rb +1 -2
  39. data/test/appender/splunk_http_test.rb +8 -6
  40. data/test/appender/splunk_test.rb +48 -45
  41. data/test/appender/syslog_test.rb +3 -3
  42. data/test/appender/tcp_test.rb +68 -0
  43. data/test/appender/udp_test.rb +61 -0
  44. data/test/appender/wrapper_test.rb +5 -5
  45. data/test/concerns/compatibility_test.rb +6 -6
  46. data/test/debug_as_trace_logger_test.rb +2 -2
  47. data/test/loggable_test.rb +2 -2
  48. data/test/logger_test.rb +48 -45
  49. metadata +13 -3
  50. data/lib/semantic_logger/appender/base.rb +0 -101
@@ -0,0 +1,80 @@
1
+ require 'socket'
2
+ module SemanticLogger
3
+ module Metrics
4
+ class Udp < Subscriber
5
+ attr_accessor :server, :separator, :udp_flags
6
+ attr_reader :socket
7
+
8
+ # Write metrics to in JSON format to Udp
9
+ #
10
+ # Parameters:
11
+ # server: [String]
12
+ # Host name and port to write UDP messages to
13
+ # Example:
14
+ # localhost:8125
15
+ #
16
+ # udp_flags: [Integer]
17
+ # Should be a bitwise OR of Socket::MSG_* constants.
18
+ # Default: 0
19
+ #
20
+ # Limitations:
21
+ # * UDP packet size is limited by the connected network and any routers etc
22
+ # that the message has to traverse. See https://en.wikipedia.org/wiki/Maximum_transmission_unit
23
+ #
24
+ # Example:
25
+ # subscriber = SemanticLogger::Metrics::Udp.new(server: 'localhost:8125')
26
+ # SemanticLogger.on_metric(subscriber)
27
+ def initialize(options = {}, &block)
28
+ options = options.dup
29
+ @server = options.delete(:server)
30
+ @udp_flags = options.delete(:udp_flags) || 0
31
+ raise(ArgumentError, 'Missing mandatory argument: :server') unless @server
32
+
33
+ super(options, &block)
34
+ reopen
35
+ end
36
+
37
+ # After forking an active process call #reopen to re-open
38
+ # open the handles to resources
39
+ def reopen
40
+ close
41
+ @socket = UDPSocket.new
42
+ host, port = server.split(':')
43
+ @socket.connect(host, port)
44
+ end
45
+
46
+ def call(log)
47
+ metric = log.metric
48
+ if duration = log.duration
49
+ @statsd.timing(metric, duration)
50
+ else
51
+ amount = (log.metric_amount || 1).round
52
+ if amount < 0
53
+ amount.times { @statsd.decrement(metric) }
54
+ else
55
+ amount.times { @statsd.increment(metric) }
56
+ end
57
+ end
58
+ @socket.send(data, udp_flags)
59
+ end
60
+
61
+ # Flush is called by the semantic_logger during shutdown.
62
+ def flush
63
+ @socket.flush if @socket
64
+ end
65
+
66
+ # Close is called during shutdown, or with reopen
67
+ def close
68
+ @socket.close if @socket
69
+ end
70
+
71
+ private
72
+
73
+ # Returns [SemanticLogger::Formatters::Default] formatter default for this Appender
74
+ def default_formatter
75
+ SemanticLogger::Formatters::Json.new
76
+ end
77
+
78
+ end
79
+ end
80
+ end
@@ -63,7 +63,7 @@ module SemanticLogger
63
63
  # Returns [String] name of this application for logging purposes
64
64
  # Note: Not all appenders use `application`
65
65
  def self.application
66
- @@application ||= 'Semantic Logger'
66
+ @@application
67
67
  end
68
68
 
69
69
  # Override the default application
@@ -71,6 +71,8 @@ module SemanticLogger
71
71
  @@application = application
72
72
  end
73
73
 
74
+ @@application = 'Semantic Logger'
75
+
74
76
  # Add a new logging appender as a new destination for all log messages
75
77
  # emitted from Semantic Logger
76
78
  #
@@ -90,12 +92,12 @@ module SemanticLogger
90
92
  # For example STDOUT, STDERR, etc.
91
93
  #
92
94
  # Or,
93
- # appender: [Symbol|SemanticLogger::Appender::Base]
95
+ # appender: [Symbol|SemanticLogger::Subscriber]
94
96
  # A symbol identifying the appender to create.
95
97
  # For example:
96
98
  # :bugsnag, :elasticsearch, :graylog, :http, :mongodb, :new_relic, :splunk_http, :syslog, :wrapper
97
99
  # Or,
98
- # An instance of an appender derived from SemanticLogger::Appender::Base
100
+ # An instance of an appender derived from SemanticLogger::Subscriber
99
101
  # For example:
100
102
  # SemanticLogger::Appender::Http.new(url: 'http://localhost:8088/path')
101
103
  #
@@ -165,7 +167,7 @@ module SemanticLogger
165
167
  @@appenders.delete(appender)
166
168
  end
167
169
 
168
- # Returns [SemanticLogger::Appender::Base] a copy of the list of active
170
+ # Returns [SemanticLogger::Subscriber] a copy of the list of active
169
171
  # appenders for debugging etc.
170
172
  # Use SemanticLogger.add_appender and SemanticLogger.remove_appender
171
173
  # to manipulate the active appenders list
@@ -179,6 +181,11 @@ module SemanticLogger
179
181
  SemanticLogger::Logger.flush
180
182
  end
181
183
 
184
+ # Close and flush all appenders
185
+ def self.close
186
+ SemanticLogger::Logger.close
187
+ end
188
+
182
189
  # After forking an active process call SemanticLogger.reopen to re-open
183
190
  # any open file handles etc to resources
184
191
  #
@@ -429,51 +436,40 @@ module SemanticLogger
429
436
  options[:file_name] = appender
430
437
  elsif appender.is_a?(IO)
431
438
  options[:io] = appender
432
- elsif appender.is_a?(Symbol) || appender.is_a?(Appender::Base)
439
+ elsif appender.is_a?(Symbol) || appender.is_a?(Subscriber)
433
440
  options[:appender] = appender
434
441
  else
435
442
  options[:logger] = appender
436
443
  end
437
- warn "[DEPRECATED] SemanticLogger.add_appender parameters have changed. Please use: #{options.inspect}"
444
+ warn "[DEPRECATED] SemanticLogger.add_appender parameters have changed. Please use: #{options.inspect}" if $VERBOSE
438
445
  options
439
446
  end
440
447
 
441
- # Returns [SemanticLogger::Appender::Base] appender for the supplied options
448
+ # Returns [SemanticLogger::Subscriber] appender for the supplied options
442
449
  def self.appender_from_options(options, &block)
443
450
  if options[:io] || options[:file_name]
444
451
  SemanticLogger::Appender::File.new(options, &block)
445
452
  elsif appender = options.delete(:appender)
446
453
  if appender.is_a?(Symbol)
447
- named_appender(appender).new(options)
448
- elsif appender.is_a?(Appender::Base)
454
+ constantize_symbol(appender).new(options)
455
+ elsif appender.is_a?(Subscriber)
449
456
  appender
450
457
  else
451
- raise(ArgumentError, "Parameter :appender must be either a Symbol or an object derived from SemanticLogger::Appender::Base, not: #{appender.inspect}")
458
+ raise(ArgumentError, "Parameter :appender must be either a Symbol or an object derived from SemanticLogger::Subscriber, not: #{appender.inspect}")
452
459
  end
453
460
  elsif options[:logger]
454
461
  SemanticLogger::Appender::Wrapper.new(options, &block)
455
462
  end
456
463
  end
457
464
 
458
- def self.named_appender(appender, root = 'SemanticLogger::Appender')
459
- appender = appender.to_s
460
- klass = appender.respond_to?(:camelize) ? appender.camelize : camelize(appender)
461
- klass = "#{root}::#{klass}"
465
+ def self.constantize_symbol(symbol, namespace = 'SemanticLogger::Appender')
466
+ symbol = symbol.to_s
467
+ klass = symbol.respond_to?(:camelize) ? symbol.camelize : camelize(symbol)
468
+ klass = "#{namespace}::#{klass}"
462
469
  begin
463
- appender.respond_to?(:constantize) ? klass.constantize : eval(klass)
470
+ symbol.respond_to?(:constantize) ? klass.constantize : eval(klass)
464
471
  rescue NameError
465
- raise(ArgumentError, "Could not find #{root} class: #{klass} for #{appender}")
466
- end
467
- end
468
-
469
- def self.named_formatter(formatter)
470
- formatter = formatter.to_s
471
- klass = formatter.respond_to?(:camelize) ? formatter.camelize : camelize(formatter)
472
- klass = "SemanticLogger::Formatters::#{klass}"
473
- begin
474
- formatter.respond_to?(:constantize) ? klass.constantize : eval(klass)
475
- rescue NameError => exc
476
- raise(ArgumentError, "Could not find formatter class: #{klass} for #{appender}")
472
+ raise(ArgumentError, "Could not convert symbol: #{symbol} to a class in: #{namespace}. Looking for: #{klass}")
477
473
  end
478
474
  end
479
475
 
@@ -0,0 +1,127 @@
1
+ # Abstract Subscriber
2
+ #
3
+ # Abstract base class for appender and metrics subscribers.
4
+ module SemanticLogger
5
+ class Subscriber < SemanticLogger::Base
6
+ # Every logger has its own formatter
7
+ attr_accessor :formatter
8
+ attr_writer :application, :host
9
+
10
+ # Returns the current log level if set, otherwise it logs everything it receives
11
+ def level
12
+ @level || :trace
13
+ end
14
+
15
+ # A subscriber should implement flush if it can.
16
+ def flush
17
+ # NOOP
18
+ end
19
+
20
+ # A subscriber should implement close if it can.
21
+ def close
22
+ # NOOP
23
+ end
24
+
25
+ # Returns [SemanticLogger::Formatters::Default] formatter default for this subscriber
26
+ def default_formatter
27
+ SemanticLogger::Formatters::Default.new
28
+ end
29
+
30
+ # Allow application name to be set globally or per subscriber
31
+ def application
32
+ @application || SemanticLogger.application
33
+ end
34
+
35
+ # Allow host name to be set globally or per subscriber
36
+ def host
37
+ @host || SemanticLogger.host
38
+ end
39
+
40
+ private
41
+
42
+ # Initializer for Abstract Class SemanticLogger::Subscriber
43
+ #
44
+ # Parameters
45
+ # level: [:trace | :debug | :info | :warn | :error | :fatal]
46
+ # Override the log level for this subscriber.
47
+ # Default: :error
48
+ #
49
+ # formatter: [Object|Proc]
50
+ # An instance of a class that implements #call, or a Proc to be used to format
51
+ # the output from this subscriber
52
+ # Default: Use the built-in formatter (See: #call)
53
+ #
54
+ # filter: [Regexp|Proc]
55
+ # RegExp: Only include log messages where the class name matches the supplied.
56
+ # regular expression. All other messages will be ignored.
57
+ # Proc: Only include log messages where the supplied Proc returns true
58
+ # The Proc must return true or false.
59
+ #
60
+ # host: [String]
61
+ # Name of this host to appear in log messages.
62
+ # Default: SemanticLogger.host
63
+ #
64
+ # application: [String]
65
+ # Name of this application to appear in log messages.
66
+ # Default: SemanticLogger.application
67
+ def initialize(options={}, &block)
68
+ # Backward compatibility
69
+ options = {level: options} unless options.is_a?(Hash)
70
+ options = options.dup
71
+ level = options.delete(:level)
72
+ filter = options.delete(:filter)
73
+ @formatter = extract_formatter(options.delete(:formatter), &block)
74
+ @application = options.delete(:application)
75
+ @host = options.delete(:host)
76
+ raise(ArgumentError, "Unknown options: #{options.inspect}") if options.size > 0
77
+
78
+ # Subscribers don't take a class name, so use this class name if an subscriber
79
+ # is logged to directly
80
+ super(self.class, level, filter)
81
+ end
82
+
83
+ # Return the level index for fast comparisons
84
+ # Returns the lowest level index if the level has not been explicitly
85
+ # set for this instance
86
+ def level_index
87
+ @level_index || 0
88
+ end
89
+
90
+ # Return formatter that responds to call
91
+ # Supports formatter supplied as:
92
+ # - Symbol
93
+ # - Hash ( Symbol => { options })
94
+ # - Instance of any of SemanticLogger::Formatters
95
+ # - Proc
96
+ # - Any object that responds to :call
97
+ # - If none of the above apply, then the supplied block is returned as the formatter.
98
+ # - Otherwise an instance of the default formatter is returned.
99
+ def extract_formatter(formatter, &block)
100
+ case
101
+ when formatter.is_a?(Symbol)
102
+ SemanticLogger.constantize_symbol(formatter, 'SemanticLogger::Formatters').new
103
+ when formatter.is_a?(Hash) && formatter.size > 0
104
+ fmt, options = formatter.first
105
+ SemanticLogger.constantize_symbol(fmt.to_sym, 'SemanticLogger::Formatters').new(options)
106
+ when formatter.respond_to?(:call)
107
+ formatter
108
+ when block
109
+ block
110
+ when respond_to?(:call)
111
+ self
112
+ else
113
+ default_formatter
114
+ end
115
+ end
116
+
117
+ SUBSCRIBER_OPTIONS = [:level, :formatter, :filter, :application, :host].freeze
118
+
119
+ # Returns [Hash] the subscriber common options from the supplied Hash
120
+ def extract_subscriber_options!(options)
121
+ subscriber_options = {}
122
+ SUBSCRIBER_OPTIONS.each { |key| subscriber_options[key] = options.delete(key) if options.has_key?(key) }
123
+ subscriber_options
124
+ end
125
+
126
+ end
127
+ end
@@ -1,3 +1,3 @@
1
1
  module SemanticLogger #:nodoc
2
- VERSION = '3.2.1'
2
+ VERSION = '3.3.0'
3
3
  end
@@ -7,10 +7,12 @@ module Appender
7
7
 
8
8
  describe SemanticLogger::Appender::Elasticsearch do
9
9
  before do
10
- @appender = SemanticLogger::Appender::Elasticsearch.new(
11
- url: 'http://localhost:9200'
12
- )
13
- @message = 'AppenderElasticsearchTest log message'
10
+ Net::HTTP.stub_any_instance(:start, true) do
11
+ @appender = SemanticLogger::Appender::Elasticsearch.new(
12
+ url: 'http://localhost:9200'
13
+ )
14
+ end
15
+ @message = 'AppenderElasticsearchTest log message'
14
16
  end
15
17
 
16
18
  it 'logs to daily indexes' do
@@ -20,48 +20,48 @@ module Appender
20
20
  describe 'format logs into text form' do
21
21
  it 'handle no message or payload' do
22
22
  @appender.debug
23
- assert_match /\d+-\d+-\d+ \d+:\d+:\d+.\d+ D \[\d+:#{@thread_name}\] SemanticLogger::Appender::File\n/, @io.string
23
+ assert_match(/\d+-\d+-\d+ \d+:\d+:\d+.\d+ D \[\d+:#{@thread_name}\] SemanticLogger::Appender::File\n/, @io.string)
24
24
  end
25
25
 
26
26
  it 'handle message' do
27
27
  @appender.debug 'hello world'
28
- assert_match /\d+-\d+-\d+ \d+:\d+:\d+.\d+ D \[\d+:#{@thread_name}\] SemanticLogger::Appender::File -- hello world\n/, @io.string
28
+ assert_match(/\d+-\d+-\d+ \d+:\d+:\d+.\d+ D \[\d+:#{@thread_name}\] SemanticLogger::Appender::File -- hello world\n/, @io.string)
29
29
  end
30
30
 
31
31
  it 'handle message and payload' do
32
32
  @appender.debug 'hello world', @hash
33
- assert_match /\d+-\d+-\d+ \d+:\d+:\d+.\d+ D \[\d+:#{@thread_name}\] SemanticLogger::Appender::File -- hello world -- #{@hash_str}\n/, @io.string
33
+ assert_match(/\d+-\d+-\d+ \d+:\d+:\d+.\d+ D \[\d+:#{@thread_name}\] SemanticLogger::Appender::File -- hello world -- #{@hash_str}\n/, @io.string)
34
34
  end
35
35
 
36
36
  it 'handle message, payload, and exception' do
37
37
  @appender.debug 'hello world', @hash, StandardError.new('StandardError')
38
- assert_match /\d+-\d+-\d+ \d+:\d+:\d+.\d+ D \[\d+:#{@thread_name}\] SemanticLogger::Appender::File -- hello world -- #{@hash_str} -- Exception: StandardError: StandardError\n\n/, @io.string
38
+ assert_match(/\d+-\d+-\d+ \d+:\d+:\d+.\d+ D \[\d+:#{@thread_name}\] SemanticLogger::Appender::File -- hello world -- #{@hash_str} -- Exception: StandardError: StandardError\n\n/, @io.string)
39
39
  end
40
40
 
41
41
  it 'logs exception with nil backtrace' do
42
42
  @appender.debug StandardError.new('StandardError')
43
- assert_match /\d+-\d+-\d+ \d+:\d+:\d+.\d+ D \[\d+:#{@thread_name}\] SemanticLogger::Appender::File -- Exception: StandardError: StandardError\n\n/, @io.string
43
+ assert_match(/\d+-\d+-\d+ \d+:\d+:\d+.\d+ D \[\d+:#{@thread_name}\] SemanticLogger::Appender::File -- Exception: StandardError: StandardError\n\n/, @io.string)
44
44
  end
45
45
 
46
46
  it 'handle nested exception' do
47
47
  begin
48
48
  raise StandardError, 'FirstError'
49
- rescue Exception => e
49
+ rescue Exception
50
50
  begin
51
51
  raise StandardError, 'SecondError'
52
52
  rescue Exception => e2
53
53
  @appender.debug e2
54
54
  end
55
55
  end
56
- assert_match /\d+-\d+-\d+ \d+:\d+:\d+.\d+ D \[\d+:#{@thread_name} file_test.rb:\d+\] SemanticLogger::Appender::File -- Exception: StandardError: SecondError\n/, @io.string
57
- assert_match /^Cause: StandardError: FirstError\n/, @io.string if Exception.instance_methods.include?(:cause)
56
+ assert_match(/\d+-\d+-\d+ \d+:\d+:\d+.\d+ D \[\d+:#{@thread_name} file_test.rb:\d+\] SemanticLogger::Appender::File -- Exception: StandardError: SecondError\n/, @io.string)
57
+ assert_match(/^Cause: StandardError: FirstError\n/, @io.string) if Exception.instance_methods.include?(:cause)
58
58
  end
59
59
 
60
60
  it 'logs exception with empty backtrace' do
61
61
  exc = StandardError.new('StandardError')
62
62
  exc.set_backtrace([])
63
63
  @appender.debug exc
64
- assert_match /\d+-\d+-\d+ \d+:\d+:\d+.\d+ D \[\d+:#{@thread_name}\] SemanticLogger::Appender::File -- Exception: StandardError: StandardError\n\n/, @io.string
64
+ assert_match(/\d+-\d+-\d+ \d+:\d+:\d+.\d+ D \[\d+:#{@thread_name}\] SemanticLogger::Appender::File -- Exception: StandardError: StandardError\n\n/, @io.string)
65
65
  end
66
66
  end
67
67
 
@@ -71,14 +71,14 @@ module Appender
71
71
  it "log #{level} with file_name" do
72
72
  SemanticLogger.stub(:backtrace_level_index, 0) do
73
73
  @appender.send(level, 'hello world', @hash)
74
- assert_match /\d+-\d+-\d+ \d+:\d+:\d+.\d+ \w \[\d+:#{@thread_name}#{@file_name_reg_exp}\] SemanticLogger::Appender::File -- hello world -- #{@hash_str}\n/, @io.string
74
+ assert_match(/\d+-\d+-\d+ \d+:\d+:\d+.\d+ \w \[\d+:#{@thread_name}#{@file_name_reg_exp}\] SemanticLogger::Appender::File -- hello world -- #{@hash_str}\n/, @io.string)
75
75
  end
76
76
  end
77
77
 
78
78
  it "log #{level} without file_name" do
79
79
  SemanticLogger.stub(:backtrace_level_index, 100) do
80
80
  @appender.send(level, 'hello world', @hash)
81
- assert_match /\d+-\d+-\d+ \d+:\d+:\d+.\d+ \w \[\d+:#{@thread_name}\] SemanticLogger::Appender::File -- hello world -- #{@hash_str}\n/, @io.string
81
+ assert_match(/\d+-\d+-\d+ \d+:\d+:\d+.\d+ \w \[\d+:#{@thread_name}\] SemanticLogger::Appender::File -- hello world -- #{@hash_str}\n/, @io.string)
82
82
  end
83
83
  end
84
84
  end
@@ -101,7 +101,7 @@ module Appender
101
101
 
102
102
  it 'format using formatter' do
103
103
  @appender.debug
104
- assert_match /\d+-\d+-\d+ \d+:\d+:\d+.\d+ DEBUG \[\d+:#{@thread_name}\] SemanticLogger::Appender::File -- \n/, @io.string
104
+ assert_match(/\d+-\d+-\d+ \d+:\d+:\d+.\d+ DEBUG \[\d+:#{@thread_name}\] SemanticLogger::Appender::File -- \n/, @io.string)
105
105
  end
106
106
  end
107
107
 
@@ -7,6 +7,7 @@ module Appender
7
7
  before do
8
8
  @appender = SemanticLogger::Appender::Honeybadger.new(:trace)
9
9
  @message = 'AppenderHoneybadgerTest log message'
10
+ SemanticLogger.backtrace_level = :error
10
11
  end
11
12
 
12
13
  SemanticLogger::LEVELS.each do |level|
@@ -17,7 +18,12 @@ module Appender
17
18
  end
18
19
  assert_equal @message, hash[:error_message]
19
20
  assert_equal 'SemanticLogger::Appender::Honeybadger', hash[:error_class]
20
- assert_equal true, hash.has_key?(:backtrace)
21
+
22
+ if [:error, :fatal].include?(level)
23
+ assert hash.has_key?(:backtrace)
24
+ else
25
+ refute hash.has_key?(:backtrace)
26
+ end
21
27
  assert_equal true, hash.has_key?(:context)
22
28
  assert_equal level, hash[:context][:level]
23
29
  end