activesupport 8.0.5.2 → 8.1.0.beta1
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/CHANGELOG.md +220 -278
- data/lib/active_support/backtrace_cleaner.rb +71 -0
- data/lib/active_support/cache/mem_cache_store.rb +14 -17
- data/lib/active_support/cache/redis_cache_store.rb +37 -31
- data/lib/active_support/cache/strategy/local_cache.rb +16 -7
- data/lib/active_support/cache/strategy/local_cache_middleware.rb +7 -7
- data/lib/active_support/cache.rb +69 -6
- data/lib/active_support/configurable.rb +28 -0
- data/lib/active_support/continuous_integration.rb +145 -0
- data/lib/active_support/core_ext/benchmark.rb +0 -1
- data/lib/active_support/core_ext/class/attribute.rb +6 -8
- data/lib/active_support/core_ext/date_and_time/compatibility.rb +1 -1
- data/lib/active_support/core_ext/enumerable.rb +2 -2
- data/lib/active_support/core_ext/erb/util.rb +3 -3
- data/lib/active_support/core_ext/object/json.rb +9 -2
- data/lib/active_support/core_ext/object/to_query.rb +5 -0
- data/lib/active_support/core_ext/range.rb +0 -1
- data/lib/active_support/core_ext/string/inflections.rb +1 -1
- data/lib/active_support/core_ext/string/multibyte.rb +10 -1
- data/lib/active_support/core_ext/string/output_safety.rb +20 -15
- data/lib/active_support/current_attributes/test_helper.rb +2 -2
- data/lib/active_support/current_attributes.rb +13 -10
- data/lib/active_support/delegation.rb +42 -25
- data/lib/active_support/deprecation/reporting.rb +4 -2
- data/lib/active_support/deprecation.rb +1 -1
- data/lib/active_support/editor.rb +70 -0
- data/lib/active_support/error_reporter.rb +50 -6
- data/lib/active_support/event_reporter/test_helper.rb +32 -0
- data/lib/active_support/event_reporter.rb +570 -0
- data/lib/active_support/evented_file_update_checker.rb +5 -1
- data/lib/active_support/execution_context.rb +64 -7
- data/lib/active_support/file_update_checker.rb +8 -6
- data/lib/active_support/gem_version.rb +3 -3
- data/lib/active_support/gzip.rb +1 -0
- data/lib/active_support/hash_with_indifferent_access.rb +27 -7
- data/lib/active_support/i18n_railtie.rb +1 -2
- data/lib/active_support/inflector/inflections.rb +32 -17
- data/lib/active_support/inflector/methods.rb +21 -5
- data/lib/active_support/inflector/transliterate.rb +6 -8
- data/lib/active_support/isolated_execution_state.rb +9 -17
- data/lib/active_support/json/decoding.rb +2 -2
- data/lib/active_support/json/encoding.rb +103 -14
- data/lib/active_support/log_subscriber.rb +2 -0
- data/lib/active_support/message_encryptors.rb +52 -0
- data/lib/active_support/message_pack/extensions.rb +6 -1
- data/lib/active_support/message_verifiers.rb +52 -0
- data/lib/active_support/messages/rotation_coordinator.rb +9 -0
- data/lib/active_support/messages/rotator.rb +5 -0
- data/lib/active_support/multibyte/chars.rb +8 -1
- data/lib/active_support/multibyte.rb +4 -0
- data/lib/active_support/notifications/instrumenter.rb +1 -1
- data/lib/active_support/number_helper/number_converter.rb +1 -1
- data/lib/active_support/number_helper/number_to_delimited_converter.rb +2 -17
- data/lib/active_support/railtie.rb +26 -12
- data/lib/active_support/syntax_error_proxy.rb +3 -0
- data/lib/active_support/test_case.rb +61 -12
- data/lib/active_support/testing/assertions.rb +37 -14
- data/lib/active_support/testing/autorun.rb +0 -5
- data/lib/active_support/testing/error_reporter_assertions.rb +18 -1
- data/lib/active_support/testing/event_reporter_assertions.rb +217 -0
- data/lib/active_support/testing/notification_assertions.rb +92 -0
- data/lib/active_support/testing/parallelization/server.rb +2 -15
- data/lib/active_support/testing/parallelization/worker.rb +5 -7
- data/lib/active_support/testing/parallelization.rb +14 -12
- data/lib/active_support/testing/tests_without_assertions.rb +1 -1
- data/lib/active_support/testing/time_helpers.rb +7 -3
- data/lib/active_support/time_with_zone.rb +19 -5
- data/lib/active_support/values/time_zone.rb +8 -1
- data/lib/active_support/xml_mini.rb +1 -4
- data/lib/active_support.rb +11 -0
- metadata +11 -6
- data/lib/active_support/core_ext/range/each.rb +0 -24
|
@@ -36,6 +36,7 @@ module ActiveSupport
|
|
|
36
36
|
@subscribers = subscribers.flatten
|
|
37
37
|
@logger = logger
|
|
38
38
|
@debug_mode = false
|
|
39
|
+
@context_middlewares = ErrorContextMiddlewareStack.new
|
|
39
40
|
end
|
|
40
41
|
|
|
41
42
|
# Evaluates the given block, reporting and swallowing any unhandled error.
|
|
@@ -202,6 +203,22 @@ module ActiveSupport
|
|
|
202
203
|
ActiveSupport::ExecutionContext.set(...)
|
|
203
204
|
end
|
|
204
205
|
|
|
206
|
+
# Add a middleware to modify the error context before it is sent to subscribers.
|
|
207
|
+
#
|
|
208
|
+
# Middleware is added to a stack of callables run on an error's execution context
|
|
209
|
+
# before passing to subscribers. Allows creation of entries in error context that
|
|
210
|
+
# are shared by all subscribers.
|
|
211
|
+
#
|
|
212
|
+
# A context middleware receives the same parameters as #report.
|
|
213
|
+
# It must return a hash - the middleware stack returns the hash after it has
|
|
214
|
+
# run through all middlewares. A middleware can mutate or replace the hash.
|
|
215
|
+
#
|
|
216
|
+
# Rails.error.add_middleware(-> (error, context) { context.merge({ foo: :bar }) })
|
|
217
|
+
#
|
|
218
|
+
def add_middleware(middleware)
|
|
219
|
+
@context_middlewares.use(middleware)
|
|
220
|
+
end
|
|
221
|
+
|
|
205
222
|
# Report an error directly to subscribers. You can use this method when the
|
|
206
223
|
# block-based #handle and #record methods are not suitable.
|
|
207
224
|
#
|
|
@@ -215,13 +232,22 @@ module ActiveSupport
|
|
|
215
232
|
# string argument.
|
|
216
233
|
def report(error, handled: true, severity: handled ? :warning : :error, context: {}, source: DEFAULT_SOURCE)
|
|
217
234
|
return if error.instance_variable_defined?(:@__rails_error_reported)
|
|
235
|
+
raise ArgumentError, "Reported error must be an Exception, got: #{error.inspect}" unless error.is_a?(Exception)
|
|
236
|
+
|
|
218
237
|
ensure_backtrace(error)
|
|
219
238
|
|
|
220
239
|
unless SEVERITIES.include?(severity)
|
|
221
240
|
raise ArgumentError, "severity must be one of #{SEVERITIES.map(&:inspect).join(", ")}, got: #{severity.inspect}"
|
|
222
241
|
end
|
|
223
242
|
|
|
224
|
-
full_context =
|
|
243
|
+
full_context = @context_middlewares.execute(
|
|
244
|
+
error,
|
|
245
|
+
context: ActiveSupport::ExecutionContext.to_h.merge(context || {}),
|
|
246
|
+
handled:,
|
|
247
|
+
severity:,
|
|
248
|
+
source:
|
|
249
|
+
)
|
|
250
|
+
|
|
225
251
|
disabled_subscribers = ActiveSupport::IsolatedExecutionState[self]
|
|
226
252
|
@subscribers.each do |subscriber|
|
|
227
253
|
unless disabled_subscribers&.any? { |s| s === subscriber }
|
|
@@ -250,14 +276,12 @@ module ActiveSupport
|
|
|
250
276
|
|
|
251
277
|
private
|
|
252
278
|
def ensure_backtrace(error)
|
|
253
|
-
return if error.frozen? # re-raising won't add a backtrace
|
|
279
|
+
return if error.frozen? # re-raising won't add a backtrace or set the cause
|
|
254
280
|
return unless error.backtrace.nil?
|
|
255
281
|
|
|
256
282
|
begin
|
|
257
|
-
#
|
|
258
|
-
#
|
|
259
|
-
# `Exception#backtrace_locations`. So raising the exception
|
|
260
|
-
# is a good way to build a real backtrace.
|
|
283
|
+
# As of Ruby 3.4, we could use Exception#set_backtrace to set the backtrace,
|
|
284
|
+
# but there's nothing like Exception#set_cause. Raising+rescuing is the only way to set the cause.
|
|
261
285
|
raise error
|
|
262
286
|
rescue error.class => error
|
|
263
287
|
end
|
|
@@ -270,5 +294,25 @@ module ActiveSupport
|
|
|
270
294
|
|
|
271
295
|
error.backtrace.shift(count)
|
|
272
296
|
end
|
|
297
|
+
|
|
298
|
+
class ErrorContextMiddlewareStack # :nodoc:
|
|
299
|
+
def initialize
|
|
300
|
+
@stack = []
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Add a middleware to the error context stack.
|
|
304
|
+
def use(middleware)
|
|
305
|
+
unless middleware.respond_to?(:call)
|
|
306
|
+
raise ArgumentError, "Error context middleware must respond to #call"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
@stack << middleware
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Run all middlewares in the stack
|
|
313
|
+
def execute(error, handled:, severity:, context:, source:)
|
|
314
|
+
@stack.inject(context) { |c, middleware| middleware.call(error, context: c, handled:, severity:, source:) }
|
|
315
|
+
end
|
|
316
|
+
end
|
|
273
317
|
end
|
|
274
318
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveSupport::EventReporter::TestHelper # :nodoc:
|
|
4
|
+
class EventSubscriber # :nodoc:
|
|
5
|
+
attr_reader :events
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@events = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def emit(event)
|
|
12
|
+
@events << event
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def event_matcher(name:, payload: nil, tags: {}, context: {}, source_location: nil)
|
|
17
|
+
->(event) {
|
|
18
|
+
return false unless event[:name] == name
|
|
19
|
+
return false unless event[:payload] == payload
|
|
20
|
+
return false unless event[:tags] == tags
|
|
21
|
+
return false unless event[:context] == context
|
|
22
|
+
|
|
23
|
+
[:filepath, :lineno, :label].each do |key|
|
|
24
|
+
if source_location && source_location[key]
|
|
25
|
+
return false unless event[:source_location][key] == source_location[key]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
true
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveSupport
|
|
4
|
+
class TagStack # :nodoc:
|
|
5
|
+
EMPTY_TAGS = {}.freeze
|
|
6
|
+
FIBER_KEY = :event_reporter_tags
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def tags
|
|
10
|
+
Fiber[FIBER_KEY] || EMPTY_TAGS
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def with_tags(*args, **kwargs)
|
|
14
|
+
existing_tags = tags
|
|
15
|
+
tags = existing_tags.dup
|
|
16
|
+
tags.merge!(resolve_tags(args, kwargs))
|
|
17
|
+
new_tags = tags.freeze
|
|
18
|
+
|
|
19
|
+
begin
|
|
20
|
+
Fiber[FIBER_KEY] = new_tags
|
|
21
|
+
yield
|
|
22
|
+
ensure
|
|
23
|
+
Fiber[FIBER_KEY] = existing_tags
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
def resolve_tags(args, kwargs)
|
|
29
|
+
tags = args.each_with_object({}) do |arg, tags|
|
|
30
|
+
case arg
|
|
31
|
+
when String
|
|
32
|
+
tags[arg.to_sym] = true
|
|
33
|
+
when Symbol
|
|
34
|
+
tags[arg] = true
|
|
35
|
+
when Hash
|
|
36
|
+
arg.each { |key, value| tags[key.to_sym] = value }
|
|
37
|
+
else
|
|
38
|
+
tags[arg.class.name.to_sym] = arg
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
kwargs.each { |key, value| tags[key.to_sym] = value }
|
|
42
|
+
tags
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class EventContext # :nodoc:
|
|
48
|
+
EMPTY_CONTEXT = {}.freeze
|
|
49
|
+
FIBER_KEY = :event_reporter_context
|
|
50
|
+
|
|
51
|
+
class << self
|
|
52
|
+
def context
|
|
53
|
+
Fiber[FIBER_KEY] || EMPTY_CONTEXT
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def set_context(context_hash)
|
|
57
|
+
new_context = self.context.dup
|
|
58
|
+
context_hash.each { |key, value| new_context[key.to_sym] = value }
|
|
59
|
+
|
|
60
|
+
Fiber[FIBER_KEY] = new_context.freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def clear
|
|
64
|
+
Fiber[FIBER_KEY] = EMPTY_CONTEXT
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# = Active Support \Event Reporter
|
|
70
|
+
#
|
|
71
|
+
# +ActiveSupport::EventReporter+ provides an interface for reporting structured events to subscribers.
|
|
72
|
+
#
|
|
73
|
+
# To report an event, you can use the +notify+ method:
|
|
74
|
+
#
|
|
75
|
+
# Rails.event.notify("user_created", { id: 123 })
|
|
76
|
+
# # Emits event:
|
|
77
|
+
# # {
|
|
78
|
+
# # name: "user_created",
|
|
79
|
+
# # payload: { id: 123 },
|
|
80
|
+
# # timestamp: 1738964843208679035,
|
|
81
|
+
# # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" }
|
|
82
|
+
# # }
|
|
83
|
+
#
|
|
84
|
+
# The +notify+ API can receive either an event name and a payload hash, or an event object. Names are coerced to strings.
|
|
85
|
+
#
|
|
86
|
+
# === Event Objects
|
|
87
|
+
#
|
|
88
|
+
# If an event object is passed to the +notify+ API, it will be passed through to subscribers as-is, and the name of the
|
|
89
|
+
# object's class will be used as the event name.
|
|
90
|
+
#
|
|
91
|
+
# class UserCreatedEvent
|
|
92
|
+
# def initialize(id:, name:)
|
|
93
|
+
# @id = id
|
|
94
|
+
# @name = name
|
|
95
|
+
# end
|
|
96
|
+
#
|
|
97
|
+
# def serialize
|
|
98
|
+
# {
|
|
99
|
+
# id: @id,
|
|
100
|
+
# name: @name
|
|
101
|
+
# }
|
|
102
|
+
# end
|
|
103
|
+
# end
|
|
104
|
+
#
|
|
105
|
+
# Rails.event.notify(UserCreatedEvent.new(id: 123, name: "John Doe"))
|
|
106
|
+
# # Emits event:
|
|
107
|
+
# # {
|
|
108
|
+
# # name: "UserCreatedEvent",
|
|
109
|
+
# # payload: #<UserCreatedEvent:0x111>,
|
|
110
|
+
# # timestamp: 1738964843208679035,
|
|
111
|
+
# # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" }
|
|
112
|
+
# # }
|
|
113
|
+
#
|
|
114
|
+
# An event is any Ruby object representing a schematized event. While payload hashes allow arbitrary,
|
|
115
|
+
# implicitly-structured data, event objects are intended to enforce a particular schema.
|
|
116
|
+
#
|
|
117
|
+
# Subscribers are responsible for serializing event objects.
|
|
118
|
+
#
|
|
119
|
+
# === Subscribers
|
|
120
|
+
#
|
|
121
|
+
# Subscribers must implement the +emit+ method, which will be called with the event hash.
|
|
122
|
+
#
|
|
123
|
+
# The event hash has the following keys:
|
|
124
|
+
#
|
|
125
|
+
# name: String (The name of the event)
|
|
126
|
+
# payload: Hash, Object (The payload of the event, or the event object itself)
|
|
127
|
+
# tags: Hash (The tags of the event)
|
|
128
|
+
# context: Hash (The context of the event)
|
|
129
|
+
# timestamp: Float (The timestamp of the event, in nanoseconds)
|
|
130
|
+
# source_location: Hash (The source location of the event, containing the filepath, lineno, and label)
|
|
131
|
+
#
|
|
132
|
+
# Subscribers are responsible for encoding events to their desired format before emitting them to their
|
|
133
|
+
# target destination, such as a streaming platform, a log device, or an alerting service.
|
|
134
|
+
#
|
|
135
|
+
# class JSONEventSubscriber
|
|
136
|
+
# def emit(event)
|
|
137
|
+
# json_data = JSON.generate(event)
|
|
138
|
+
# LogExporter.export(json_data)
|
|
139
|
+
# end
|
|
140
|
+
# end
|
|
141
|
+
#
|
|
142
|
+
# class LogSubscriber
|
|
143
|
+
# def emit(event)
|
|
144
|
+
# payload = event[:payload].map { |key, value| "#{key}=#{value}" }.join(" ")
|
|
145
|
+
# source_location = event[:source_location]
|
|
146
|
+
# log = "[#{event[:name]}] #{payload} at #{source_location[:filepath]}:#{source_location[:lineno]}"
|
|
147
|
+
# Rails.logger.info(log)
|
|
148
|
+
# end
|
|
149
|
+
# end
|
|
150
|
+
#
|
|
151
|
+
# Note that event objects are passed through to subscribers as-is, and may need to be serialized before being encoded:
|
|
152
|
+
#
|
|
153
|
+
# class UserCreatedEvent
|
|
154
|
+
# def initialize(id:, name:)
|
|
155
|
+
# @id = id
|
|
156
|
+
# @name = name
|
|
157
|
+
# end
|
|
158
|
+
#
|
|
159
|
+
# def serialize
|
|
160
|
+
# {
|
|
161
|
+
# id: @id,
|
|
162
|
+
# name: @name
|
|
163
|
+
# }
|
|
164
|
+
# end
|
|
165
|
+
# end
|
|
166
|
+
#
|
|
167
|
+
# class LogSubscriber
|
|
168
|
+
# def emit(event)
|
|
169
|
+
# payload = event[:payload]
|
|
170
|
+
# json_data = JSON.generate(payload.serialize)
|
|
171
|
+
# LogExporter.export(json_data)
|
|
172
|
+
# end
|
|
173
|
+
# end
|
|
174
|
+
#
|
|
175
|
+
# ==== Filtered Subscriptions
|
|
176
|
+
#
|
|
177
|
+
# Subscribers can be configured with an optional filter proc to only receive a subset of events:
|
|
178
|
+
#
|
|
179
|
+
# # Only receive events with names starting with "user."
|
|
180
|
+
# Rails.event.subscribe(user_subscriber) { |event| event[:name].start_with?("user.") }
|
|
181
|
+
#
|
|
182
|
+
# # Only receive events with specific payload types
|
|
183
|
+
# Rails.event.subscribe(audit_subscriber) { |event| event[:payload].is_a?(AuditEvent) }
|
|
184
|
+
#
|
|
185
|
+
# === Debug Events
|
|
186
|
+
#
|
|
187
|
+
# You can use the +debug+ method to report an event that will only be reported if the
|
|
188
|
+
# event reporter is in debug mode:
|
|
189
|
+
#
|
|
190
|
+
# Rails.event.debug("my_debug_event", { foo: "bar" })
|
|
191
|
+
#
|
|
192
|
+
# === Tags
|
|
193
|
+
#
|
|
194
|
+
# To add additional context to an event, separate from the event payload, you can add
|
|
195
|
+
# tags via the +tagged+ method:
|
|
196
|
+
#
|
|
197
|
+
# Rails.event.tagged("graphql") do
|
|
198
|
+
# Rails.event.notify("user_created", { id: 123 })
|
|
199
|
+
# end
|
|
200
|
+
#
|
|
201
|
+
# # Emits event:
|
|
202
|
+
# # {
|
|
203
|
+
# # name: "user_created",
|
|
204
|
+
# # payload: { id: 123 },
|
|
205
|
+
# # tags: { graphql: true },
|
|
206
|
+
# # context: {},
|
|
207
|
+
# # timestamp: 1738964843208679035,
|
|
208
|
+
# # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" }
|
|
209
|
+
# # }
|
|
210
|
+
#
|
|
211
|
+
# === Context Store
|
|
212
|
+
#
|
|
213
|
+
# You may want to attach metadata to every event emitted by the reporter. While tags
|
|
214
|
+
# provide domain-specific context for a series of events, context is scoped to the job / request
|
|
215
|
+
# and should be used for metadata associated with the execution context.
|
|
216
|
+
# Context can be set via the +set_context+ method:
|
|
217
|
+
#
|
|
218
|
+
# Rails.event.set_context(request_id: "abcd123", user_agent: "TestAgent")
|
|
219
|
+
# Rails.event.notify("user_created", { id: 123 })
|
|
220
|
+
#
|
|
221
|
+
# # Emits event:
|
|
222
|
+
# # {
|
|
223
|
+
# # name: "user_created",
|
|
224
|
+
# # payload: { id: 123 },
|
|
225
|
+
# # tags: {},
|
|
226
|
+
# # context: { request_id: "abcd123", user_agent: TestAgent" },
|
|
227
|
+
# # timestamp: 1738964843208679035,
|
|
228
|
+
# # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" }
|
|
229
|
+
# # }
|
|
230
|
+
#
|
|
231
|
+
# Context is reset automatically before and after each request.
|
|
232
|
+
#
|
|
233
|
+
# A custom context store can be configured via +config.active_support.event_reporter_context_store+.
|
|
234
|
+
#
|
|
235
|
+
# # config/application.rb
|
|
236
|
+
# config.active_support.event_reporter_context_store = CustomContextStore
|
|
237
|
+
#
|
|
238
|
+
# class CustomContextStore
|
|
239
|
+
# class << self
|
|
240
|
+
# def context
|
|
241
|
+
# # Return the context.
|
|
242
|
+
# end
|
|
243
|
+
#
|
|
244
|
+
# def set_context(context_hash)
|
|
245
|
+
# # Append context_hash to the existing context store.
|
|
246
|
+
# end
|
|
247
|
+
#
|
|
248
|
+
# def clear
|
|
249
|
+
# # Delete the stored context.
|
|
250
|
+
# end
|
|
251
|
+
# end
|
|
252
|
+
# end
|
|
253
|
+
#
|
|
254
|
+
# The Event Reporter standardizes on symbol keys for all payload data, tags, and context store entries.
|
|
255
|
+
# String keys are automatically converted to symbols for consistency.
|
|
256
|
+
#
|
|
257
|
+
# Rails.event.notify("user.created", { "id" => 123 })
|
|
258
|
+
# # Emits event:
|
|
259
|
+
# # {
|
|
260
|
+
# # name: "user.created",
|
|
261
|
+
# # payload: { id: 123 },
|
|
262
|
+
# # }
|
|
263
|
+
class EventReporter
|
|
264
|
+
# Sets whether to raise an error if a subscriber raises an error during
|
|
265
|
+
# event emission, or when unexpected arguments are passed to +notify+.
|
|
266
|
+
attr_writer :raise_on_error
|
|
267
|
+
|
|
268
|
+
attr_writer :debug_mode # :nodoc:
|
|
269
|
+
|
|
270
|
+
class << self
|
|
271
|
+
attr_accessor :context_store # :nodoc:
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
self.context_store = EventContext
|
|
275
|
+
|
|
276
|
+
def initialize(*subscribers, raise_on_error: false)
|
|
277
|
+
@subscribers = []
|
|
278
|
+
subscribers.each { |subscriber| subscribe(subscriber) }
|
|
279
|
+
@debug_mode = false
|
|
280
|
+
@raise_on_error = raise_on_error
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Registers a new event subscriber. The subscriber must respond to
|
|
284
|
+
#
|
|
285
|
+
# emit(event: Hash)
|
|
286
|
+
#
|
|
287
|
+
# The event hash will have the following keys:
|
|
288
|
+
#
|
|
289
|
+
# name: String (The name of the event)
|
|
290
|
+
# payload: Hash, Object (The payload of the event, or the event object itself)
|
|
291
|
+
# tags: Hash (The tags of the event)
|
|
292
|
+
# context: Hash (The context of the event)
|
|
293
|
+
# timestamp: Float (The timestamp of the event, in nanoseconds)
|
|
294
|
+
# source_location: Hash (The source location of the event, containing the filepath, lineno, and label)
|
|
295
|
+
#
|
|
296
|
+
# An optional filter proc can be provided to only receive a subset of events:
|
|
297
|
+
#
|
|
298
|
+
# Rails.event.subscribe(subscriber) { |event| event[:name].start_with?("user.") }
|
|
299
|
+
# Rails.event.subscribe(subscriber) { |event| event[:payload].is_a?(UserEvent) }
|
|
300
|
+
#
|
|
301
|
+
def subscribe(subscriber, &filter)
|
|
302
|
+
unless subscriber.respond_to?(:emit)
|
|
303
|
+
raise ArgumentError, "Event subscriber #{subscriber.class.name} must respond to #emit"
|
|
304
|
+
end
|
|
305
|
+
@subscribers << { subscriber: subscriber, filter: filter }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Unregister an event subscriber. Accepts either a subscriber or a class.
|
|
309
|
+
#
|
|
310
|
+
# subscriber = MyEventSubscriber.new
|
|
311
|
+
# Rails.event.subscribe(subscriber)
|
|
312
|
+
#
|
|
313
|
+
# Rails.event.unsubscribe(subscriber)
|
|
314
|
+
# # or
|
|
315
|
+
# Rails.event.unsubscribe(MyEventSubscriber)
|
|
316
|
+
def unsubscribe(subscriber)
|
|
317
|
+
@subscribers.delete_if { |s| subscriber === s[:subscriber] }
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Reports an event to all registered subscribers. An event name and payload can be provided:
|
|
321
|
+
#
|
|
322
|
+
# Rails.event.notify("user.created", { id: 123 })
|
|
323
|
+
# # Emits event:
|
|
324
|
+
# # {
|
|
325
|
+
# # name: "user.created",
|
|
326
|
+
# # payload: { id: 123 },
|
|
327
|
+
# # tags: {},
|
|
328
|
+
# # context: {},
|
|
329
|
+
# # timestamp: 1738964843208679035,
|
|
330
|
+
# # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" }
|
|
331
|
+
# # }
|
|
332
|
+
#
|
|
333
|
+
# Alternatively, an event object can be provided:
|
|
334
|
+
#
|
|
335
|
+
# Rails.event.notify(UserCreatedEvent.new(id: 123))
|
|
336
|
+
# # Emits event:
|
|
337
|
+
# # {
|
|
338
|
+
# # name: "UserCreatedEvent",
|
|
339
|
+
# # payload: #<UserCreatedEvent:0x111>,
|
|
340
|
+
# # tags: {},
|
|
341
|
+
# # context: {},
|
|
342
|
+
# # timestamp: 1738964843208679035,
|
|
343
|
+
# # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" }
|
|
344
|
+
# # }
|
|
345
|
+
#
|
|
346
|
+
# ==== Arguments
|
|
347
|
+
#
|
|
348
|
+
# * +:payload+ - The event payload when using string/symbol event names.
|
|
349
|
+
#
|
|
350
|
+
# * +:caller_depth+ - The stack depth to use for source location (default: 1).
|
|
351
|
+
#
|
|
352
|
+
# * +:kwargs+ - Additional payload data when using string/symbol event names.
|
|
353
|
+
def notify(name_or_object, payload = nil, caller_depth: 1, **kwargs)
|
|
354
|
+
name = resolve_name(name_or_object)
|
|
355
|
+
payload = resolve_payload(name_or_object, payload, **kwargs)
|
|
356
|
+
|
|
357
|
+
event = {
|
|
358
|
+
name: name,
|
|
359
|
+
payload: payload,
|
|
360
|
+
tags: TagStack.tags,
|
|
361
|
+
context: context_store.context,
|
|
362
|
+
timestamp: Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond),
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
caller_location = caller_locations(caller_depth, 1)&.first
|
|
366
|
+
|
|
367
|
+
if caller_location
|
|
368
|
+
source_location = {
|
|
369
|
+
filepath: caller_location.path,
|
|
370
|
+
lineno: caller_location.lineno,
|
|
371
|
+
label: caller_location.label,
|
|
372
|
+
}
|
|
373
|
+
event[:source_location] = source_location
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
@subscribers.each do |subscriber_entry|
|
|
377
|
+
subscriber = subscriber_entry[:subscriber]
|
|
378
|
+
filter = subscriber_entry[:filter]
|
|
379
|
+
|
|
380
|
+
next if filter && !filter.call(event)
|
|
381
|
+
|
|
382
|
+
subscriber.emit(event)
|
|
383
|
+
rescue => subscriber_error
|
|
384
|
+
if raise_on_error?
|
|
385
|
+
raise
|
|
386
|
+
else
|
|
387
|
+
ActiveSupport.error_reporter.report(subscriber_error, handled: true)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
nil
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Temporarily enables debug mode for the duration of the block.
|
|
395
|
+
# Calls to +debug+ will only be reported if debug mode is enabled.
|
|
396
|
+
#
|
|
397
|
+
# Rails.event.with_debug do
|
|
398
|
+
# Rails.event.debug("sql.query", { sql: "SELECT * FROM users" })
|
|
399
|
+
# end
|
|
400
|
+
def with_debug
|
|
401
|
+
prior = Fiber[:event_reporter_debug_mode]
|
|
402
|
+
Fiber[:event_reporter_debug_mode] = true
|
|
403
|
+
yield
|
|
404
|
+
ensure
|
|
405
|
+
Fiber[:event_reporter_debug_mode] = prior
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Check if debug mode is currently enabled. Debug mode is enabled on the reporter
|
|
409
|
+
# via +with_debug+, and in local environments.
|
|
410
|
+
def debug_mode?
|
|
411
|
+
@debug_mode || Fiber[:event_reporter_debug_mode]
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Report an event only when in debug mode. For example:
|
|
415
|
+
#
|
|
416
|
+
# Rails.event.debug("sql.query", { sql: "SELECT * FROM users" })
|
|
417
|
+
#
|
|
418
|
+
# ==== Arguments
|
|
419
|
+
#
|
|
420
|
+
# * +:payload+ - The event payload when using string/symbol event names.
|
|
421
|
+
#
|
|
422
|
+
# * +:caller_depth+ - The stack depth to use for source location (default: 1).
|
|
423
|
+
#
|
|
424
|
+
# * +:kwargs+ - Additional payload data when using string/symbol event names.
|
|
425
|
+
def debug(name_or_object, payload = nil, caller_depth: 1, **kwargs)
|
|
426
|
+
if debug_mode?
|
|
427
|
+
if block_given?
|
|
428
|
+
notify(name_or_object, payload, caller_depth: caller_depth + 1, **kwargs.merge(yield))
|
|
429
|
+
else
|
|
430
|
+
notify(name_or_object, payload, caller_depth: caller_depth + 1, **kwargs)
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Add tags to events to supply additional context. Tags operate in a stack-oriented manner,
|
|
436
|
+
# so all events emitted within the block inherit the same set of tags. For example:
|
|
437
|
+
#
|
|
438
|
+
# Rails.event.tagged("graphql") do
|
|
439
|
+
# Rails.event.notify("user.created", { id: 123 })
|
|
440
|
+
# end
|
|
441
|
+
#
|
|
442
|
+
# # Emits event:
|
|
443
|
+
# # {
|
|
444
|
+
# # name: "user.created",
|
|
445
|
+
# # payload: { id: 123 },
|
|
446
|
+
# # tags: { graphql: true },
|
|
447
|
+
# # context: {},
|
|
448
|
+
# # timestamp: 1738964843208679035,
|
|
449
|
+
# # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" }
|
|
450
|
+
# # }
|
|
451
|
+
#
|
|
452
|
+
# Tags can be provided as arguments or as keyword arguments, and can be nested:
|
|
453
|
+
#
|
|
454
|
+
# Rails.event.tagged("graphql") do
|
|
455
|
+
# # Other code here...
|
|
456
|
+
# Rails.event.tagged(section: "admin") do
|
|
457
|
+
# Rails.event.notify("user.created", { id: 123 })
|
|
458
|
+
# end
|
|
459
|
+
# end
|
|
460
|
+
#
|
|
461
|
+
# # Emits event:
|
|
462
|
+
# # {
|
|
463
|
+
# # name: "user.created",
|
|
464
|
+
# # payload: { id: 123 },
|
|
465
|
+
# # tags: { section: "admin", graphql: true },
|
|
466
|
+
# # context: {},
|
|
467
|
+
# # timestamp: 1738964843208679035,
|
|
468
|
+
# # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" }
|
|
469
|
+
# # }
|
|
470
|
+
#
|
|
471
|
+
# The +tagged+ API can also receive a tag object:
|
|
472
|
+
#
|
|
473
|
+
# graphql_tag = GraphqlTag.new(operation_name: "user_created", operation_type: "mutation")
|
|
474
|
+
# Rails.event.tagged(graphql_tag) do
|
|
475
|
+
# Rails.event.notify("user.created", { id: 123 })
|
|
476
|
+
# end
|
|
477
|
+
#
|
|
478
|
+
# # Emits event:
|
|
479
|
+
# # {
|
|
480
|
+
# # name: "user.created",
|
|
481
|
+
# # payload: { id: 123 },
|
|
482
|
+
# # tags: { "GraphqlTag": #<GraphqlTag:0x111> },
|
|
483
|
+
# # context: {},
|
|
484
|
+
# # timestamp: 1738964843208679035,
|
|
485
|
+
# # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" }
|
|
486
|
+
# # }
|
|
487
|
+
def tagged(*args, **kwargs, &block)
|
|
488
|
+
TagStack.with_tags(*args, **kwargs, &block)
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Sets context data that will be included with all events emitted by the reporter.
|
|
492
|
+
# Context data should be scoped to the job or request, and is reset automatically
|
|
493
|
+
# before and after each request and job.
|
|
494
|
+
#
|
|
495
|
+
# Rails.event.set_context(user_agent: "TestAgent")
|
|
496
|
+
# Rails.event.set_context(job_id: "abc123")
|
|
497
|
+
# Rails.event.tagged("graphql") do
|
|
498
|
+
# Rails.event.notify("user_created", { id: 123 })
|
|
499
|
+
# end
|
|
500
|
+
#
|
|
501
|
+
# # Emits event:
|
|
502
|
+
# # {
|
|
503
|
+
# # name: "user_created",
|
|
504
|
+
# # payload: { id: 123 },
|
|
505
|
+
# # tags: { graphql: true },
|
|
506
|
+
# # context: { user_agent: "TestAgent", job_id: "abc123" },
|
|
507
|
+
# # timestamp: 1738964843208679035
|
|
508
|
+
# # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" }
|
|
509
|
+
# # }
|
|
510
|
+
def set_context(context)
|
|
511
|
+
context_store.set_context(context)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Clears all context data.
|
|
515
|
+
def clear_context
|
|
516
|
+
context_store.clear
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Returns the current context data.
|
|
520
|
+
def context
|
|
521
|
+
context_store.context
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
private
|
|
525
|
+
def raise_on_error?
|
|
526
|
+
@raise_on_error
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def context_store
|
|
530
|
+
self.class.context_store
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def resolve_name(name_or_object)
|
|
534
|
+
case name_or_object
|
|
535
|
+
when String, Symbol
|
|
536
|
+
name_or_object.to_s
|
|
537
|
+
else
|
|
538
|
+
name_or_object.class.name
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def resolve_payload(name_or_object, payload, **kwargs)
|
|
543
|
+
case name_or_object
|
|
544
|
+
when String, Symbol
|
|
545
|
+
handle_unexpected_args(name_or_object, payload, kwargs) if payload && kwargs.any?
|
|
546
|
+
if kwargs.any?
|
|
547
|
+
kwargs.transform_keys(&:to_sym)
|
|
548
|
+
elsif payload
|
|
549
|
+
payload.transform_keys(&:to_sym)
|
|
550
|
+
end
|
|
551
|
+
else
|
|
552
|
+
handle_unexpected_args(name_or_object, payload, kwargs) if payload || kwargs.any?
|
|
553
|
+
name_or_object
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def handle_unexpected_args(name_or_object, payload, kwargs)
|
|
558
|
+
message = <<~MESSAGE
|
|
559
|
+
Rails.event.notify accepts either an event object, a payload hash, or keyword arguments.
|
|
560
|
+
Received: #{name_or_object.inspect}, #{payload.inspect}, #{kwargs.inspect}
|
|
561
|
+
MESSAGE
|
|
562
|
+
|
|
563
|
+
if raise_on_error?
|
|
564
|
+
raise ArgumentError, message
|
|
565
|
+
else
|
|
566
|
+
ActiveSupport.error_reporter.report(ArgumentError.new(message), handled: true)
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
end
|