semantic_logger 5.1.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +13 -12
  3. data/Rakefile +15 -4
  4. data/docs/api.md +37 -11
  5. data/docs/appenders.md +123 -39
  6. data/docs/config.md +115 -13
  7. data/docs/index.md +31 -5
  8. data/docs/log.md +19 -11
  9. data/docs/metrics.md +5 -2
  10. data/docs/operations.md +63 -17
  11. data/docs/rails.md +77 -3
  12. data/docs/security.md +183 -16
  13. data/docs/testing.md +5 -3
  14. data/docs/upgrading.md +295 -7
  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 +122 -27
  18. data/lib/semantic_logger/appender/elasticsearch_base.rb +31 -6
  19. data/lib/semantic_logger/appender/elasticsearch_http.rb +1 -1
  20. data/lib/semantic_logger/appender/file.rb +138 -30
  21. data/lib/semantic_logger/appender/graylog.rb +22 -6
  22. data/lib/semantic_logger/appender/honeybadger.rb +1 -1
  23. data/lib/semantic_logger/appender/honeybadger_insights.rb +1 -1
  24. data/lib/semantic_logger/appender/http.rb +70 -11
  25. data/lib/semantic_logger/appender/io.rb +1 -1
  26. data/lib/semantic_logger/appender/kafka.rb +26 -12
  27. data/lib/semantic_logger/appender/loki.rb +13 -3
  28. data/lib/semantic_logger/appender/mongodb.rb +20 -1
  29. data/lib/semantic_logger/appender/new_relic.rb +1 -1
  30. data/lib/semantic_logger/appender/new_relic_logs.rb +9 -11
  31. data/lib/semantic_logger/appender/open_telemetry.rb +2 -2
  32. data/lib/semantic_logger/appender/rabbitmq.rb +6 -1
  33. data/lib/semantic_logger/appender/sentry.rb +1 -1
  34. data/lib/semantic_logger/appender/sentry_ruby.rb +91 -17
  35. data/lib/semantic_logger/appender/splunk.rb +10 -1
  36. data/lib/semantic_logger/appender/syslog.rb +64 -26
  37. data/lib/semantic_logger/appender/tcp.rb +6 -1
  38. data/lib/semantic_logger/appender/udp.rb +11 -1
  39. data/lib/semantic_logger/appender/wrapper.rb +1 -1
  40. data/lib/semantic_logger/appenders.rb +9 -1
  41. data/lib/semantic_logger/base.rb +82 -30
  42. data/lib/semantic_logger/exception_details.rb +50 -0
  43. data/lib/semantic_logger/formatters/base.rb +52 -6
  44. data/lib/semantic_logger/formatters/color.rb +18 -2
  45. data/lib/semantic_logger/formatters/default.rb +22 -5
  46. data/lib/semantic_logger/formatters/logfmt.rb +153 -21
  47. data/lib/semantic_logger/formatters/loki.rb +26 -22
  48. data/lib/semantic_logger/formatters/one_line.rb +67 -3
  49. data/lib/semantic_logger/formatters/open_telemetry.rb +16 -17
  50. data/lib/semantic_logger/formatters/pattern.rb +8 -4
  51. data/lib/semantic_logger/formatters/raw.rb +4 -5
  52. data/lib/semantic_logger/formatters/syslog.rb +110 -18
  53. data/lib/semantic_logger/formatters/syslog_cee.rb +29 -16
  54. data/lib/semantic_logger/levels.rb +10 -1
  55. data/lib/semantic_logger/log.rb +183 -37
  56. data/lib/semantic_logger/loggable.rb +26 -3
  57. data/lib/semantic_logger/metric/new_relic.rb +1 -1
  58. data/lib/semantic_logger/metric/signalfx.rb +8 -6
  59. data/lib/semantic_logger/metric/statsd.rb +15 -7
  60. data/lib/semantic_logger/queue_processor/request.rb +55 -0
  61. data/lib/semantic_logger/queue_processor/throttled_counter.rb +46 -0
  62. data/lib/semantic_logger/queue_processor.rb +165 -66
  63. data/lib/semantic_logger/semantic_logger.rb +15 -2
  64. data/lib/semantic_logger/subscriber.rb +96 -10
  65. data/lib/semantic_logger/sync_processor.rb +11 -0
  66. data/lib/semantic_logger/test/capture_log_events.rb +8 -5
  67. data/lib/semantic_logger/utils.rb +244 -16
  68. data/lib/semantic_logger/version.rb +1 -1
  69. data/lib/semantic_logger.rb +1 -1
  70. metadata +21 -5
  71. data/lib/semantic_logger/core_ext/thread.rb +0 -30
@@ -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
@@ -56,7 +59,7 @@ module SemanticLogger
56
59
 
57
60
  # Class / app name
58
61
  def name
59
- log.name
62
+ escape_control_characters(log.name)
60
63
  end
61
64
 
62
65
  # Log message
@@ -69,15 +72,22 @@ module SemanticLogger
69
72
  pl = log.payload_to_s
70
73
  return unless pl
71
74
 
72
- "-- #{pl}"
75
+ "-- #{escape_control_characters(pl)}"
73
76
  end
74
77
 
75
78
  # Exception
76
79
  def exception
77
80
  return unless log.exception
78
81
 
82
+ details = exception_details
79
83
  "-- Exception: #{log.exception.class}: #{escape_control_characters(log.exception.message)}\n" \
80
- "#{log.backtrace_to_s}"
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
81
91
  end
82
92
 
83
93
  # Default text log format
@@ -89,6 +99,13 @@ module SemanticLogger
89
99
 
90
100
  [time, level, process_info, tags, named_tags, duration, name, message, payload, exception].compact.join(" ")
91
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
92
109
  end
93
110
  end
94
111
  end
@@ -13,15 +13,69 @@ module SemanticLogger
13
13
  #
14
14
  # All timestamps are ISO8601 formatteed
15
15
  # All user supplied values are escaped and surrounded by double quotes to avoid ambiguious message delimeters
16
- # `tags` are treated as keys with boolean values. Tag names are not formatted or validated;
17
- # ensure you use valid logfmt format for tag names.
18
- # `named_tags` are flattened are merged into the top level message field. Any conflicting fields are overridden.
19
- # `payload` values take precedence over `tags` and `named_tags`. Any conflicting fields are overridden.
16
+ # `tags` are treated as keys with boolean values.
17
+ # In every key (tags, named tags, payload), spaces, control and format characters, `=`, and `"`
18
+ # are replaced with `_`, so that untrusted data cannot inject fields or lines, or disguise a key.
19
+ # `tags`, `named_tags`, and `payload` are flattened into the top level fields. A key named like
20
+ # one of the entry's own fields (the time key, `level`, `name`, `message`, `duration`,
21
+ # `duration_ms`, `tag`) or an exception field is prefixed with its source, as in `payload.level`,
22
+ # so that logged data cannot replace those fields. So is a key that starts with `tags.`,
23
+ # `named_tags.`, or `payload.`, so that it cannot forge another source's field. Other keys that
24
+ # are the same once encoded, such as `:user` and `"user"`, or `"a b"` and `"a=b"`, are written
25
+ # once, with the value from the later source: `payload` values take precedence over
26
+ # `named_tags`, and `named_tags` over `tags`.
27
+ # An exception is emitted as `exception_name`, `exception_message`, `stack_trace`, and `cause`,
28
+ # and with `log_exception_details` also `exception_details`.
20
29
  #
21
30
  # Futher Reading https://brandur.org/logfmt
22
31
  class Logfmt < Raw
32
+ # The entry's own fields in the Raw hash, written in this order after the time key.
33
+ ENTRY_FIELDS = %i[level name message duration duration_ms].freeze
34
+
35
+ # The field that tells an entry with an exception from one without.
36
+ TAG_FIELD = "tag".freeze
37
+
38
+ # The exception's fields in the Raw hash, mapped to their logfmt names, so that the exception's
39
+ # name and message do not replace the entry's own.
40
+ EXCEPTION_FIELDS = {
41
+ name: "exception_name",
42
+ message: "exception_message",
43
+ details: "exception_details",
44
+ stack_trace: "stack_trace",
45
+ cause: "cause"
46
+ }.freeze
47
+
48
+ # Prefixes that mark a key from logged data with its source, when the key alone would name
49
+ # one of the entry's own fields, or look like a key marked this way.
50
+ TAGS_PREFIX = "tags.".freeze
51
+ NAMED_TAGS_PREFIX = "named_tags.".freeze
52
+ PAYLOAD_PREFIX = "payload.".freeze
53
+ SOURCE_PREFIXES = [TAGS_PREFIX, NAMED_TAGS_PREFIX, PAYLOAD_PREFIX].freeze
54
+
55
+ # Characters that cannot appear in a logfmt key: spaces and control characters (including
56
+ # NEL), which would end the key or the line, the `=` and `"` delimiters, LINE SEPARATOR and
57
+ # PARAGRAPH SEPARATOR, which some readers treat as newlines, and the invisible format
58
+ # characters, such as ZERO WIDTH SPACE and RIGHT-TO-LEFT OVERRIDE, which would let a key pass
59
+ # for another one, such as "level" followed by a ZERO WIDTH SPACE for "level".
60
+ UNSAFE_KEY_CHARS = /[\p{Cc}\p{Cf} ="\u2028\u2029]/
61
+
62
+ # The ASCII subset of UNSAFE_KEY_CHARS, which is much faster to match against the ASCII keys
63
+ # that make up nearly every log event.
64
+ UNSAFE_ASCII_KEY_CHARS = /[\x00-\x20="\x7f]/
65
+
66
+ # The most Symbol keys to remember the names of, so that untrusted keys cannot grow the cache
67
+ # without bound.
68
+ PLAIN_SYMBOL_CACHE_SIZE = 512
69
+
23
70
  def initialize(time_format: :iso_8601, time_key: :timestamp, **args)
24
71
  super
72
+ name_entry_fields
73
+ end
74
+
75
+ # Also writes and reserves the new time key in place of the old one.
76
+ def time_key=(time_key)
77
+ super
78
+ name_entry_fields
25
79
  end
26
80
 
27
81
  def call(log, logger)
@@ -32,8 +86,12 @@ module SemanticLogger
32
86
 
33
87
  private
34
88
 
89
+ # @parsed maps the logfmt name of each field to its value. Logged data is added after the
90
+ # entry's own fields, and a field with the same name as an earlier one replaces its value, so
91
+ # the payload takes precedence over named tags, and named tags over tags.
35
92
  def raw_to_logfmt
36
- @parsed = @raw.slice(time_key, :level, :name, :message, :duration, :duration_ms).merge(tag: "success")
93
+ @parsed = @raw.slice(*@entry_keys).transform_keys(@entry_names)
94
+ @parsed[TAG_FIELD] = "success"
37
95
  handle_tags
38
96
  handle_payload
39
97
  handle_exception
@@ -42,37 +100,111 @@ module SemanticLogger
42
100
  end
43
101
 
44
102
  def handle_tags
45
- tags = @raw.fetch(:tags) { [] }.
46
- each_with_object({}) { |tag, accum| accum[tag] = true }
47
-
48
- @parsed = @parsed.merge(tags).
49
- merge(@raw.fetch(:named_tags) { {} })
103
+ @raw[:tags]&.each { |tag| add_field(TAGS_PREFIX, tag, true) }
104
+ @raw[:named_tags]&.each_pair { |key, value| add_field(NAMED_TAGS_PREFIX, key, value) }
50
105
  end
51
106
 
52
107
  def handle_payload
53
108
  return unless @raw.key? :payload
54
109
 
55
- @parsed = @parsed.merge(@raw[:payload])
110
+ payload = @raw[:payload]
111
+ # A payload is normally a Hash, but a log call can supply any value, so emit anything else
112
+ # as a single field rather than failing to format the whole entry.
113
+ if payload.is_a?(Hash)
114
+ payload.each_pair { |key, value| add_field(PAYLOAD_PREFIX, key, value) }
115
+ else
116
+ add_field(PAYLOAD_PREFIX, "payload", payload)
117
+ end
56
118
  end
57
119
 
58
120
  def handle_exception
59
121
  return unless @raw.key? :exception
60
122
 
61
- @parsed[:tag] = "exception"
62
- @parsed = @parsed.merge(@raw[:exception])
123
+ @parsed[TAG_FIELD] = "exception"
124
+ @raw[:exception].each_pair { |key, value| @parsed[EXCEPTION_FIELDS[key] || logfmt_key(key)] = value }
125
+ end
126
+
127
+ # Adds a field from logged data, under the name returned by #field_name.
128
+ def add_field(prefix, key, value)
129
+ name = (key.is_a?(Symbol) && @plain_symbols[key]) || field_name(prefix, key)
130
+ @parsed[name] = value
131
+ end
132
+
133
+ # Returns [String] the logfmt name of a key from logged data: the key encoded by #logfmt_key,
134
+ # prefixed with its source, as in "payload.level", when it names one of the entry's own fields
135
+ # or starts with a source's prefix, so that untrusted data cannot replace or forge that field,
136
+ # or a field from another source.
137
+ def field_name(prefix, key)
138
+ name = key.is_a?(Symbol) ? key.name : key.to_s
139
+ name = logfmt_key(name) unless plain_name?(name)
140
+ return prefix + name if @reserved_names.key?(name) || name.start_with?(*SOURCE_PREFIXES)
141
+
142
+ remember_symbol(key, name) if key.is_a?(Symbol)
143
+ name
144
+ end
145
+
146
+ # Remembers the name of a Symbol key that is not reserved, while there is room. The cache is
147
+ # replaced rather than updated, since a formatter can be shared by several threads.
148
+ def remember_symbol(symbol, name)
149
+ plain_symbols = @plain_symbols
150
+ @plain_symbols = plain_symbols.merge(symbol => name).freeze if plain_symbols.size < PLAIN_SYMBOL_CACHE_SIZE
63
151
  end
64
152
 
65
153
  def flatten_log
66
- flattened = @parsed.map do |key, value|
67
- case value
68
- when Hash, Array
69
- "#{key}=#{Utils.to_json(value.to_s)}"
70
- else
71
- "#{key}=#{Utils.to_json(value)}"
72
- end
154
+ line = +""
155
+ @parsed.each_pair do |name, value|
156
+ line << " " unless line.empty?
157
+ line << name << "=" << format_value(value)
158
+ end
159
+ line
160
+ end
161
+
162
+ # Integers, true, false, and nil have a single JSON form, so they skip the cost of `to_json`.
163
+ # Strings, the most common values, are checked first.
164
+ def format_value(value)
165
+ return Utils.to_json(value) if value.is_a?(String)
166
+
167
+ case value
168
+ when Integer, true, false
169
+ value.to_s
170
+ when nil
171
+ "null"
172
+ else
173
+ # The JSON form rather than #inspect, which can print an object's instance variables
174
+ # and has no depth limit. A structure is quoted, since its JSON holds spaces and quotes.
175
+ json = Utils.to_json(value)
176
+ json.start_with?("{", "[") ? Utils.to_json(json) : json
73
177
  end
178
+ end
179
+
180
+ # Names the entry's own fields from the time key, and reserves those names and the
181
+ # exception's, so that logged data cannot use them.
182
+ def name_entry_fields
183
+ @entry_names = [time_key, *ENTRY_FIELDS].to_h { |key| [key, logfmt_key(key)] }.freeze
184
+ @entry_keys = @entry_names.keys.freeze
185
+ @reserved_names = [*@entry_names.values, TAG_FIELD, *EXCEPTION_FIELDS.values].
186
+ to_h { |name| [name, true] }.freeze
187
+ # Symbol keys from logged data that need no prefix, mapped to their logfmt names.
188
+ @plain_symbols = {}.freeze
189
+ end
74
190
 
75
- flattened.join(" ")
191
+ # Whether the name is already a valid logfmt key, made of ASCII characters that are all safe.
192
+ def plain_name?(name)
193
+ !name.empty? && name.ascii_only? && !name.match?(UNSAFE_ASCII_KEY_CHARS)
194
+ end
195
+
196
+ # Returns [String] the key as valid UTF-8, with every character that would break the logfmt
197
+ # structure replaced by an underscore. Keys come from tags, named tags, and payloads, which
198
+ # can hold untrusted data; unescaped, they could inject fields or forge an entire line.
199
+ def logfmt_key(key)
200
+ key = key.is_a?(Symbol) ? key.name : key.to_s
201
+ unsafe = UNSAFE_ASCII_KEY_CHARS
202
+ unless key.ascii_only?
203
+ key = Utils.encode_utf8_string(key)
204
+ unsafe = UNSAFE_KEY_CHARS
205
+ end
206
+ key = key.gsub(unsafe, "_") if key.match?(unsafe)
207
+ key.empty? ? "_" : key
76
208
  end
77
209
  end
78
210
  end
@@ -81,13 +81,7 @@ module SemanticLogger
81
81
  return unless log.context && !log.context.empty?
82
82
 
83
83
  log.context.each do |key, value|
84
- serialized_value = if value.is_a?(Hash)
85
- Utils.to_json(value)
86
- else
87
- value.to_s
88
- end
89
-
90
- stream[:stream].merge!(key.to_s => serialized_value)
84
+ stream[:stream].merge!(key.to_s => loki_value(value))
91
85
  end
92
86
  end
93
87
 
@@ -104,11 +98,14 @@ module SemanticLogger
104
98
  end
105
99
 
106
100
  def payload
107
- self.payload_value = if log.payload.respond_to?(:empty?) && !log.payload.empty?
108
- # Loki only accepts strings as key and values
101
+ self.payload_value = if !log.payload?
102
+ {}
103
+ elsif log.payload.is_a?(Hash)
109
104
  stringify_hash(log.payload)
110
105
  else
111
- {}
106
+ # A log call can supply any value as the payload, such as a String
107
+ # parsed from a request body.
108
+ {"payload" => loki_value(log.payload)}
112
109
  end
113
110
  end
114
111
 
@@ -136,21 +133,28 @@ module SemanticLogger
136
133
  )
137
134
  end
138
135
 
136
+ # Loki only accepts Strings as keys and values.
139
137
  def stringify_hash(hash)
140
- result = {}
141
-
142
- hash.each do |key, value|
143
- string_key = key.to_s
144
-
145
- result[string_key] = case value
146
- when Hash
147
- Utils.to_json(stringify_hash(value))
148
- else
149
- value.to_s
150
- end
138
+ hash.each_with_object({}) do |(key, value), result|
139
+ result[key.to_s] = loki_value(value)
151
140
  end
141
+ end
152
142
 
153
- result
143
+ # Returns [String] the value as Loki accepts it.
144
+ #
145
+ # A structure is written once, as JSON, depth-limited and repaired by Utils.to_json.
146
+ # Encoding each nested level into a String that the next level encodes again doubled the
147
+ # escaping at every level, so a request parameter 30 levels deep produced gigabytes. A
148
+ # structure's #to_s is its #inspect, which would also expose the instance variables of any
149
+ # object it holds.
150
+ def loki_value(value)
151
+ if value.is_a?(String)
152
+ value
153
+ elsif Utils.structure?(value)
154
+ Utils.to_json(value)
155
+ else
156
+ value.to_s
157
+ end
154
158
  end
155
159
  end
156
160
  end
@@ -3,15 +3,79 @@ module SemanticLogger
3
3
  # Only output one line for each log entry.
4
4
  #
5
5
  # Notes:
6
- # * New lines are stripped from log messages.
6
+ # * New lines and carriage returns are stripped from every text field: the name, thread name,
7
+ # file name, tags, named tags, message, payload and exception message.
8
+ # With `escape_control_chars: true` they, and all other control characters, are escaped
9
+ # instead, like the other text formatters.
7
10
  # * Exceptions only include the class and message, the stack trace is not shown.
11
+ # * Exception details, when enabled, are joined onto the same line.
8
12
  class OneLine < Default
13
+ def thread_name
14
+ format("%.30s", one_line(log.thread_name))
15
+ end
16
+
17
+ def tags
18
+ return if log.tags.nil? || log.tags.empty?
19
+
20
+ "[#{log.tags.map { |tag| one_line(tag) }.join('] [')}]"
21
+ end
22
+
23
+ def named_tags
24
+ named_tags = log.named_tags
25
+ return if named_tags.nil? || named_tags.empty?
26
+
27
+ list = named_tags.map { |name, value| "#{one_line(name)}: #{one_line(value)}" }
28
+ "{#{list.join(', ')}}"
29
+ end
30
+
31
+ def name
32
+ one_line(log.name) if log.name
33
+ end
34
+
35
+ # A log call can supply the backtrace that the file name comes from.
36
+ def file_name_and_line
37
+ file, line = log.file_name_and_line(true)
38
+ "#{one_line(file)}:#{line}" if file
39
+ end
40
+
9
41
  def message
10
- "-- #{log.message.delete("\n")}" if log.message
42
+ "-- #{one_line(log.message)}" if log.message
43
+ end
44
+
45
+ def payload
46
+ pl = log.payload_to_s
47
+ "-- #{one_line(pl)}" if pl
11
48
  end
12
49
 
13
50
  def exception
14
- "-- Exception: #{log.exception.class}: #{log.exception.message.delete("\n")}" if log.exception
51
+ return unless log.exception
52
+
53
+ details = exception_details
54
+ "-- Exception: #{log.exception.class}: #{one_line(log.exception.message)}#{" -- #{details}" if details}"
55
+ end
56
+
57
+ # The exception details, with each line trimmed and joined by a space.
58
+ def exception_details
59
+ details = exception_details_for(log.exception) if log.exception
60
+ return unless details
61
+
62
+ one_line(Utils.encode_utf8_string(details).split(/\s*\n\s*/).reject(&:empty?).join(" ").strip)
63
+ end
64
+
65
+ private
66
+
67
+ # Off by default, so that existing one line output does not change.
68
+ def default_log_exception_details
69
+ false
70
+ end
71
+
72
+ # Returns [String] the value without line breaks, so that the entry stays on one line.
73
+ #
74
+ # A String with invalid bytes, such as a request parameter decoded from "%FF", or in another
75
+ # encoding, such as a binary request body, is converted to valid UTF-8 first, since deleting
76
+ # from it, or joining it with UTF-8 text, would raise and lose the entry.
77
+ def one_line(value)
78
+ Utils.encode_utf8_string(escape_control_characters(value.to_s)).delete("\r\n")
15
79
  end
16
80
  end
17
81
  end
@@ -20,21 +20,29 @@ module SemanticLogger
20
20
  hash[:level_index] = severity_number(log.level)
21
21
  end
22
22
 
23
- # Payload is submitted directly as attributes
23
+ # Payload is submitted directly as attributes, which must be a Hash. A log call can supply
24
+ # any value as the payload, such as a String parsed from a request body, so any other value
25
+ # is submitted as a single "payload" attribute.
24
26
  def payload
25
- return unless log.payload.respond_to?(:empty?) && !log.payload.empty?
27
+ return unless log.payload?
26
28
 
27
- hash[:payload] = coerce_map(log.payload)
29
+ hash[:payload] = coerce_map(log.payload.is_a?(Hash) ? log.payload : {payload: log.payload})
28
30
  end
29
31
 
30
32
  private
31
33
 
34
+ # An attribute value: a primitive, or an Array of them.
32
35
  def coerce_value(v)
36
+ v.is_a?(Array) ? v.map { |e| coerce_element(e) }.compact : coerce_element(v)
37
+ end
38
+
39
+ # A primitive as-is, and anything else as a String: a structure (such as a Hash, Struct, or
40
+ # Set) as JSON, depth-limited and repaired by Utils.to_json, rather than its #to_s, which is
41
+ # its #inspect and would expose the instance variables of any object it holds.
42
+ def coerce_element(v)
33
43
  case v
34
- when *PRIMS then v
35
- when Array then v.map { |e| coerce_value(e) }.compact # arrays of scalars only.
36
- when NilClass then nil # drop nils by caller.
37
- else v.to_s # stringify objects / hashes.
44
+ when *PRIMS, NilClass then v
45
+ else Utils.structure?(v) ? Utils.to_json(v) : v.to_s
38
46
  end
39
47
  end
40
48
 
@@ -42,16 +50,7 @@ module SemanticLogger
42
50
  h.each_with_object({}) do |(k, v), out|
43
51
  next if v.nil?
44
52
 
45
- out[k.to_s] =
46
- if v.is_a?(Hash)
47
- # Stringify whole hash. Cleanse here too: this runs while building the
48
- # hash, before the top-level call cleanse, so it must not raise on its own.
49
- Utils.to_json(
50
- v.transform_values { |vv| coerce_value(vv) }.transform_keys!(&:to_s)
51
- )
52
- else
53
- coerce_value(v)
54
- end
53
+ out[k.to_s] = coerce_value(v)
55
54
  end
56
55
  end
57
56
 
@@ -30,6 +30,8 @@ module SemanticLogger
30
30
  # payload Payload rendered as a string.
31
31
  # exception_class Class of the logged exception, e.g. "RuntimeError".
32
32
  # exception_message Message of the logged exception.
33
+ # exception_details Detail Ruby adds to the exception's message, such as
34
+ # did_you_mean suggestions (see the log_exception_details option).
33
35
  # backtrace Backtrace of the logged exception.
34
36
  # duration Human readable duration, e.g. "1.2ms".
35
37
  # duration_ms Duration in milliseconds (numeric).
@@ -59,6 +61,7 @@ module SemanticLogger
59
61
  payload: false,
60
62
  exception_class: false,
61
63
  exception_message: false,
64
+ exception_details: false,
62
65
  backtrace: false,
63
66
  duration: false,
64
67
  duration_ms: false,
@@ -118,7 +121,7 @@ module SemanticLogger
118
121
 
119
122
  # Raw payload rendered as a string.
120
123
  def payload
121
- log.payload_to_s
124
+ escape_control_characters(log.payload_to_s)
122
125
  end
123
126
 
124
127
  # Class of the logged exception, e.g. "RuntimeError".
@@ -133,7 +136,7 @@ module SemanticLogger
133
136
 
134
137
  # Backtrace of the logged exception.
135
138
  def backtrace
136
- log.backtrace_to_s if log.exception
139
+ exception_backtrace if log.exception
137
140
  end
138
141
 
139
142
  # Human readable duration (without the Default formatter's surrounding parentheses).
@@ -146,9 +149,10 @@ module SemanticLogger
146
149
  log.duration
147
150
  end
148
151
 
149
- # Name of the Ruby file that logged the message, e.g. "app.rb".
152
+ # Name of the Ruby file that logged the message, e.g. "app.rb". Escaped like the other
153
+ # fields, since a log call can supply the backtrace it comes from.
150
154
  def file_name
151
- log.file_name_and_line(true)&.first
155
+ escape_control_characters(log.file_name_and_line(true)&.first)
152
156
  end
153
157
 
154
158
  # Line number within the Ruby file that logged the message.
@@ -96,11 +96,10 @@ module SemanticLogger
96
96
  root = hash
97
97
  log.each_exception do |exception, i|
98
98
  name = i.zero? ? :exception : :cause
99
- root[name] = {
100
- name: exception.class.name,
101
- message: exception.message,
102
- stack_trace: exception.backtrace
103
- }
99
+ root[name] = {name: exception.class.name, message: exception.message}
100
+ details = exception_details_for(exception)
101
+ root[name][:details] = details if details
102
+ root[name][:stack_trace] = exception.backtrace
104
103
  root = root[name]
105
104
  end
106
105
  end