brainzlab 0.1.11 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/README.md +210 -3
  4. data/lib/brainzlab/beacon/client.rb +21 -1
  5. data/lib/brainzlab/configuration.rb +81 -4
  6. data/lib/brainzlab/cortex/client.rb +21 -1
  7. data/lib/brainzlab/debug.rb +305 -0
  8. data/lib/brainzlab/dendrite/client.rb +21 -1
  9. data/lib/brainzlab/development/logger.rb +150 -0
  10. data/lib/brainzlab/development/store.rb +121 -0
  11. data/lib/brainzlab/development.rb +72 -0
  12. data/lib/brainzlab/devtools/assets/devtools.css +245 -109
  13. data/lib/brainzlab/devtools/assets/devtools.js +40 -0
  14. data/lib/brainzlab/devtools/middleware/asset_server.rb +1 -0
  15. data/lib/brainzlab/devtools/middleware/debug_panel.rb +1 -0
  16. data/lib/brainzlab/devtools/middleware/error_page.rb +56 -8
  17. data/lib/brainzlab/errors.rb +490 -0
  18. data/lib/brainzlab/flux/buffer.rb +2 -2
  19. data/lib/brainzlab/flux/client.rb +2 -2
  20. data/lib/brainzlab/instrumentation/active_support_cache.rb +60 -30
  21. data/lib/brainzlab/instrumentation/net_http.rb +21 -16
  22. data/lib/brainzlab/instrumentation.rb +6 -0
  23. data/lib/brainzlab/nerve/client.rb +21 -1
  24. data/lib/brainzlab/pulse/client.rb +66 -5
  25. data/lib/brainzlab/pulse.rb +24 -5
  26. data/lib/brainzlab/rails/log_formatter.rb +1 -1
  27. data/lib/brainzlab/rails/railtie.rb +18 -3
  28. data/lib/brainzlab/recall/buffer.rb +3 -1
  29. data/lib/brainzlab/recall/client.rb +74 -6
  30. data/lib/brainzlab/recall.rb +19 -2
  31. data/lib/brainzlab/reflex/client.rb +66 -5
  32. data/lib/brainzlab/reflex.rb +40 -8
  33. data/lib/brainzlab/sentinel/client.rb +21 -1
  34. data/lib/brainzlab/synapse/client.rb +21 -1
  35. data/lib/brainzlab/testing/event_store.rb +377 -0
  36. data/lib/brainzlab/testing/helpers.rb +650 -0
  37. data/lib/brainzlab/testing/matchers.rb +391 -0
  38. data/lib/brainzlab/testing.rb +327 -0
  39. data/lib/brainzlab/utilities/circuit_breaker.rb +32 -3
  40. data/lib/brainzlab/vault/client.rb +21 -1
  41. data/lib/brainzlab/version.rb +1 -1
  42. data/lib/brainzlab/vision/client.rb +53 -6
  43. data/lib/brainzlab.rb +67 -0
  44. data/lib/fluyenta-ruby.rb +3 -0
  45. metadata +34 -11
@@ -31,14 +31,24 @@ module BrainzLab
31
31
  def log(level, message, **data)
32
32
  config = BrainzLab.configuration
33
33
  return unless config.recall_effectively_enabled?
34
+ return unless config.level_enabled?(level)
35
+
36
+ entry = build_entry(level, message, data)
37
+
38
+ # Log debug output for the operation
39
+ log_debug_operation(level, message, data)
40
+
41
+ # In development mode, log locally instead of sending to server
42
+ if config.development_mode?
43
+ Development.record(service: :recall, event_type: 'log', payload: entry)
44
+ return
45
+ end
34
46
 
35
47
  # Auto-provision project on first log if app_name is configured
36
48
  ensure_provisioned!
37
49
 
38
- return unless config.level_enabled?(level)
39
50
  return unless config.valid?
40
51
 
41
- entry = build_entry(level, message, data)
42
52
  buffer.push(entry)
43
53
  end
44
54
 
@@ -153,6 +163,13 @@ module BrainzLab
153
163
  end
154
164
  end
155
165
  end
166
+
167
+ def log_debug_operation(level, message, data)
168
+ return unless BrainzLab::Debug.enabled?
169
+
170
+ truncated_message = message.to_s.length > 50 ? "#{message.to_s[0..47]}..." : message.to_s
171
+ BrainzLab::Debug.log_operation(:recall, "#{level.to_s.upcase} \"#{truncated_message}\"", **data.slice(*data.keys.first(3)))
172
+ end
156
173
  end
157
174
  end
158
175
  end
@@ -31,20 +31,29 @@ module BrainzLab
31
31
 
32
32
  def post(path, body)
33
33
  uri = URI.join(@config.reflex_url, path)
34
+
35
+ # Call on_send callback if configured
36
+ invoke_on_send(:reflex, :post, path, body)
37
+
38
+ # Log debug output for request
39
+ log_debug_request(path, body)
40
+
34
41
  request = Net::HTTP::Post.new(uri)
35
42
  request['Content-Type'] = 'application/json'
36
43
  request['Authorization'] = "Bearer #{@config.reflex_auth_key}"
37
44
  request['User-Agent'] = "brainzlab-sdk-ruby/#{BrainzLab::VERSION}"
38
45
  request.body = JSON.generate(body)
39
46
 
40
- execute_with_retry(uri, request)
47
+ execute_with_retry(uri, request, path)
41
48
  rescue StandardError => e
42
- log_error("Failed to send to Reflex: #{e.message}")
49
+ handle_error(e, context: { path: path, body_size: body.to_s.length })
43
50
  nil
44
51
  end
45
52
 
46
- def execute_with_retry(uri, request)
53
+ def execute_with_retry(uri, request, path)
47
54
  retries = 0
55
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
56
+
48
57
  begin
49
58
  http = Net::HTTP.new(uri.host, uri.port)
50
59
  http.use_ssl = uri.scheme == 'https'
@@ -52,6 +61,10 @@ module BrainzLab
52
61
  http.read_timeout = 10
53
62
 
54
63
  response = http.request(request)
64
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
65
+
66
+ # Log debug output for response
67
+ log_debug_response(response.code.to_i, duration_ms)
55
68
 
56
69
  case response.code.to_i
57
70
  when 200..299
@@ -63,7 +76,10 @@ module BrainzLab
63
76
  when 429, 500..599
64
77
  raise RetryableError, "Server error: #{response.code}"
65
78
  else
66
- log_error("Reflex API error: #{response.code} - #{response.body}")
79
+ handle_error(
80
+ StandardError.new("Reflex API error: #{response.code}"),
81
+ context: { path: path, status: response.code, body: response.body }
82
+ )
67
83
  nil
68
84
  end
69
85
  rescue RetryableError, Net::OpenTimeout, Net::ReadTimeout => e
@@ -72,12 +88,57 @@ module BrainzLab
72
88
  sleep(RETRY_DELAY * retries)
73
89
  retry
74
90
  end
75
- log_error("Failed after #{MAX_RETRIES} retries: #{e.message}")
91
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
92
+ log_debug_response(0, duration_ms, error: e.message)
93
+ handle_error(e, context: { path: path, retries: retries })
76
94
  nil
77
95
  end
78
96
  end
79
97
 
98
+ def log_debug_request(path, body)
99
+ return unless BrainzLab::Debug.enabled?
100
+
101
+ data = if body.is_a?(Hash) && body[:errors]
102
+ { count: body[:errors].size }
103
+ elsif body.is_a?(Hash) && body[:exception]
104
+ { exception: body[:exception][:type] }
105
+ else
106
+ {}
107
+ end
108
+
109
+ BrainzLab::Debug.log_request(:reflex, 'POST', path, data: data)
110
+ end
111
+
112
+ def log_debug_response(status, duration_ms, error: nil)
113
+ return unless BrainzLab::Debug.enabled?
114
+
115
+ BrainzLab::Debug.log_response(:reflex, status, duration_ms, error: error)
116
+ end
117
+
118
+ def invoke_on_send(service, method, path, payload)
119
+ return unless @config.on_send
120
+
121
+ @config.on_send.call(service, method, path, payload)
122
+ rescue StandardError => e
123
+ # Don't let callback errors break the SDK
124
+ log_error("on_send callback error: #{e.message}")
125
+ end
126
+
127
+ def handle_error(error, context: {})
128
+ log_error("#{error.message}")
129
+
130
+ # Call on_error callback if configured
131
+ return unless @config.on_error
132
+
133
+ @config.on_error.call(error, context.merge(service: :reflex))
134
+ rescue StandardError => e
135
+ # Don't let callback errors break the SDK
136
+ log_error("on_error callback error: #{e.message}")
137
+ end
138
+
80
139
  def log_error(message)
140
+ BrainzLab::Debug.log(message, level: :error) if BrainzLab::Debug.enabled?
141
+
81
142
  return unless @config.logger
82
143
 
83
144
  @config.logger.error("[BrainzLab::Reflex] #{message}")
@@ -15,15 +15,24 @@ module BrainzLab
15
15
  return if excluded?(exception)
16
16
  return if sampled_out?
17
17
 
18
- # Auto-provision project on first capture if app_name is configured
19
- ensure_provisioned!
20
-
21
- return unless BrainzLab.configuration.reflex_valid?
18
+ # Log debug output for the operation
19
+ log_debug_capture(exception)
22
20
 
23
21
  payload = build_payload(exception, context)
24
22
  payload = run_before_send(payload, exception)
25
23
  return if payload.nil?
26
24
 
25
+ # In development mode, log locally instead of sending to server
26
+ if BrainzLab.configuration.development_mode?
27
+ Development.record(service: :reflex, event_type: 'error', payload: payload)
28
+ return
29
+ end
30
+
31
+ # Auto-provision project on first capture if app_name is configured
32
+ ensure_provisioned!
33
+
34
+ return unless BrainzLab.configuration.reflex_valid?
35
+
27
36
  client.send_error(payload)
28
37
  end
29
38
 
@@ -32,15 +41,24 @@ module BrainzLab
32
41
  return if capture_disabled?
33
42
  return if sampled_out?
34
43
 
35
- # Auto-provision project on first capture if app_name is configured
36
- ensure_provisioned!
37
-
38
- return unless BrainzLab.configuration.reflex_valid?
44
+ # Log debug output for the operation
45
+ log_debug_message(message, level)
39
46
 
40
47
  payload = build_message_payload(message, level, context)
41
48
  payload = run_before_send(payload, nil)
42
49
  return if payload.nil?
43
50
 
51
+ # In development mode, log locally instead of sending to server
52
+ if BrainzLab.configuration.development_mode?
53
+ Development.record(service: :reflex, event_type: 'message', payload: payload)
54
+ return
55
+ end
56
+
57
+ # Auto-provision project on first capture if app_name is configured
58
+ ensure_provisioned!
59
+
60
+ return unless BrainzLab.configuration.reflex_valid?
61
+
44
62
  client.send_error(payload)
45
63
  end
46
64
 
@@ -384,6 +402,20 @@ module BrainzLab
384
402
  frame
385
403
  end
386
404
  end
405
+
406
+ def log_debug_capture(exception)
407
+ return unless BrainzLab::Debug.enabled?
408
+
409
+ truncated_message = exception.message.to_s.length > 40 ? "#{exception.message.to_s[0..37]}..." : exception.message.to_s
410
+ BrainzLab::Debug.log_operation(:reflex, "capture #{exception.class.name}: \"#{truncated_message}\"")
411
+ end
412
+
413
+ def log_debug_message(message, level)
414
+ return unless BrainzLab::Debug.enabled?
415
+
416
+ truncated_message = message.to_s.length > 40 ? "#{message.to_s[0..37]}..." : message.to_s
417
+ BrainzLab::Debug.log_operation(:reflex, "message [#{level.to_s.upcase}] \"#{truncated_message}\"")
418
+ end
387
419
  end
388
420
  end
389
421
  end
@@ -209,7 +209,27 @@ module BrainzLab
209
209
  end
210
210
 
211
211
  def log_error(operation, error)
212
- BrainzLab.debug_log("[Sentinel::Client] #{operation} failed: #{error.message}")
212
+ structured_error = ErrorHandler.wrap(error, service: 'Sentinel', operation: operation)
213
+ BrainzLab.debug_log("[Sentinel::Client] #{operation} failed: #{structured_error.message}")
214
+
215
+ # Call on_error callback if configured
216
+ if @config.on_error
217
+ @config.on_error.call(structured_error, { service: 'Sentinel', operation: operation })
218
+ end
219
+ end
220
+
221
+ def handle_response_error(response, operation)
222
+ return if response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPCreated) || response.is_a?(Net::HTTPNoContent) || response.is_a?(Net::HTTPAccepted)
223
+
224
+ structured_error = ErrorHandler.from_response(response, service: 'Sentinel', operation: operation)
225
+ BrainzLab.debug_log("[Sentinel::Client] #{operation} failed: #{structured_error.message}")
226
+
227
+ # Call on_error callback if configured
228
+ if @config.on_error
229
+ @config.on_error.call(structured_error, { service: 'Sentinel', operation: operation })
230
+ end
231
+
232
+ structured_error
213
233
  end
214
234
  end
215
235
  end
@@ -281,7 +281,27 @@ module BrainzLab
281
281
  end
282
282
 
283
283
  def log_error(operation, error)
284
- BrainzLab.debug_log("[Synapse::Client] #{operation} failed: #{error.message}")
284
+ structured_error = ErrorHandler.wrap(error, service: 'Synapse', operation: operation)
285
+ BrainzLab.debug_log("[Synapse::Client] #{operation} failed: #{structured_error.message}")
286
+
287
+ # Call on_error callback if configured
288
+ if @config.on_error
289
+ @config.on_error.call(structured_error, { service: 'Synapse', operation: operation })
290
+ end
291
+ end
292
+
293
+ def handle_response_error(response, operation)
294
+ return if response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPCreated) || response.is_a?(Net::HTTPNoContent) || response.is_a?(Net::HTTPAccepted)
295
+
296
+ structured_error = ErrorHandler.from_response(response, service: 'Synapse', operation: operation)
297
+ BrainzLab.debug_log("[Synapse::Client] #{operation} failed: #{structured_error.message}")
298
+
299
+ # Call on_error callback if configured
300
+ if @config.on_error
301
+ @config.on_error.call(structured_error, { service: 'Synapse', operation: operation })
302
+ end
303
+
304
+ structured_error
285
305
  end
286
306
  end
287
307
  end
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BrainzLab
4
+ module Testing
5
+ # Thread-safe store for captured events, logs, errors, and metrics during tests
6
+ #
7
+ # This class is used internally by the testing helpers to store all
8
+ # captured data from stubbed SDK calls.
9
+ class EventStore
10
+ def initialize
11
+ @mutex = Mutex.new
12
+ @events = []
13
+ @metrics = []
14
+ @logs = []
15
+ @errors = []
16
+ @error_messages = []
17
+ @traces = []
18
+ @alerts = []
19
+ @notifications = []
20
+ @triggers = []
21
+ end
22
+
23
+ # === Events (Flux) ===
24
+
25
+ def record_event(name, properties = {})
26
+ @mutex.synchronize do
27
+ @events << {
28
+ name: name.to_s,
29
+ properties: properties,
30
+ timestamp: Time.now.utc
31
+ }
32
+ end
33
+ end
34
+
35
+ def events
36
+ @mutex.synchronize { @events.dup }
37
+ end
38
+
39
+ def events_named(name)
40
+ @mutex.synchronize do
41
+ @events.select { |e| e[:name] == name.to_s }
42
+ end
43
+ end
44
+
45
+ def event_tracked?(name, properties = nil)
46
+ @mutex.synchronize do
47
+ @events.any? do |event|
48
+ next false unless event[:name] == name.to_s
49
+ next true if properties.nil?
50
+
51
+ properties_match?(event[:properties], properties)
52
+ end
53
+ end
54
+ end
55
+
56
+ def last_event
57
+ @mutex.synchronize { @events.last }
58
+ end
59
+
60
+ def clear_events!
61
+ @mutex.synchronize { @events.clear }
62
+ end
63
+
64
+ # === Metrics (Flux) ===
65
+
66
+ def record_metric(type, name, value, opts = {})
67
+ @mutex.synchronize do
68
+ @metrics << {
69
+ type: type.to_sym,
70
+ name: name.to_s,
71
+ value: value,
72
+ tags: opts[:tags] || {},
73
+ timestamp: Time.now.utc
74
+ }
75
+ end
76
+ end
77
+
78
+ def metrics
79
+ @mutex.synchronize { @metrics.dup }
80
+ end
81
+
82
+ def metrics_named(name)
83
+ @mutex.synchronize do
84
+ @metrics.select { |m| m[:name] == name.to_s }
85
+ end
86
+ end
87
+
88
+ def metric_recorded?(type, name, value: nil, tags: nil)
89
+ @mutex.synchronize do
90
+ @metrics.any? do |metric|
91
+ next false unless metric[:type] == type.to_sym
92
+ next false unless metric[:name] == name.to_s
93
+ next false if value && metric[:value] != value
94
+ next false if tags && !properties_match?(metric[:tags], tags)
95
+
96
+ true
97
+ end
98
+ end
99
+ end
100
+
101
+ def clear_metrics!
102
+ @mutex.synchronize { @metrics.clear }
103
+ end
104
+
105
+ # === Logs (Recall) ===
106
+
107
+ def record_log(level, message, data = {})
108
+ @mutex.synchronize do
109
+ @logs << {
110
+ level: level.to_sym,
111
+ message: message.to_s,
112
+ data: data,
113
+ timestamp: Time.now.utc
114
+ }
115
+ end
116
+ end
117
+
118
+ def logs
119
+ @mutex.synchronize { @logs.dup }
120
+ end
121
+
122
+ def logs_at_level(level)
123
+ @mutex.synchronize do
124
+ @logs.select { |l| l[:level] == level.to_sym }
125
+ end
126
+ end
127
+
128
+ def logged?(level, message = nil, data = nil)
129
+ @mutex.synchronize do
130
+ @logs.any? do |log|
131
+ next false unless log[:level] == level.to_sym
132
+ next true if message.nil?
133
+
134
+ message_matches = case message
135
+ when Regexp
136
+ log[:message].match?(message)
137
+ else
138
+ log[:message].include?(message.to_s)
139
+ end
140
+
141
+ next false unless message_matches
142
+ next true if data.nil?
143
+
144
+ properties_match?(log[:data], data)
145
+ end
146
+ end
147
+ end
148
+
149
+ def clear_logs!
150
+ @mutex.synchronize { @logs.clear }
151
+ end
152
+
153
+ # === Errors (Reflex) ===
154
+
155
+ def record_error(exception, context = {})
156
+ @mutex.synchronize do
157
+ @errors << {
158
+ exception: exception,
159
+ error_class: exception.class.name,
160
+ message: exception.message,
161
+ backtrace: exception.backtrace,
162
+ context: context,
163
+ timestamp: Time.now.utc
164
+ }
165
+ end
166
+ end
167
+
168
+ def record_error_message(message, level, context = {})
169
+ @mutex.synchronize do
170
+ @error_messages << {
171
+ message: message.to_s,
172
+ level: level.to_sym,
173
+ context: context,
174
+ timestamp: Time.now.utc
175
+ }
176
+ end
177
+ end
178
+
179
+ def errors
180
+ @mutex.synchronize { @errors.dup }
181
+ end
182
+
183
+ def error_messages
184
+ @mutex.synchronize { @error_messages.dup }
185
+ end
186
+
187
+ def error_captured?(error_class = nil, message: nil, context: nil)
188
+ @mutex.synchronize do
189
+ @errors.any? do |error|
190
+ if error_class
191
+ next false unless error[:error_class] == error_class.to_s ||
192
+ (error_class.is_a?(Class) && error[:exception].is_a?(error_class))
193
+ end
194
+
195
+ if message
196
+ message_matches = case message
197
+ when Regexp
198
+ error[:message].match?(message)
199
+ else
200
+ error[:message].include?(message.to_s)
201
+ end
202
+ next false unless message_matches
203
+ end
204
+
205
+ next false if context && !properties_match?(error[:context], context)
206
+
207
+ true
208
+ end
209
+ end
210
+ end
211
+
212
+ def last_error
213
+ @mutex.synchronize { @errors.last }
214
+ end
215
+
216
+ def clear_errors!
217
+ @mutex.synchronize do
218
+ @errors.clear
219
+ @error_messages.clear
220
+ end
221
+ end
222
+
223
+ # === Traces (Pulse) ===
224
+
225
+ def record_trace(name, opts = {})
226
+ @mutex.synchronize do
227
+ @traces << {
228
+ name: name.to_s,
229
+ options: opts,
230
+ timestamp: Time.now.utc
231
+ }
232
+ end
233
+ end
234
+
235
+ def traces
236
+ @mutex.synchronize { @traces.dup }
237
+ end
238
+
239
+ def trace_recorded?(name, opts = nil)
240
+ @mutex.synchronize do
241
+ @traces.any? do |trace|
242
+ next false unless trace[:name] == name.to_s
243
+ next true if opts.nil?
244
+
245
+ properties_match?(trace[:options], opts)
246
+ end
247
+ end
248
+ end
249
+
250
+ def clear_traces!
251
+ @mutex.synchronize { @traces.clear }
252
+ end
253
+
254
+ # === Alerts (Signal) ===
255
+
256
+ def record_alert(name, message, severity, channels, data)
257
+ @mutex.synchronize do
258
+ @alerts << {
259
+ name: name.to_s,
260
+ message: message.to_s,
261
+ severity: severity.to_sym,
262
+ channels: channels,
263
+ data: data,
264
+ timestamp: Time.now.utc
265
+ }
266
+ end
267
+ end
268
+
269
+ def alerts
270
+ @mutex.synchronize { @alerts.dup }
271
+ end
272
+
273
+ def alert_sent?(name, message: nil, severity: nil)
274
+ @mutex.synchronize do
275
+ @alerts.any? do |alert|
276
+ next false unless alert[:name] == name.to_s
277
+ next false if message && !alert[:message].include?(message.to_s)
278
+ next false if severity && alert[:severity] != severity.to_sym
279
+
280
+ true
281
+ end
282
+ end
283
+ end
284
+
285
+ def clear_alerts!
286
+ @mutex.synchronize { @alerts.clear }
287
+ end
288
+
289
+ # === Notifications (Signal) ===
290
+
291
+ def record_notification(channel, message, title, data)
292
+ @mutex.synchronize do
293
+ @notifications << {
294
+ channel: Array(channel).map(&:to_s),
295
+ message: message.to_s,
296
+ title: title,
297
+ data: data,
298
+ timestamp: Time.now.utc
299
+ }
300
+ end
301
+ end
302
+
303
+ def notifications
304
+ @mutex.synchronize { @notifications.dup }
305
+ end
306
+
307
+ def clear_notifications!
308
+ @mutex.synchronize { @notifications.clear }
309
+ end
310
+
311
+ # === Triggers (Signal) ===
312
+
313
+ def record_trigger(rule_name, context)
314
+ @mutex.synchronize do
315
+ @triggers << {
316
+ rule_name: rule_name.to_s,
317
+ context: context,
318
+ timestamp: Time.now.utc
319
+ }
320
+ end
321
+ end
322
+
323
+ def triggers
324
+ @mutex.synchronize { @triggers.dup }
325
+ end
326
+
327
+ def clear_triggers!
328
+ @mutex.synchronize { @triggers.clear }
329
+ end
330
+
331
+ # === General ===
332
+
333
+ def clear!
334
+ @mutex.synchronize do
335
+ @events.clear
336
+ @metrics.clear
337
+ @logs.clear
338
+ @errors.clear
339
+ @error_messages.clear
340
+ @traces.clear
341
+ @alerts.clear
342
+ @notifications.clear
343
+ @triggers.clear
344
+ end
345
+ end
346
+
347
+ def empty?
348
+ @mutex.synchronize do
349
+ @events.empty? &&
350
+ @metrics.empty? &&
351
+ @logs.empty? &&
352
+ @errors.empty? &&
353
+ @error_messages.empty? &&
354
+ @traces.empty? &&
355
+ @alerts.empty? &&
356
+ @notifications.empty? &&
357
+ @triggers.empty?
358
+ end
359
+ end
360
+
361
+ private
362
+
363
+ def properties_match?(actual, expected)
364
+ expected.all? do |key, value|
365
+ actual_value = actual[key] || actual[key.to_s] || actual[key.to_sym]
366
+
367
+ case value
368
+ when Regexp
369
+ actual_value.to_s.match?(value)
370
+ else
371
+ actual_value == value
372
+ end
373
+ end
374
+ end
375
+ end
376
+ end
377
+ end