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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/skills/rabbitmq-expert/SKILL.md +1555 -0
  3. data/.claude/commands/gem-ai-setup.md +174 -0
  4. data/.claude/commands/pr.md +53 -0
  5. data/.claude/commands/release.md +52 -0
  6. data/.claude/commands/rubocop.md +22 -0
  7. data/.claude/commands/service-ai-setup.md +168 -0
  8. data/.claude/commands/test.md +28 -0
  9. data/.claude/commands/yard.md +46 -0
  10. data/CHANGELOG.md +50 -15
  11. data/CLAUDE.md +240 -0
  12. data/README.md +154 -221
  13. data/Rakefile +19 -3
  14. data/docs/_index.md +50 -0
  15. data/docs/ai/_index.md +56 -0
  16. data/docs/ai/antipatterns.md +166 -0
  17. data/docs/ai/api.md +251 -0
  18. data/docs/ai/architecture.md +92 -0
  19. data/docs/ai/errors.md +158 -0
  20. data/docs/ai/faq_external.md +133 -0
  21. data/docs/ai/faq_internal.md +86 -0
  22. data/docs/ai/glossary.md +45 -0
  23. data/docs/concepts.md +140 -0
  24. data/docs/howto/controller.md +194 -0
  25. data/docs/howto/middleware_client.md +119 -0
  26. data/docs/howto/middleware_consumer.md +127 -0
  27. data/docs/howto/rails.md +214 -0
  28. data/docs/howto/resource.md +200 -0
  29. data/docs/howto/routing.md +133 -0
  30. data/docs/howto/testing.md +259 -0
  31. data/docs/howto/tracing.md +119 -0
  32. data/lib/bug_bunny/client.rb +45 -21
  33. data/lib/bug_bunny/configuration.rb +63 -0
  34. data/lib/bug_bunny/consumer.rb +51 -37
  35. data/lib/bug_bunny/consumer_middleware.rb +14 -5
  36. data/lib/bug_bunny/controller.rb +39 -18
  37. data/lib/bug_bunny/exception.rb +5 -1
  38. data/lib/bug_bunny/middleware/raise_error.rb +3 -3
  39. data/lib/bug_bunny/observability.rb +28 -6
  40. data/lib/bug_bunny/producer.rb +11 -13
  41. data/lib/bug_bunny/railtie.rb +8 -7
  42. data/lib/bug_bunny/request.rb +3 -11
  43. data/lib/bug_bunny/resource.rb +81 -41
  44. data/lib/bug_bunny/routing/route.rb +6 -1
  45. data/lib/bug_bunny/routing/route_set.rb +60 -22
  46. data/lib/bug_bunny/session.rb +18 -11
  47. data/lib/bug_bunny/version.rb +1 -1
  48. data/lib/bug_bunny.rb +4 -2
  49. data/lib/generators/bug_bunny/install/install_generator.rb +45 -5
  50. data/lib/tasks/bug_bunny.rake +50 -0
  51. data/plan_test.txt +63 -0
  52. data/skills-lock.json +10 -0
  53. data/spec/integration/client_spec.rb +117 -0
  54. data/spec/integration/consumer_middleware_spec.rb +86 -0
  55. data/spec/integration/controller_spec.rb +140 -0
  56. data/spec/integration/error_handling_spec.rb +57 -0
  57. data/spec/integration/infrastructure_spec.rb +52 -0
  58. data/spec/integration/resource_spec.rb +113 -0
  59. data/spec/spec_helper.rb +70 -0
  60. data/spec/support/bunny_mocks.rb +18 -0
  61. data/spec/support/integration_helper.rb +87 -0
  62. data/spec/unit/client_session_pool_spec.rb +159 -0
  63. data/spec/unit/configuration_spec.rb +164 -0
  64. data/spec/unit/consumer_middleware_spec.rb +129 -0
  65. data/spec/unit/consumer_spec.rb +90 -0
  66. data/spec/unit/controller_after_action_spec.rb +155 -0
  67. data/spec/unit/observability_spec.rb +167 -0
  68. data/spec/unit/resource_attributes_spec.rb +69 -0
  69. data/spec/unit/session_spec.rb +98 -0
  70. metadata +50 -3
  71. data/sig/bug_bunny.rbs +0 -4
@@ -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: {}, queue_opts: {}, block: true)
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
- .merge(BugBunny.configuration.exchange_options || {})
78
- .merge(exchange_opts || {})
78
+ .merge(BugBunny.configuration.exchange_options || {})
79
+ .merge(exchange_opts || {})
79
80
  final_q_opts = BugBunny::Session::DEFAULT_QUEUE_OPTIONS
80
- .merge(BugBunny.configuration.queue_options || {})
81
- .merge(queue_opts || {})
81
+ .merge(BugBunny.configuration.queue_options || {})
82
+ .merge(queue_opts || {})
82
83
 
83
- safe_log(:info, "consumer.start", queue: queue_name, queue_opts: final_q_opts)
84
- safe_log(:info, "consumer.bound", exchange: exchange_name, exchange_type: exchange_type, routing_key: routing_key, exchange_opts: final_x_opts)
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, "consumer.reconnect_exhausted", max_attempts_count: max_attempts, **exception_metadata(e))
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 ** (attempt - 1)),
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, "consumer.connection_error", attempt_count: attempt, max_attempts_count: max_attempts || 'infinity', retry_in_s: wait, **exception_metadata(e))
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, "consumer.message_rejected", reason: :missing_type_header)
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, "consumer.message_received", method: http_method, path: path, routing_key: delivery_info.routing_key)
151
- safe_log(:debug, "consumer.message_received_body", body: body.truncate(200))
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, "consumer.route_not_found", method: http_method, path: uri.path)
169
- handle_fatal_error(properties, 404, "Not Found", "No route matches [#{http_method}] \"/#{uri.path}\"")
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
- namespace = BugBunny.configuration.controller_namespace
197
+ base_namespace = route_info[:namespace] || BugBunny.configuration.controller_namespace
181
198
  controller_name = route_info[:controller].camelize
182
- controller_class_name = "#{namespace}::#{controller_name}Controller"
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, "consumer.controller_not_found", controller: controller_class_name)
188
- handle_fatal_error(properties, 404, "Not Found", "Controller #{controller_class_name} not found")
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, "consumer.security_violation", reason: :invalid_controller, controller: controller_class)
196
- handle_fatal_error(properties, 403, "Forbidden", "Invalid Controller Class")
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, "consumer.route_matched", controller: controller_class_name, action: route_info[:action])
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, "consumer.message_processed",
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, "consumer.execution_error", duration_s: duration_s(start_time), **exception_metadata(e))
234
- safe_log(:debug, "consumer.execution_error_backtrace", backtrace: e.backtrace.first(5).join(' | '))
235
- handle_fatal_error(properties, 500, "Internal Server Error", e.message)
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, "consumer.rpc_reply", reply_to: reply_to, correlation_id: correlation_id)
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, "consumer.health_check_failed", queue: q_name, **exception_metadata(e))
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, "consumer.health_check_file_error", path: file_path, **exception_metadata(e))
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 = @middlewares.reverse.inject(terminal) do |next_step, middleware_class|
83
+ chain = snapshot.reverse.inject(terminal) do |next_step, middleware_class|
75
84
  middleware_class.new(next_step)
76
85
  end
77
86
 
@@ -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 = self.rescue_handlers.dup
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
- if respond_to?(action_name)
159
- public_send(action_name)
160
- else
161
- raise NameError, "Action '#{action_name}' not found in #{self.class.name}"
162
- end
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
- lambda { send(method_name, &next_step) }
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, "controller.unhandled_exception", backtrace: exception.backtrace.first(5).join(" | "), **exception_metadata(exception))
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: "Internal Server Error", detail: exception.message, type: exception.class.name }
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) { yield }
296
+ Rails.logger.tagged(*tags, &block)
276
297
  else
277
298
  yield
278
299
  end
@@ -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
- return # Flujo normal (Success)
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 "Unknown Error" if body.nil? || (body.respond_to?(:empty?) && body.empty?)
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 = %i[password token secret api_key auth].include?(k.to_sym) ? "[FILTERED]" : v
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 then val.include?(" ") ? val.inspect : val
29
- else val.to_s.include?(" ") ? val.to_s.inspect : val
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("::").first.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
92
+ klass.name.split('::').first.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
71
93
  rescue StandardError
72
- "unknown"
94
+ 'unknown'
73
95
  end
74
96
  end
75
97
  end
@@ -79,18 +79,16 @@ module BugBunny
79
79
  begin
80
80
  fire(request)
81
81
 
82
- safe_log(:debug, "producer.rpc_waiting", correlation_id: cid, timeout_s: wait_timeout)
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, "producer.rpc_response_received", correlation_id: cid)
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
- .merge(BugBunny.configuration.exchange_options || {})
117
- .merge(request.exchange_options || {})
114
+ .merge(BugBunny.configuration.exchange_options || {})
115
+ .merge(request.exchange_options || {})
118
116
 
119
- safe_log(:info, "producer.publish", method: verb, path: target, routing_key: rk, correlation_id: id)
120
- safe_log(:debug, "producer.publish_detail", exchange: request.exchange, exchange_opts: final_x_opts)
121
- safe_log(:debug, "producer.publish_payload", payload: payload.truncate(300)) if payload.is_a?(String)
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, "Invalid JSON response"
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, "producer.reply_listener_start")
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, "producer.rpc_response_orphaned", correlation_id: cid)
161
+ safe_log(:warn, 'producer.rpc_response_orphaned', correlation_id: cid)
164
162
  end
165
163
  end
166
164
  @reply_listener_started = true
@@ -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. Hook de Spring (Preloader)
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
@@ -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
- attr_accessor :headers
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.