rails_semantic_logging 0.1.2 → 0.3.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 +62 -6
- 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/railtie.rb +17 -10
- data/lib/rails_semantic_logging/rspec/matchers.rb +167 -8
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8dabf186d9982fb21140d284857db16d25867425b0f9430bc51ba6513851bcc8
|
|
4
|
+
data.tar.gz: '03886b8629bef05d49a21f11d5e59dd434740e6d40b1b2e9de24bffe4929a1a0'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1bd15a9bbce788f3dbccac1f52d2ef0bbbddfe6838b1532abe0fce62c0982a7a29d84b71b3ac028eafa9a721d4485972e5b0d402d15189b21f07ea7110c79e62
|
|
7
|
+
data.tar.gz: 96836a1fd5830939269323239819805c293285d76889c86bdbef6fd3a343b5250ade36266a939658ee1f0fdb82b7cddac1cc5371238115860dcca5ce8cd7cf27
|
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,9 +249,40 @@ 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
|
+
With `semantic_logger` >= 5 appenders are reopened automatically after forking, so no Puma
|
|
283
|
+
configuration is needed (opt out with `SemanticLogger.reopen_on_fork = false`).
|
|
284
|
+
|
|
285
|
+
When still on `semantic_logger` 4.x, reopen log appenders after forking in clustered mode:
|
|
230
286
|
|
|
231
287
|
```ruby
|
|
232
288
|
# config/puma.rb
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.3.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
|
|
@@ -11,25 +11,32 @@ module RailsSemanticLogging
|
|
|
11
11
|
# This ensures our configuration is applied before the logger is set up.
|
|
12
12
|
initializer 'rails_semantic_logging.configure', before: :initialize_logger do |app|
|
|
13
13
|
cfg = RailsSemanticLogging.config
|
|
14
|
+
formatter = cfg.formatter_for(Rails.env)
|
|
14
15
|
|
|
15
|
-
# Configure rails_semantic_logger options
|
|
16
16
|
app.config.rails_semantic_logger.quiet_assets = cfg.quiet_assets
|
|
17
|
-
app.config.rails_semantic_logger.console_logger = false
|
|
18
|
-
app.config.rails_semantic_logger.add_file_appender = false
|
|
19
17
|
|
|
20
|
-
#
|
|
21
|
-
|
|
18
|
+
# Declare a single stdout appender with the configured formatter.
|
|
19
|
+
# IMPORTANT: Do NOT pass level: parameter. Subscriber#level defaults to :trace
|
|
20
|
+
# when unset, which is required by the host app's spec/support/output.rb check.
|
|
21
|
+
if app.config.rails_semantic_logger.respond_to?(:appenders?)
|
|
22
|
+
# rails_semantic_logger >= 5: declaring an appender replaces the default
|
|
23
|
+
# file appender and the automatic console stderr appender.
|
|
24
|
+
app.config.rails_semantic_logger.appenders do |appenders|
|
|
25
|
+
appenders.add(io: $stdout, formatter: formatter)
|
|
26
|
+
end
|
|
27
|
+
else
|
|
28
|
+
# rails_semantic_logger 4.x: disable the built-in appenders and add ours.
|
|
29
|
+
app.config.rails_semantic_logger.console_logger = false
|
|
30
|
+
app.config.rails_semantic_logger.add_file_appender = false
|
|
31
|
+
app.config.rails_semantic_logger.format = formatter
|
|
32
|
+
app.config.semantic_logger.add_appender(io: $stdout, formatter: formatter)
|
|
33
|
+
end
|
|
22
34
|
|
|
23
35
|
# Merge default tags (request_id, client_ip) with app-specific custom tags
|
|
24
36
|
app.config.log_tags = cfg.effective_log_tags
|
|
25
37
|
|
|
26
38
|
# Set log level based on environment, respecting LOG_LEVEL env var
|
|
27
39
|
app.config.log_level = cfg.log_level_for(Rails.env)
|
|
28
|
-
|
|
29
|
-
# Add stdout appender with the configured formatter.
|
|
30
|
-
# IMPORTANT: Do NOT pass level: parameter. Subscriber#level defaults to :trace
|
|
31
|
-
# when unset, which is required by the host app's spec/support/output.rb check.
|
|
32
|
-
app.config.semantic_logger.add_appender(io: $stdout, formatter: app.config.rails_semantic_logger.format)
|
|
33
40
|
end
|
|
34
41
|
|
|
35
42
|
config.to_prepare do
|
|
@@ -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
|
|
@@ -20,6 +22,47 @@ module RailsSemanticLogging
|
|
|
20
22
|
end
|
|
21
23
|
end
|
|
22
24
|
|
|
25
|
+
# Shared capture plumbing for the block matchers. Opens the logger gate to
|
|
26
|
+
# :trace so every event reaches the in-memory capture appender, while
|
|
27
|
+
# pinning the pre-existing appenders to the previously *visible* threshold
|
|
28
|
+
# (default_level, or an active SemanticLogger.silence override such as the
|
|
29
|
+
# LOG env var hook) so widening the gate does not spill every captured
|
|
30
|
+
# event to stdout.
|
|
31
|
+
module CaptureAppender
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def with_capture_appender(capture)
|
|
35
|
+
previous_level = ::SemanticLogger.default_level
|
|
36
|
+
pinned = pin_live_appenders(visible_level(previous_level))
|
|
37
|
+
::SemanticLogger.add_appender(appender: capture)
|
|
38
|
+
::SemanticLogger.default_level = :trace
|
|
39
|
+
yield
|
|
40
|
+
::SemanticLogger.flush
|
|
41
|
+
ensure
|
|
42
|
+
::SemanticLogger.default_level = previous_level
|
|
43
|
+
pinned&.each { |appender, level| appender.level = level }
|
|
44
|
+
::SemanticLogger.remove_appender(capture)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Pins every live appender to +level+, returning the [appender, original_level]
|
|
48
|
+
# pairs needed to restore them afterwards.
|
|
49
|
+
def pin_live_appenders(level)
|
|
50
|
+
::SemanticLogger.appenders.to_a.map do |appender|
|
|
51
|
+
original = appender.level
|
|
52
|
+
appender.level = level
|
|
53
|
+
[appender, original]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The threshold actually in effect before the matcher widened the gate:
|
|
58
|
+
# a SemanticLogger.silence override (stored as a level index in a
|
|
59
|
+
# thread-local by both semantic_logger 4.x and 5.x) wins over default_level.
|
|
60
|
+
def visible_level(default_level)
|
|
61
|
+
silence_index = Thread.current[:semantic_logger_silence]
|
|
62
|
+
silence_index ? ::SemanticLogger::Levels::LEVELS[silence_index] : default_level
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
23
66
|
# RSpec matcher for asserting log output from a block.
|
|
24
67
|
#
|
|
25
68
|
# Usage:
|
|
@@ -36,14 +79,7 @@ module RailsSemanticLogging
|
|
|
36
79
|
|
|
37
80
|
def matches?(block)
|
|
38
81
|
appender = InMemoryAppender.new
|
|
39
|
-
|
|
40
|
-
# Temporarily lower log level to capture all messages
|
|
41
|
-
previous_level = SemanticLogger.default_level
|
|
42
|
-
SemanticLogger.default_level = :trace
|
|
43
|
-
block.call
|
|
44
|
-
SemanticLogger.flush
|
|
45
|
-
SemanticLogger.default_level = previous_level
|
|
46
|
-
SemanticLogger.remove_appender(appender)
|
|
82
|
+
CaptureAppender.with_capture_appender(appender) { block.call }
|
|
47
83
|
|
|
48
84
|
@captured_logs = appender.logs
|
|
49
85
|
@captured_logs.any? { |log| matches_log?(log) }
|
|
@@ -111,10 +147,133 @@ module RailsSemanticLogging
|
|
|
111
147
|
end
|
|
112
148
|
end
|
|
113
149
|
|
|
150
|
+
# Block matcher that finds a specific log event by message and exposes both
|
|
151
|
+
# the raw SemanticLogger::Log and an optionally formatted version to a user
|
|
152
|
+
# block, so the test can make arbitrary assertions on the event.
|
|
153
|
+
#
|
|
154
|
+
# Complementary to `log_semantic`:
|
|
155
|
+
# * `log_semantic` is declarative ("was anything matching these criteria
|
|
156
|
+
# logged?") and returns a boolean.
|
|
157
|
+
# * `have_logged_message` is imperative — it finds the event, hands it over
|
|
158
|
+
# to the test, and lets the test assert whatever it needs.
|
|
159
|
+
#
|
|
160
|
+
# Usage:
|
|
161
|
+
# expect { logger.info("hello", key: "val") }.to have_logged_message("hello")
|
|
162
|
+
# expect { do_work }.to have_logged_message(/import/, :warn)
|
|
163
|
+
#
|
|
164
|
+
# expect { service.call }
|
|
165
|
+
# .to have_logged_message("Completed").with_formatted_event { |event, formatted|
|
|
166
|
+
# expect(event.payload).to include(order_id: order.id)
|
|
167
|
+
# expect(formatted.dig("http", "status_code")).to eq(200)
|
|
168
|
+
# }
|
|
169
|
+
#
|
|
170
|
+
# The optional `with_formatted_event` block receives `(event, formatted)`
|
|
171
|
+
# where `formatted` is the JSON output of the chosen formatter parsed back
|
|
172
|
+
# with indifferent access. By default the gem's Datadog formatter is used;
|
|
173
|
+
# pass any `SemanticLogger::Formatters::Base` subclass to override:
|
|
174
|
+
#
|
|
175
|
+
# .with_formatted_event(SemanticLogger::Formatters::Json) { |event, formatted| ... }
|
|
176
|
+
class HaveLoggedMessageMatcher
|
|
177
|
+
attr_reader :event_block
|
|
178
|
+
|
|
179
|
+
DEFAULT_FORMATTER = ::RailsSemanticLogging::Formatters::Datadog
|
|
180
|
+
|
|
181
|
+
def initialize(expected, expected_level = nil)
|
|
182
|
+
@expected_message = expected
|
|
183
|
+
@expected_level = expected_level
|
|
184
|
+
@formatter_class = DEFAULT_FORMATTER
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def matches?(block)
|
|
188
|
+
# Capture the live appender BEFORE we attach our test appender. The
|
|
189
|
+
# Datadog formatter reads `logger.host` on the appender it receives, so
|
|
190
|
+
# the formatter call later on needs a real one (any subscriber will do).
|
|
191
|
+
@live_appender = ::SemanticLogger.appenders.first
|
|
192
|
+
@log_event = capture_first_matching_event(block)
|
|
193
|
+
return false unless @log_event
|
|
194
|
+
return false if @expected_level && @log_event.level != @expected_level
|
|
195
|
+
|
|
196
|
+
@event_block&.call(@log_event, formatted)
|
|
197
|
+
true
|
|
198
|
+
rescue ::RSpec::Expectations::ExpectationNotMetError => e
|
|
199
|
+
@error = e
|
|
200
|
+
false
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def supports_block_expectations?
|
|
204
|
+
true
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Attach a block that receives the matched `(log_event, formatted)` pair.
|
|
208
|
+
# `formatted` is the JSON output of the configured formatter, parsed back
|
|
209
|
+
# with `HashWithIndifferentAccess` so callers can navigate the structure
|
|
210
|
+
# with either string or symbol keys.
|
|
211
|
+
def with_formatted_event(formatter_class = nil, &block)
|
|
212
|
+
raise ArgumentError, 'block is required' unless block
|
|
213
|
+
if formatter_class && !(formatter_class < ::SemanticLogger::Formatters::Base)
|
|
214
|
+
raise ArgumentError,
|
|
215
|
+
"formatter must inherit from SemanticLogger::Formatters::Base, got #{formatter_class.inspect}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
@formatter_class = formatter_class if formatter_class
|
|
219
|
+
@event_block = block
|
|
220
|
+
self
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def failure_message
|
|
224
|
+
return @error.message if @error
|
|
225
|
+
if @log_event && @expected_level && @log_event.level != @expected_level
|
|
226
|
+
return "expected log level #{@expected_level.inspect}, got #{@log_event.level.inspect}"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
"expected block to log a message matching #{@expected_message.inspect}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def failure_message_when_negated
|
|
233
|
+
"expected block NOT to log a message matching #{@expected_message.inspect}, but a matching event was emitted"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
private
|
|
237
|
+
|
|
238
|
+
def capture_first_matching_event(block)
|
|
239
|
+
capture = InMemoryAppender.new
|
|
240
|
+
CaptureAppender.with_capture_appender(capture) { block.call }
|
|
241
|
+
capture.logs.find { |evt| message_matches?(evt.message) }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Matches String, Regex and any object that implements `===` (incl. RSpec
|
|
245
|
+
# built-in matchers like `a_string_starting_with("...")`).
|
|
246
|
+
def message_matches?(actual)
|
|
247
|
+
@expected_message === actual.to_s # rubocop:disable Style/CaseEquality
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Format a *copy* of the log event so a buggy formatter that mutates
|
|
251
|
+
# log.payload / log.named_tags cannot leak back into the event we hand
|
|
252
|
+
# off to the user block.
|
|
253
|
+
def formatted
|
|
254
|
+
@formatted ||= begin
|
|
255
|
+
safe_log = duped_log_event
|
|
256
|
+
::JSON.parse(@formatter_class.new.call(safe_log, @live_appender)).with_indifferent_access
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def duped_log_event
|
|
261
|
+
copy = @log_event.dup
|
|
262
|
+
copy.payload = @log_event.payload.dup if @log_event.payload.is_a?(::Hash)
|
|
263
|
+
copy.named_tags = @log_event.named_tags.dup if @log_event.named_tags.is_a?(::Hash)
|
|
264
|
+
copy.tags = @log_event.tags.dup if @log_event.tags.is_a?(::Array)
|
|
265
|
+
copy
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
114
269
|
module Matchers
|
|
115
270
|
def log_semantic(expected = {})
|
|
116
271
|
LogSemanticMatcher.new(expected)
|
|
117
272
|
end
|
|
273
|
+
|
|
274
|
+
def have_logged_message(expected, level = nil) # rubocop:disable Naming/PredicatePrefix
|
|
275
|
+
HaveLoggedMessageMatcher.new(expected, level)
|
|
276
|
+
end
|
|
118
277
|
end
|
|
119
278
|
end
|
|
120
279
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_semantic_logging
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Fabio Napoleoni
|
|
@@ -47,16 +47,22 @@ dependencies:
|
|
|
47
47
|
name: rails_semantic_logger
|
|
48
48
|
requirement: !ruby/object:Gem::Requirement
|
|
49
49
|
requirements:
|
|
50
|
-
- - "
|
|
50
|
+
- - ">="
|
|
51
51
|
- !ruby/object:Gem::Version
|
|
52
52
|
version: '4.0'
|
|
53
|
+
- - "<"
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '6'
|
|
53
56
|
type: :runtime
|
|
54
57
|
prerelease: false
|
|
55
58
|
version_requirements: !ruby/object:Gem::Requirement
|
|
56
59
|
requirements:
|
|
57
|
-
- - "
|
|
60
|
+
- - ">="
|
|
58
61
|
- !ruby/object:Gem::Version
|
|
59
62
|
version: '4.0'
|
|
63
|
+
- - "<"
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '6'
|
|
60
66
|
description: Provides a consistent, opinionated setup for structured JSON logging
|
|
61
67
|
in Rails, with specific hooks for Sidekiq, ActiveJob, and Puma, as well as Datadog-friendly
|
|
62
68
|
formatters.
|