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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 486b6add6a541fe35dfc1f09e09c6c028f4e9dfa1d62ac49e219d668f26b6a40
4
- data.tar.gz: 85c08bdb85fb2372486966cc278b6d1d82e9c89879dc121da9d4a17ddd1f3086
3
+ metadata.gz: 8dabf186d9982fb21140d284857db16d25867425b0f9430bc51ba6513851bcc8
4
+ data.tar.gz: '03886b8629bef05d49a21f11d5e59dd434740e6d40b1b2e9de24bffe4929a1a0'
5
5
  SHA512:
6
- metadata.gz: a5d4cae0b1752ba768a769c435a4f46c2bb43c3ac51b3e9bc4178b2e950dc39cb758091d7a6ca2e813d53a80e5932990d0225307d7bdf1f2bc41d60763ba2c95
7
- data.tar.gz: f6640bcfffb3adc4c2773112b2e85b1da7238a8992bafc54b3ec5123b06ccb4373926f378be43851836f1578e228aeead1377d129dd8b1fb4d486d4771963024
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 Matcher
228
+ ## RSpec Matchers
208
229
 
209
- Include the matcher module in your spec config:
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
- Usage:
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
- When using Puma in clustered mode, reopen log appenders after forking:
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.2
1
+ 0.3.0
@@ -1,7 +1,22 @@
1
- # Monkey patch for Datadog's ActiveJob LogInjection to use hash-style
2
- # correlation tags instead of string-style. This is necessary because
3
- # we patch ActiveJob::Logging to use named tags (job_class, job_id, queue),
4
- # so Datadog must also use hash-style tags for compatibility with SemanticLogger.
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 first
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
- # Replace the original module with our patched version
16
- ::Datadog::Tracing::Contrib::ActiveJob.send(:remove_const, :LogInjection)
17
- ::Datadog::Tracing::Contrib::ActiveJob.const_set(:LogInjection, PatchedLogInjection)
31
+ patch_perform_now
32
+ patch_enqueue
18
33
  end
19
34
 
20
- # Replacement module that uses correlation.to_h instead of log_correlation
21
- module PatchedLogInjection
22
- def self.included(base)
23
- base.class_eval do
24
- around_perform do |_, block|
25
- if ::Datadog.configuration.tracing.log_injection && logger.respond_to?(:tagged)
26
- logger.tagged(::Datadog::Tracing.correlation.to_h, &block)
27
- else
28
- block.call
29
- end
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 blank values from a hash
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.blank?
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 structured logging.
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
- super(job_class: job_class, job_id: job_id, queue: queue_name, &)
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
- # Set formatter based on environment
21
- app.config.rails_semantic_logger.format = cfg.formatter_for(Rails.env)
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
- SemanticLogger.add_appender(appender: appender)
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.1.2
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.