bug_bunny 4.6.1 → 4.8.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/.agents/skills/rabbitmq-expert/SKILL.md +1555 -0
- data/.claude/commands/gem-ai-setup.md +174 -0
- data/.claude/commands/pr.md +53 -0
- data/.claude/commands/release.md +52 -0
- data/.claude/commands/rubocop.md +22 -0
- data/.claude/commands/service-ai-setup.md +168 -0
- data/.claude/commands/test.md +28 -0
- data/.claude/commands/yard.md +46 -0
- data/CHANGELOG.md +50 -15
- data/CLAUDE.md +240 -0
- data/README.md +154 -221
- data/Rakefile +19 -3
- data/docs/_index.md +50 -0
- data/docs/ai/_index.md +56 -0
- data/docs/ai/antipatterns.md +166 -0
- data/docs/ai/api.md +251 -0
- data/docs/ai/architecture.md +92 -0
- data/docs/ai/errors.md +158 -0
- data/docs/ai/faq_external.md +133 -0
- data/docs/ai/faq_internal.md +86 -0
- data/docs/ai/glossary.md +45 -0
- data/docs/concepts.md +140 -0
- data/docs/howto/controller.md +194 -0
- data/docs/howto/middleware_client.md +119 -0
- data/docs/howto/middleware_consumer.md +127 -0
- data/docs/howto/rails.md +214 -0
- data/docs/howto/resource.md +200 -0
- data/docs/howto/routing.md +133 -0
- data/docs/howto/testing.md +259 -0
- data/docs/howto/tracing.md +119 -0
- data/lib/bug_bunny/client.rb +45 -21
- data/lib/bug_bunny/configuration.rb +63 -0
- data/lib/bug_bunny/consumer.rb +51 -37
- data/lib/bug_bunny/consumer_middleware.rb +14 -5
- data/lib/bug_bunny/controller.rb +39 -18
- data/lib/bug_bunny/exception.rb +5 -1
- data/lib/bug_bunny/middleware/raise_error.rb +3 -3
- data/lib/bug_bunny/observability.rb +28 -6
- data/lib/bug_bunny/producer.rb +11 -13
- data/lib/bug_bunny/railtie.rb +8 -7
- data/lib/bug_bunny/request.rb +3 -11
- data/lib/bug_bunny/resource.rb +81 -41
- data/lib/bug_bunny/routing/route.rb +6 -1
- data/lib/bug_bunny/routing/route_set.rb +60 -22
- data/lib/bug_bunny/session.rb +18 -11
- data/lib/bug_bunny/version.rb +1 -1
- data/lib/bug_bunny.rb +4 -2
- data/lib/generators/bug_bunny/install/install_generator.rb +45 -5
- data/lib/tasks/bug_bunny.rake +50 -0
- data/plan_test.txt +63 -0
- data/skills-lock.json +10 -0
- data/spec/integration/client_spec.rb +117 -0
- data/spec/integration/consumer_middleware_spec.rb +86 -0
- data/spec/integration/controller_spec.rb +140 -0
- data/spec/integration/error_handling_spec.rb +57 -0
- data/spec/integration/infrastructure_spec.rb +52 -0
- data/spec/integration/resource_spec.rb +113 -0
- data/spec/spec_helper.rb +70 -0
- data/spec/support/bunny_mocks.rb +18 -0
- data/spec/support/integration_helper.rb +87 -0
- data/spec/unit/client_session_pool_spec.rb +159 -0
- data/spec/unit/configuration_spec.rb +164 -0
- data/spec/unit/consumer_middleware_spec.rb +129 -0
- data/spec/unit/consumer_spec.rb +90 -0
- data/spec/unit/controller_after_action_spec.rb +155 -0
- data/spec/unit/observability_spec.rb +167 -0
- data/spec/unit/resource_attributes_spec.rb +69 -0
- data/spec/unit/session_spec.rb +98 -0
- metadata +50 -3
- data/sig/bug_bunny.rbs +0 -4
data/lib/bug_bunny/consumer.rb
CHANGED
|
@@ -63,7 +63,8 @@ module BugBunny
|
|
|
63
63
|
# @param queue_opts [Hash] Opciones adicionales para la cola (durable, auto_delete).
|
|
64
64
|
# @param block [Boolean] Si es `true`, bloquea el hilo actual (loop infinito).
|
|
65
65
|
# @return [void]
|
|
66
|
-
def subscribe(queue_name:, exchange_name:, routing_key:, exchange_type: 'direct', exchange_opts: {},
|
|
66
|
+
def subscribe(queue_name:, exchange_name:, routing_key:, exchange_type: 'direct', exchange_opts: {},
|
|
67
|
+
queue_opts: {}, block: true)
|
|
67
68
|
attempt = 0
|
|
68
69
|
|
|
69
70
|
begin
|
|
@@ -74,14 +75,15 @@ module BugBunny
|
|
|
74
75
|
|
|
75
76
|
# 📊 LOGGING DE OBSERVABILIDAD: Calculamos las opciones finales para mostrarlas en consola
|
|
76
77
|
final_x_opts = BugBunny::Session::DEFAULT_EXCHANGE_OPTIONS
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
.merge(BugBunny.configuration.exchange_options || {})
|
|
79
|
+
.merge(exchange_opts || {})
|
|
79
80
|
final_q_opts = BugBunny::Session::DEFAULT_QUEUE_OPTIONS
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
.merge(BugBunny.configuration.queue_options || {})
|
|
82
|
+
.merge(queue_opts || {})
|
|
82
83
|
|
|
83
|
-
safe_log(:info,
|
|
84
|
-
safe_log(:info,
|
|
84
|
+
safe_log(:info, 'consumer.start', queue: queue_name, queue_opts: final_q_opts)
|
|
85
|
+
safe_log(:info, 'consumer.bound', exchange: exchange_name, exchange_type: exchange_type,
|
|
86
|
+
routing_key: routing_key, exchange_opts: final_x_opts)
|
|
85
87
|
|
|
86
88
|
start_health_check(queue_name)
|
|
87
89
|
|
|
@@ -89,7 +91,7 @@ module BugBunny
|
|
|
89
91
|
trace_id = properties.correlation_id
|
|
90
92
|
logger = BugBunny.configuration.logger
|
|
91
93
|
|
|
92
|
-
core =
|
|
94
|
+
core = lambda {
|
|
93
95
|
if logger.respond_to?(:tagged)
|
|
94
96
|
logger.tagged(trace_id) { process_message(delivery_info, properties, body) }
|
|
95
97
|
elsif defined?(Rails) && Rails.logger.respond_to?(:tagged)
|
|
@@ -106,19 +108,35 @@ module BugBunny
|
|
|
106
108
|
max_attempts = BugBunny.configuration.max_reconnect_attempts
|
|
107
109
|
|
|
108
110
|
if max_attempts && attempt >= max_attempts
|
|
109
|
-
safe_log(:error,
|
|
111
|
+
safe_log(:error, 'consumer.reconnect_exhausted', max_attempts_count: max_attempts, **exception_metadata(e))
|
|
110
112
|
raise
|
|
111
113
|
end
|
|
112
114
|
|
|
113
115
|
wait = [
|
|
114
|
-
BugBunny.configuration.network_recovery_interval * (2
|
|
116
|
+
BugBunny.configuration.network_recovery_interval * (2**(attempt - 1)),
|
|
115
117
|
BugBunny.configuration.max_reconnect_interval
|
|
116
118
|
].min
|
|
117
119
|
|
|
118
|
-
safe_log(:error,
|
|
120
|
+
safe_log(:error, 'consumer.connection_error', attempt_count: attempt,
|
|
121
|
+
max_attempts_count: max_attempts || 'infinity', retry_in_s: wait, **exception_metadata(e))
|
|
119
122
|
sleep wait
|
|
120
123
|
retry
|
|
121
124
|
end
|
|
125
|
+
ensure
|
|
126
|
+
shutdown
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Detiene el health check timer y cierra el canal de forma ordenada.
|
|
130
|
+
#
|
|
131
|
+
# Llamar explícitamente al hacer shutdown del worker (SIGTERM, at_exit, etc.).
|
|
132
|
+
# También se invoca automáticamente cuando `subscribe` termina por cualquier motivo.
|
|
133
|
+
#
|
|
134
|
+
# @return [void]
|
|
135
|
+
def shutdown
|
|
136
|
+
safe_log(:info, 'consumer.shutdown')
|
|
137
|
+
@health_timer&.shutdown
|
|
138
|
+
@health_timer = nil
|
|
139
|
+
session.close
|
|
122
140
|
end
|
|
123
141
|
|
|
124
142
|
private
|
|
@@ -138,7 +156,7 @@ module BugBunny
|
|
|
138
156
|
path = properties.type || (properties.headers && properties.headers['path'])
|
|
139
157
|
|
|
140
158
|
if path.nil? || path.empty?
|
|
141
|
-
safe_log(:error,
|
|
159
|
+
safe_log(:error, 'consumer.message_rejected', reason: :missing_type_header)
|
|
142
160
|
session.channel.reject(delivery_info.delivery_tag, false)
|
|
143
161
|
return
|
|
144
162
|
end
|
|
@@ -147,8 +165,9 @@ module BugBunny
|
|
|
147
165
|
headers_hash = properties.headers || {}
|
|
148
166
|
http_method = (headers_hash['x-http-method'] || headers_hash['method'] || 'GET').to_s.upcase
|
|
149
167
|
|
|
150
|
-
safe_log(:info,
|
|
151
|
-
|
|
168
|
+
safe_log(:info, 'consumer.message_received', method: http_method, path: path,
|
|
169
|
+
routing_key: delivery_info.routing_key)
|
|
170
|
+
safe_log(:debug, 'consumer.message_received_body', body: body.truncate(200))
|
|
152
171
|
|
|
153
172
|
# ===================================================================
|
|
154
173
|
# 3. Ruteo Declarativo
|
|
@@ -157,16 +176,14 @@ module BugBunny
|
|
|
157
176
|
|
|
158
177
|
# Extraemos query params (ej. /nodes?status=active)
|
|
159
178
|
query_params = uri.query ? Rack::Utils.parse_nested_query(uri.query) : {}
|
|
160
|
-
if defined?(ActiveSupport::HashWithIndifferentAccess)
|
|
161
|
-
query_params = query_params.with_indifferent_access
|
|
162
|
-
end
|
|
179
|
+
query_params = query_params.with_indifferent_access if defined?(ActiveSupport::HashWithIndifferentAccess)
|
|
163
180
|
|
|
164
181
|
# Le preguntamos al motor de rutas global quién debe manejar esto
|
|
165
182
|
route_info = BugBunny.routes.recognize(http_method, uri.path)
|
|
166
183
|
|
|
167
184
|
if route_info.nil?
|
|
168
|
-
safe_log(:warn,
|
|
169
|
-
handle_fatal_error(properties, 404,
|
|
185
|
+
safe_log(:warn, 'consumer.route_not_found', method: http_method, path: uri.path)
|
|
186
|
+
handle_fatal_error(properties, 404, 'Not Found', "No route matches [#{http_method}] \"/#{uri.path}\"")
|
|
170
187
|
session.channel.reject(delivery_info.delivery_tag, false)
|
|
171
188
|
return
|
|
172
189
|
end
|
|
@@ -177,28 +194,28 @@ module BugBunny
|
|
|
177
194
|
# ===================================================================
|
|
178
195
|
# 4. Instanciación del Controlador
|
|
179
196
|
# ===================================================================
|
|
180
|
-
|
|
197
|
+
base_namespace = route_info[:namespace] || BugBunny.configuration.controller_namespace
|
|
181
198
|
controller_name = route_info[:controller].camelize
|
|
182
|
-
controller_class_name = "#{
|
|
199
|
+
controller_class_name = "#{base_namespace}::#{controller_name}Controller"
|
|
183
200
|
|
|
184
201
|
begin
|
|
185
202
|
controller_class = controller_class_name.constantize
|
|
186
203
|
rescue NameError
|
|
187
|
-
safe_log(:warn,
|
|
188
|
-
handle_fatal_error(properties, 404,
|
|
204
|
+
safe_log(:warn, 'consumer.controller_not_found', controller: controller_class_name)
|
|
205
|
+
handle_fatal_error(properties, 404, 'Not Found', "Controller #{controller_class_name} not found")
|
|
189
206
|
session.channel.reject(delivery_info.delivery_tag, false)
|
|
190
207
|
return
|
|
191
208
|
end
|
|
192
209
|
|
|
193
210
|
# Verificación estricta de Seguridad (RCE Prevention)
|
|
194
211
|
unless controller_class < BugBunny::Controller
|
|
195
|
-
safe_log(:error,
|
|
196
|
-
handle_fatal_error(properties, 403,
|
|
212
|
+
safe_log(:error, 'consumer.security_violation', reason: :invalid_controller, controller: controller_class)
|
|
213
|
+
handle_fatal_error(properties, 403, 'Forbidden', 'Invalid Controller Class')
|
|
197
214
|
session.channel.reject(delivery_info.delivery_tag, false)
|
|
198
215
|
return
|
|
199
216
|
end
|
|
200
217
|
|
|
201
|
-
safe_log(:debug,
|
|
218
|
+
safe_log(:debug, 'consumer.route_matched', controller: controller_class_name, action: route_info[:action])
|
|
202
219
|
|
|
203
220
|
request_metadata = {
|
|
204
221
|
type: path,
|
|
@@ -217,22 +234,19 @@ module BugBunny
|
|
|
217
234
|
# ===================================================================
|
|
218
235
|
response_payload = controller_class.call(headers: request_metadata, body: body)
|
|
219
236
|
|
|
220
|
-
if properties.reply_to
|
|
221
|
-
reply(response_payload, properties.reply_to, properties.correlation_id)
|
|
222
|
-
end
|
|
237
|
+
reply(response_payload, properties.reply_to, properties.correlation_id) if properties.reply_to
|
|
223
238
|
|
|
224
239
|
session.channel.ack(delivery_info.delivery_tag)
|
|
225
240
|
|
|
226
|
-
safe_log(:info,
|
|
241
|
+
safe_log(:info, 'consumer.message_processed',
|
|
227
242
|
status: response_payload[:status],
|
|
228
243
|
duration_s: duration_s(start_time),
|
|
229
244
|
controller: controller_class_name,
|
|
230
245
|
action: route_info[:action])
|
|
231
|
-
|
|
232
246
|
rescue StandardError => e
|
|
233
|
-
safe_log(:error,
|
|
234
|
-
safe_log(:debug,
|
|
235
|
-
handle_fatal_error(properties, 500,
|
|
247
|
+
safe_log(:error, 'consumer.execution_error', duration_s: duration_s(start_time), **exception_metadata(e))
|
|
248
|
+
safe_log(:debug, 'consumer.execution_error_backtrace', backtrace: e.backtrace.first(5).join(' | '))
|
|
249
|
+
handle_fatal_error(properties, 500, 'Internal Server Error', e.message)
|
|
236
250
|
session.channel.reject(delivery_info.delivery_tag, false)
|
|
237
251
|
end
|
|
238
252
|
|
|
@@ -243,7 +257,7 @@ module BugBunny
|
|
|
243
257
|
# @param correlation_id [String] ID para correlacionar la respuesta con la petición original.
|
|
244
258
|
# @return [void]
|
|
245
259
|
def reply(payload, reply_to, correlation_id)
|
|
246
|
-
safe_log(:debug,
|
|
260
|
+
safe_log(:debug, 'consumer.rpc_reply', reply_to: reply_to, correlation_id: correlation_id)
|
|
247
261
|
extra_headers = BugBunny.configuration.rpc_reply_headers&.call || {}
|
|
248
262
|
session.channel.default_exchange.publish(
|
|
249
263
|
payload.to_json,
|
|
@@ -294,7 +308,7 @@ module BugBunny
|
|
|
294
308
|
# 2. Si llegamos aquí, RabbitMQ y la cola están vivos. Avisamos al orquestador actualizando el archivo.
|
|
295
309
|
touch_health_file(file_path) if file_path
|
|
296
310
|
rescue StandardError => e
|
|
297
|
-
safe_log(:warn,
|
|
311
|
+
safe_log(:warn, 'consumer.health_check_failed', queue: q_name, **exception_metadata(e))
|
|
298
312
|
session.close
|
|
299
313
|
end
|
|
300
314
|
@health_timer.execute
|
|
@@ -309,7 +323,7 @@ module BugBunny
|
|
|
309
323
|
def touch_health_file(file_path)
|
|
310
324
|
FileUtils.touch(file_path)
|
|
311
325
|
rescue StandardError => e
|
|
312
|
-
safe_log(:error,
|
|
326
|
+
safe_log(:error, 'consumer.health_check_file_error', path: file_path, **exception_metadata(e))
|
|
313
327
|
end
|
|
314
328
|
end
|
|
315
329
|
end
|
|
@@ -42,36 +42,45 @@ module BugBunny
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# Gestiona y ejecuta la cadena de middlewares del Consumer.
|
|
45
|
+
#
|
|
46
|
+
# Thread-safe: `use` y `empty?` están protegidos por un Mutex. `call` toma un
|
|
47
|
+
# snapshot del array bajo mutex y ejecuta la cadena fuera del lock para no
|
|
48
|
+
# serializar el procesamiento de mensajes.
|
|
45
49
|
class Stack
|
|
46
50
|
def initialize
|
|
47
51
|
@middlewares = []
|
|
52
|
+
@mutex = Mutex.new
|
|
48
53
|
end
|
|
49
54
|
|
|
50
|
-
# Registra un middleware en la cadena.
|
|
55
|
+
# Registra un middleware en la cadena. Thread-safe.
|
|
51
56
|
#
|
|
52
57
|
# @param middleware_class [Class] Clase que hereda de {Base}.
|
|
53
58
|
# @return [self]
|
|
54
59
|
def use(middleware_class)
|
|
55
|
-
@middlewares << middleware_class
|
|
60
|
+
@mutex.synchronize { @middlewares << middleware_class }
|
|
56
61
|
self
|
|
57
62
|
end
|
|
58
63
|
|
|
59
|
-
# @return [Boolean] `true` si no hay middlewares registrados.
|
|
64
|
+
# @return [Boolean] `true` si no hay middlewares registrados. Thread-safe.
|
|
60
65
|
def empty?
|
|
61
|
-
@middlewares.empty?
|
|
66
|
+
@mutex.synchronize { @middlewares.empty? }
|
|
62
67
|
end
|
|
63
68
|
|
|
64
69
|
# Ejecuta la cadena de middlewares envolviendo el bloque core.
|
|
65
70
|
#
|
|
71
|
+
# Toma un snapshot del array de middlewares bajo mutex y construye la cadena
|
|
72
|
+
# fuera del lock para no bloquear registros concurrentes durante la ejecución.
|
|
73
|
+
#
|
|
66
74
|
# @param delivery_info [Bunny::DeliveryInfo]
|
|
67
75
|
# @param properties [Bunny::MessageProperties]
|
|
68
76
|
# @param body [String]
|
|
69
77
|
# @yieldreturn [void] El bloque core a ejecutar al final de la cadena.
|
|
70
78
|
# @return [void]
|
|
71
79
|
def call(delivery_info, properties, body, &core)
|
|
80
|
+
snapshot = @mutex.synchronize { @middlewares.dup }
|
|
72
81
|
terminal = ->(_di, _props, _body) { core.call }
|
|
73
82
|
|
|
74
|
-
chain =
|
|
83
|
+
chain = snapshot.reverse.inject(terminal) do |next_step, middleware_class|
|
|
75
84
|
middleware_class.new(next_step)
|
|
76
85
|
end
|
|
77
86
|
|
data/lib/bug_bunny/controller.rb
CHANGED
|
@@ -9,7 +9,7 @@ module BugBunny
|
|
|
9
9
|
#
|
|
10
10
|
# Actúa como el receptor final de los mensajes enrutados desde el consumidor.
|
|
11
11
|
# Implementa un ciclo de vida similar a ActionController en Rails, soportando:
|
|
12
|
-
# - Filtros (`before_action`, `around_action`).
|
|
12
|
+
# - Filtros (`before_action`, `around_action`, `after_action`).
|
|
13
13
|
# - Manejo declarativo de errores (`rescue_from`).
|
|
14
14
|
# - Parsing de parámetros unificados (`params`).
|
|
15
15
|
# - Respuestas estructuradas (`render`).
|
|
@@ -40,7 +40,6 @@ module BugBunny
|
|
|
40
40
|
|
|
41
41
|
# @!endgroup
|
|
42
42
|
|
|
43
|
-
|
|
44
43
|
# ==========================================
|
|
45
44
|
# INFRAESTRUCTURA DE FILTROS Y LOGS (HEREDABLES)
|
|
46
45
|
# ==========================================
|
|
@@ -49,6 +48,7 @@ module BugBunny
|
|
|
49
48
|
# hacia las subclases (ej. de ApplicationController a ServicesController).
|
|
50
49
|
class_attribute :before_actions, default: {}
|
|
51
50
|
class_attribute :around_actions, default: {}
|
|
51
|
+
class_attribute :after_actions, default: {}
|
|
52
52
|
class_attribute :log_tags, default: []
|
|
53
53
|
class_attribute :rescue_handlers, default: []
|
|
54
54
|
|
|
@@ -72,6 +72,17 @@ module BugBunny
|
|
|
72
72
|
register_callback(:around_actions, method_name, options)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
# Registra un filtro que se ejecutará **después** de la acción.
|
|
76
|
+
# No se ejecuta si un `before_action` halted la cadena (render anticipado)
|
|
77
|
+
# ni si la acción lanzó una excepción, siguiendo el comportamiento de Rails.
|
|
78
|
+
#
|
|
79
|
+
# @param method_name [Symbol] Nombre del método privado a ejecutar.
|
|
80
|
+
# @param options [Hash] Opciones como `only: [:create, :update]`.
|
|
81
|
+
# @return [void]
|
|
82
|
+
def self.after_action(method_name, **options)
|
|
83
|
+
register_callback(:after_actions, method_name, options)
|
|
84
|
+
end
|
|
85
|
+
|
|
75
86
|
# Manejo declarativo de excepciones.
|
|
76
87
|
# Atrapa errores específicos que ocurran durante la ejecución de la acción.
|
|
77
88
|
#
|
|
@@ -90,7 +101,7 @@ module BugBunny
|
|
|
90
101
|
raise ArgumentError, "Need a handler. Supply 'with: :method' or a block." unless handler
|
|
91
102
|
|
|
92
103
|
# Duplicamos el array del padre para no mutarlo al registrar reglas en el hijo
|
|
93
|
-
new_handlers =
|
|
104
|
+
new_handlers = rescue_handlers.dup
|
|
94
105
|
|
|
95
106
|
klasses.each do |klass|
|
|
96
107
|
new_handlers.unshift([klass, handler])
|
|
@@ -121,14 +132,16 @@ module BugBunny
|
|
|
121
132
|
# Aplicamos automáticamente las etiquetas de logs a todas las acciones.
|
|
122
133
|
around_action :apply_log_tags
|
|
123
134
|
|
|
124
|
-
|
|
125
135
|
# ==========================================
|
|
126
136
|
# INICIALIZACIÓN Y CICLO DE VIDA
|
|
127
137
|
# ==========================================
|
|
128
138
|
|
|
139
|
+
# @return [Hash] Headers que se enviarán en la respuesta.
|
|
140
|
+
attr_accessor :response_headers
|
|
141
|
+
|
|
129
142
|
def initialize(attributes = {})
|
|
130
143
|
super
|
|
131
|
-
@response_headers = {}
|
|
144
|
+
@response_headers = {}.with_indifferent_access
|
|
132
145
|
@logger = BugBunny.configuration.logger
|
|
133
146
|
end
|
|
134
147
|
|
|
@@ -155,23 +168,22 @@ module BugBunny
|
|
|
155
168
|
core_execution = lambda do
|
|
156
169
|
return unless run_before_actions(action_name)
|
|
157
170
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
171
|
+
raise NameError, "Action '#{action_name}' not found in #{self.class.name}" unless respond_to?(action_name)
|
|
172
|
+
|
|
173
|
+
public_send(action_name)
|
|
174
|
+
|
|
175
|
+
run_after_actions(action_name)
|
|
163
176
|
end
|
|
164
177
|
|
|
165
178
|
# Construir e invocar la cadena de responsabilidad (Middlewares/Around Actions)
|
|
166
179
|
execution_chain = current_arounds.reverse.inject(core_execution) do |next_step, method_name|
|
|
167
|
-
|
|
180
|
+
-> { send(method_name, &next_step) }
|
|
168
181
|
end
|
|
169
182
|
|
|
170
183
|
execution_chain.call
|
|
171
184
|
|
|
172
185
|
# Si no hubo renderización explícita, devuelve 204 No Content
|
|
173
186
|
rendered_response || { status: 204, headers: response_headers, body: nil }
|
|
174
|
-
|
|
175
187
|
rescue StandardError => e
|
|
176
188
|
handle_exception(e)
|
|
177
189
|
end
|
|
@@ -206,12 +218,13 @@ module BugBunny
|
|
|
206
218
|
end
|
|
207
219
|
|
|
208
220
|
# Fallback genérico si la excepción no fue mapeada
|
|
209
|
-
safe_log(:error,
|
|
221
|
+
safe_log(:error, 'controller.unhandled_exception', backtrace: exception.backtrace.first(5).join(' | '),
|
|
222
|
+
**exception_metadata(exception))
|
|
210
223
|
|
|
211
224
|
{
|
|
212
225
|
status: 500,
|
|
213
226
|
headers: response_headers,
|
|
214
|
-
body: { error:
|
|
227
|
+
body: { error: 'Internal Server Error', detail: exception.message, type: exception.class.name }
|
|
215
228
|
}
|
|
216
229
|
end
|
|
217
230
|
|
|
@@ -219,14 +232,15 @@ module BugBunny
|
|
|
219
232
|
#
|
|
220
233
|
# @param status [Symbol, Integer] Código HTTP (ej. :ok, :not_found, 201).
|
|
221
234
|
# @param json [Object] El payload a serializar como JSON.
|
|
235
|
+
# @param headers [Hash] Headers adicionales opcionales para esta respuesta.
|
|
222
236
|
# @return [Hash] La estructura renderizada interna.
|
|
223
|
-
def render(status:, json: nil)
|
|
237
|
+
def render(status:, json: nil, headers: {})
|
|
224
238
|
code = Rack::Utils::SYMBOL_TO_STATUS_CODE[status] || status.to_i
|
|
225
239
|
code = 200 if code.zero? # Fallback de seguridad
|
|
226
240
|
|
|
227
241
|
@rendered_response = {
|
|
228
242
|
status: code,
|
|
229
|
-
headers: response_headers,
|
|
243
|
+
headers: response_headers.merge(headers),
|
|
230
244
|
body: json
|
|
231
245
|
}
|
|
232
246
|
end
|
|
@@ -267,12 +281,19 @@ module BugBunny
|
|
|
267
281
|
true
|
|
268
282
|
end
|
|
269
283
|
|
|
284
|
+
# Ejecuta secuencialmente todos los after_actions.
|
|
285
|
+
# Solo se invoca si la cadena no fue interrumpida por before_action ni por una excepción.
|
|
286
|
+
def run_after_actions(action_name)
|
|
287
|
+
current_afters = resolve_callbacks(self.class.after_actions, action_name)
|
|
288
|
+
current_afters.uniq.each { |method_name| send(method_name) }
|
|
289
|
+
end
|
|
290
|
+
|
|
270
291
|
# --- LÓGICA DE LOGGING ENCAPSULADA ---
|
|
271
292
|
|
|
272
|
-
def apply_log_tags
|
|
293
|
+
def apply_log_tags(&block)
|
|
273
294
|
tags = compute_tags
|
|
274
295
|
if defined?(Rails) && Rails.logger.respond_to?(:tagged) && tags.any?
|
|
275
|
-
Rails.logger.tagged(*tags)
|
|
296
|
+
Rails.logger.tagged(*tags, &block)
|
|
276
297
|
else
|
|
277
298
|
yield
|
|
278
299
|
end
|
data/lib/bug_bunny/exception.rb
CHANGED
|
@@ -11,6 +11,10 @@ module BugBunny
|
|
|
11
11
|
# Suele envolver excepciones nativas de la gema `bunny` (ej: TCP connection failure).
|
|
12
12
|
class CommunicationError < Error; end
|
|
13
13
|
|
|
14
|
+
# Error lanzado cuando la configuración de la gema es inválida.
|
|
15
|
+
# Se levanta al final de {BugBunny.configure} si algún atributo no pasa las validaciones.
|
|
16
|
+
class ConfigurationError < Error; end
|
|
17
|
+
|
|
14
18
|
# Error lanzado cuando ocurren un acceso no permitido a controladores.
|
|
15
19
|
# Protege contra vulnerabilidades de RCE validando la herencia de las clases enrutadas.
|
|
16
20
|
class SecurityError < Error; end
|
|
@@ -63,7 +67,7 @@ module BugBunny
|
|
|
63
67
|
# Indica que la solicitud fue bien formada pero contenía errores semánticos,
|
|
64
68
|
# típicamente fallos de validación en el modelo remoto (ActiveRecord).
|
|
65
69
|
#
|
|
66
|
-
# Esta excepción es "inteligente": intenta parsear automáticamente el cuerpo
|
|
70
|
+
# Esta excepción es "inteligente": intenta parsear automáticamente el cuerpo
|
|
67
71
|
# de la respuesta para extraer y exponer los mensajes de error de forma estructurada,
|
|
68
72
|
# buscando por convención la clave `errors`.
|
|
69
73
|
class UnprocessableEntity < ClientError
|
|
@@ -35,7 +35,7 @@ module BugBunny
|
|
|
35
35
|
|
|
36
36
|
case status
|
|
37
37
|
when 200..299
|
|
38
|
-
|
|
38
|
+
nil # Flujo normal (Success)
|
|
39
39
|
when 400
|
|
40
40
|
raise BugBunny::BadRequest, format_error_message(body)
|
|
41
41
|
when 404
|
|
@@ -66,12 +66,12 @@ module BugBunny
|
|
|
66
66
|
# @param body [Hash, String, nil] El cuerpo de la respuesta.
|
|
67
67
|
# @return [String] Un mensaje de error limpio y estructurado.
|
|
68
68
|
def format_error_message(body)
|
|
69
|
-
return
|
|
69
|
+
return 'Unknown Error' if body.nil? || (body.respond_to?(:empty?) && body.empty?)
|
|
70
70
|
return body if body.is_a?(String)
|
|
71
71
|
|
|
72
72
|
# Si el worker devolvió un JSON con una key 'error' (nuestra convención en Controller)
|
|
73
73
|
if body.is_a?(Hash) && body['error']
|
|
74
|
-
detail = body['detail'] ? " - #{body['detail']}" :
|
|
74
|
+
detail = body['detail'] ? " - #{body['detail']}" : ''
|
|
75
75
|
"#{body['error']}#{detail}"
|
|
76
76
|
else
|
|
77
77
|
# Fallback: Convertir todo el Hash a JSON string para que se vea claro en Sentry/Logs
|
|
@@ -5,6 +5,28 @@ require 'json'
|
|
|
5
5
|
module BugBunny
|
|
6
6
|
# @api private
|
|
7
7
|
module Observability
|
|
8
|
+
# Patrones de keys que deben ser ocultados en los logs.
|
|
9
|
+
# Se usa substring matching en lowercase para cubrir variantes como
|
|
10
|
+
# "user_password", "accessToken", "X-Authorization", etc.
|
|
11
|
+
# Excluye "pass" y "session" bare para evitar falsos positivos
|
|
12
|
+
# en keys como "passport_number" o "processing_session_count".
|
|
13
|
+
SENSITIVE_KEYS = %w[
|
|
14
|
+
password passwd secret token api_key auth authorization
|
|
15
|
+
credential private_key csrf session_id
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
# Determina si una key es sensible y debe filtrarse en los logs.
|
|
19
|
+
# Accesible como método de módulo para que otros componentes puedan reutilizarlo.
|
|
20
|
+
#
|
|
21
|
+
# @param key [String, Symbol] La clave a evaluar.
|
|
22
|
+
# @return [Boolean] `true` si la key es sensible.
|
|
23
|
+
def self.sensitive_key?(key)
|
|
24
|
+
# Normalize hyphens → underscores so HTTP headers like "X-Api-Key"
|
|
25
|
+
# match the same patterns as Ruby symbol keys like :api_key.
|
|
26
|
+
key_str = key.to_s.downcase.tr('-', '_')
|
|
27
|
+
SENSITIVE_KEYS.any? { |sensitive| key_str.include?(sensitive) }
|
|
28
|
+
end
|
|
29
|
+
|
|
8
30
|
private
|
|
9
31
|
|
|
10
32
|
# Registra un evento estructurado. Nunca eleva excepciones.
|
|
@@ -18,18 +40,18 @@ module BugBunny
|
|
|
18
40
|
fields = { component: observability_name, event: event }.merge(metadata)
|
|
19
41
|
|
|
20
42
|
log_line = fields.map do |k, v|
|
|
21
|
-
val =
|
|
43
|
+
val = BugBunny::Observability.sensitive_key?(k) ? '[FILTERED]' : v
|
|
22
44
|
next if val.nil?
|
|
23
45
|
|
|
24
46
|
formatted = case val
|
|
25
47
|
when Numeric then val
|
|
26
48
|
when Hash
|
|
27
49
|
val.to_json
|
|
28
|
-
when String
|
|
29
|
-
else val.to_s.include?(
|
|
50
|
+
when String then val.include?(' ') ? val.inspect : val
|
|
51
|
+
else val.to_s.include?(' ') ? val.to_s.inspect : val
|
|
30
52
|
end
|
|
31
53
|
"#{k}=#{formatted}"
|
|
32
|
-
end.compact.join(
|
|
54
|
+
end.compact.join(' ')
|
|
33
55
|
|
|
34
56
|
@logger.send(level) { log_line }
|
|
35
57
|
rescue StandardError
|
|
@@ -67,9 +89,9 @@ module BugBunny
|
|
|
67
89
|
# @return [String] Nombre del componente en snake_case.
|
|
68
90
|
def observability_name
|
|
69
91
|
klass = is_a?(Class) ? self : self.class
|
|
70
|
-
klass.name.split(
|
|
92
|
+
klass.name.split('::').first.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
71
93
|
rescue StandardError
|
|
72
|
-
|
|
94
|
+
'unknown'
|
|
73
95
|
end
|
|
74
96
|
end
|
|
75
97
|
end
|
data/lib/bug_bunny/producer.rb
CHANGED
|
@@ -79,18 +79,16 @@ module BugBunny
|
|
|
79
79
|
begin
|
|
80
80
|
fire(request)
|
|
81
81
|
|
|
82
|
-
safe_log(:debug,
|
|
82
|
+
safe_log(:debug, 'producer.rpc_waiting', correlation_id: cid, timeout_s: wait_timeout)
|
|
83
83
|
|
|
84
84
|
# Bloqueamos el hilo aquí hasta que llegue la respuesta o expire el timeout
|
|
85
85
|
result = future.value(wait_timeout)
|
|
86
86
|
|
|
87
|
-
if result.nil?
|
|
88
|
-
raise BugBunny::RequestTimeout, "Timeout waiting for RPC: #{request.path} [#{request.method}]"
|
|
89
|
-
end
|
|
87
|
+
raise BugBunny::RequestTimeout, "Timeout waiting for RPC: #{request.path} [#{request.method}]" if result.nil?
|
|
90
88
|
|
|
91
89
|
BugBunny.configuration.on_rpc_reply&.call(result[:headers])
|
|
92
90
|
|
|
93
|
-
safe_log(:debug,
|
|
91
|
+
safe_log(:debug, 'producer.rpc_response_received', correlation_id: cid)
|
|
94
92
|
|
|
95
93
|
parse_response(result[:body])
|
|
96
94
|
ensure
|
|
@@ -113,12 +111,12 @@ module BugBunny
|
|
|
113
111
|
|
|
114
112
|
# 📊 LOGGING DE OBSERVABILIDAD: Calculamos las opciones finales para mostrarlas en consola
|
|
115
113
|
final_x_opts = BugBunny::Session::DEFAULT_EXCHANGE_OPTIONS
|
|
116
|
-
|
|
117
|
-
|
|
114
|
+
.merge(BugBunny.configuration.exchange_options || {})
|
|
115
|
+
.merge(request.exchange_options || {})
|
|
118
116
|
|
|
119
|
-
safe_log(:info,
|
|
120
|
-
safe_log(:debug,
|
|
121
|
-
safe_log(:debug,
|
|
117
|
+
safe_log(:info, 'producer.publish', method: verb, path: target, routing_key: rk, correlation_id: id)
|
|
118
|
+
safe_log(:debug, 'producer.publish_detail', exchange: request.exchange, exchange_opts: final_x_opts)
|
|
119
|
+
safe_log(:debug, 'producer.publish_payload', payload: payload.truncate(300)) if payload.is_a?(String)
|
|
122
120
|
end
|
|
123
121
|
|
|
124
122
|
# Serializa el mensaje para su transporte.
|
|
@@ -137,7 +135,7 @@ module BugBunny
|
|
|
137
135
|
def parse_response(payload)
|
|
138
136
|
JSON.parse(payload)
|
|
139
137
|
rescue JSON::ParserError
|
|
140
|
-
raise BugBunny::InternalServerError,
|
|
138
|
+
raise BugBunny::InternalServerError, 'Invalid JSON response'
|
|
141
139
|
end
|
|
142
140
|
|
|
143
141
|
# Inicia el consumidor de respuestas RPC de forma perezosa (Lazy Initialization).
|
|
@@ -152,7 +150,7 @@ module BugBunny
|
|
|
152
150
|
@reply_listener_mutex.synchronize do
|
|
153
151
|
return if @reply_listener_started
|
|
154
152
|
|
|
155
|
-
safe_log(:debug,
|
|
153
|
+
safe_log(:debug, 'producer.reply_listener_start')
|
|
156
154
|
|
|
157
155
|
# Consumimos sin ack (auto-ack) porque reply-to no soporta acks manuales de forma estándar
|
|
158
156
|
@session.channel.basic_consume('amq.rabbitmq.reply-to', '', true, false, nil) do |_, props, body|
|
|
@@ -160,7 +158,7 @@ module BugBunny
|
|
|
160
158
|
if (future = @pending_requests[cid])
|
|
161
159
|
future.set({ body: body, headers: props.headers || {} })
|
|
162
160
|
else
|
|
163
|
-
safe_log(:warn,
|
|
161
|
+
safe_log(:warn, 'producer.rpc_response_orphaned', correlation_id: cid)
|
|
164
162
|
end
|
|
165
163
|
end
|
|
166
164
|
@reply_listener_started = true
|
data/lib/bug_bunny/railtie.rb
CHANGED
|
@@ -14,9 +14,7 @@ module BugBunny
|
|
|
14
14
|
# 1. Configuración de Autoload
|
|
15
15
|
initializer 'bug_bunny.add_autoload_paths' do |app|
|
|
16
16
|
rabbit_path = File.join(app.root, 'app', 'rabbit')
|
|
17
|
-
if Dir.exist?(rabbit_path)
|
|
18
|
-
app.config.paths.add 'app/rabbit', eager_load: true
|
|
19
|
-
end
|
|
17
|
+
app.config.paths.add 'app/rabbit', eager_load: true if Dir.exist?(rabbit_path)
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
# 2. Gestión de Forks (Puma / Spring / otros)
|
|
@@ -25,9 +23,7 @@ module BugBunny
|
|
|
25
23
|
# el hijo empiece a trabajar, para evitar compartir el mismo socket TCP.
|
|
26
24
|
config.after_initialize do
|
|
27
25
|
# Estrategia 1: Rails 7.1+ ForkTracker (La forma estándar moderna)
|
|
28
|
-
if defined?(ActiveSupport::ForkTracker)
|
|
29
|
-
ActiveSupport::ForkTracker.after_fork { BugBunny.disconnect }
|
|
30
|
-
end
|
|
26
|
+
ActiveSupport::ForkTracker.after_fork { BugBunny.disconnect } if defined?(ActiveSupport::ForkTracker)
|
|
31
27
|
|
|
32
28
|
# Estrategia 2: Hook específico de Puma (Legacy)
|
|
33
29
|
# Solo intentamos usarlo si la API 'events' está disponible (Puma < 5).
|
|
@@ -38,7 +34,12 @@ module BugBunny
|
|
|
38
34
|
end
|
|
39
35
|
end
|
|
40
36
|
|
|
41
|
-
# 3.
|
|
37
|
+
# 3. Rake tasks (bug_bunny:sync)
|
|
38
|
+
rake_tasks do
|
|
39
|
+
load File.expand_path('../tasks/bug_bunny.rake', __dir__)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# 4. Hook de Spring (Preloader)
|
|
42
43
|
if defined?(Spring)
|
|
43
44
|
Spring.after_fork do
|
|
44
45
|
BugBunny.disconnect
|
data/lib/bug_bunny/request.rb
CHANGED
|
@@ -23,20 +23,11 @@ module BugBunny
|
|
|
23
23
|
# @attr exchange_options [Hash] Opciones específicas para la declaración del Exchange en esta petición.
|
|
24
24
|
# @attr queue_options [Hash] Opciones específicas para la declaración de la Cola en esta petición.
|
|
25
25
|
class Request
|
|
26
|
-
attr_accessor :body
|
|
27
|
-
|
|
28
|
-
attr_accessor :params
|
|
29
|
-
attr_accessor :path
|
|
30
|
-
attr_accessor :method
|
|
31
|
-
attr_accessor :exchange
|
|
32
|
-
attr_accessor :exchange_type
|
|
33
|
-
attr_accessor :routing_key
|
|
34
|
-
attr_accessor :timeout
|
|
35
|
-
attr_accessor :delivery_mode
|
|
26
|
+
attr_accessor :body, :headers, :params, :path, :method, :exchange, :exchange_type, :routing_key, :timeout,
|
|
27
|
+
:delivery_mode, :queue_options
|
|
36
28
|
|
|
37
29
|
# Configuración de Infraestructura Específica
|
|
38
30
|
attr_accessor :exchange_options
|
|
39
|
-
attr_accessor :queue_options
|
|
40
31
|
|
|
41
32
|
# Metadatos AMQP Estándar
|
|
42
33
|
attr_accessor :app_id, :content_type, :content_encoding, :priority,
|
|
@@ -61,6 +52,7 @@ module BugBunny
|
|
|
61
52
|
@exchange_options = {}
|
|
62
53
|
@queue_options = {}
|
|
63
54
|
end
|
|
55
|
+
|
|
64
56
|
# Combina el path con los params como query string.
|
|
65
57
|
#
|
|
66
58
|
# @return [String] El path completo con query string si hay params, o solo el path.
|