semantic_logger 3.2.1 → 3.3.0

Sign up to get free protection for your applications and to get access to all the features.
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