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/skill/SKILL.md
CHANGED
|
@@ -19,7 +19,11 @@ Gema Ruby: capa de routing RESTful sobre AMQP/RabbitMQ. Microservicios se comuni
|
|
|
19
19
|
|
|
20
20
|
## Contrato resumido (piso mínimo)
|
|
21
21
|
|
|
22
|
-
> Resume el contrato de **`bug_bunny`
|
|
22
|
+
> Resume el contrato de **`bug_bunny` 5.2.0** (anclado a `v5.2.0`). Suficiente para el uso típico sin abrir el detalle; el detalle version-locked está en el **Índice de artefactos** de abajo. Antipatrones/API completa: más abajo (embebido interim, ver Cobertura y fronteras).
|
|
23
|
+
>
|
|
24
|
+
> **Si venís de 5.1.x, un cambio de conducta (5.2.0):** una entrega cuyo middleware —o `handle_fatal_error`— levanta **antes del ack** ahora se **rechaza sin requeue** (evento `consumer.delivery_failed`), en `subscribe` y en `drain`. Antes quedaba sin ack ni reject y trababa el consumidor. Si un middleware levantaba a propósito para que el mensaje se reintentara, ahora el mensaje se pierde: el reintento es tuyo. Detalle en [`docs/behavior/behavior.md`](../docs/behavior/behavior.md).
|
|
25
|
+
>
|
|
26
|
+
> **Si venís de 4.x, dos breaking a mirar antes de subir:** `5.0.0` eliminó la constante pública `BugBunny::SecurityError` (un `rescue BugBunny::SecurityError` que sobreviva revienta con `NameError` y **enmascara la excepción original**) y `4.18.0` cambió el wrapping `Bunny::Exception` → `CommunicationError`. Detalle en `CHANGELOG.md`.
|
|
23
27
|
|
|
24
28
|
**Símbolos públicos clave**
|
|
25
29
|
|
|
@@ -27,7 +31,7 @@ Gema Ruby: capa de routing RESTful sobre AMQP/RabbitMQ. Microservicios se comuni
|
|
|
27
31
|
|---|---|
|
|
28
32
|
| `BugBunny::Client` | `client.request(url, method: :get)` (RPC sync) · `client.publish(url, body:)` (fire-and-forget, 202) · `client.publish(url, confirmed: true, mandatory: true)` (publisher confirms) |
|
|
29
33
|
| `BugBunny::Resource` | ORM tipo AR: `self.exchange=` / `self.resource_name=` / `connection_pool=`; `find/where/create/save/destroy` |
|
|
30
|
-
| `BugBunny::Consumer` | `BugBunny::Consumer.subscribe(connection
|
|
34
|
+
| `BugBunny::Consumer` | `BugBunny::Consumer.subscribe(connection:, queue_name:, exchange_name:, routing_key:)` (loop bloqueante) · `BugBunny::Consumer.drain(...)` (mismos args; consume hasta que la cola queda quieta y retorna cuántos procesó — para correrlo como job, 5.2.0) |
|
|
31
35
|
| `BugBunny::Controller` | `before/around/after_action`, `rescue_from`, `render status:, json:` |
|
|
32
36
|
| `BugBunny.routes.draw` | `resources :x` · `namespace` · `member`/`collection` |
|
|
33
37
|
| `BugBunny.configure` | `host/port/username/password` · `rpc_timeout` (default 10) · `nack_raise`/`return_raise` (default `true`) · `on_return` |
|
|
@@ -54,18 +58,26 @@ client.publish('events', body: { type: 'x' }) # => { 'status' => 202 }
|
|
|
54
58
|
- `confirmed:true + mandatory:true` con `return_raise` (default `true`) → `PublishUnroutable` si no rutea.
|
|
55
59
|
- `BugBunny::Consumer.subscribe` requiere `connection:`. No correr el Consumer en threads de Puma (loop bloqueante).
|
|
56
60
|
- `exchange_options: { durable: true }` debe matchear la declaración del consumer, o `Bunny::PreconditionFailed`.
|
|
61
|
+
- **`drain` (5.2.0):** la conexión es **de quien llama** — `drain` cierra su canal, no la conexión; si la creás por corrida, cerrala (`ensure connection.close`) o cada corrida deja una abierta. Con un flujo sostenido más rápido que `drain_idle_timeout` **no retorna**: acotalo desde el job. Detalle en [`skill/references/consumer.md`](references/consumer.md) y [`docs/behavior/behavior.md`](../docs/behavior/behavior.md).
|
|
57
62
|
- **Errores de transporte (4.18+):** TCP fail, conn rota, canal cerrado → siempre `BugBunny::CommunicationError`. No rescatar `Bunny::TCPConnectionFailed`/`ConnectionClosedError` directo — quedó atrás de la frontera. La original sigue accesible vía `.cause`.
|
|
58
63
|
|
|
59
64
|
## Índice de artefactos (fuente de verdad)
|
|
60
65
|
|
|
61
|
-
El detalle vive en `docs/<capa>/` (modelo `dev-*`); esta skill **indexa y resume**, no duplica. Links relativos = version-locked (mismo tag del release
|
|
66
|
+
El detalle vive en `docs/<capa>/` (modelo `dev-*`); esta skill **indexa y resume**, no duplica. Links relativos = version-locked (mismo tag del release, `v5.2.0`; `gemspec.files` incluye `docs/**`, así que estos archivos viajan dentro del `.gem` que ya tenés instalado).
|
|
62
67
|
|
|
63
68
|
| Capa | Artefacto | Estado |
|
|
64
69
|
|---|---|---|
|
|
65
70
|
| Glosario de dominio | [docs/glossary/glossary.md](../docs/glossary/glossary.md) | parcial, acreta por PR |
|
|
66
71
|
| Comportamiento (flujos) | [docs/behavior/behavior.md](../docs/behavior/behavior.md) | completa — 6 flujos |
|
|
72
|
+
| Configuración | [docs/config/configuracion.md](../docs/config/configuracion.md) | §a-§e/§i estructura + §f/§g/§h enrich — completa |
|
|
73
|
+
| Dependencias consumidas | [docs/consumed/rabbitmq.md](../docs/consumed/rabbitmq.md) | §a/§b/§d estructura + §c/§e enrich |
|
|
74
|
+
| Errores | [docs/errors/errors.md](../docs/errors/errors.md) | §a/§b/§d completas; §c política inferida (verificación humana pendiente) |
|
|
75
|
+
| Test | [docs/test/testing.md](../docs/test/testing.md) | §a-§h — 16 unit / 7 integration |
|
|
76
|
+
| Release | [docs/release/release.md](../docs/release/release.md) | completa (régimen gema: build→publish; §e/f/g n/a) |
|
|
67
77
|
| Datos | — | n/a — gema sin DB |
|
|
68
|
-
|
|
|
78
|
+
| Eventos | — | n/a — la gema **es** el transporte; no declara catálogo de eventos de dominio propio |
|
|
79
|
+
| Seguridad | — | n/a — sin authn/authz propias; el guard anti-RCE (403) está en `docs/errors/` |
|
|
80
|
+
| Operaciones / Interfaz / Topología | — | pendiente — ver Cobertura y fronteras |
|
|
69
81
|
|
|
70
82
|
> **Glosario:** migrado a [docs/glossary/glossary.md](../docs/glossary/glossary.md)
|
|
71
83
|
> (RFC-008 §2 — el compuesto referencia, no copia). Términos AMQP base
|
|
@@ -74,13 +86,15 @@ El detalle vive en `docs/<capa>/` (modelo `dev-*`); esta skill **indexa y resume
|
|
|
74
86
|
|
|
75
87
|
## Cobertura y fronteras
|
|
76
88
|
|
|
77
|
-
**Coexistencia transitoria con destino pendiente (RFC-008 §2 — interim de migración):** mientras
|
|
89
|
+
**Coexistencia transitoria con destino pendiente (RFC-008 §2 — interim de migración):** mientras las capas `docs/api/` (operaciones), `docs/interface/` y `docs/topology/` no estén generadas para este repo, el contrato que les correspondería permanece embebido bajo el interim normado:
|
|
78
90
|
|
|
79
91
|
- **En esta skill (abajo):** el contrato detallado (jerarquía de excepciones, API de config, modos de entrega) **y** el diagrama de arquitectura (flujo RPC). El *Contrato resumido* de arriba es el piso mínimo (RFC-008 §2); lo de abajo es el detalle interim hasta que exista `docs/api|interface|topology`.
|
|
80
92
|
- **En `README.md`:** el contrato (sin el diagrama de arquitectura).
|
|
81
93
|
- **Guías how-to** (`references/*.md`, pre-estándar): destino futuro `docs/howto/`.
|
|
82
94
|
|
|
83
|
-
Por RFC-008 §2: no se fabrica la capa, no se borra contrato sin destino, no se duplica; migra cuando
|
|
95
|
+
Por RFC-008 §2: no se fabrica la capa, no se borra contrato sin destino, no se duplica; migra cuando se generen, mismo PR. Estado transitorio declarado, no excepción permanente. Origen del gap (resuelto, normado): [sequre/ai_knowledge#95](https://github.com/sequre/ai_knowledge/issues/95).
|
|
96
|
+
|
|
97
|
+
> **Nota de alcance (2026-07-31):** la justificación original de este interim era que el generador no implementaba esas capas. Ya las implementa, así que el pendiente es de **este repo**, no de tooling — en particular `docs/interface/` (RFC-004), donde correspondería registrar la API pública (incluidos `Observability.redact_value` y `.redact_structure`, agregados en `5.1.1`). Queda como trabajo propio, fuera del alcance de este release.
|
|
84
98
|
|
|
85
99
|
---
|
|
86
100
|
|
|
@@ -129,7 +143,7 @@ Por RFC-008 §2: no se fabrica la capa, no se borra contrato sin destino, no se
|
|
|
129
143
|
| `BugBunny::Controller` | Base class tipo Rails. `before_action`, `around_action`, `after_action`, `rescue_from`, `render`. |
|
|
130
144
|
| `BugBunny::Resource` | ORM sobre AMQP. `find`, `where`, `create`, `save`, `destroy`. ActiveModel validations y callbacks. |
|
|
131
145
|
| `BugBunny::Routing::RouteSet` | DSL de rutas: `resources`, `namespace`, `member`, `collection`. |
|
|
132
|
-
| `BugBunny::Observability` | Mixin de logging estructurado. `safe_log` nunca lanza excepciones.
|
|
146
|
+
| `BugBunny::Observability` | Mixin de logging estructurado. `safe_log` nunca lanza excepciones. Redacta credenciales en dos capas: por nombre de clave (`sensitive_key?`) y por contenido del valor (`redact_value` / `redact_structure`, para la credencial embebida en texto libre). |
|
|
133
147
|
| `BugBunny::Middleware::Stack` | Builder de middlewares client-side (onion architecture tipo Faraday). |
|
|
134
148
|
| BugBunny::Request | Value object del mensaje saliente con metadata AMQP completa. |
|
|
135
149
|
| BugBunny::OTel | Helpers para emitir campos siguiendo las OTel semantic conventions for messaging. |
|
|
@@ -241,6 +255,10 @@ BugBunny.configure do |config|
|
|
|
241
255
|
config.health_check_interval = 60
|
|
242
256
|
config.health_check_file = 'tmp/bb_health'
|
|
243
257
|
|
|
258
|
+
# Consumer.drain (5.2.0): segundos sin entregas para dar la cola por vacía, y cada cuánto se chequea
|
|
259
|
+
config.drain_idle_timeout = 5
|
|
260
|
+
config.drain_poll_interval = 0.1
|
|
261
|
+
|
|
244
262
|
# Routing
|
|
245
263
|
config.controller_namespace = 'BugBunny::Controllers'
|
|
246
264
|
end
|
|
@@ -417,7 +435,7 @@ Limitación de RSpec: `instance_double` valida que el método exista pero **no**
|
|
|
417
435
|
|
|
418
436
|
### Guard anti-RCE (403, no es excepción)
|
|
419
437
|
**Causa:** El mensaje intenta ejecutar un controlador que no hereda de `BugBunny::Controller`.
|
|
420
|
-
**Comportamiento:** El worker responde **403 Forbidden** + reject + log `event=consumer.security_violation` (`consumer.rb:
|
|
438
|
+
**Comportamiento:** El worker responde **403 Forbidden** + reject + log `event=consumer.security_violation` (`consumer.rb:375-381`); no levanta una excepción dedicada.
|
|
421
439
|
**Resolución:** Verificar la jerarquía de controladores y que `config.controller_namespace` coincida.
|
|
422
440
|
|
|
423
441
|
### BugBunny::RouteNotFoundError (404)
|
|
@@ -11,10 +11,36 @@ consumer = BugBunny::Consumer.subscribe(
|
|
|
11
11
|
exchange_type: 'topic',
|
|
12
12
|
exchange_opts: { durable: true },
|
|
13
13
|
queue_opts: { auto_delete: false },
|
|
14
|
-
block: true #
|
|
14
|
+
block: true # false retorna al instante y cierra el canal: no consume nada (#64)
|
|
15
15
|
)
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
## Drain (drenar y salir)
|
|
19
|
+
|
|
20
|
+
Para correr un consumidor **como job**: consume hasta que la cola queda quieta y retorna cuántos mensajes procesó (incluye los rechazados). Con la cola vacía retorna `0` sin esperar.
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
connection = BugBunny.create_connection
|
|
24
|
+
begin
|
|
25
|
+
processed_count = BugBunny::Consumer.drain(
|
|
26
|
+
connection: connection,
|
|
27
|
+
queue_name: 'my_app_queue',
|
|
28
|
+
exchange_name: 'my_exchange',
|
|
29
|
+
routing_key: 'users.*'
|
|
30
|
+
)
|
|
31
|
+
ensure
|
|
32
|
+
connection.close # drain cierra su canal, no la conexión: la conexión es de quien llama
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- Respeta `channel_prefetch`, igual que `subscribe`.
|
|
37
|
+
- Termina tras `drain_idle_timeout` segundos (default `5`) sin entregas y sin nada en proceso; lo chequea cada `drain_poll_interval` (default `0.1`).
|
|
38
|
+
- Un mensaje que llega dentro de esa ventana entra en esta vuelta; los posteriores, en la próxima corrida. Una entrega ya recibida al cancelar se procesa antes de volver; si igual no se ack-eara, vuelve a la cola (at-least-once).
|
|
39
|
+
- **Con un flujo sostenido no retorna**: si los mensajes llegan más seguido que `drain_idle_timeout`, la ventana nunca vence. Acotalo desde afuera (timeout del job).
|
|
40
|
+
- **Una entrega que falla sale de la cola**: si un middleware levanta antes del ack, se rechaza sin requeue y no traba el prefetch.
|
|
41
|
+
- **La conexión es de quien llama**: `drain` cierra su canal, no la conexión. Si la creaste para la corrida, cerrala (si no, cada corrida deja una abierta).
|
|
42
|
+
- **No** tiene loop de reconexión ni health check: si falla, lo reintenta el framework del job (Bunny sí recupera la conexión por su cuenta con `automatically_recover`).
|
|
43
|
+
|
|
18
44
|
## Flujo de Procesamiento
|
|
19
45
|
|
|
20
46
|
1. Escucha en la queue con `manual_ack: true`.
|
data/skill/references/errores.md
CHANGED
|
@@ -37,9 +37,13 @@ rescue BugBunny::Error => e
|
|
|
37
37
|
end
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
> ⚠️ **No loguear `raw_response` crudo.** Puede contener datos sensibles.
|
|
41
|
-
>
|
|
42
|
-
>
|
|
40
|
+
> ⚠️ **No loguear `raw_response` crudo.** Puede contener datos sensibles. Sanear
|
|
41
|
+
> antes de Sentry/logs con `Observability.redact_structure` (recorre la
|
|
42
|
+
> estructura: filtra por nombre de clave y también redacta la credencial
|
|
43
|
+
> embebida en el valor). **No replicar la lista de claves** — la canónica es
|
|
44
|
+
> `Observability::SENSITIVE_KEYS`; replicarla la desincroniza (y `pass` bare NO
|
|
45
|
+
> está en ella, a propósito, para no filtrar `passport_number`). La gema entrega
|
|
46
|
+
> el cuerpo crudo a propósito; sanitizar es del consumidor.
|
|
43
47
|
|
|
44
48
|
Para consumir un envelope estructurado de dominio (ej. `{ error: { code,
|
|
45
49
|
message, details } }`), parsealo en el boundary del servicio desde
|
|
@@ -59,7 +63,7 @@ message, details } }`), parsealo en el boundary del servicio desde
|
|
|
59
63
|
|
|
60
64
|
### Guard anti-RCE (403, no es excepción)
|
|
61
65
|
**Causa:** Un mensaje intenta ejecutar un controlador que no hereda de `BugBunny::Controller`.
|
|
62
|
-
**Cuándo:** El consumer resuelve la clase (`constantize`) pero falla `controller_class < BugBunny::Controller` (`consumer.rb:
|
|
66
|
+
**Cuándo:** El consumer resuelve la clase (`constantize`) pero falla `controller_class < BugBunny::Controller` (`consumer.rb:375-381`).
|
|
63
67
|
**Comportamiento:** El worker **no levanta una excepción** — loguea `event=consumer.security_violation`, responde **403 Forbidden** al caller RPC y rechaza el mensaje sin requeue.
|
|
64
68
|
**Resolución:** Verificar que el controlador herede de `BugBunny::Controller` y que `config.controller_namespace` sea correcto.
|
|
65
69
|
|
data/skill/references/routing.md
CHANGED
|
@@ -69,7 +69,7 @@ El consumer resuelve el controlador concatenando:
|
|
|
69
69
|
|
|
70
70
|
Ejemplo: namespace `:admin`, controller `:reports` → `BugBunny::Controllers::Admin::ReportsController`
|
|
71
71
|
|
|
72
|
-
Valida que el controlador sea subclase de `BugBunny::Controller` (guard anti-RCE). Si no, el worker loguea `consumer.security_violation`, responde **403 Forbidden** y rechaza el mensaje sin requeue (`consumer.rb:
|
|
72
|
+
Valida que el controlador sea subclase de `BugBunny::Controller` (guard anti-RCE). Si no, el worker loguea `consumer.security_violation`, responde **403 Forbidden** y rechaza el mensaje sin requeue (`consumer.rb:375-381`) — no levanta una excepción dedicada.
|
|
73
73
|
|
|
74
74
|
## Route Object
|
|
75
75
|
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'support/integration_helper'
|
|
5
|
+
|
|
6
|
+
module DrainSpec
|
|
7
|
+
# Controller que sólo cuenta cuántas veces se lo invocó.
|
|
8
|
+
class PingController < BugBunny::Controller
|
|
9
|
+
def self.handled_count
|
|
10
|
+
@handled_count ||= Concurrent::AtomicFixnum.new(0)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def index
|
|
14
|
+
self.class.handled_count.increment
|
|
15
|
+
render status: 200, json: { pong: true }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
RSpec.describe 'Consumer.drain', :integration do
|
|
21
|
+
let(:queue_name) { unique('drain_q') }
|
|
22
|
+
let(:exchange_name) { unique('drain_x') }
|
|
23
|
+
let(:client) { BugBunny::Client.new(pool: TEST_POOL) }
|
|
24
|
+
let(:admin_connection) { BugBunny.create_connection }
|
|
25
|
+
|
|
26
|
+
# Durable y no exclusiva: drain declara la cola con su propia conexión, y RabbitMQ 4
|
|
27
|
+
# rechaza las colas no durables y no exclusivas.
|
|
28
|
+
let(:queue_opts) { { durable: true, exclusive: false, auto_delete: false } }
|
|
29
|
+
|
|
30
|
+
let(:drain_args) do
|
|
31
|
+
{ queue_name: queue_name, exchange_name: exchange_name, exchange_type: 'topic',
|
|
32
|
+
routing_key: 'ping', queue_opts: queue_opts }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
before do
|
|
36
|
+
DrainSpec::PingController.handled_count.value = 0
|
|
37
|
+
BugBunny.configure do |config|
|
|
38
|
+
config.controller_namespace = 'DrainSpec'
|
|
39
|
+
config.drain_idle_timeout = 1
|
|
40
|
+
config.drain_poll_interval = 0.05
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
admin_channel = admin_connection.create_channel
|
|
44
|
+
effective_exchange_opts = BugBunny::Session::DEFAULT_EXCHANGE_OPTIONS.merge(BugBunny.configuration.exchange_options)
|
|
45
|
+
exchange = admin_channel.topic(exchange_name, effective_exchange_opts)
|
|
46
|
+
admin_channel.queue(queue_name, queue_opts).bind(exchange, routing_key: 'ping')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
after do
|
|
50
|
+
BugBunny.configure do |config|
|
|
51
|
+
config.controller_namespace = 'BugBunny::Controllers'
|
|
52
|
+
config.drain_idle_timeout = 5
|
|
53
|
+
config.drain_poll_interval = 0.1
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
cleanup_channel = admin_connection.create_channel
|
|
57
|
+
cleanup_channel.queue_delete(queue_name)
|
|
58
|
+
cleanup_channel.exchange_delete(exchange_name)
|
|
59
|
+
admin_connection.close
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def publish_ping
|
|
63
|
+
client.publish('ping', method: :get, exchange: exchange_name, exchange_type: 'topic', routing_key: 'ping')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def passive_queue
|
|
67
|
+
admin_connection.create_channel.queue(queue_name, queue_opts.merge(passive: true))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def messages_in_queue
|
|
71
|
+
passive_queue.message_count
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# La conexión es del llamador: drain cierra su canal, no la conexión.
|
|
75
|
+
def drain
|
|
76
|
+
connection = BugBunny.create_connection
|
|
77
|
+
BugBunny::Consumer.drain(connection: connection, **drain_args)
|
|
78
|
+
ensure
|
|
79
|
+
connection&.close
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'procesa todos los mensajes encolados, los ack-ea y retorna cuántos fueron' do
|
|
83
|
+
BugBunny.configure { |config| config.channel_prefetch = 3 }
|
|
84
|
+
5.times { publish_ping }
|
|
85
|
+
sleep 0.3
|
|
86
|
+
|
|
87
|
+
expect(drain).to eq(5)
|
|
88
|
+
expect(DrainSpec::PingController.handled_count.value).to eq(5)
|
|
89
|
+
expect(messages_in_queue).to eq(0)
|
|
90
|
+
ensure
|
|
91
|
+
BugBunny.configure { |config| config.channel_prefetch = 1 }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'con la cola vacía retorna 0 sin esperar la ventana de inactividad' do
|
|
95
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
96
|
+
|
|
97
|
+
expect(drain).to eq(0)
|
|
98
|
+
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at).to be < 1
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Criterio 5: shutdown corre al volver y cierra el canal. Se mira el canal y no el
|
|
102
|
+
# `consumer_count`: ése queda en 0 por el `cancel` aunque falte el `ensure shutdown`, así
|
|
103
|
+
# que un test sobre él pasaría con el defecto intacto. El canal se toma antes: es el mismo
|
|
104
|
+
# que después usa drain.
|
|
105
|
+
it 'cierra su canal al volver, con la cola vacía y con mensajes' do
|
|
106
|
+
[0, 2].each do |pending|
|
|
107
|
+
pending.times { publish_ping }
|
|
108
|
+
sleep 0.3 if pending.positive?
|
|
109
|
+
|
|
110
|
+
connection = BugBunny.create_connection
|
|
111
|
+
consumer = BugBunny::Consumer.new(connection)
|
|
112
|
+
channel = consumer.session.channel
|
|
113
|
+
|
|
114
|
+
consumer.drain(**drain_args)
|
|
115
|
+
|
|
116
|
+
expect(channel).not_to be_open, "con #{pending} mensajes el canal quedó abierto"
|
|
117
|
+
ensure
|
|
118
|
+
connection&.close
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Review de #65: un middleware que levanta dejaba la entrega sin ack ni reject; con
|
|
123
|
+
# prefetch 1 trababa la cola y drain volvía "con éxito" sin haber sacado nada.
|
|
124
|
+
it 'una entrega cuyo middleware levanta sale de la cola y no traba el prefetch' do
|
|
125
|
+
exploding = Class.new(BugBunny::ConsumerMiddleware::Base) do
|
|
126
|
+
def call(*)
|
|
127
|
+
raise 'middleware roto'
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
BugBunny.consumer_middlewares.use exploding
|
|
131
|
+
BugBunny.configure { |config| config.channel_prefetch = 1 }
|
|
132
|
+
3.times { publish_ping }
|
|
133
|
+
sleep 0.3
|
|
134
|
+
|
|
135
|
+
expect(drain).to eq(3)
|
|
136
|
+
expect(messages_in_queue).to eq(0)
|
|
137
|
+
expect(DrainSpec::PingController.handled_count.value).to eq(0)
|
|
138
|
+
ensure
|
|
139
|
+
BugBunny.configuration.instance_variable_set(:@consumer_middlewares, BugBunny::ConsumerMiddleware::Stack.new)
|
|
140
|
+
BugBunny.configure { |config| config.channel_prefetch = 1 }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Review de #65: la guarda `unless settled` de `settle_failed_delivery`. Si el error
|
|
144
|
+
# llega DESPUÉS del ack, rechazar el tag ya confirmado hace que el broker cierre el
|
|
145
|
+
# canal; sin la guarda, drain terminaba en Timeout::Error con mensajes en la cola.
|
|
146
|
+
it 'un middleware que levanta después del ack no rechaza la entrega ya resuelta' do
|
|
147
|
+
after_ack = Class.new(BugBunny::ConsumerMiddleware::Base) do
|
|
148
|
+
def call(*args)
|
|
149
|
+
@app.call(*args)
|
|
150
|
+
raise 'después del ack'
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
BugBunny.consumer_middlewares.use after_ack
|
|
154
|
+
BugBunny.configure { |config| config.channel_prefetch = 1 }
|
|
155
|
+
3.times { publish_ping }
|
|
156
|
+
sleep 0.3
|
|
157
|
+
|
|
158
|
+
expect(drain).to eq(3)
|
|
159
|
+
expect(messages_in_queue).to eq(0)
|
|
160
|
+
expect(DrainSpec::PingController.handled_count.value).to eq(3)
|
|
161
|
+
ensure
|
|
162
|
+
BugBunny.configuration.instance_variable_set(:@consumer_middlewares, BugBunny::ConsumerMiddleware::Stack.new)
|
|
163
|
+
BugBunny.configure { |config| config.channel_prefetch = 1 }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it 'procesa en la misma vuelta un mensaje que llega dentro de la ventana de inactividad' do
|
|
167
|
+
publish_ping
|
|
168
|
+
sleep 0.3
|
|
169
|
+
|
|
170
|
+
drain_thread = Thread.new { drain }
|
|
171
|
+
sleep 0.5
|
|
172
|
+
publish_ping
|
|
173
|
+
|
|
174
|
+
expect(drain_thread.value).to eq(2)
|
|
175
|
+
expect(messages_in_queue).to eq(0)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -11,7 +11,15 @@ RSpec.describe BugBunny::Configuration do
|
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
# Restaura la configuración de spec_helper (host, credenciales) y no una con defaults:
|
|
15
|
+
# si no, los specs de integración que corren después se conectan como `guest` y se
|
|
16
|
+
# saltean como "RabbitMQ no disponible" en vez de correr.
|
|
17
|
+
around do |example|
|
|
18
|
+
original_configuration = BugBunny.configuration
|
|
19
|
+
example.run
|
|
20
|
+
ensure
|
|
21
|
+
BugBunny.configuration = original_configuration
|
|
22
|
+
end
|
|
15
23
|
|
|
16
24
|
describe 'defaults' do
|
|
17
25
|
it 'pasan validate! sin ninguna configuración adicional' do
|
|
@@ -124,6 +132,24 @@ RSpec.describe BugBunny::Configuration do
|
|
|
124
132
|
end
|
|
125
133
|
end
|
|
126
134
|
|
|
135
|
+
describe 'drain_idle_timeout' do
|
|
136
|
+
it 'levanta ConfigurationError si es 0' do
|
|
137
|
+
expect { configure_with(drain_idle_timeout: 0) }
|
|
138
|
+
.to raise_error(BugBunny::ConfigurationError, /drain_idle_timeout must be in/)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
describe 'drain_poll_interval' do
|
|
143
|
+
it 'acepta fracciones de segundo' do
|
|
144
|
+
expect { configure_with(drain_poll_interval: 0.05) }.not_to raise_error
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'levanta ConfigurationError si es 0' do
|
|
148
|
+
expect { configure_with(drain_poll_interval: 0) }
|
|
149
|
+
.to raise_error(BugBunny::ConfigurationError, /drain_poll_interval must be in/)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
127
153
|
describe 'configuración válida completa' do
|
|
128
154
|
it 'acepta todos los atributos con valores correctos' do
|
|
129
155
|
expect do
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe BugBunny::DrainTracker do
|
|
6
|
+
# Reloj controlado por el test: el tiempo avanza sólo cuando el spec lo mueve.
|
|
7
|
+
let(:current_time) { [100.0] }
|
|
8
|
+
let(:tracker) { described_class.new(clock: -> { current_time.first }) }
|
|
9
|
+
|
|
10
|
+
def advance(seconds)
|
|
11
|
+
current_time[0] += seconds
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'no está ocioso mientras una entrega sigue en proceso, aunque venza la ventana' do
|
|
15
|
+
tracker.track do
|
|
16
|
+
advance(10)
|
|
17
|
+
expect(tracker.idle?(5)).to be(false)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'queda ocioso recién cuando pasa la ventana completa desde la última entrega' do
|
|
22
|
+
tracker.track { advance(1) }
|
|
23
|
+
|
|
24
|
+
advance(4.9)
|
|
25
|
+
expect(tracker.idle?(5)).to be(false)
|
|
26
|
+
|
|
27
|
+
advance(0.1)
|
|
28
|
+
expect(tracker.idle?(5)).to be(true)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'cuenta la entrega como procesada y la libera aunque el procesamiento levante' do
|
|
32
|
+
expect { tracker.track { raise 'boom' } }.to raise_error('boom')
|
|
33
|
+
|
|
34
|
+
expect(tracker.processed).to eq(1)
|
|
35
|
+
expect(tracker.busy?).to be(false)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -29,6 +29,164 @@ RSpec.describe BugBunny::Observability do
|
|
|
29
29
|
log_output.string.split("\n").last.to_s.sub(/\A.*?:\s*/, '')
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
describe '.redact_value (contenido sensible en texto libre)' do
|
|
33
|
+
# Complementa a .sensitive_key?: esa mira el NOMBRE de la clave, esta el
|
|
34
|
+
# CONTENIDO. El caso que motiva la feature es el `message` de una excepción.
|
|
35
|
+
it 'redacta un Bearer token' do
|
|
36
|
+
redacted = BugBunny::Observability.redact_value(
|
|
37
|
+
'undefined method for #<Faraday::Response headers={"Authorization"=>"Bearer eyJhbGciOiJIUzI1NiJ9.abc"}>'
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
expect(redacted).not_to include('eyJhbGciOiJIUzI1NiJ9.abc')
|
|
41
|
+
expect(redacted).to include('[FILTERED]')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'redacta un esquema Basic' do
|
|
45
|
+
redacted = BugBunny::Observability.redact_value('Authorization: Basic dXNlcjpwYXNzd29yZA==')
|
|
46
|
+
|
|
47
|
+
expect(redacted).not_to include('dXNlcjpwYXNzd29yZA')
|
|
48
|
+
expect(redacted).to include('[FILTERED]')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'redacta el valor cuando la key viaja DENTRO del texto, conservando el nombre' do
|
|
52
|
+
redacted = BugBunny::Observability.redact_value('connect failed (token=abc123, host=rabbit)')
|
|
53
|
+
|
|
54
|
+
expect(redacted).not_to include('abc123')
|
|
55
|
+
expect(redacted).to include('token=[FILTERED]')
|
|
56
|
+
# El resto del mensaje sobrevive: la redacción es quirúrgica, no destructiva.
|
|
57
|
+
expect(redacted).to include('host=rabbit')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'matchea la key más larga primero (authorization no se parte en auth)' do
|
|
61
|
+
redacted = BugBunny::Observability.redact_value('authorization: "Bearer-less-secret-value"')
|
|
62
|
+
|
|
63
|
+
expect(redacted).not_to include('Bearer-less-secret-value')
|
|
64
|
+
expect(redacted).to include('authorization=[FILTERED]')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'redacta credenciales de una URL conservando esquema y host' do
|
|
68
|
+
redacted = BugBunny::Observability.redact_value('amqp://guest:s3cr3t@rabbit:5672/vhost')
|
|
69
|
+
|
|
70
|
+
expect(redacted).not_to include('s3cr3t')
|
|
71
|
+
expect(redacted).to include('amqp://[FILTERED]@rabbit:5672/vhost')
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'deja intacto un texto sin credenciales' do
|
|
75
|
+
expect(BugBunny::Observability.redact_value('timeout after 30s on queue acs.rpc'))
|
|
76
|
+
.to eq('timeout after 30s on queue acs.rpc')
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'no confunde una key no sensible que contiene un substring parecido' do
|
|
80
|
+
expect(BugBunny::Observability.redact_value('passport_number=AB123'))
|
|
81
|
+
.to include('AB123')
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# `_` es word-char: un `\b` antes de la key NO encuentra borde dentro de
|
|
85
|
+
# `access_token` ni de `accessToken`, y esas variantes se colaban en claro.
|
|
86
|
+
# Son exactamente las que sensitive_key? cubre a propósito con substring matching.
|
|
87
|
+
it 'redacta la variante con separador (access_token) conservando el nombre completo' do
|
|
88
|
+
redacted = BugBunny::Observability.redact_value('request failed access_token=eyJsecret.jwt')
|
|
89
|
+
|
|
90
|
+
expect(redacted).not_to include('eyJsecret.jwt')
|
|
91
|
+
expect(redacted).to include('access_token=[FILTERED]')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'redacta la variante con prefijo (user_password)' do
|
|
95
|
+
redacted = BugBunny::Observability.redact_value('invalid params user_password=hunter2')
|
|
96
|
+
|
|
97
|
+
expect(redacted).not_to include('hunter2')
|
|
98
|
+
expect(redacted).to include('user_password=[FILTERED]')
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it 'redacta la variante camelCase (accessToken)' do
|
|
102
|
+
redacted = BugBunny::Observability.redact_value('boom accessToken=eyJsecret.jwt')
|
|
103
|
+
|
|
104
|
+
expect(redacted).not_to include('eyJsecret.jwt')
|
|
105
|
+
expect(redacted).to include('accessToken=[FILTERED]')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'no redacta una key no sensible con sufijo numérico (processing_session_count)' do
|
|
109
|
+
expect(BugBunny::Observability.redact_value('processing_session_count=5'))
|
|
110
|
+
.to eq('processing_session_count=5')
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
describe '.redact_structure (estructura antes de serializar)' do
|
|
115
|
+
it 'redacta por key interna y deja la forma intacta' do
|
|
116
|
+
redacted = BugBunny::Observability.redact_structure(
|
|
117
|
+
'token' => 'abc123', 'host' => 'rabbit'
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
expect(redacted).to eq('token' => '[FILTERED]', 'host' => 'rabbit')
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it 'recorre Hash anidado y Array' do
|
|
124
|
+
redacted = BugBunny::Observability.redact_structure(
|
|
125
|
+
'nested' => { 'api_key' => 'xyz', 'n' => 1 },
|
|
126
|
+
'list' => ['token=abc123', 'clean']
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
expect(redacted).to eq(
|
|
130
|
+
'nested' => { 'api_key' => '[FILTERED]', 'n' => 1 },
|
|
131
|
+
'list' => ['token=[FILTERED]', 'clean']
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it 'no altera numéricos ni booleanos ni nil' do
|
|
136
|
+
expect(BugBunny::Observability.redact_structure('n' => 1, 'ok' => true, 'x' => nil))
|
|
137
|
+
.to eq('n' => 1, 'ok' => true, 'x' => nil)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
describe '#safe_log — redacción por contenido' do
|
|
142
|
+
# El caso real: el nombre de la clave NO es sensible (`reason`), así que el
|
|
143
|
+
# filtro por-clave lo deja pasar; la credencial va en el valor.
|
|
144
|
+
it 'filtra una credencial embebida en el valor de una key NO sensible' do
|
|
145
|
+
host.safe_log(:error, 'unhandled_exception',
|
|
146
|
+
reason: 'NoMethodError on headers {"Authorization"=>"Bearer eyJsupersecret.jwt"}')
|
|
147
|
+
|
|
148
|
+
expect(last_log_line).not_to include('eyJsupersecret.jwt')
|
|
149
|
+
expect(last_log_line).to include('[FILTERED]')
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it 'filtra dentro de un Hash serializado (las keys internas no pasan por sensitive_key?)' do
|
|
153
|
+
host.safe_log(:error, 'unhandled_exception', details: { 'token' => 'abc123xyz' })
|
|
154
|
+
|
|
155
|
+
expect(last_log_line).not_to include('abc123xyz')
|
|
156
|
+
expect(last_log_line).to include('[FILTERED]')
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# La redacción no puede costar la estructura: quien consume el log parsea este campo
|
|
160
|
+
# como JSON, y un objeto roto le hace perder TODOS los pares, no solo el redactado.
|
|
161
|
+
it 'mantiene el campo Hash como JSON parseable después de redactar' do
|
|
162
|
+
host.safe_log(:error, 'unhandled_exception',
|
|
163
|
+
details: { 'token' => 'abc123xyz', 'host' => 'rabbit',
|
|
164
|
+
'nested' => { 'api_key' => 'xyz789', 'n' => 1 } })
|
|
165
|
+
|
|
166
|
+
field = last_log_line[/details=(\S+)/, 1]
|
|
167
|
+
|
|
168
|
+
expect { JSON.parse(field) }.not_to raise_error
|
|
169
|
+
expect(JSON.parse(field)).to eq(
|
|
170
|
+
'token' => '[FILTERED]', 'host' => 'rabbit',
|
|
171
|
+
'nested' => { 'api_key' => '[FILTERED]', 'n' => 1 }
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it 'no altera un valor numérico' do
|
|
176
|
+
host.safe_log(:info, 'done', duration_s: 1.5, status: 200)
|
|
177
|
+
|
|
178
|
+
expect(last_log_line).to include('duration_s=1.5', 'status=200')
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it 'preserva el resto de la línea (component, event y campos limpios)' do
|
|
182
|
+
host.safe_log(:error, 'request_error', kind: 'unavailable', reason: 'ACS down')
|
|
183
|
+
|
|
184
|
+
line = last_log_line
|
|
185
|
+
expect(line).to include('event=request_error', 'kind=unavailable')
|
|
186
|
+
expect(line).to include('reason="ACS down"')
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
32
190
|
describe '.sensitive_key? (módulo público)' do
|
|
33
191
|
subject(:sensitive?) { BugBunny::Observability.method(:sensitive_key?) }
|
|
34
192
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bug_bunny
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.
|
|
4
|
+
version: 5.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- gabix
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-09-29 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bunny
|
|
@@ -248,6 +248,7 @@ files:
|
|
|
248
248
|
- lib/bug_bunny/consumer.rb
|
|
249
249
|
- lib/bug_bunny/consumer_middleware.rb
|
|
250
250
|
- lib/bug_bunny/controller.rb
|
|
251
|
+
- lib/bug_bunny/drain_tracker.rb
|
|
251
252
|
- lib/bug_bunny/exception.rb
|
|
252
253
|
- lib/bug_bunny/middleware/base.rb
|
|
253
254
|
- lib/bug_bunny/middleware/json_response.rb
|
|
@@ -281,6 +282,7 @@ files:
|
|
|
281
282
|
- spec/integration/client_spec.rb
|
|
282
283
|
- spec/integration/consumer_middleware_spec.rb
|
|
283
284
|
- spec/integration/controller_spec.rb
|
|
285
|
+
- spec/integration/drain_spec.rb
|
|
284
286
|
- spec/integration/error_handling_spec.rb
|
|
285
287
|
- spec/integration/infrastructure_spec.rb
|
|
286
288
|
- spec/integration/publisher_confirms_spec.rb
|
|
@@ -294,6 +296,7 @@ files:
|
|
|
294
296
|
- spec/unit/consumer_middleware_spec.rb
|
|
295
297
|
- spec/unit/consumer_spec.rb
|
|
296
298
|
- spec/unit/controller_after_action_spec.rb
|
|
299
|
+
- spec/unit/drain_tracker_spec.rb
|
|
297
300
|
- spec/unit/extra_top_level_params_spec.rb
|
|
298
301
|
- spec/unit/observability_spec.rb
|
|
299
302
|
- spec/unit/otel_spec.rb
|
|
@@ -314,7 +317,7 @@ metadata:
|
|
|
314
317
|
homepage_uri: https://github.com/gedera/bug_bunny
|
|
315
318
|
source_code_uri: https://github.com/gedera/bug_bunny
|
|
316
319
|
changelog_uri: https://github.com/gedera/bug_bunny/blob/main/CHANGELOG.md
|
|
317
|
-
documentation_uri: https://github.com/gedera/bug_bunny/blob/v5.
|
|
320
|
+
documentation_uri: https://github.com/gedera/bug_bunny/blob/v5.2.0/skill
|
|
318
321
|
post_install_message:
|
|
319
322
|
rdoc_options: []
|
|
320
323
|
require_paths:
|