bug_bunny 5.1.0 → 5.2.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.
data/docs/test/testing.md CHANGED
@@ -1,14 +1,15 @@
1
1
  # Test — bug_bunny
2
2
 
3
3
  > meta: artefacto test · RFC-013 · generado `arch-structure` (§a-§d) +
4
- > `arch-enrich` (§e-§h) · anclado a `7bf1da7`, `Rakefile`, `bug_bunny.gemspec`,
4
+ > `arch-enrich` (§e-§h) · anclado a `35b075a` (squash de #65 en `main`; el ancla anterior `7bf1da7` no era ancestro de `main`), `Rakefile`, `bug_bunny.gemspec`,
5
5
  > `spec/spec_helper.rb`, `spec/support/integration_helper.rb`,
6
6
  > `.github/workflows/main.yml`, `CHANGELOG.md` · fecha 2026-07-22 · cobertura:
7
7
  > §a-§d (estructura) + §e-§h (enrich, anclado a specs/CHANGELOG) completas.
8
+ > Incremento 2026-09-28 (#64): conteos, `drain_*_spec` y el gap de aislamiento.
8
9
 
9
10
  ## 1. Resumen
10
11
 
11
- Suite principal **RSpec** (`spec/`, 23 specs: 16 unit + 7 integration). Tarea
12
+ Suite principal **RSpec** (`spec/`, 25 specs: 17 unit + 8 integration). Tarea
12
13
  `:test` legacy de **Minitest** (`test/`, 2 archivos) fuera del default y del CI.
13
14
  CI corre `bundle exec rake` (= `:spec`) en Ruby 3.4.4. Sin coverage tool
14
15
  configurado.
@@ -19,8 +20,8 @@ configurado.
19
20
 
20
21
  | framework | dir | nivel | nº | propósito |
21
22
  |---|---|---|---|---|
22
- | **RSpec** `~> 3.0` | `spec/unit/` | unit | 16 | client/session pool, configuration, consumer, producer, controller, raise_error, remote_error, request, route, observability, otel, resource, middleware, `extra_top_level_params` (hook de params hermanos en `Resource#save`) |
23
- | **RSpec** | `spec/integration/` | integration | 7 | client, consumer_middleware, controller, error_handling, infrastructure, publisher_confirms, resource — **requieren RabbitMQ real** (usan `BugBunny.create_connection` + pool) |
23
+ | **RSpec** `~> 3.0` | `spec/unit/` | unit | 17 | client/session pool, configuration, consumer, `drain_tracker`, producer, controller, raise_error, remote_error, request, route, observability, otel, resource, middleware, `extra_top_level_params` (hook de params hermanos en `Resource#save`) |
24
+ | **RSpec** | `spec/integration/` | integration | 8 | client, consumer_middleware, controller, drain, error_handling, infrastructure, publisher_confirms, resource — **requieren RabbitMQ real** (usan `BugBunny.create_connection` + pool) |
24
25
  | **Minitest** `~> 5.0` (+ `mocha`, `minitest-reporters`) | `test/integration/` | integration (legacy) | 2 | `manual_client_test.rb`, `infrastructure_test.rb` — tarea `:test`, **no** en default ni CI |
25
26
 
26
27
  Sin tags declarados (`:slow`/`:js`) en la config de RSpec.
@@ -58,9 +59,9 @@ umbral de coverage declarado.
58
59
  ### e. Gaps de cobertura
59
60
 
60
61
  - **Integration specs no corren en CI:** `main.yml` no declara servicio RabbitMQ;
61
- las 7 integration specs **se skipean** vía `rabbitmq_available?`
62
+ las 8 integration specs **se skipean** vía `rabbitmq_available?`
62
63
  (`spec/support/integration_helper.rb:14`, ver `publisher_confirms_spec.rb:10`).
63
- En CI solo se ejercitan las **16 unit specs** → el contrato AMQP real (publish/
64
+ En CI solo se ejercitan las **17 unit specs** → el contrato AMQP real (publish/
64
65
  consume/confirms contra broker) **no se valida en pipeline**, solo localmente
65
66
  con broker. Gap relevante.
66
67
  - **Sin medición de cobertura:** no hay SimpleCov ni umbral → la cobertura no está
@@ -75,9 +76,18 @@ umbral de coverage declarado.
75
76
  | **Errores RFC-020** (status→excepción, materia prima) | `raise_error_spec`, `remote_error_spec`, `communication_error_wrapping_spec`, `error_handling_spec` (integration) | **bien cubierto** (unit) |
76
77
  | **Consumed RFC-018** (Bunny::Exception→`CommunicationError`) | `communication_error_wrapping_spec`, `client_session_pool_spec` | cubierto (unit) |
77
78
  | **Config RFC-012** (validaciones de `Configuration`) | `configuration_spec` | cubierto |
79
+ | **Consumer#drain** (drenar y salir, #64) | `drain_tracker_spec` (unit, reloj inyectado), `drain_spec` (integration) | cubierto; la parte integration skipea sin broker. Validado por mutación: quitar la espera, el retorno temprano o el chequeo de `busy?` hace fallar el test correspondiente |
78
80
  | **Confirms** (`PublishNacked`/`PublishUnroutable`) | `producer_spec`, `publisher_confirms_spec` (integration) | parcial en CI (la parte integration skipea sin broker) |
79
81
  | **Operaciones/routing** (RFC-003, capa F2) | `route_spec`, `request_spec`, `controller_spec`, `controller_after_action_spec`, `resource_spec` | cubierto (unit) |
80
82
 
83
+ - **Aislamiento entre specs — corregido 2026-09-28 (#64):** el `after` de
84
+ `configuration_spec` reemplazaba la configuración global por una con defaults
85
+ (`guest`), así que los specs de integración que corrían después se conectaban
86
+ como `guest` y se **skipeaban como "RabbitMQ no disponible" aun con broker**.
87
+ Cuántos, dependía del seed (11 en `main` con `--seed 1`). Ahora un `around`
88
+ restaura la configuración original (`spec/unit/configuration_spec.rb:14-22`);
89
+ medido con broker local: 311 examples, 0 failures, 0 pending en los seeds 1, 2 y 3.
90
+
81
91
  ### g. Link a incidente → test de regresión
82
92
 
83
93
  | incidente | test de regresión | ancla |
@@ -21,7 +21,7 @@ module BugBunny
21
21
  # Claves soportadas:
22
22
  # - `:type` — clase que debe responder `is_a?`
23
23
  # - `:required` — si `true`, nil o string vacío lanzan ConfigurationError
24
- # - `:range` — rango válido de valores (solo para Integer)
24
+ # - `:range` — rango válido de valores (solo para numéricos)
25
25
  VALIDATIONS = {
26
26
  host: { type: String, required: true },
27
27
  port: { type: Integer, required: true, range: 1..65_535 },
@@ -33,7 +33,9 @@ module BugBunny
33
33
  read_timeout: { type: Integer, range: 1..300 },
34
34
  write_timeout: { type: Integer, range: 1..300 },
35
35
  rpc_timeout: { type: Integer, range: 1..3_600 },
36
- channel_prefetch: { type: Integer, range: 1..10_000 }
36
+ channel_prefetch: { type: Integer, range: 1..10_000 },
37
+ drain_idle_timeout: { type: Integer, range: 1..3_600 },
38
+ drain_poll_interval: { type: Numeric, range: 0.01..10 }
37
39
  }.freeze
38
40
 
39
41
  # @return [String] Host o IP del servidor RabbitMQ (ej: 'localhost').
@@ -94,6 +96,14 @@ module BugBunny
94
96
  # @return [Integer] Intervalo en segundos para verificar la salud de la cola.
95
97
  attr_accessor :health_check_interval
96
98
 
99
+ # @return [Integer] Segundos sin entregas tras los cuales {BugBunny::Consumer#drain}
100
+ # da la cola por vacía y retorna (default: 5).
101
+ attr_accessor :drain_idle_timeout
102
+
103
+ # @return [Numeric] Cada cuántos segundos {BugBunny::Consumer#drain} revisa si se
104
+ # cumplió la ventana de inactividad (default: 0.1).
105
+ attr_accessor :drain_poll_interval
106
+
97
107
  # @return [String, nil] Ruta del archivo que se actualizará (touch) en cada health check exitoso.
98
108
  # Ideal para sondas (probes) de orquestadores como Docker Swarm o Kubernetes.
99
109
  # Si es `nil`, la funcionalidad de touchfile se desactiva.
@@ -217,6 +227,7 @@ module BugBunny
217
227
 
218
228
  @consumer_middlewares = ConsumerMiddleware::Stack.new
219
229
  init_callback_defaults
230
+ init_drain_defaults
220
231
  end
221
232
 
222
233
  # Construye la URL de conexión AMQP basada en los atributos configurados.
@@ -255,6 +266,15 @@ module BugBunny
255
266
  @return_raise = true
256
267
  end
257
268
 
269
+ # Defaults de {BugBunny::Consumer#drain}.
270
+ # Extraído de {#initialize} para mantener el ABC size dentro de los límites.
271
+ #
272
+ # @return [void]
273
+ def init_drain_defaults
274
+ @drain_idle_timeout = 5
275
+ @drain_poll_interval = 0.1
276
+ end
277
+
258
278
  def validate_required!(attr, value, rules)
259
279
  return unless rules[:required]
260
280
  return unless value.nil? || (value.is_a?(String) && value.empty?)
@@ -41,6 +41,16 @@ module BugBunny
41
41
  new(connection).subscribe(**args)
42
42
  end
43
43
 
44
+ # Método de conveniencia para instanciar y drenar en un solo paso.
45
+ #
46
+ # @param connection [Bunny::Session] Una conexión TCP activa a RabbitMQ. Es de quien
47
+ # llama: `drain` no la cierra (ver {#drain}).
48
+ # @param args [Hash] Argumentos que se pasarán al método {#drain}.
49
+ # @return [Integer] Cantidad de mensajes procesados.
50
+ def self.drain(connection:, **args)
51
+ new(connection).drain(**args)
52
+ end
53
+
44
54
  # Inicializa un nuevo consumidor.
45
55
  #
46
56
  # @param connection [Bunny::Session] Conexión nativa de Bunny.
@@ -68,40 +78,14 @@ module BugBunny
68
78
  attempt = 0
69
79
 
70
80
  begin
71
- # Declaración de Infraestructura
72
- x = session.exchange(name: exchange_name, type: exchange_type, opts: exchange_opts)
73
- q = session.queue(queue_name, queue_opts)
74
- q.bind(x, routing_key: routing_key)
75
-
76
- # 📊 LOGGING DE OBSERVABILIDAD: Calculamos las opciones finales para mostrarlas en consola
77
- final_x_opts = BugBunny::Session::DEFAULT_EXCHANGE_OPTIONS
78
- .merge(BugBunny.configuration.exchange_options || {})
79
- .merge(exchange_opts || {})
80
- final_q_opts = BugBunny::Session::DEFAULT_QUEUE_OPTIONS
81
- .merge(BugBunny.configuration.queue_options || {})
82
- .merge(queue_opts || {})
83
-
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)
81
+ queue = declare_infrastructure(queue_name: queue_name, exchange_name: exchange_name,
82
+ routing_key: routing_key, exchange_type: exchange_type,
83
+ exchange_opts: exchange_opts, queue_opts: queue_opts)
87
84
 
88
85
  start_health_check(queue_name)
89
86
 
90
- q.subscribe(manual_ack: true, block: block) do |delivery_info, properties, body|
91
- trace_id = properties.correlation_id
92
- logger = BugBunny.configuration.logger
93
-
94
- core = lambda {
95
- if logger.respond_to?(:tagged)
96
- logger.tagged(trace_id) { process_message(delivery_info, properties, body) }
97
- elsif defined?(Rails) && Rails.logger.respond_to?(:tagged)
98
- Rails.logger.tagged(trace_id) { process_message(delivery_info, properties, body) }
99
- else
100
- process_message(delivery_info, properties, body)
101
- end
102
- }
103
-
104
- BugBunny.configuration.consumer_middlewares.call(delivery_info, properties, body, &core)
87
+ queue.subscribe(manual_ack: true, block: block) do |delivery_info, properties, body|
88
+ handle_delivery(delivery_info, properties, body)
105
89
  end
106
90
  rescue StandardError => e
107
91
  attempt += 1
@@ -126,6 +110,71 @@ module BugBunny
126
110
  shutdown
127
111
  end
128
112
 
113
+ # Consume la cola hasta vaciarla y retorna: el modo para correr un consumidor como job
114
+ # (Sidekiq, un Job de k8s) en vez de como un proceso eterno.
115
+ #
116
+ # A diferencia de `subscribe(block: false)`, que retorna al instante, este método
117
+ # bloquea mientras haya mensajes y vuelve cuando la cola queda quieta:
118
+ #
119
+ # 1. Si la cola tiene 0 mensajes al arrancar, retorna `0` sin esperar.
120
+ # 2. Si hay mensajes, se suscribe con `manual_ack: true` respetando `channel_prefetch`,
121
+ # igual que el modo bloqueante.
122
+ # 3. Termina cuando pasaron `drain_idle_timeout` segundos (ver {Configuration}) sin
123
+ # entregas y no queda ningún mensaje en proceso. Cancela el consumer y cierra el
124
+ # canal ({#shutdown}).
125
+ #
126
+ # **Mensajes que llegan mientras drena:** un mensaje que llega antes de que venza la
127
+ # ventana de inactividad se procesa en esta vuelta; lo que llega después queda para
128
+ # la próxima corrida. Una entrega ya recibida cuando se cancela se procesa antes de
129
+ # volver (Bunny drena su work pool al cancelar); si igual no llegara a ack-earse, vuelve
130
+ # a la cola (at-least-once, nunca se pierde).
131
+ #
132
+ # **Con un flujo sostenido, no retorna.** Si los mensajes llegan más seguido que
133
+ # `drain_idle_timeout`, la ventana nunca vence: la duración la decide el productor, no
134
+ # la cola que había al arrancar. Acotalo desde afuera (el timeout del job de Sidekiq,
135
+ # `activeDeadlineSeconds` en k8s), sabiendo que cortarlo a mitad de una entrega da
136
+ # redelivery.
137
+ #
138
+ # **Una entrega que falla sale de la cola.** Si un middleware o el manejo de error
139
+ # levanta antes del ack, se rechaza sin requeue, igual que los errores de
140
+ # `process_message`; no queda ocupando el prefetch.
141
+ #
142
+ # No tiene loop de reconexión ni health check: un job que falla lo reintenta el
143
+ # framework que lo corre. (Bunny sí recupera la conexión por su cuenta si
144
+ # `automatically_recover` está activo.)
145
+ #
146
+ # **La conexión es de quien llama:** `drain` cierra su canal al volver, no la conexión,
147
+ # porque puede ser compartida (un pool, la del publisher). Si la creaste para esta
148
+ # corrida, cerrala vos (ver el README).
149
+ #
150
+ # @param queue_name [String] Nombre de la cola a drenar.
151
+ # @param exchange_name [String] Nombre del exchange al cual enlazar la cola.
152
+ # @param routing_key [String] Patrón de enrutamiento (ej: 'users.*').
153
+ # @param exchange_type [String] Tipo de exchange ('direct', 'topic', 'fanout').
154
+ # @param exchange_opts [Hash] Opciones adicionales para el exchange (durable, auto_delete).
155
+ # @param queue_opts [Hash] Opciones adicionales para la cola (durable, auto_delete).
156
+ # @return [Integer] Cantidad de mensajes procesados en esta vuelta (incluye los rechazados:
157
+ # también salieron de la cola).
158
+ def drain(queue_name:, exchange_name:, routing_key:, exchange_type: 'direct', exchange_opts: {},
159
+ queue_opts: {})
160
+ started_at = monotonic_now
161
+ queue = declare_infrastructure(queue_name: queue_name, exchange_name: exchange_name,
162
+ routing_key: routing_key, exchange_type: exchange_type,
163
+ exchange_opts: exchange_opts, queue_opts: queue_opts)
164
+
165
+ pending_count = queue.message_count
166
+ safe_log(:info, 'consumer.drain_start', queue: queue_name, pending_count: pending_count)
167
+ return 0 if pending_count.zero?
168
+
169
+ processed_count = consume_until_idle(queue)
170
+
171
+ safe_log(:info, 'consumer.drain_finished', queue: queue_name, processed_count: processed_count,
172
+ duration_s: (monotonic_now - started_at).round(3))
173
+ processed_count
174
+ ensure
175
+ shutdown
176
+ end
177
+
129
178
  # Detiene el health check timer y cierra el canal de forma ordenada.
130
179
  #
131
180
  # Llamar explícitamente al hacer shutdown del worker (SIGTERM, at_exit, etc.).
@@ -141,6 +190,110 @@ module BugBunny
141
190
 
142
191
  private
143
192
 
193
+ # Declara exchange y cola, los enlaza y loguea las opciones efectivas.
194
+ #
195
+ # @param queue_name [String] Nombre de la cola.
196
+ # @param exchange_name [String] Nombre del exchange.
197
+ # @param routing_key [String] Patrón de enrutamiento del binding.
198
+ # @param exchange_type [String] Tipo de exchange.
199
+ # @param exchange_opts [Hash] Opciones del exchange para esta llamada.
200
+ # @param queue_opts [Hash] Opciones de la cola para esta llamada.
201
+ # @return [Bunny::Queue] La cola declarada y enlazada.
202
+ def declare_infrastructure(queue_name:, exchange_name:, routing_key:, exchange_type:, exchange_opts:, queue_opts:)
203
+ exchange = session.exchange(name: exchange_name, type: exchange_type, opts: exchange_opts)
204
+ queue = session.queue(queue_name, queue_opts)
205
+ queue.bind(exchange, routing_key: routing_key)
206
+
207
+ # 📊 LOGGING DE OBSERVABILIDAD: Calculamos las opciones finales para mostrarlas en consola
208
+ effective_exchange_opts = BugBunny::Session::DEFAULT_EXCHANGE_OPTIONS
209
+ .merge(BugBunny.configuration.exchange_options || {})
210
+ .merge(exchange_opts || {})
211
+ effective_queue_opts = BugBunny::Session::DEFAULT_QUEUE_OPTIONS
212
+ .merge(BugBunny.configuration.queue_options || {})
213
+ .merge(queue_opts || {})
214
+
215
+ safe_log(:info, 'consumer.start', queue: queue_name, queue_opts: effective_queue_opts)
216
+ safe_log(:info, 'consumer.bound', exchange: exchange_name, exchange_type: exchange_type,
217
+ routing_key: routing_key, exchange_opts: effective_exchange_opts)
218
+ queue
219
+ end
220
+
221
+ # Pasa una entrega por los middlewares y el logger con tags, y la procesa.
222
+ #
223
+ # @param delivery_info [Bunny::DeliveryInfo, Bunny::GetResponse] Metadatos de entrega.
224
+ # @param properties [Bunny::MessageProperties] Headers y propiedades AMQP.
225
+ # @param body [String] El payload crudo del mensaje.
226
+ # @return [void]
227
+ def handle_delivery(delivery_info, properties, body)
228
+ settled = false
229
+
230
+ core = lambda {
231
+ with_log_tags(properties.correlation_id) { process_message(delivery_info, properties, body) }
232
+ settled = true
233
+ }
234
+
235
+ BugBunny.configuration.consumer_middlewares.call(delivery_info, properties, body, &core)
236
+ rescue StandardError => e
237
+ settle_failed_delivery(delivery_info, settled, e)
238
+ end
239
+
240
+ # Corre el bloque con el `correlation_id` como tag del logger, si el logger los soporta.
241
+ #
242
+ # @param trace_id [String, nil]
243
+ # @return [Object] lo que devuelva el bloque
244
+ def with_log_tags(trace_id, &block)
245
+ logger = BugBunny.configuration.logger
246
+ if logger.respond_to?(:tagged)
247
+ logger.tagged(trace_id, &block)
248
+ elsif defined?(Rails) && Rails.logger.respond_to?(:tagged)
249
+ Rails.logger.tagged(trace_id, &block)
250
+ else
251
+ yield
252
+ end
253
+ end
254
+
255
+ # Una entrega cuyo procesamiento levantó FUERA del rescue de `process_message` (un
256
+ # middleware, o `handle_fatal_error`) quedaba sin ack ni reject: con prefetch 1 ocupaba
257
+ # el único lugar, el broker dejaba de entregar y `drain` volvía "con éxito" con la cola
258
+ # llena. Se rechaza sin requeue, igual que `process_message` hace con sus propios
259
+ # errores. Si ya se había resuelto (el error vino después del ack), sólo se loguea:
260
+ # rechazar un tag ya confirmado cierra el canal.
261
+ #
262
+ # @param delivery_info [Bunny::DeliveryInfo, Bunny::GetResponse] Metadatos de entrega.
263
+ # @param settled [Boolean] si `process_message` ya hizo ack o reject.
264
+ # @param error [StandardError] lo que levantó.
265
+ # @return [void]
266
+ def settle_failed_delivery(delivery_info, settled, error)
267
+ safe_log(:error, 'consumer.delivery_failed', settled: settled, **exception_metadata(error))
268
+ session.channel.reject(delivery_info.delivery_tag, false) unless settled
269
+ end
270
+
271
+ # Se suscribe sin bloquear y espera a que la cola quede quieta `drain_idle_timeout`
272
+ # segundos sin ningún mensaje en proceso; después cancela la suscripción.
273
+ #
274
+ # @param queue [Bunny::Queue] La cola ya declarada y enlazada.
275
+ # @return [Integer] Cantidad de mensajes procesados.
276
+ def consume_until_idle(queue)
277
+ idle_timeout = BugBunny.configuration.drain_idle_timeout
278
+ poll_interval = BugBunny.configuration.drain_poll_interval
279
+ tracker = BugBunny::DrainTracker.new
280
+
281
+ subscription = queue.subscribe(manual_ack: true, block: false) do |delivery_info, properties, body|
282
+ tracker.track { handle_delivery(delivery_info, properties, body) }
283
+ end
284
+
285
+ sleep poll_interval until tracker.idle?(idle_timeout)
286
+
287
+ subscription.cancel
288
+ sleep poll_interval while tracker.busy?
289
+ tracker.processed
290
+ end
291
+
292
+ # @return [Float] Reloj monotónico en segundos.
293
+ def monotonic_now
294
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
295
+ end
296
+
144
297
  # Procesa un mensaje individual recibido de la cola orquestando el ruteo declarativo.
145
298
  #
146
299
  # Realiza la orquestación completa: Parsing -> Reconocimiento de Ruta -> Ejecución -> Respuesta.
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+
5
+ module BugBunny
6
+ # Lleva la cuenta de un drenaje de {Consumer#drain}: cuántos mensajes se procesaron,
7
+ # cuántos están en proceso y cuándo fue la última actividad.
8
+ #
9
+ # Es thread-safe: las entregas corren en el work pool de Bunny y la espera en el hilo
10
+ # que llamó a `drain`.
11
+ #
12
+ # @api private
13
+ class DrainTracker
14
+ # @param clock [#call] Reloj monotónico en segundos (inyectable para tests).
15
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
16
+ @clock = clock
17
+ @processed = Concurrent::AtomicFixnum.new(0)
18
+ @in_flight = Concurrent::AtomicFixnum.new(0)
19
+ @last_activity = Concurrent::AtomicReference.new(clock.call)
20
+ end
21
+
22
+ # Envuelve el procesamiento de una entrega. Cuenta la entrega como procesada aunque
23
+ # el bloque levante: {Consumer#handle_delivery} rechaza la que falla antes del ack, así
24
+ # que en todos los caminos el mensaje ya salió de la cola (ack o reject).
25
+ #
26
+ # @yield El procesamiento de la entrega.
27
+ # @return [void]
28
+ def track
29
+ @in_flight.increment
30
+ yield
31
+ ensure
32
+ @processed.increment
33
+ @last_activity.set(@clock.call)
34
+ @in_flight.decrement
35
+ end
36
+
37
+ # @return [Boolean] `true` si hay alguna entrega en proceso.
38
+ def busy?
39
+ @in_flight.value.positive?
40
+ end
41
+
42
+ # @param idle_timeout [Numeric] Segundos sin actividad.
43
+ # @return [Boolean] `true` si no hay nada en proceso y pasaron `idle_timeout` segundos
44
+ # desde la última actividad.
45
+ def idle?(idle_timeout)
46
+ !busy? && @clock.call - @last_activity.get >= idle_timeout
47
+ end
48
+
49
+ # @return [Integer] Entregas procesadas hasta ahora.
50
+ def processed
51
+ @processed.value
52
+ end
53
+ end
54
+ end
@@ -23,11 +23,15 @@ module BugBunny
23
23
  # respuesta RPC (ej: {CommunicationError}, {ConfigurationError}).
24
24
  #
25
25
  # @note **No loguear ni enviar a sinks (Sentry/logs) sin sanitizar.** El
26
- # cuerpo crudo puede contener datos sensibles (p. ej. en `details`). Antes
27
- # de cualquier sink, filtrar las claves sensibles del fleet
28
- # (`password|pass|passwd|secret|token|api_key|auth`) → `[FILTERED]`. La
29
- # gema entrega el cuerpo crudo a propósito; sanitizarlo es responsabilidad
30
- # del consumidor.
26
+ # cuerpo crudo puede contener datos sensibles (p. ej. en `details`). La
27
+ # lista canónica de claves sensibles es
28
+ # {BugBunny::Observability::SENSITIVE_KEYS} — no la repliques acá ni en el
29
+ # consumidor: se desincroniza (ojo que `pass` bare NO está en la lista, a
30
+ # propósito, para no filtrar `passport_number`). Para sanear, reusá
31
+ # {BugBunny::Observability.sensitive_key?} (filtra por NOMBRE de clave) y
32
+ # {BugBunny::Observability.redact_structure} (recorre la estructura y
33
+ # además redacta credenciales embebidas en el VALOR). La gema entrega el
34
+ # cuerpo crudo a propósito; sanitizarlo es responsabilidad del consumidor.
31
35
  attr_accessor :raw_response
32
36
 
33
37
  # @return [Integer, nil] El código de estado de la respuesta que originó el
@@ -27,6 +27,81 @@ module BugBunny
27
27
  SENSITIVE_KEYS.any? { |sensitive| key_str.include?(sensitive) }
28
28
  end
29
29
 
30
+ # Alternación de keys sensibles ordenada de más larga a más corta: en un regex
31
+ # la alternación matchea leftmost-first, así que sin este orden `auth` ganaría
32
+ # sobre `authorization` y el patrón dejaría de matchear (`orization=x` no sigue
33
+ # con `[:=]`).
34
+ SENSITIVE_KEYS_ALTERNATION = SENSITIVE_KEYS.sort_by { |k| -k.length }.join('|').freeze
35
+
36
+ # Reglas de VALOR sensible, como pares `[regex, reemplazo]`.
37
+ #
38
+ # {.sensitive_key?} solo ve el NOMBRE de la clave; no puede ver una credencial
39
+ # embebida en TEXTO LIBRE. El caso canónico es el `message` de una excepción
40
+ # inesperada (llega como `reason=` o `error_message=`, nombres no sensibles):
41
+ # un `NoMethodError` sobre un objeto de respuesta HTTP puede arrastrar
42
+ # `Authorization: "Bearer eyJ..."` en su mensaje y el filtro por-clave lo deja
43
+ # pasar entero al log.
44
+ #
45
+ # El reemplazo conserva el nombre de la clave cuando viaja dentro del texto
46
+ # (`token=[FILTERED]`, no `[FILTERED]`): saber QUÉ credencial apareció es
47
+ # diagnóstico útil; su valor no.
48
+ SENSITIVE_VALUE_RULES = [
49
+ # Esquemas de autenticación HTTP: "Bearer <jwt>", "Basic <base64>".
50
+ [/\b(?:bearer|basic)\s+[A-Za-z0-9\-._~+\/]{8,}={0,2}/i, '[FILTERED]'],
51
+ # La key viaja DENTRO del texto: `token=abc`, `password: 'x'`, `"api_key" => "y"`.
52
+ #
53
+ # El prefijo `\w*` va en lugar de un `\b`: `_` es word-char, así que un borde de
54
+ # palabra NO existe dentro de `access_token` ni de `accessToken` y esas variantes
55
+ # se colarían en claro — justo las que {.sensitive_key?} cubre a propósito con
56
+ # substring matching. Se captura el prefijo para conservar el nombre COMPLETO de la
57
+ # key en el log (`access_token=[FILTERED]`): saber qué credencial apareció es
58
+ # diagnóstico útil. No reintroduce el falso positivo de `passport_number` porque
59
+ # ninguna key de SENSITIVE_KEYS es substring suyo (por eso `pass` bare está excluida).
60
+ [/(\w*(?:#{SENSITIVE_KEYS_ALTERNATION}))["']?\s*(?:=>|[:=])\s*["']?[^\s,;"'}\])]+/i,
61
+ '\1=[FILTERED]'],
62
+ # Credenciales en una URL: `amqp://user:pass@host` → conserva el esquema y el host.
63
+ [%r{(://)[^\s/:@]+:[^\s/@]+@}, '\1[FILTERED]@']
64
+ ].freeze
65
+
66
+ # Redacta credenciales embebidas en un valor de texto libre.
67
+ #
68
+ # Complementa a {.sensitive_key?}: esa filtra por NOMBRE de clave, esta por
69
+ # CONTENIDO. Se aplica a todo valor no numérico que {#safe_log} serializa.
70
+ #
71
+ # @param value [Object] El valor a redactar (se serializa con `to_s`).
72
+ # @return [String] El valor con las credenciales reemplazadas por `[FILTERED]`.
73
+ def self.redact_value(value)
74
+ SENSITIVE_VALUE_RULES.reduce(value.to_s) do |acc, (pattern, replacement)|
75
+ acc.gsub(pattern, replacement)
76
+ end
77
+ end
78
+
79
+ # Redacta una estructura ANTES de serializarla, recorriendo keys y valores.
80
+ #
81
+ # Se usa para los valores `Hash` de {#safe_log}. Redactar el JSON ya serializado con
82
+ # {.redact_value} no sirve: la regla de key-dentro-del-texto normaliza el separador a
83
+ # `=` y se come la comilla de cierre de la key, dejando un objeto donde el par
84
+ # `"token": "abc"` quedó colapsado en `"token=[FILTERED]"` — el secreto desaparece,
85
+ # pero el campo deja de ser JSON parseable y quien consume el log pierde el objeto
86
+ # entero, no solo el valor redactado.
87
+ #
88
+ # Recorriendo la estructura, además, las keys internas SÍ pasan por {.sensitive_key?}
89
+ # (que solo veía las keys de primer nivel del metadata).
90
+ #
91
+ # @param obj [Object] Estructura a redactar (Hash/Array anidados incluidos).
92
+ # @return [Object] La misma forma, con los valores sensibles reemplazados.
93
+ def self.redact_structure(obj)
94
+ case obj
95
+ when Hash
96
+ obj.each_with_object({}) do |(k, v), acc|
97
+ acc[k] = sensitive_key?(k) ? '[FILTERED]' : redact_structure(v)
98
+ end
99
+ when Array then obj.map { |element| redact_structure(element) }
100
+ when Numeric, TrueClass, FalseClass, NilClass then obj
101
+ else redact_value(obj)
102
+ end
103
+ end
104
+
30
105
  private
31
106
 
32
107
  # Registra un evento estructurado. Nunca eleva excepciones.
@@ -43,12 +118,17 @@ module BugBunny
43
118
  val = BugBunny::Observability.sensitive_key?(k) ? '[FILTERED]' : v
44
119
  next if val.nil?
45
120
 
121
+ # La redacción por CONTENIDO se aplica a todo valor no numérico: el filtro
122
+ # por-clave de arriba no ve una credencial embebida en texto libre.
46
123
  formatted = case val
47
124
  when Numeric then val
48
125
  when Hash
49
- val.to_json
50
- when String then val.include?(' ') ? val.inspect : val
51
- else val.to_s.include?(' ') ? val.to_s.inspect : val
126
+ # Se redacta la estructura y DESPUÉS se serializa: al revés el campo
127
+ # queda con el secreto tapado pero el JSON roto (ver .redact_structure).
128
+ BugBunny::Observability.redact_structure(val).to_json
129
+ else
130
+ redacted = BugBunny::Observability.redact_value(val)
131
+ redacted.include?(' ') ? redacted.inspect : redacted
52
132
  end
53
133
  "#{k}=#{formatted}"
54
134
  end.compact.join(' ')
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = '5.1.0'
4
+ VERSION = '5.2.0'
5
5
  end
data/lib/bug_bunny.rb CHANGED
@@ -16,6 +16,7 @@ require_relative 'bug_bunny/middleware/raise_error'
16
16
  require_relative 'bug_bunny/middleware/json_response'
17
17
  require_relative 'bug_bunny/client'
18
18
  require_relative 'bug_bunny/session'
19
+ require_relative 'bug_bunny/drain_tracker'
19
20
  require_relative 'bug_bunny/consumer'
20
21
  require_relative 'bug_bunny/request'
21
22
  require_relative 'bug_bunny/producer'