rails_semantic_logging 0.1.2 → 0.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 +58 -5
- data/VERSION +1 -1
- data/lib/rails_semantic_logging/datadog/log_injection.rb +46 -18
- data/lib/rails_semantic_logging/formatters/datadog.rb +73 -2
- data/lib/rails_semantic_logging/job_logging/active_job_patch.rb +12 -2
- data/lib/rails_semantic_logging/rspec/matchers.rb +136 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 45760af9983f2c22b841521dede71966f21d51b02ac68909adc334a9c59d1347
|
|
4
|
+
data.tar.gz: d143a49b3a2044e30ac92e20d0ea85d81c4634322526e1e03480e4b9f731e0e0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cd642159648180526467d511be9f232571e9f7fffcac3840c02706eb1e4f0a18143921d612d8a3cb356d04ed8b51127fa2a05d746791f3a2655eb1caa87a58a2
|
|
7
|
+
data.tar.gz: cf2ce998170d859f106e83b9b11648d1135a5015abd4a8790c67e4ce076bfaf685f1e3622eaaddf9a7127720afbc2378d01fcb8b320672ac4a4a39e9b3536c9e
|
data/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Opinionated Rails semantic logger configuration with Datadog support. Provides a
|
|
|
7
7
|
- **Datadog formatter** with [Standard Attributes](https://docs.datadoghq.com/standard-attributes/) mapping
|
|
8
8
|
- **Default payload enrichment** for controllers (host, user_agent, referer) mapped to `http.*`
|
|
9
9
|
- **JSON formatter** for structured logging without Datadog-specific fields
|
|
10
|
-
- **ActiveJob integration** with named tags (`job_class`, `job_id`, `queue`) instead of array tags
|
|
10
|
+
- **ActiveJob integration** with named tags (`job_class`, `job_id`, `queue`, `executions`, and `provider_job_id` when the adapter assigns one) instead of array tags
|
|
11
11
|
- **Sidekiq integration** with job context in all log lines
|
|
12
12
|
- **Configurable** via [anyway_config](https://github.com/palkan/anyway_config) (YAML, env vars, or code)
|
|
13
13
|
- **Environment-aware** defaults (Datadog JSON in production, color in development, fatal in test)
|
|
@@ -130,6 +130,17 @@ When `default_payload` is enabled (default), controller request logs include:
|
|
|
130
130
|
|
|
131
131
|
When Datadog tracing is active, the formatter injects: `dd.trace_id`, `dd.span_id`, `dd.env`, `dd.service`, `dd.version`.
|
|
132
132
|
|
|
133
|
+
#### Routing error enrichment
|
|
134
|
+
|
|
135
|
+
`rails_semantic_logger` logs unmatched routes as a real `ActionController::RoutingError`.
|
|
136
|
+
When the formatter sees one, it parses the standard `No route matches [GET] "/foo"`
|
|
137
|
+
message and populates `http.method`, `http.url_details.path` and `http.status_code`
|
|
138
|
+
(the canonical `404`) on the log event. The exception message is also used as the
|
|
139
|
+
top-level `message` so the log line is never empty. This keeps unmatched-route logs
|
|
140
|
+
correlated with the original request URL in Datadog without requiring a custom
|
|
141
|
+
server-side log pipeline. Actual request data (when present in the payload) always
|
|
142
|
+
wins over the message-derived fields.
|
|
143
|
+
|
|
133
144
|
### Example: Complete request log (production)
|
|
134
145
|
|
|
135
146
|
```json
|
|
@@ -163,11 +174,21 @@ When Datadog tracing is active, the formatter injects: `dd.trace_id`, `dd.span_i
|
|
|
163
174
|
"named_tags": {
|
|
164
175
|
"job_class": "ImportBikesJob",
|
|
165
176
|
"job_id": "abc-123",
|
|
166
|
-
"queue": "default"
|
|
177
|
+
"queue": "default",
|
|
178
|
+
"executions": 0,
|
|
179
|
+
"provider_job_id": "9632"
|
|
167
180
|
}
|
|
168
181
|
}
|
|
169
182
|
```
|
|
170
183
|
|
|
184
|
+
`executions` is the ActiveJob attempt counter (0 on the first run), so retries
|
|
185
|
+
are visible directly in the logs. `provider_job_id` is the queue backend's
|
|
186
|
+
native identifier — the `solid_queue_jobs` row id under Solid Queue, the `jid`
|
|
187
|
+
under Sidekiq — which lets a log line be correlated with the job in Mission
|
|
188
|
+
Control and the backend's own tables. It is only present once the job has been
|
|
189
|
+
enqueued, so synchronous (`perform_now` / `:inline` / `:test`) executions omit
|
|
190
|
+
it.
|
|
191
|
+
|
|
171
192
|
### Example: Error log
|
|
172
193
|
|
|
173
194
|
```json
|
|
@@ -204,9 +225,9 @@ Plain structured JSON without Datadog-specific field mapping. Useful for non-Dat
|
|
|
204
225
|
|
|
205
226
|
Uses the built-in `SemanticLogger::Formatters::Color` for human-readable development output.
|
|
206
227
|
|
|
207
|
-
## RSpec
|
|
228
|
+
## RSpec Matchers
|
|
208
229
|
|
|
209
|
-
Include the
|
|
230
|
+
Include the matchers module in your spec config:
|
|
210
231
|
|
|
211
232
|
```ruby
|
|
212
233
|
require 'rails_semantic_logging/rspec/matchers'
|
|
@@ -216,7 +237,11 @@ RSpec.configure do |config|
|
|
|
216
237
|
end
|
|
217
238
|
```
|
|
218
239
|
|
|
219
|
-
|
|
240
|
+
Two matchers are provided. They cover complementary use cases.
|
|
241
|
+
|
|
242
|
+
### `log_semantic` — declarative "was X logged?" assertion
|
|
243
|
+
|
|
244
|
+
Returns a boolean: true if **any** captured log line matches all the supplied criteria. Use it when the question is "did this happen?".
|
|
220
245
|
|
|
221
246
|
```ruby
|
|
222
247
|
expect { logger.info("hello") }.to log_semantic(level: :info, message: /hello/)
|
|
@@ -224,6 +249,34 @@ expect { logger.warn("oops", key: "val") }.to log_semantic(payload: { key: "val"
|
|
|
224
249
|
expect { do_work }.to log_semantic(named_tags: { job_class: 'MyJob' })
|
|
225
250
|
```
|
|
226
251
|
|
|
252
|
+
### `have_logged_message` — find a specific log and assert on it
|
|
253
|
+
|
|
254
|
+
Finds the first log event whose message matches the expected pattern, then hands the raw `SemanticLogger::Log` (and, optionally, a formatted version) over to a user block so you can make arbitrary assertions on it.
|
|
255
|
+
|
|
256
|
+
The expected message can be a String, a Regexp, or any object with `===` (so RSpec built-in matchers like `a_string_starting_with("...")` work too). An optional second argument constrains the level.
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
expect { service.call }.to have_logged_message("Imported")
|
|
260
|
+
expect { service.call }.to have_logged_message(/import/, :warn)
|
|
261
|
+
|
|
262
|
+
expect { service.call }
|
|
263
|
+
.to have_logged_message("Imported").with_formatted_event { |event, formatted|
|
|
264
|
+
expect(event.payload).to include(items: 3)
|
|
265
|
+
expect(event.named_tags).to include(tenant: 'eu')
|
|
266
|
+
expect(formatted.dig('http', 'status_code')).to eq(200)
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`with_formatted_event` defaults to the gem's Datadog formatter; pass any `SemanticLogger::Formatters::Base` subclass to use a different one:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
.with_formatted_event(SemanticLogger::Formatters::Json) { |event, formatted| ... }
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`formatted` is the formatter's JSON output, re-parsed with indifferent access — so both `formatted[:status]` and `formatted['status']` work.
|
|
277
|
+
|
|
278
|
+
The matcher passes a **duplicated** copy of the log event to the formatter, so a formatter that mutates `log.payload` / `log.named_tags` cannot leak those mutations into the event you receive in the block.
|
|
279
|
+
|
|
227
280
|
## Puma Integration
|
|
228
281
|
|
|
229
282
|
When using Puma in clustered mode, reopen log appenders after forking:
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.2.0
|
|
@@ -1,7 +1,22 @@
|
|
|
1
|
-
# Monkey
|
|
2
|
-
# correlation tags instead of string-style.
|
|
3
|
-
#
|
|
4
|
-
#
|
|
1
|
+
# Monkey patches Datadog's ActiveJob LogInjection to use hash-style
|
|
2
|
+
# correlation tags instead of string-style.
|
|
3
|
+
#
|
|
4
|
+
# Background: Datadog ships LogInjection sub-modules (PerformNowPatch for
|
|
5
|
+
# Rails 6+, EnqueuePatch for the singleton enqueue path) and patches
|
|
6
|
+
# ActiveJob::Base by `prepend`ing them. By default they call
|
|
7
|
+
# `Datadog::Tracing.log_correlation`, which returns a flat string
|
|
8
|
+
# ("dd.trace_id=... dd.span_id=...") and ends up under `event.tags`.
|
|
9
|
+
#
|
|
10
|
+
# Once we patch ActiveJob::Logging#tag_logger to emit named tags, we want
|
|
11
|
+
# Datadog correlation to land in `named_tags` too, so traces and logs
|
|
12
|
+
# correlate properly in the Datadog UI. We achieve that by re-defining
|
|
13
|
+
# `perform_now` and `enqueue` on Datadog's own patch modules to call
|
|
14
|
+
# `Datadog::Tracing.correlation.to_h` and pass the resulting hash to
|
|
15
|
+
# `logger.tagged`.
|
|
16
|
+
#
|
|
17
|
+
# Because Datadog prepends those modules into ActiveJob::Base, our
|
|
18
|
+
# re-definitions take effect on every method dispatch — independent of
|
|
19
|
+
# load order, as long as `apply!` runs before any job is executed.
|
|
5
20
|
|
|
6
21
|
module RailsSemanticLogging
|
|
7
22
|
module Datadog
|
|
@@ -9,24 +24,37 @@ module RailsSemanticLogging
|
|
|
9
24
|
def self.apply!
|
|
10
25
|
return unless defined?(::Datadog::Tracing::Contrib::ActiveJob::LogInjection)
|
|
11
26
|
|
|
12
|
-
# Ensure the SemanticLogger ActiveJob extension is loaded
|
|
27
|
+
# Ensure the SemanticLogger ActiveJob extension is loaded before we
|
|
28
|
+
# override Datadog's patches, so the named-tags `tag_logger` is in place.
|
|
13
29
|
require 'rails_semantic_logger/extensions/active_job/logging'
|
|
14
30
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
::Datadog::Tracing::Contrib::ActiveJob.const_set(:LogInjection, PatchedLogInjection)
|
|
31
|
+
patch_perform_now
|
|
32
|
+
patch_enqueue
|
|
18
33
|
end
|
|
19
34
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
def self.patch_perform_now
|
|
36
|
+
return unless defined?(::Datadog::Tracing::Contrib::ActiveJob::LogInjection::PerformNowPatch)
|
|
37
|
+
|
|
38
|
+
::Datadog::Tracing::Contrib::ActiveJob::LogInjection::PerformNowPatch.module_eval do
|
|
39
|
+
define_method(:perform_now) do
|
|
40
|
+
if ::Datadog.configuration.tracing.log_injection && logger.respond_to?(:tagged)
|
|
41
|
+
logger.tagged(::Datadog::Tracing.correlation.to_h) { super() }
|
|
42
|
+
else
|
|
43
|
+
super()
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.patch_enqueue
|
|
50
|
+
return unless defined?(::Datadog::Tracing::Contrib::ActiveJob::LogInjection::EnqueuePatch)
|
|
51
|
+
|
|
52
|
+
::Datadog::Tracing::Contrib::ActiveJob::LogInjection::EnqueuePatch.module_eval do
|
|
53
|
+
define_method(:enqueue) do |*args, **kwargs, &block|
|
|
54
|
+
if ::Datadog.configuration.tracing.log_injection && logger.respond_to?(:tagged)
|
|
55
|
+
logger.tagged(::Datadog::Tracing.correlation.to_h) { super(*args, **kwargs, &block) }
|
|
56
|
+
else
|
|
57
|
+
super(*args, **kwargs, &block)
|
|
30
58
|
end
|
|
31
59
|
end
|
|
32
60
|
end
|
|
@@ -33,12 +33,27 @@ module RailsSemanticLogging
|
|
|
33
33
|
user_role: :role
|
|
34
34
|
}.freeze
|
|
35
35
|
|
|
36
|
+
# Matches ActionController::RoutingError messages like:
|
|
37
|
+
# No route matches [GET] "/some/path"
|
|
38
|
+
# Used to extract http.method and http.url_details.path from the exception.
|
|
39
|
+
ROUTING_ERROR_MESSAGE = /\ANo route matches \[(?<method>\w+)\]\s+"(?<path>[^"]+)"/
|
|
40
|
+
|
|
36
41
|
def initialize(time_format: :iso_8601, time_key: :timestamp, **args) # rubocop:disable Naming/VariableNumber
|
|
37
42
|
super(time_format:, time_key:, log_application: false, log_host: true, log_environment: false, **args)
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
def call(log, logger)
|
|
41
46
|
super
|
|
47
|
+
# SemanticLogger::Formatters::Raw assigns log.payload and log.named_tags
|
|
48
|
+
# to hash[:payload] / hash[:named_tags] BY REFERENCE. We mutate those
|
|
49
|
+
# below (remap_http_payload deletes :host/:method/etc., remap_named_tags
|
|
50
|
+
# deletes :request_id/:client_ip/:dd/etc., deep_compact_blank! drops
|
|
51
|
+
# every blank value). Without these dups the formatter strips keys off
|
|
52
|
+
# the underlying log event, which breaks any consumer that reads
|
|
53
|
+
# log.payload / log.named_tags after formatting (other appenders,
|
|
54
|
+
# RSpec matchers that re-inspect the captured event).
|
|
55
|
+
hash[:payload] = hash[:payload].dup if hash[:payload].is_a?(Hash)
|
|
56
|
+
hash[:named_tags] = hash[:named_tags].dup if hash[:named_tags].is_a?(Hash)
|
|
42
57
|
remap_named_tags
|
|
43
58
|
remap_http_payload
|
|
44
59
|
parse_url_details
|
|
@@ -59,6 +74,16 @@ module RailsSemanticLogging
|
|
|
59
74
|
hash[:status] = log.level
|
|
60
75
|
end
|
|
61
76
|
|
|
77
|
+
# Fall back to the exception message when the log carries no message of
|
|
78
|
+
# its own. rails_semantic_logger logs unmatched routes (and other
|
|
79
|
+
# rescued exceptions) via ActionDispatch::DebugExceptions by passing only
|
|
80
|
+
# the exception object, so without this the Datadog `message` field would
|
|
81
|
+
# be empty for those events.
|
|
82
|
+
def message
|
|
83
|
+
super
|
|
84
|
+
hash[:message] ||= log.exception&.message
|
|
85
|
+
end
|
|
86
|
+
|
|
62
87
|
def duration
|
|
63
88
|
# Propagate duration from payload if not set on log
|
|
64
89
|
log.duration = log.payload[:duration] if log.duration.nil? && log.payload&.dig(:duration)
|
|
@@ -78,10 +103,45 @@ module RailsSemanticLogging
|
|
|
78
103
|
message: log.exception.message,
|
|
79
104
|
stack: log.exception.backtrace&.join("\n")
|
|
80
105
|
}
|
|
106
|
+
|
|
107
|
+
parse_routing_error
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# SemanticLogger's :dimensions log attribute carries metric tags
|
|
111
|
+
# (e.g. `logger.info('Processed feed', metric: 'feed_size', metric_amount: 12,
|
|
112
|
+
# dimensions: { feed: 'amazon' })`). The Raw formatter sets :metric and
|
|
113
|
+
# :metric_amount but not :dimensions — surface it at the top level so the
|
|
114
|
+
# Datadog UI can filter on those tags directly without diving into named_tags.
|
|
115
|
+
def metric
|
|
116
|
+
super
|
|
117
|
+
hash[:dimensions] = log.dimensions if log.dimensions.respond_to?(:any?) && log.dimensions.any?
|
|
81
118
|
end
|
|
82
119
|
|
|
83
120
|
private
|
|
84
121
|
|
|
122
|
+
# Extracts http.method and http.url_details.path from
|
|
123
|
+
# ActionController::RoutingError messages so logs for unmatched routes
|
|
124
|
+
# are still correlated with the request URL in Datadog.
|
|
125
|
+
#
|
|
126
|
+
# rails_semantic_logger patches ActionDispatch::DebugExceptions to log the
|
|
127
|
+
# real exception object, so unmatched-route requests reach this formatter
|
|
128
|
+
# as a RoutingError carrying a message like: No route matches [GET] "/foo".
|
|
129
|
+
def parse_routing_error
|
|
130
|
+
# Fully qualified so the constant resolves to Rails' ActionController and
|
|
131
|
+
# not the gem's own RailsSemanticLogging::ActionController namespace.
|
|
132
|
+
return unless log.exception.is_a?(::ActionController::RoutingError)
|
|
133
|
+
return unless (match = ROUTING_ERROR_MESSAGE.match(log.exception.message))
|
|
134
|
+
|
|
135
|
+
hash[:http] ||= {}
|
|
136
|
+
hash[:http][:method] ||= match[:method]
|
|
137
|
+
# The DebugExceptions log carries no HTTP status (there is no completed
|
|
138
|
+
# request), so map the exception to its canonical status the same way
|
|
139
|
+
# rails_semantic_logger does (RoutingError => 404).
|
|
140
|
+
hash[:http][:status_code] ||= ::ActionDispatch::ExceptionWrapper.status_code_for_exception(log.exception.class.name)
|
|
141
|
+
url_details = hash[:http][:url_details] ||= {}
|
|
142
|
+
url_details[:path] ||= match[:path]
|
|
143
|
+
end
|
|
144
|
+
|
|
85
145
|
# Parses http.url into url_details with host, path and queryString
|
|
86
146
|
def parse_url_details
|
|
87
147
|
return unless hash.dig(:http, :url)
|
|
@@ -176,14 +236,25 @@ module RailsSemanticLogging
|
|
|
176
236
|
hash[:http].is_a?(Hash) ? hash[:http].merge!(http) : hash[:http] = http
|
|
177
237
|
end
|
|
178
238
|
|
|
179
|
-
# Recursively removes
|
|
239
|
+
# Recursively removes empty values from a hash. nil, "", [], {} are dropped
|
|
240
|
+
# to keep the log payload tidy, but `false` and `0` are kept because they
|
|
241
|
+
# are meaningful values in things like `bot: false` or `count: 0`.
|
|
242
|
+
# (ActiveSupport's `blank?` treats `false` as blank, which would silently
|
|
243
|
+
# strip boolean flags from payloads.)
|
|
180
244
|
def deep_compact_blank!(h)
|
|
181
245
|
h.each do |key, value|
|
|
182
246
|
deep_compact_blank!(value) if value.is_a?(Hash)
|
|
183
|
-
h.delete(key) if value
|
|
247
|
+
h.delete(key) if blank_value?(value)
|
|
184
248
|
end
|
|
185
249
|
h
|
|
186
250
|
end
|
|
251
|
+
|
|
252
|
+
def blank_value?(value)
|
|
253
|
+
return true if value.nil?
|
|
254
|
+
return value.empty? if value.respond_to?(:empty?)
|
|
255
|
+
|
|
256
|
+
false
|
|
257
|
+
end
|
|
187
258
|
end
|
|
188
259
|
end
|
|
189
260
|
end
|
|
@@ -4,13 +4,23 @@ module RailsSemanticLogging
|
|
|
4
4
|
module JobLogging
|
|
5
5
|
# ActiveJob patch to provide named tags instead of array tags.
|
|
6
6
|
# Converts the default tag_logger(class, id) call into named tags
|
|
7
|
-
# (job_class, job_id, queue) for
|
|
7
|
+
# (job_class, job_id, queue, provider_job_id, executions) for
|
|
8
|
+
# structured logging.
|
|
9
|
+
#
|
|
10
|
+
# `provider_job_id` is the queue backend's native id (e.g. the
|
|
11
|
+
# solid_queue_jobs row id, or the Sidekiq jid) and lets a log line be
|
|
12
|
+
# correlated with the job in Mission Control / the solid_queue_* tables.
|
|
13
|
+
# It is only present once the job has been enqueued, so it is added
|
|
14
|
+
# conditionally. `executions` is the ActiveJob attempt counter, which
|
|
15
|
+
# makes retries visible in the logs.
|
|
8
16
|
module ActiveJobPatch
|
|
9
17
|
extend ActiveSupport::Concern
|
|
10
18
|
|
|
11
19
|
def tag_logger(job_class = nil, job_id = nil, &)
|
|
12
20
|
if job_class && job_id
|
|
13
|
-
|
|
21
|
+
tags = { job_class: job_class, job_id: job_id, queue: queue_name, executions: executions }
|
|
22
|
+
tags[:provider_job_id] = provider_job_id if provider_job_id
|
|
23
|
+
super(**tags, &)
|
|
14
24
|
else
|
|
15
25
|
super(&)
|
|
16
26
|
end
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
require 'json'
|
|
1
2
|
require 'semantic_logger'
|
|
3
|
+
require_relative '../formatters/datadog'
|
|
2
4
|
|
|
3
5
|
module RailsSemanticLogging
|
|
4
6
|
module RSpec
|
|
@@ -111,10 +113,144 @@ module RailsSemanticLogging
|
|
|
111
113
|
end
|
|
112
114
|
end
|
|
113
115
|
|
|
116
|
+
# Block matcher that finds a specific log event by message and exposes both
|
|
117
|
+
# the raw SemanticLogger::Log and an optionally formatted version to a user
|
|
118
|
+
# block, so the test can make arbitrary assertions on the event.
|
|
119
|
+
#
|
|
120
|
+
# Complementary to `log_semantic`:
|
|
121
|
+
# * `log_semantic` is declarative ("was anything matching these criteria
|
|
122
|
+
# logged?") and returns a boolean.
|
|
123
|
+
# * `have_logged_message` is imperative — it finds the event, hands it over
|
|
124
|
+
# to the test, and lets the test assert whatever it needs.
|
|
125
|
+
#
|
|
126
|
+
# Usage:
|
|
127
|
+
# expect { logger.info("hello", key: "val") }.to have_logged_message("hello")
|
|
128
|
+
# expect { do_work }.to have_logged_message(/import/, :warn)
|
|
129
|
+
#
|
|
130
|
+
# expect { service.call }
|
|
131
|
+
# .to have_logged_message("Completed").with_formatted_event { |event, formatted|
|
|
132
|
+
# expect(event.payload).to include(order_id: order.id)
|
|
133
|
+
# expect(formatted.dig("http", "status_code")).to eq(200)
|
|
134
|
+
# }
|
|
135
|
+
#
|
|
136
|
+
# The optional `with_formatted_event` block receives `(event, formatted)`
|
|
137
|
+
# where `formatted` is the JSON output of the chosen formatter parsed back
|
|
138
|
+
# with indifferent access. By default the gem's Datadog formatter is used;
|
|
139
|
+
# pass any `SemanticLogger::Formatters::Base` subclass to override:
|
|
140
|
+
#
|
|
141
|
+
# .with_formatted_event(SemanticLogger::Formatters::Json) { |event, formatted| ... }
|
|
142
|
+
class HaveLoggedMessageMatcher
|
|
143
|
+
attr_reader :event_block
|
|
144
|
+
|
|
145
|
+
DEFAULT_FORMATTER = ::RailsSemanticLogging::Formatters::Datadog
|
|
146
|
+
|
|
147
|
+
def initialize(expected, expected_level = nil)
|
|
148
|
+
@expected_message = expected
|
|
149
|
+
@expected_level = expected_level
|
|
150
|
+
@formatter_class = DEFAULT_FORMATTER
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def matches?(block)
|
|
154
|
+
# Capture the live appender BEFORE we attach our test appender. The
|
|
155
|
+
# Datadog formatter reads `logger.host` on the appender it receives, so
|
|
156
|
+
# the formatter call later on needs a real one (any subscriber will do).
|
|
157
|
+
@live_appender = ::SemanticLogger.appenders.first
|
|
158
|
+
@log_event = capture_first_matching_event(block)
|
|
159
|
+
return false unless @log_event
|
|
160
|
+
return false if @expected_level && @log_event.level != @expected_level
|
|
161
|
+
|
|
162
|
+
@event_block&.call(@log_event, formatted)
|
|
163
|
+
true
|
|
164
|
+
rescue ::RSpec::Expectations::ExpectationNotMetError => e
|
|
165
|
+
@error = e
|
|
166
|
+
false
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def supports_block_expectations?
|
|
170
|
+
true
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Attach a block that receives the matched `(log_event, formatted)` pair.
|
|
174
|
+
# `formatted` is the JSON output of the configured formatter, parsed back
|
|
175
|
+
# with `HashWithIndifferentAccess` so callers can navigate the structure
|
|
176
|
+
# with either string or symbol keys.
|
|
177
|
+
def with_formatted_event(formatter_class = nil, &block)
|
|
178
|
+
raise ArgumentError, 'block is required' unless block
|
|
179
|
+
if formatter_class && !(formatter_class < ::SemanticLogger::Formatters::Base)
|
|
180
|
+
raise ArgumentError,
|
|
181
|
+
"formatter must inherit from SemanticLogger::Formatters::Base, got #{formatter_class.inspect}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
@formatter_class = formatter_class if formatter_class
|
|
185
|
+
@event_block = block
|
|
186
|
+
self
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def failure_message
|
|
190
|
+
return @error.message if @error
|
|
191
|
+
if @log_event && @expected_level && @log_event.level != @expected_level
|
|
192
|
+
return "expected log level #{@expected_level.inspect}, got #{@log_event.level.inspect}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
"expected block to log a message matching #{@expected_message.inspect}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def failure_message_when_negated
|
|
199
|
+
"expected block NOT to log a message matching #{@expected_message.inspect}, but a matching event was emitted"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
def capture_first_matching_event(block)
|
|
205
|
+
capture = InMemoryAppender.new
|
|
206
|
+
with_capture_appender(capture) { block.call }
|
|
207
|
+
capture.logs.find { |evt| message_matches?(evt.message) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def with_capture_appender(capture)
|
|
211
|
+
::SemanticLogger.add_appender(appender: capture)
|
|
212
|
+
previous_level = ::SemanticLogger.default_level
|
|
213
|
+
::SemanticLogger.default_level = :trace
|
|
214
|
+
yield
|
|
215
|
+
::SemanticLogger.flush
|
|
216
|
+
ensure
|
|
217
|
+
::SemanticLogger.default_level = previous_level
|
|
218
|
+
::SemanticLogger.remove_appender(capture)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Matches String, Regex and any object that implements `===` (incl. RSpec
|
|
222
|
+
# built-in matchers like `a_string_starting_with("...")`).
|
|
223
|
+
def message_matches?(actual)
|
|
224
|
+
@expected_message === actual.to_s # rubocop:disable Style/CaseEquality
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Format a *copy* of the log event so a buggy formatter that mutates
|
|
228
|
+
# log.payload / log.named_tags cannot leak back into the event we hand
|
|
229
|
+
# off to the user block.
|
|
230
|
+
def formatted
|
|
231
|
+
@formatted ||= begin
|
|
232
|
+
safe_log = duped_log_event
|
|
233
|
+
::JSON.parse(@formatter_class.new.call(safe_log, @live_appender)).with_indifferent_access
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def duped_log_event
|
|
238
|
+
copy = @log_event.dup
|
|
239
|
+
copy.payload = @log_event.payload.dup if @log_event.payload.is_a?(::Hash)
|
|
240
|
+
copy.named_tags = @log_event.named_tags.dup if @log_event.named_tags.is_a?(::Hash)
|
|
241
|
+
copy.tags = @log_event.tags.dup if @log_event.tags.is_a?(::Array)
|
|
242
|
+
copy
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
114
246
|
module Matchers
|
|
115
247
|
def log_semantic(expected = {})
|
|
116
248
|
LogSemanticMatcher.new(expected)
|
|
117
249
|
end
|
|
250
|
+
|
|
251
|
+
def have_logged_message(expected, level = nil) # rubocop:disable Naming/PredicatePrefix
|
|
252
|
+
HaveLoggedMessageMatcher.new(expected, level)
|
|
253
|
+
end
|
|
118
254
|
end
|
|
119
255
|
end
|
|
120
256
|
end
|