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
data/docs/security.md ADDED
@@ -0,0 +1,286 @@
1
+ ---
2
+ layout: default
3
+ title: Security
4
+ description: >-
5
+ Keeping logs trustworthy: log injection and forging, redacting sensitive
6
+ data, transport encryption, log file permissions, and the gem's dependency
7
+ footprint.
8
+ ---
9
+
10
+
11
+ **Contents**
12
+
13
+ * TOC
14
+ {:toc}
15
+
16
+ Logging frameworks sit on a sensitive boundary: they take data from all over an
17
+ application, including data that originated from untrusted users, and write it to
18
+ files, terminals, and centralized logging systems. This page describes the security
19
+ properties of Semantic Logger and how to configure it when logging untrusted data.
20
+
21
+ ### Log injection and forging
22
+
23
+ When a log message, tag, or exception message contains untrusted, attacker-controlled
24
+ data (for example a user name, request parameter, or `User-Agent` header), control
25
+ characters in that data can be abused:
26
+
27
+ * A newline can forge an additional, fake log entry ("log forging"), or split one
28
+ record into two at a collector that frames records with a separator.
29
+ * An ANSI escape sequence can spoof or hide terminal output when the log is viewed in
30
+ a terminal.
31
+
32
+ The structured formatters, such as `:json`, are **not** affected, because JSON encoding
33
+ always escapes control characters. They are the recommended choice when forwarding logs
34
+ that may contain untrusted data to a centralized logging system.
35
+
36
+ By design the human readable text formatters (`:default` and `:color`) preserve
37
+ newlines and ANSI color codes, since multi-line and colorized output is useful when
38
+ reading logs locally. When the text output may contain untrusted data, enable the
39
+ `escape_control_chars` option to replace control characters with a printable, escaped
40
+ form (for example a newline becomes `\n`):
41
+
42
+ ~~~ruby
43
+ SemanticLogger.add_appender(file_name: "production.log", formatter: {default: {escape_control_chars: true}})
44
+ ~~~
45
+
46
+ See [Escaping Control Characters](config.html#escaping-control-characters) for
47
+ details.
48
+
49
+ The record-framed network appenders are already safe by default:
50
+
51
+ * The **TCP** and **UDP** appenders default to the JSON formatter.
52
+ * The **Syslog** formatters escape control characters by default, since syslog frames
53
+ each record.
54
+
55
+ If you replace the formatter on any of these appenders with a text formatter, enable
56
+ `escape_control_chars` as shown above.
57
+
58
+ ### Logging untrusted hashes
59
+
60
+ A hash passed after the message is the payload, but a few of its keys are also log arguments:
61
+ `duration`, `min_duration`, `metric`, `metric_amount`, `exception`, `log_exception`, and so on.
62
+ That is convenient for code you write, but when the hash holds untrusted data, such as parsed
63
+ request parameters or a webhook body with Symbol keys, it can set those arguments too. For
64
+ example `{duration: 0, min_duration: 1}` would suppress the entry.
65
+
66
+ Semantic Logger only uses such a key as an argument when its value is valid for it: for example
67
+ `duration` must be a finite real number, `backtrace` an Array of Strings, and `on_exception_level` a
68
+ valid level. Any other value stays in the payload as data, and a `nil` value is ignored, so an
69
+ untrusted hash passed to a level method such as `info` can never make the logging call raise.
70
+
71
+ To make sure none of its keys are treated as arguments, pass it as the explicit `payload:`. Any other
72
+ keys supplied alongside an explicit `payload:` are merged into it, and win over the explicit
73
+ payload's values of the same name, so that the untrusted data cannot replace a value your code
74
+ supplies. A String key, as `JSON.parse` returns without `symbolize_names`, has the same name as the
75
+ Symbol key it spells:
76
+
77
+ ~~~ruby
78
+ # The hash is logged as-is, and none of its keys are interpreted. Its own account_id, if any,
79
+ # cannot replace the one supplied here:
80
+ logger.info("Webhook received", payload: JSON.parse(body, symbolize_names: true), account_id: account.id)
81
+ ~~~
82
+
83
+ A hash returned from a logging block, such as
84
+ `logger.debug("Webhook received") { JSON.parse(body, symbolize_names: true) }`, is checked the
85
+ same way, and never replaces a value the log call supplied. A key is only used as an argument when
86
+ its value is valid for it and the log entry does not have that argument yet: an empty `message`
87
+ counts as none, and a backtrace captured at the `backtrace_level` counts as supplied. Its `payload`
88
+ is only used when it is a Hash. Every other key, including an argument the entry already has, is
89
+ added to the payload without replacing the log call's values, and wins over the block's own
90
+ `payload` values of the same name. The caller's own payload Hash is left unchanged.
91
+
92
+ The options of a `measure_*` call are checked the same way: an option with a value that is not valid
93
+ for it is ignored, and its default is used. Keys that are not measure options are not logged at all,
94
+ so pass untrusted data to a measure call as its `payload:` too. A measure call without a block still
95
+ raises unless it is given a valid `:duration`.
96
+
97
+ ### Redacting sensitive data
98
+
99
+ Semantic Logger does not automatically redact secrets or personal information; it logs
100
+ the message and payload it is given. Avoid logging passwords, tokens, full credit card
101
+ numbers, and similar values in the first place.
102
+
103
+ When sensitive values can reach the logs indirectly, redact them. A global `on_log`
104
+ subscriber runs inline, once per log event, before the event is handed to any appender,
105
+ so it can scrub the payload for every destination. The example below matches keys at any depth,
106
+ whether they are Strings, as in parsed JSON and Rails params, or Symbols, in any case and style,
107
+ such as `api_key`, `apiKey`, or `X-Api-Key`. It builds a new payload rather than changing the logged
108
+ one, which can be the application's own Hash. It fails closed: a payload that it cannot redact is
109
+ replaced, never written as it is.
110
+
111
+ ~~~ruby
112
+ module LogRedaction
113
+ # Matched against each key in lower case with "_" between its words, see sensitive_key?.
114
+ SENSITIVE_KEY = Regexp.union(
115
+ # Anywhere in the key, like the default filter_parameters of Rails.
116
+ /passw|passphrase|secret|token|_key|apikey|credential|authorization|cookie|crypt|certificate|email/,
117
+ # Only as a whole word, so that "otp" does not match "footprint", nor "ssn" "business_name".
118
+ /(?<![a-z])(?:auth|pwd|otp|ssn|salt|cvv|cvc)(?![a-z])/
119
+ )
120
+ REDACTED = "[REDACTED]".freeze
121
+ MAX_DEPTH = 32
122
+
123
+ # Returns a copy of the value with the value of every sensitive key replaced, at any depth.
124
+ #
125
+ # copies maps each structure already walked to its copy, so that a structure held more than once
126
+ # is walked only once, and one that holds itself is cut off.
127
+ def self.redact(value, copies = {}.compare_by_identity, depth = 0)
128
+ return copies[value] || "[REDACTED: circular]" if copies.key?(value)
129
+
130
+ members = members(value)
131
+ return value if members.nil?
132
+ return "[REDACTED: nested too deeply]" if depth >= MAX_DEPTH
133
+
134
+ copies[value] = nil # Until its copy is complete.
135
+ copies[value] =
136
+ if members.is_a?(Hash)
137
+ members.each_with_object({}) do |(key, val), copy|
138
+ copy[key] = sensitive_key?(key) ? REDACTED : redact(val, copies, depth + 1)
139
+ end
140
+ else
141
+ members.map { |val| redact(val, copies, depth + 1) }
142
+ end
143
+ end
144
+
145
+ def self.sensitive_key?(key)
146
+ # As valid UTF-8, since a key with invalid bytes, as from a query string, makes a match raise.
147
+ name = key.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
148
+ # "apiKey", "API_KEY", and "X-Api-Key" become "api_key", "api_key", and "x_api_key".
149
+ name = name.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.gsub(/[^a-z\d]+/, "_")
150
+ SENSITIVE_KEY.match?(name)
151
+ end
152
+
153
+ # Returns the members of a Hash, Array, Struct, Data, Set, OpenStruct, or
154
+ # ActionController::Parameters, as a Hash or an Array. Returns nil for any other value.
155
+ def self.members(value)
156
+ case value
157
+ when Hash, Array then value
158
+ when Struct, Data then value.to_h
159
+ when Set then value.to_a
160
+ else
161
+ if defined?(OpenStruct) && value.is_a?(OpenStruct)
162
+ value.to_h
163
+ elsif value.respond_to?(:to_unsafe_h) # ActionController::Parameters
164
+ value.to_unsafe_h
165
+ end
166
+ end
167
+ end
168
+ end
169
+
170
+ # Register it before any other on_log subscriber.
171
+ SemanticLogger.on_log do |log|
172
+ log.payload = LogRedaction.redact(log.payload) unless log.payload.nil?
173
+ rescue Exception => e
174
+ # Fail closed. Any Exception, since Semantic Logger rescues whatever an on_log subscriber raises
175
+ # and goes on to write the entry.
176
+ log.payload = {redaction_failed: e.class.name}
177
+ end
178
+ ~~~
179
+
180
+ `SENSITIVE_KEY` is a starting point: adapt it to the keys that your application logs. Like the
181
+ default `filter_parameters` of Rails, it also matches some keys that are not secret, such as
182
+ `max_tokens` or `cache_key`.
183
+
184
+ The example looks inside Hashes and Arrays, and inside the Struct, Data, Set, and OpenStruct values
185
+ that the JSON formatters write as objects and arrays, which it replaces with Hashes and Arrays. It
186
+ reads `ActionController::Parameters` with `to_unsafe_h`. Any other object is left as it is, and is
187
+ written the way the formatter writes it, which for some objects includes all of their attributes:
188
+ for example a JSON formatter writes an Active Record model with `as_json`. Log a Hash of the values
189
+ you need instead.
190
+
191
+ Register it before any other `on_log` subscriber that reads the payload. Subscribers run in the
192
+ order they were registered, so one registered before it, such as the [Rollbar](appenders.html#rollbar)
193
+ integration that sends `log.to_h`, receives the payload before it is redacted.
194
+
195
+ It only redacts the payload. The message, the tags and named tags, the message of the exception and
196
+ those of its causes, the context that `on_log` subscribers add, and metric dimensions are written
197
+ as they are, so keep secrets out of them. That includes a value other than a Hash passed after the
198
+ message, such as `logger.info("Login", user)`, which is added to the message: pass it as
199
+ `payload: user` instead.
200
+
201
+ It copies the payload of every entry that is logged, even when there is nothing to redact, on the
202
+ thread that logs it, so its cost grows with the size of the payloads.
203
+
204
+ A `filter` on a logger instance can redact the same way for that logger's entries; see
205
+ [Filtering](config.html#filtering). Do not redact in an appender's `filter`: every appender writes
206
+ the same log entry, so that only protects the appenders added after it, while the ones added before
207
+ it have already written the secret.
208
+
209
+ Rails applications should use the sister gem
210
+ [rails_semantic_logger](https://github.com/reidmorrison/rails_semantic_logger), which
211
+ honours `config.filter_parameters` so that parameters Rails already considers sensitive
212
+ are filtered out of the logs. To match the payload's keys against the same list, `sensitive_key?`
213
+ can return `!filter.filter_param(name, nil).nil?`, where `filter` is
214
+ `ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)`, created once the
215
+ application has initialized, since one created from an incomplete list filters less. Proc filters
216
+ in the list are not applied this way. Do not call its `#filter` in place of `redact`: it only looks
217
+ inside Hashes and Arrays, raises for a key with invalid UTF-8, and has no protection against a Hash
218
+ that holds itself or is held more than once.
219
+
220
+ ### Transport encryption
221
+
222
+ Appenders that send logs over the network support TLS. The appenders built on the
223
+ `:http` appender (`:http`, `:splunk_http`, `:elasticsearch_http`, and `:loki`) default to
224
+ verifying the server certificate (`OpenSSL::SSL::VERIFY_PEER`) when an `https` URL is
225
+ used. The `:tcp` appender, and the `:syslog` appender over TCP, verify both the certificate
226
+ and the host name when `ssl:` is enabled.
227
+
228
+ The deprecated `:splunk` appender is the exception: its `splunk-sdk-ruby` gem never verifies
229
+ the certificate. Use `:splunk_http` instead.
230
+
231
+ Do not disable certificate verification. Settings such as
232
+ `ssl: {verify_mode: OpenSSL::SSL::VERIFY_NONE}` expose the connection to
233
+ man-in-the-middle attacks. To connect to a server with a self-signed certificate, or one issued
234
+ by a private certificate authority, trust that certificate instead, for example with
235
+ `ssl: {ca_file: "/path/to/ca.pem"}`.
236
+
237
+ Credentials can be supplied in the url of the HTTP based appenders, for example
238
+ `https://user:password@host`, or as a query parameter. They are removed from the url
239
+ written to the internal logger when the server rejects a request, and from the error raised
240
+ for an invalid url. `#inspect` on any appender, for example when `SemanticLogger.appenders` is
241
+ printed in a console, shows only what that appender says is safe, such as its url without
242
+ credentials, and none of its passwords, tokens, keys, or client options.
243
+
244
+ ### Log file permissions
245
+
246
+ Log files frequently contain sensitive information. By default the file appender
247
+ creates files using the process umask, the standard Ruby behavior. To restrict access,
248
+ supply the `permissions` option, which is applied both when the file is created and to
249
+ an existing log file:
250
+
251
+ ~~~ruby
252
+ # Owner read/write, group read, no access for others:
253
+ SemanticLogger.add_appender(file_name: "production.log", permissions: 0o640)
254
+ ~~~
255
+
256
+ With `permissions`, the log file is opened without following a symbolic link at its name, so a
257
+ link planted in the log directory can neither create nor open the file it points at. The file must
258
+ be a regular file owned by the process, with a single hard link, that the file name still names
259
+ once it is open. Otherwise, or when the file cannot be opened, nothing is written to it:
260
+ `add_appender` raises `IOError` giving the reason, with the error from opening the file, if any, as
261
+ its `cause`. When a file only fails the check once it is reopened later, on a rotation or after a
262
+ fork, the appender reports the reason once for each file, drops the log messages instead of
263
+ raising, and tries the file again every few seconds and at the next scheduled reopen, so that
264
+ logging resumes once the file is fixed. A symbolic link, hard link, or file planted in a shared log
265
+ directory, for example under the name of tomorrow's log file, can therefore neither loosen the
266
+ permissions of another file nor receive the log messages. A FIFO planted there fails to open
267
+ instead of blocking the process.
268
+
269
+ Without `permissions`, the file appender opens the log file the way Ruby's own `Logger` does,
270
+ following symbolic links, so that a log file can for example be linked to `/dev/stdout`. Keep the
271
+ log directory writable only by the application.
272
+
273
+ ### Dependencies and supply chain
274
+
275
+ Semantic Logger has two runtime dependencies, `concurrent-ruby` and `logger` (part of
276
+ Ruby's standard library until Ruby 4.0 moved it to the bundled gems). Appenders for
277
+ third-party services (Kafka, MongoDB, Sentry, etc.) keep their backing gem optional: it
278
+ is required lazily inside the appender only when that appender is used, and is never
279
+ added to the gemspec. This keeps the dependency surface of an application that only uses
280
+ the built-in file and IO appenders very small.
281
+
282
+ Auditing the resolved dependency versions for known advisories is the responsibility of
283
+ the consuming application, which should run a tool such as
284
+ [bundler-audit](https://github.com/rubysec/bundler-audit) against its own
285
+ `Gemfile.lock`. Pinning dependencies with a committed lockfile, and verifying it in CI
286
+ with `bundle install --frozen`, prevents an unexpected dependency from being pulled in.
data/docs/testing.md ADDED
@@ -0,0 +1,287 @@
1
+ ---
2
+ layout: default
3
+ title: Testing
4
+ description: >-
5
+ Assert that your code logged the expected message, level, payload or metric,
6
+ using the capture helpers for Minitest, RSpec, and other test frameworks.
7
+ ---
8
+
9
+
10
+ **Contents**
11
+
12
+ * TOC
13
+ {:toc}
14
+
15
+ When testing application code, you often want to assert that it logged the right thing: the expected
16
+ message, level, payload, or metric. Semantic Logger logs through a global, asynchronous pipeline, so
17
+ you cannot simply read it back from a normal appender. Instead it ships a test helper that **captures
18
+ log events in memory** during a block, so you can make assertions on them.
19
+
20
+ The events are captured as raw `SemanticLogger::Log` objects, before any appender or formatter runs,
21
+ so your assertions are not affected by how logging happens to be configured.
22
+
23
+ Helpers are provided for both Minitest and RSpec. Other frameworks are covered further down.
24
+
25
+ ## Minitest
26
+
27
+ ### Step 1: install the helpers
28
+
29
+ Add the helper methods to your tests, once, in `test_helper.rb`:
30
+
31
+ ~~~ruby
32
+ Minitest::Test.include SemanticLogger::Test::Minitest
33
+ ~~~
34
+
35
+ ### Step 2: capture events
36
+
37
+ Wrap the code under test in `semantic_logger_events`. It returns every log event created during the
38
+ block:
39
+
40
+ ~~~ruby
41
+ messages = semantic_logger_events do
42
+ User.new.enable!
43
+ end
44
+ ~~~
45
+
46
+ By default it captures events from every class. To capture only the events from one class, pass that
47
+ class:
48
+
49
+ ~~~ruby
50
+ messages = semantic_logger_events(ApiClient) do
51
+ # Only ApiClient log events created during this block are captured.
52
+ end
53
+ ~~~
54
+
55
+ ### Step 3: assert on the events
56
+
57
+ Check how many events were produced, then assert on each one with `assert_semantic_logger_event`:
58
+
59
+ ~~~ruby
60
+ require_relative "test_helper"
61
+
62
+ class UserTest < ActiveSupport::TestCase
63
+ describe User do
64
+ it "logs message" do
65
+ messages = semantic_logger_events do
66
+ User.new.enable!
67
+ end
68
+
69
+ # How many events were logged
70
+ assert_equal 2, messages.count, messages
71
+
72
+ # The first event
73
+ assert_semantic_logger_event(
74
+ messages[0],
75
+ level: :info,
76
+ message: "User enabled"
77
+ )
78
+
79
+ # The second event
80
+ assert_semantic_logger_event(
81
+ messages[1],
82
+ level: :debug,
83
+ message: "Completed"
84
+ )
85
+ end
86
+ end
87
+ end
88
+ ~~~
89
+
90
+ Every argument other than the event itself is optional, so you assert only on what matters to the
91
+ test. The available checks are:
92
+
93
+ | Argument | Checks |
94
+ |----------|--------|
95
+ | `message` | Exact match of the text message. |
96
+ | `message_includes` | Partial (substring) match of the message. |
97
+ | `level` | Log level: `:trace`, `:debug`, `:info`, `:warn`, `:error`, `:fatal`. |
98
+ | `payload` | Exact match of the payload Hash. |
99
+ | `payload_includes` | Partial match: the given keys/values, ignoring any others. |
100
+ | `tags` | Tags active on the thread when logged. |
101
+ | `named_tags` | Named tags active on the thread when logged. |
102
+ | `name` | Class name of the logger. |
103
+ | `exception` | The Ruby exception that was logged. |
104
+ | `metric` | The metric name. |
105
+ | `metric_amount` | The metric amount. |
106
+ | `dimensions` | The metric dimensions. |
107
+ | `context` | Named contexts captured with the entry. |
108
+ | `time` | When the entry was created. |
109
+ | `duration` | Duration of a measure call, in milliseconds. |
110
+ | `backtrace` | The captured backtrace, if any. |
111
+ | `thread_name` | Name of the thread that logged the entry, or its object id when the thread has no name. |
112
+
113
+ ### Match part of a message or payload
114
+
115
+ For assertions that should not break on incidental detail, match partially.
116
+
117
+ Use `message_includes` for a substring of the message:
118
+
119
+ ~~~ruby
120
+ assert_semantic_logger_event(
121
+ messages[0],
122
+ level: :info,
123
+ message_includes: "enabled"
124
+ )
125
+ ~~~
126
+
127
+ Use `payload_includes` to assert specific payload keys while ignoring any extras. Compare with
128
+ `payload`, which must match the whole Hash exactly:
129
+
130
+ ~~~ruby
131
+ # Exact: the payload must be exactly these keys and values
132
+ assert_semantic_logger_event(
133
+ messages[0],
134
+ level: :info,
135
+ message: "User enabled",
136
+ payload: {first_name: "Jack", last_name: "Jones"}
137
+ )
138
+
139
+ # Partial: first_name must be present, other keys are ignored
140
+ assert_semantic_logger_event(
141
+ messages[0],
142
+ level: :info,
143
+ message: "User enabled",
144
+ payload_includes: {first_name: "Jack"}
145
+ )
146
+ ~~~
147
+
148
+ For more examples, see the
149
+ [Rails Semantic Logger tests](https://github.com/reidmorrison/rails_semantic_logger/blob/main/test/active_record_test.rb).
150
+
151
+ ## RSpec
152
+
153
+ The RSpec helpers mirror the Minitest ones: a capture helper plus matchers for asserting on the
154
+ captured events.
155
+
156
+ ### Step 1: install the helpers
157
+
158
+ Require the helpers and include them, once, in `spec_helper.rb`:
159
+
160
+ ~~~ruby
161
+ require "semantic_logger/test/rspec"
162
+
163
+ RSpec.configure do |config|
164
+ config.include SemanticLogger::Test::RSpec
165
+ end
166
+ ~~~
167
+
168
+ ### Step 2: capture events
169
+
170
+ Wrap the code under test in `capture_semantic_logger_events`. It returns every log event created
171
+ during the block, regardless of the global default log level:
172
+
173
+ ~~~ruby
174
+ events = capture_semantic_logger_events do
175
+ User.new.enable!
176
+ end
177
+ ~~~
178
+
179
+ By default it captures events from every class. To capture only the events from one class, pass that
180
+ class:
181
+
182
+ ~~~ruby
183
+ events = capture_semantic_logger_events(ApiClient) do
184
+ # Only ApiClient log events created during this block are captured.
185
+ end
186
+ ~~~
187
+
188
+ ### Step 3: assert on the events
189
+
190
+ Use `be_a_semantic_logger_event` to assert on a single captured event:
191
+
192
+ ~~~ruby
193
+ RSpec.describe User do
194
+ it "logs message" do
195
+ events = capture_semantic_logger_events do
196
+ User.new.enable!
197
+ end
198
+
199
+ expect(events.count).to eq(2)
200
+
201
+ expect(events[0]).to be_a_semantic_logger_event(
202
+ level: :info,
203
+ message: "User enabled"
204
+ )
205
+
206
+ expect(events[1]).to be_a_semantic_logger_event(
207
+ level: :debug,
208
+ message: "Completed"
209
+ )
210
+ end
211
+ end
212
+ ~~~
213
+
214
+ Every argument is optional, so you assert only on what matters to the test. The available checks are
215
+ the same as the [Minitest table above](#step-3-assert-on-the-events), plus `message_includes`,
216
+ `payload_includes`, and `exception_includes` for partial matches:
217
+
218
+ ~~~ruby
219
+ expect(events[0]).to be_a_semantic_logger_event(
220
+ level: :info,
221
+ message_includes: "enabled",
222
+ payload_includes: {first_name: "Jack"}
223
+ )
224
+ ~~~
225
+
226
+ An expected value that is a Class matches by type, and `:nil` asserts the attribute is `nil`:
227
+
228
+ ~~~ruby
229
+ expect(events[0]).to be_a_semantic_logger_event(time: Time, exception: :nil)
230
+ ~~~
231
+
232
+ ### Match against a list of events
233
+
234
+ `a_semantic_logger_event` is a composable alias, so it works inside `include` and other matchers:
235
+
236
+ ~~~ruby
237
+ expect(events).to include(
238
+ a_semantic_logger_event(message: "User enabled")
239
+ )
240
+ ~~~
241
+
242
+ ### Assert directly on a block
243
+
244
+ `log_semantic_logger_event` captures the events for you and passes if any of them match. Pass `on:`
245
+ to capture only one class's events:
246
+
247
+ ~~~ruby
248
+ expect { User.new.enable! }.to(
249
+ log_semantic_logger_event(level: :info, message: "User enabled")
250
+ )
251
+
252
+ expect { ApiClient.new.call }.to(
253
+ log_semantic_logger_event(on: ApiClient, metric: "ApiClient/call")
254
+ )
255
+ ~~~
256
+
257
+ ## Other test frameworks
258
+
259
+ The same approach works anywhere: replace the logger with a
260
+ `SemanticLogger::Test::CaptureLogEvents` instance. It looks and behaves like a normal logger, but
261
+ keeps the raw log events in memory instead of writing them, so your assertions are unaffected by the
262
+ configured appenders or their formats.
263
+
264
+ ~~~ruby
265
+ logger = SemanticLogger::Test::CaptureLogEvents.new
266
+ ~~~
267
+
268
+ Stub it onto a single class to capture just that class's logging:
269
+
270
+ ~~~ruby
271
+ User.stub(:logger, logger) do
272
+ # Capture all logging calls to the User logger.
273
+ end
274
+ ~~~
275
+
276
+ Or stub the global processor to capture logging from every class:
277
+
278
+ ~~~ruby
279
+ SemanticLogger::Logger.stub(:processor, logger) do
280
+ # Capture all log events during the block.
281
+ end
282
+ ~~~
283
+
284
+ Either way, the captured events are available as `logger.events`.
285
+
286
+ If you add helper methods for another framework like the Minitest ones, a pull request would be
287
+ welcome.