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.
- checksums.yaml +4 -4
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +24 -0
- data/CLAUDE.md +1 -1
- data/README.md +47 -6
- data/docs/behavior/behavior.md +37 -9
- data/docs/config/configuracion.md +54 -47
- data/docs/consumed/rabbitmq.md +4 -4
- data/docs/errors/errors.md +16 -10
- data/docs/glossary/glossary.md +2 -2
- data/docs/release/release.md +24 -14
- data/docs/test/testing.md +16 -6
- data/lib/bug_bunny/configuration.rb +22 -2
- data/lib/bug_bunny/consumer.rb +184 -31
- data/lib/bug_bunny/drain_tracker.rb +54 -0
- data/lib/bug_bunny/exception.rb +9 -5
- data/lib/bug_bunny/observability.rb +83 -3
- data/lib/bug_bunny/version.rb +1 -1
- data/lib/bug_bunny.rb +1 -0
- data/skill/SKILL.md +26 -8
- data/skill/references/consumer.md +27 -1
- data/skill/references/errores.md +8 -4
- data/skill/references/routing.md +1 -1
- data/spec/integration/drain_spec.rb +177 -0
- data/spec/unit/configuration_spec.rb +27 -1
- data/spec/unit/drain_tracker_spec.rb +37 -0
- data/spec/unit/observability_spec.rb +158 -0
- metadata +6 -3
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
|
|
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/`,
|
|
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 |
|
|
23
|
-
| **RSpec** | `spec/integration/` | integration |
|
|
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
|
|
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 **
|
|
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
|
|
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?)
|
data/lib/bug_bunny/consumer.rb
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
data/lib/bug_bunny/exception.rb
CHANGED
|
@@ -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`).
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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(' ')
|
data/lib/bug_bunny/version.rb
CHANGED
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'
|