semantic_logger 5.0.0 → 5.2.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -10
  3. data/Rakefile +40 -0
  4. data/docs/api.md +506 -0
  5. data/docs/appenders.md +1074 -0
  6. data/docs/config.md +707 -0
  7. data/docs/index.md +223 -0
  8. data/docs/log.md +116 -0
  9. data/docs/metrics.md +171 -0
  10. data/docs/operations.md +446 -0
  11. data/docs/rails.md +890 -0
  12. data/docs/security.md +286 -0
  13. data/docs/testing.md +287 -0
  14. data/docs/upgrading.md +697 -0
  15. data/lib/semantic_logger/appender/async.rb +18 -5
  16. data/lib/semantic_logger/appender/bugsnag.rb +1 -1
  17. data/lib/semantic_logger/appender/cloudwatch_logs.rb +126 -29
  18. data/lib/semantic_logger/appender/elasticsearch.rb +2 -5
  19. data/lib/semantic_logger/appender/elasticsearch_base.rb +48 -23
  20. data/lib/semantic_logger/appender/elasticsearch_http.rb +15 -8
  21. data/lib/semantic_logger/appender/file.rb +141 -28
  22. data/lib/semantic_logger/appender/graylog.rb +22 -6
  23. data/lib/semantic_logger/appender/honeybadger.rb +1 -1
  24. data/lib/semantic_logger/appender/honeybadger_insights.rb +1 -1
  25. data/lib/semantic_logger/appender/http.rb +70 -11
  26. data/lib/semantic_logger/appender/io.rb +1 -1
  27. data/lib/semantic_logger/appender/kafka.rb +26 -12
  28. data/lib/semantic_logger/appender/loki.rb +13 -3
  29. data/lib/semantic_logger/appender/mongodb.rb +20 -1
  30. data/lib/semantic_logger/appender/new_relic.rb +7 -1
  31. data/lib/semantic_logger/appender/new_relic_logs.rb +11 -12
  32. data/lib/semantic_logger/appender/open_telemetry.rb +4 -3
  33. data/lib/semantic_logger/appender/opensearch.rb +2 -1
  34. data/lib/semantic_logger/appender/rabbitmq.rb +10 -4
  35. data/lib/semantic_logger/appender/sentry.rb +6 -1
  36. data/lib/semantic_logger/appender/sentry_ruby.rb +91 -17
  37. data/lib/semantic_logger/appender/splunk.rb +10 -1
  38. data/lib/semantic_logger/appender/syslog.rb +66 -25
  39. data/lib/semantic_logger/appender/tcp.rb +10 -4
  40. data/lib/semantic_logger/appender/udp.rb +11 -1
  41. data/lib/semantic_logger/appender/wrapper.rb +3 -2
  42. data/lib/semantic_logger/appender.rb +6 -3
  43. data/lib/semantic_logger/appenders.rb +11 -2
  44. data/lib/semantic_logger/base.rb +82 -32
  45. data/lib/semantic_logger/exception_details.rb +50 -0
  46. data/lib/semantic_logger/formatters/base.rb +52 -6
  47. data/lib/semantic_logger/formatters/color.rb +22 -3
  48. data/lib/semantic_logger/formatters/default.rb +28 -6
  49. data/lib/semantic_logger/formatters/logfmt.rb +155 -21
  50. data/lib/semantic_logger/formatters/loki.rb +26 -22
  51. data/lib/semantic_logger/formatters/one_line.rb +67 -3
  52. data/lib/semantic_logger/formatters/open_telemetry.rb +16 -17
  53. data/lib/semantic_logger/formatters/pattern.rb +11 -5
  54. data/lib/semantic_logger/formatters/raw.rb +4 -5
  55. data/lib/semantic_logger/formatters/syslog.rb +110 -17
  56. data/lib/semantic_logger/formatters/syslog_cee.rb +29 -15
  57. data/lib/semantic_logger/levels.rb +10 -1
  58. data/lib/semantic_logger/log.rb +183 -37
  59. data/lib/semantic_logger/loggable.rb +26 -3
  60. data/lib/semantic_logger/metric/new_relic.rb +1 -1
  61. data/lib/semantic_logger/metric/signalfx.rb +12 -8
  62. data/lib/semantic_logger/metric/statsd.rb +15 -7
  63. data/lib/semantic_logger/queue_processor/request.rb +55 -0
  64. data/lib/semantic_logger/queue_processor/throttled_counter.rb +46 -0
  65. data/lib/semantic_logger/queue_processor.rb +170 -68
  66. data/lib/semantic_logger/reporters/minitest.rb +4 -4
  67. data/lib/semantic_logger/semantic_logger.rb +15 -2
  68. data/lib/semantic_logger/subscriber.rb +96 -10
  69. data/lib/semantic_logger/sync_processor.rb +11 -0
  70. data/lib/semantic_logger/test/capture_log_events.rb +9 -6
  71. data/lib/semantic_logger/utils.rb +244 -16
  72. data/lib/semantic_logger/version.rb +1 -1
  73. data/lib/semantic_logger.rb +1 -1
  74. metadata +33 -6
  75. data/lib/semantic_logger/core_ext/thread.rb +0 -30
@@ -28,23 +28,38 @@ module SemanticLogger
28
28
  end
29
29
  end
30
30
 
31
- # Returns the current log level if set, otherwise it returns the global
32
- # default log level
31
+ # Returns the level set by an enclosing `#with_level` block on the current fiber,
32
+ # otherwise the level set for this instance, otherwise the global default level.
33
33
  def level
34
- @level || SemanticLogger.default_level
34
+ index = @with_level_used && with_level_index
35
+ return Levels.level(index) if index
36
+
37
+ @level || fallback_level
35
38
  end
36
39
 
37
- # Set the logging level for this logger during the execution of the given block
40
+ # Change the log level of this instance while the block runs, on the current fiber only.
38
41
  #
39
- # Refer to the documentation for `#level=` for more information about the possible log levels.
40
- def with_level(new_level)
41
- old_level = level
42
-
43
- self.level = new_level
44
-
45
- yield
46
- ensure
47
- self.level = old_level
42
+ # Other threads and fibers that share this instance keep logging at its own level.
43
+ # The level set with `#level=` is left untouched, so an instance that follows the
44
+ # global default level keeps following it after the block. Blocks can be nested.
45
+ #
46
+ # Matches Ruby's `Logger#with_level`, which Sidekiq calls to apply a job's `log_level`.
47
+ # Accepts the same levels as `#level=`. Returns the result of the block.
48
+ def with_level(level)
49
+ index = Levels.index(level)
50
+ overrides = (Thread.current[:semantic_logger_with_level] ||= {}.compare_by_identity)
51
+ previous = overrides[self]
52
+ @with_level_used = true
53
+ overrides[self] = index
54
+ begin
55
+ yield
56
+ ensure
57
+ if previous
58
+ overrides[self] = previous
59
+ else
60
+ overrides.delete(self)
61
+ end
62
+ end
48
63
  end
49
64
 
50
65
  # Implement the log level calls
@@ -150,13 +165,11 @@ module SemanticLogger
150
165
  if thread == Thread.current
151
166
  Utils.extract_backtrace(caller)
152
167
  else
153
- log.thread_name = thread.name
168
+ log.thread_name = Log.thread_name_for(thread)
154
169
  log.tags = (thread[:semantic_logger_tags] || []).clone
155
170
  log.named_tags = (thread[:semantic_logger_named_tags] || {}).clone
156
171
  thread.backtrace
157
172
  end
158
- # TODO: Keep backtrace instead of transforming into a text message at this point
159
- # Maybe log_backtrace: true
160
173
  if backtrace
161
174
  message += "\n"
162
175
  message << backtrace.join("\n")
@@ -321,6 +334,10 @@ module SemanticLogger
321
334
  # (empty) instance tags allocate no garbage. Child loggers replace them via #tagged_child.
322
335
  @instance_tags = [].freeze
323
336
  @instance_named_tags = {}.freeze
337
+ # Set the first time `#with_level` is called on this instance, so that instances that
338
+ # never use it skip the fiber-local lookup when checking their level. Only the fiber
339
+ # that sets an override needs to see the flag, and that fiber set it itself.
340
+ @with_level_used = false
324
341
  if level.nil?
325
342
  # Allow the global default level to determine this loggers log level
326
343
  @level_index = nil
@@ -330,11 +347,25 @@ module SemanticLogger
330
347
  end
331
348
  end
332
349
 
333
- # Return the level index for fast comparisons
334
- # Returns the global default level index if the level has not been explicitly
335
- # set for this instance
350
+ # Return the level index for fast comparisons, resolved in the same order as `#level`.
336
351
  def level_index
337
- @level_index || SemanticLogger.default_level_index
352
+ (@with_level_used && with_level_index) || @level_index || fallback_level_index
353
+ end
354
+
355
+ # Level to use when no level has been set for this instance: the global default level.
356
+ def fallback_level
357
+ SemanticLogger.default_level
358
+ end
359
+
360
+ # Level index to use when no level has been set for this instance. Unlike
361
+ # `#fallback_level`, it honors `SemanticLogger.silence`.
362
+ def fallback_level_index
363
+ SemanticLogger.default_level_index
364
+ end
365
+
366
+ # Level index set for this instance by an enclosing `#with_level` block on the current fiber.
367
+ def with_level_index
368
+ Thread.current[:semantic_logger_with_level]&.[](self)
338
369
  end
339
370
 
340
371
  # Whether to log the supplied message based on the current filter if any
@@ -446,6 +477,9 @@ module SemanticLogger
446
477
  end
447
478
 
448
479
  # Measure the supplied block and log the message
480
+ #
481
+ # The options can hold untrusted data, so an option with a value that is not valid for it is
482
+ # ignored (see Log.argument?) and its default is used instead.
449
483
  def measure_internal(level, index, message, params)
450
484
  exception = nil
451
485
  # Single parameter is a hash
@@ -456,9 +490,7 @@ module SemanticLogger
456
490
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
457
491
  begin
458
492
  if block_given?
459
- if (silence_level = params[:silence])
460
- # In case someone accidentally sets `silence: true` instead of `silence: :error`
461
- silence_level = :error if silence_level == true
493
+ if (silence_level = measure_silence_level(params))
462
494
  silence(silence_level) { yield(params) }
463
495
  else
464
496
  yield(params)
@@ -469,13 +501,15 @@ module SemanticLogger
469
501
  ensure
470
502
  # Must use ensure block otherwise a `return` in the yield above will skip the log entry
471
503
  log = new_log(level, index)
472
- exception ||= params[:exception]
473
- message = params[:message] if params[:message]
474
- duration =
504
+ # Only an Exception, since it is re-raised below
505
+ exception ||= params[:exception] if params[:exception].is_a?(Exception)
506
+ message = measure_option(params, :message) || message
507
+ duration =
475
508
  if block_given?
476
509
  1_000.0 * (Process.clock_gettime(Process::CLOCK_MONOTONIC) - start)
477
510
  else
478
- params[:duration] || raise("Mandatory block missing when :duration option is not supplied")
511
+ measure_option(params, :duration) ||
512
+ raise("Mandatory block missing when :duration option is not supplied")
479
513
  end
480
514
 
481
515
  # Extract options after block completes so that block can modify any of the options
@@ -485,13 +519,13 @@ module SemanticLogger
485
519
  should_log = log.assign(
486
520
  message: message,
487
521
  payload: payload,
488
- min_duration: params[:min_duration] || 0.0,
522
+ min_duration: measure_option(params, :min_duration) || 0.0,
489
523
  exception: exception,
490
- metric: params[:metric],
491
- metric_amount: params[:metric_amount],
524
+ metric: measure_option(params, :metric),
525
+ metric_amount: measure_option(params, :metric_amount),
492
526
  duration: duration,
493
- log_exception: params[:log_exception] || :partial,
494
- on_exception_level: params[:on_exception_level]
527
+ log_exception: measure_option(params, :log_exception) || :partial,
528
+ on_exception_level: measure_option(params, :on_exception_level)
495
529
  )
496
530
 
497
531
  # Log level may change during assign due to :on_exception_level
@@ -500,6 +534,22 @@ module SemanticLogger
500
534
  end
501
535
  end
502
536
 
537
+ # Returns the value of the measure option that is also a log argument, or nil when it is not
538
+ # supplied or its value is not valid for that argument.
539
+ def measure_option(params, key)
540
+ value = params[key]
541
+ value if Log.argument?(key, value)
542
+ end
543
+
544
+ # Returns the level to silence other log messages to within a measure block, or nil when none
545
+ # is supplied or it is not a valid level.
546
+ def measure_silence_level(params)
547
+ silence_level = params[:silence]
548
+ # In case someone accidentally sets `silence: true` instead of `silence: :error`
549
+ silence_level = :error if silence_level == true
550
+ silence_level if Levels.valid?(silence_level)
551
+ end
552
+
503
553
  # For measuring methods and logging their duration.
504
554
  def measure_method(index:,
505
555
  level:,
@@ -0,0 +1,50 @@
1
+ module SemanticLogger
2
+ # Separates the detail that Ruby's Exception#detailed_message adds to an exception's message,
3
+ # such as did_you_mean suggestions or error_highlight's source snippet, from the message itself.
4
+ #
5
+ # Internal: Log caches the result for each of its exceptions; see Log#exception_details.
6
+ module ExceptionDetails
7
+ # Ruby's own Exception#detailed_message: the message, decorated with the class name.
8
+ DETAILED_MESSAGE = Exception.instance_method(:detailed_message)
9
+ private_constant :DETAILED_MESSAGE
10
+
11
+ # Returns [String] the detail that #detailed_message adds to the exception's message, without
12
+ # the class name that Ruby also adds, or nil when there is none or it cannot be separated.
13
+ #
14
+ # Never raises, so that an exception's own #detailed_message cannot stop a log entry being
15
+ # written.
16
+ def self.extract(exception, source: false)
17
+ detailed = full_detailed_message(exception, source)
18
+ return unless detailed
19
+
20
+ base = DETAILED_MESSAGE.bind_call(exception, highlight: false)
21
+ # An override that does not build on Ruby's message cannot be separated from it.
22
+ return unless detailed.is_a?(String) && detailed.start_with?(base)
23
+
24
+ details = detailed.delete_prefix(base).sub(/\A\n+/, "").rstrip
25
+ details unless details.empty?
26
+ rescue StandardError
27
+ nil
28
+ end
29
+
30
+ # Returns [String] the exception's #detailed_message, with or without error_highlight's
31
+ # source snippet, or nil when the snippet cannot be left out.
32
+ def self.full_detailed_message(exception, source)
33
+ exception.detailed_message(highlight: false, error_highlight: source)
34
+ rescue ArgumentError
35
+ # An override of #detailed_message that does not accept extra keyword arguments. Calling it
36
+ # without `error_highlight: false` lets error_highlight add its source snippet, so only do so
37
+ # when the snippet was asked for, or error_highlight does not decorate this exception.
38
+ return if !source && error_highlighted?(exception)
39
+
40
+ exception.detailed_message(highlight: false)
41
+ end
42
+ private_class_method :full_detailed_message
43
+
44
+ # Whether error_highlight adds a source snippet to this exception's #detailed_message.
45
+ def self.error_highlighted?(exception)
46
+ defined?(ErrorHighlight::CoreExt) && exception.is_a?(ErrorHighlight::CoreExt)
47
+ end
48
+ private_class_method :error_highlighted?
49
+ end
50
+ end
@@ -3,7 +3,7 @@ module SemanticLogger
3
3
  module Formatters
4
4
  class Base
5
5
  attr_accessor :log, :logger, :time_format, :log_host, :log_application, :log_environment, :precision,
6
- :escape_control_chars
6
+ :escape_control_chars, :log_exception_details
7
7
 
8
8
  # Printable escapes for the most common control characters. Any other
9
9
  # control character is escaped to its hexadecimal `\xHH` form by
@@ -50,25 +50,39 @@ module SemanticLogger
50
50
  # Default: PRECISION (6, except on older JRuby, where 3)
51
51
  # escape_control_chars: [Boolean]
52
52
  # Replace control characters (newlines, the ANSI escape, etc.) in
53
- # untrusted log data (message, tags, named tags, and exception
54
- # message) with a printable escaped form, e.g. "\n".
53
+ # untrusted log data (logger name, thread name, message, payload, tags,
54
+ # named tags, and the message and backtrace lines of the exception and
55
+ # of each exception that caused it) with a printable escaped form, e.g. "\n".
55
56
  # This prevents log forging and terminal escape-sequence injection
56
57
  # when logging data that may contain attacker-controlled content.
57
58
  # Note: Has no effect on structured formatters such as :json, which
58
59
  # already escape control characters via JSON encoding.
59
60
  # Default: false (preserve newlines and ANSI colors in text output)
61
+ # log_exception_details: [true|false|:with_source]
62
+ # Whether to log the detail that Ruby's Exception#detailed_message adds to an
63
+ # exception's message, such as did_you_mean suggestions ("Did you mean? upcase") or
64
+ # the detail an exception class supplies by overriding #detailed_message.
65
+ # :with_source also logs error_highlight's snippet of the failing source line. It is
66
+ # about 100 times slower, since it re-parses that source file, and it copies source
67
+ # code into the log.
68
+ # On Ruby 3.2 a NameError or NoMethodError builds its message from receiver.inspect each
69
+ # time it is asked, and logging the details asks for it twice more, so turn this off when
70
+ # inspecting the receivers of logged exceptions is slow.
71
+ # Default: true for the Default, Color, and Pattern text formatters, false otherwise.
60
72
  def initialize(time_format: nil,
61
73
  log_host: true,
62
74
  log_application: true,
63
75
  log_environment: true,
64
76
  precision: PRECISION,
65
- escape_control_chars: false)
77
+ escape_control_chars: false,
78
+ log_exception_details: default_log_exception_details)
66
79
  @time_format = time_format || self.class.build_time_format(precision)
67
80
  @log_host = log_host
68
81
  @log_application = log_application
69
82
  @log_environment = log_environment
70
83
  @precision = precision
71
- @escape_control_chars = escape_control_chars
84
+ @escape_control_chars = escape_control_chars
85
+ @log_exception_details = log_exception_details
72
86
  end
73
87
 
74
88
  # Return default time format string
@@ -101,10 +115,42 @@ module SemanticLogger
101
115
  # Note: This escapes (preserves) control characters, and is opt-in. It is
102
116
  # distinct from Log#cleansed_message, which unconditionally *strips* ANSI
103
117
  # colorization from the message for structured (JSON/Loki) output.
118
+ #
119
+ # A string with invalid byte sequences, for example an exception message that quotes
120
+ # untrusted binary input, or in another encoding, is first converted to valid UTF-8
121
+ # (dropping the invalid bytes), since matching against it would raise and the log entry
122
+ # would be lost.
104
123
  def escape_control_characters(value)
105
124
  return value unless escape_control_chars && value
106
125
 
107
- value.to_s.gsub(CONTROL_CHARS) { |char| CONTROL_CHAR_ESCAPES[char] || format("\\x%02x", char.ord) }
126
+ string = Utils.encode_utf8_string(value.to_s)
127
+ string.gsub(CONTROL_CHARS) { |char| CONTROL_CHAR_ESCAPES[char] || format("\\x%02x", char.ord) }
128
+ end
129
+
130
+ # Whether this formatter logs exception details unless told otherwise. See #initialize.
131
+ def default_log_exception_details
132
+ false
133
+ end
134
+
135
+ # Returns [String] the detail Ruby adds to the message of the supplied exception (see
136
+ # Log#exception_details), or nil when there is none or this formatter does not log it.
137
+ def exception_details_for(exception)
138
+ return unless log_exception_details
139
+
140
+ log.exception_details(exception, source: log_exception_details == :with_source)
141
+ end
142
+
143
+ # Returns [String] the backtrace of the logged exception, followed by each exception
144
+ # that caused it, as laid out by Log#backtrace_to_s.
145
+ #
146
+ # When `escape_control_chars` is enabled, the message of each cause and each backtrace
147
+ # line is passed through #escape_control_characters, the same as the message of the
148
+ # logged exception: causes are often parser or validation errors that quote untrusted
149
+ # input, and a backtrace can be set from untrusted data.
150
+ def exception_backtrace
151
+ return log.backtrace_to_s unless escape_control_chars
152
+
153
+ log.backtrace_to_s { |text| escape_control_characters(text) }
108
154
  end
109
155
 
110
156
  # Return the Time as a formatted string
@@ -71,6 +71,8 @@ module SemanticLogger
71
71
  #
72
72
  # Note: The option :multiline is set to false if not supplied.
73
73
  # Note: Has no effect if Awesome Print is not installed.
74
+ # Note: With `escape_control_chars: true` the payload is always rendered plain
75
+ # (without colors) and on a single line, then escaped.
74
76
  #
75
77
  # color_map: [Hash | SemanticLogger::Formatters::Color::ColorMap]
76
78
  # ColorMaps each of the log levels to a color
@@ -97,7 +99,9 @@ module SemanticLogger
97
99
  return if named_tags.nil? || named_tags.empty?
98
100
 
99
101
  list = []
100
- named_tags.each_pair { |name, value| list << "#{color}#{escape_control_characters(name)}: #{escape_control_characters(value)}#{color_map.clear}" }
102
+ named_tags.each_pair do |name, value|
103
+ list << "#{color}#{escape_control_characters(name)}: #{escape_control_characters(value)}#{color_map.clear}"
104
+ end
101
105
  "{#{list.join(', ')}}"
102
106
  end
103
107
 
@@ -114,7 +118,7 @@ module SemanticLogger
114
118
 
115
119
  if log.payload.respond_to?(:ai)
116
120
  begin
117
- "-- #{log.payload.ai(@ai_options)}"
121
+ "-- #{escape_control_characters(log.payload_to_s { |payload| payload.ai(ai_options) })}"
118
122
  rescue StandardError
119
123
  super
120
124
  end
@@ -126,13 +130,28 @@ module SemanticLogger
126
130
  def exception
127
131
  return unless log.exception
128
132
 
129
- "-- Exception: #{color}#{log.exception.class}: #{escape_control_characters(log.exception.message)}#{color_map.clear}\n#{log.backtrace_to_s}"
133
+ details = exception_details
134
+ "-- Exception: #{color}#{log.exception.class}: " \
135
+ "#{escape_control_characters(log.exception.message)}#{color_map.clear}\n" \
136
+ "#{"#{details}\n" if details}#{exception_backtrace}"
130
137
  end
131
138
 
132
139
  def call(log, logger)
133
140
  self.color = color_map[log.level]
134
141
  super
135
142
  end
143
+
144
+ private
145
+
146
+ # Returns [Hash] the Amazing Print options to render the payload with.
147
+ #
148
+ # When `escape_control_chars` is enabled the payload is rendered plain, on one line:
149
+ # the colors and line breaks Amazing Print adds cannot be told apart from control
150
+ # characters in the data (for example an object whose #inspect returns them), and
151
+ # escaping them all leaves no colors or line breaks to keep.
152
+ def ai_options
153
+ escape_control_chars ? @ai_options.merge(plain: true, multiline: false) : @ai_options
154
+ end
136
155
  end
137
156
  end
138
157
  end
@@ -12,13 +12,16 @@ module SemanticLogger
12
12
 
13
13
  # Name of the thread that logged the message.
14
14
  def thread_name
15
- format("%.30s", log.thread_name)
15
+ escape_control_characters(format("%.30s", log.thread_name))
16
16
  end
17
17
 
18
18
  # Ruby file name and line number that logged the message.
19
+ #
20
+ # The file name is escaped like the other fields, since it comes from the first line of the
21
+ # backtrace, which a log call can supply from untrusted data.
19
22
  def file_name_and_line
20
23
  file, line = log.file_name_and_line(true)
21
- "#{file}:#{line}" if file
24
+ "#{escape_control_characters(file)}:#{line}" if file
22
25
  end
23
26
 
24
27
  # Returns [String] the available process info
@@ -43,7 +46,9 @@ module SemanticLogger
43
46
  return if named_tags.nil? || named_tags.empty?
44
47
 
45
48
  list = []
46
- named_tags.each_pair { |name, value| list << "#{escape_control_characters(name)}: #{escape_control_characters(value)}" }
49
+ named_tags.each_pair do |name, value|
50
+ list << "#{escape_control_characters(name)}: #{escape_control_characters(value)}"
51
+ end
47
52
  "{#{list.join(', ')}}"
48
53
  end
49
54
 
@@ -54,7 +59,7 @@ module SemanticLogger
54
59
 
55
60
  # Class / app name
56
61
  def name
57
- log.name
62
+ escape_control_characters(log.name)
58
63
  end
59
64
 
60
65
  # Log message
@@ -67,12 +72,22 @@ module SemanticLogger
67
72
  pl = log.payload_to_s
68
73
  return unless pl
69
74
 
70
- "-- #{pl}"
75
+ "-- #{escape_control_characters(pl)}"
71
76
  end
72
77
 
73
78
  # Exception
74
79
  def exception
75
- "-- Exception: #{log.exception.class}: #{escape_control_characters(log.exception.message)}\n#{log.backtrace_to_s}" if log.exception
80
+ return unless log.exception
81
+
82
+ details = exception_details
83
+ "-- Exception: #{log.exception.class}: #{escape_control_characters(log.exception.message)}\n" \
84
+ "#{"#{details}\n" if details}#{exception_backtrace}"
85
+ end
86
+
87
+ # The detail Ruby adds to the exception's message, such as did_you_mean suggestions.
88
+ # See Base#initialize for the `log_exception_details` option.
89
+ def exception_details
90
+ escape_control_characters(exception_details_for(log.exception)) if log.exception
76
91
  end
77
92
 
78
93
  # Default text log format
@@ -84,6 +99,13 @@ module SemanticLogger
84
99
 
85
100
  [time, level, process_info, tags, named_tags, duration, name, message, payload, exception].compact.join(" ")
86
101
  end
102
+
103
+ private
104
+
105
+ # Text output is read by people, who benefit most from exception details.
106
+ def default_log_exception_details
107
+ true
108
+ end
87
109
  end
88
110
  end
89
111
  end