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.
- checksums.yaml +4 -4
- data/README.md +20 -10
- data/Rakefile +40 -0
- data/docs/api.md +506 -0
- data/docs/appenders.md +1074 -0
- data/docs/config.md +707 -0
- data/docs/index.md +223 -0
- data/docs/log.md +116 -0
- data/docs/metrics.md +171 -0
- data/docs/operations.md +446 -0
- data/docs/rails.md +890 -0
- data/docs/security.md +286 -0
- data/docs/testing.md +287 -0
- data/docs/upgrading.md +697 -0
- data/lib/semantic_logger/appender/async.rb +18 -5
- data/lib/semantic_logger/appender/bugsnag.rb +1 -1
- data/lib/semantic_logger/appender/cloudwatch_logs.rb +126 -29
- data/lib/semantic_logger/appender/elasticsearch.rb +2 -5
- data/lib/semantic_logger/appender/elasticsearch_base.rb +48 -23
- data/lib/semantic_logger/appender/elasticsearch_http.rb +15 -8
- data/lib/semantic_logger/appender/file.rb +141 -28
- data/lib/semantic_logger/appender/graylog.rb +22 -6
- data/lib/semantic_logger/appender/honeybadger.rb +1 -1
- data/lib/semantic_logger/appender/honeybadger_insights.rb +1 -1
- data/lib/semantic_logger/appender/http.rb +70 -11
- data/lib/semantic_logger/appender/io.rb +1 -1
- data/lib/semantic_logger/appender/kafka.rb +26 -12
- data/lib/semantic_logger/appender/loki.rb +13 -3
- data/lib/semantic_logger/appender/mongodb.rb +20 -1
- data/lib/semantic_logger/appender/new_relic.rb +7 -1
- data/lib/semantic_logger/appender/new_relic_logs.rb +11 -12
- data/lib/semantic_logger/appender/open_telemetry.rb +4 -3
- data/lib/semantic_logger/appender/opensearch.rb +2 -1
- data/lib/semantic_logger/appender/rabbitmq.rb +10 -4
- data/lib/semantic_logger/appender/sentry.rb +6 -1
- data/lib/semantic_logger/appender/sentry_ruby.rb +91 -17
- data/lib/semantic_logger/appender/splunk.rb +10 -1
- data/lib/semantic_logger/appender/syslog.rb +66 -25
- data/lib/semantic_logger/appender/tcp.rb +10 -4
- data/lib/semantic_logger/appender/udp.rb +11 -1
- data/lib/semantic_logger/appender/wrapper.rb +3 -2
- data/lib/semantic_logger/appender.rb +6 -3
- data/lib/semantic_logger/appenders.rb +11 -2
- data/lib/semantic_logger/base.rb +82 -32
- data/lib/semantic_logger/exception_details.rb +50 -0
- data/lib/semantic_logger/formatters/base.rb +52 -6
- data/lib/semantic_logger/formatters/color.rb +22 -3
- data/lib/semantic_logger/formatters/default.rb +28 -6
- data/lib/semantic_logger/formatters/logfmt.rb +155 -21
- data/lib/semantic_logger/formatters/loki.rb +26 -22
- data/lib/semantic_logger/formatters/one_line.rb +67 -3
- data/lib/semantic_logger/formatters/open_telemetry.rb +16 -17
- data/lib/semantic_logger/formatters/pattern.rb +11 -5
- data/lib/semantic_logger/formatters/raw.rb +4 -5
- data/lib/semantic_logger/formatters/syslog.rb +110 -17
- data/lib/semantic_logger/formatters/syslog_cee.rb +29 -15
- data/lib/semantic_logger/levels.rb +10 -1
- data/lib/semantic_logger/log.rb +183 -37
- data/lib/semantic_logger/loggable.rb +26 -3
- data/lib/semantic_logger/metric/new_relic.rb +1 -1
- data/lib/semantic_logger/metric/signalfx.rb +12 -8
- data/lib/semantic_logger/metric/statsd.rb +15 -7
- data/lib/semantic_logger/queue_processor/request.rb +55 -0
- data/lib/semantic_logger/queue_processor/throttled_counter.rb +46 -0
- data/lib/semantic_logger/queue_processor.rb +170 -68
- data/lib/semantic_logger/reporters/minitest.rb +4 -4
- data/lib/semantic_logger/semantic_logger.rb +15 -2
- data/lib/semantic_logger/subscriber.rb +96 -10
- data/lib/semantic_logger/sync_processor.rb +11 -0
- data/lib/semantic_logger/test/capture_log_events.rb +9 -6
- data/lib/semantic_logger/utils.rb +244 -16
- data/lib/semantic_logger/version.rb +1 -1
- data/lib/semantic_logger.rb +1 -1
- metadata +33 -6
- 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.
|