bug_bunny 4.12.0 → 4.14.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e38ff38727e34da253f669937054e58f4bda0d5a42ce270cc9ac1e7853d8feb
4
- data.tar.gz: 47b090304aaf7743addd2136c963d82ea8fd67bb82a17aad8958551047b79015
3
+ metadata.gz: 6081a471e20278bc190a7d7517b69cf6fe6d852293bca353974c5ba1cbe4e746
4
+ data.tar.gz: 2e1b952c9627b7f3f1f9ab1e2af25af49ca0422ff59f062ac7499c14265d6a57
5
5
  SHA512:
6
- metadata.gz: 1811143e41e170668c68f8a4b57721c4e9e6111145c253f62e09c5653027e75ee32d6432ac950a54b80a5e4f282ca1a64f184535935c5b1dad7f8df4bac12593
7
- data.tar.gz: b896459d25cea7925eaa3a010aa85cd5bcd302ab6f4551eb5b6d4cad554f4478050476e7cd9a4980590343d6a65e9822b10a7f864378117778b543f665ce9eb2
6
+ metadata.gz: 6cf7de01b98c925c8f02de1e09aafc0113f24f93145498ace1616445deecf6319bde3464d9afcd89b5b461904fbe878d4bfb24ea3a2a310149a32f6f2b06f4d0
7
+ data.tar.gz: 2560004c6036b238456fe9c9af9bf478dde67b1ff07240fdac942ecab303467d1db04f3029c2579463e5017b7c9f25ad2e5f1c5ef2c999f86bcde0bedb42eb67
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.14.0] - 2026-05-12
4
+
5
+ ### Nuevas funcionalidades
6
+ - **Duraciones medidas internamente en el Producer:** BugBunny ahora emite `duration_s` automáticamente en los eventos del publisher siguiendo las [OpenTelemetry metric semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/metrics/) (`Float` en segundos). El código de aplicación ya no necesita envolver `client.publish` con `Process.clock_gettime`. — @Gabriel
7
+ - `producer.published` (INFO): `duration_s` del `basic_publish` (TCP enqueue al broker, sin esperar ACK).
8
+ - `producer.confirmed` (INFO): tres duraciones desglosadas — `publish_duration_s`, `confirm_duration_s` (espera de `wait_for_confirms`) y `duration_s` total. Útil para distinguir latencia de red vs latencia del confirm policy del broker.
9
+ - `producer.rpc_response_received`: ahora incluye `duration_s` con el round-trip RPC completo (publish + procesamiento remoto + reply).
10
+
11
+ ### Cambios de comportamiento
12
+ - **`producer.rpc_response_received` promovido de DEBUG a INFO.** No es breaking de API pero aumenta el volumen de logs en clientes RPC. Si el cambio impacta tu pipeline de observabilidad, filtralo por nivel.
13
+
14
+ ### Documentación
15
+ - README + `skill/SKILL.md` + `skill/references/client-middleware.md` actualizados con el catálogo completo de eventos de log emitidos por la gema y la tabla de qué mide cada `duration_s`. Mensaje explícito en ambas audiencias (humana + agente) advirtiendo no duplicar la medición en código de aplicación.
16
+
17
+ ## [4.13.0] - 2026-05-11
18
+
19
+ ### Nuevas funcionalidades
20
+ - **NACK explícito como excepción en modo `:confirmed` (#37):** `Producer#confirmed` ahora levanta `BugBunny::PublishNacked` cuando el broker NACKea la publicación, en lugar de retornar 202 silenciosamente. La excepción expone `path` y `nacked_count` para que callers críticos (auditoría, billing, RADIUS accounting) puedan escalar a HTTP 5xx y permitir retries upstream. Configurable globalmente vía `BugBunny.configuration.nack_raise` (default `true`) y por request via `client.publish(..., nack_raise: false)`. El evento `producer.confirms_nacked` se sigue logueando para observabilidad. — @Gabriel
21
+
22
+ ### Cambios de comportamiento (semi-breaking)
23
+ - **Default `nack_raise: true`:** Publicaciones con `confirmed: true` que reciben NACK del broker ahora levantan excepción por default. En 4.12.0, el NACK se logueaba pero retornaba 202 igualmente — comportamiento que ocultaba pérdida de mensajes desde la perspectiva del publisher. Para mantener el comportamiento previo: `BugBunny.configuration.nack_raise = false` o `nack_raise: false` per request.
24
+
3
25
  ## [4.12.0] - 2026-05-11
4
26
 
5
27
  ### Nuevas funcionalidades
data/README.md CHANGED
@@ -220,6 +220,9 @@ client.publish('acct.start',
220
220
  | `confirmed` | Boolean | `false` | Block until `wait_for_confirms` returns. |
221
221
  | `mandatory` | Boolean | `false` | Broker returns the message if it cannot be routed to any queue. Requires `confirmed: true` to be useful. |
222
222
  | `confirm_timeout` | Float | `nil` | Seconds to wait for the broker ACK. Raises `BugBunny::RequestTimeout` if exceeded. |
223
+ | `nack_raise` | Boolean | `nil` | Per-request override of `config.nack_raise`. When `nil`, falls back to the global flag. |
224
+
225
+ If the broker NACKs the publish (explicit rejection — disk full, internal confirm policy, etc.), the call raises `BugBunny::PublishNacked` by default. The exception exposes `path` and `nacked_count`. Critical publishers (audit, billing, RADIUS accounting) can let it bubble up to translate into HTTP 5xx so upstream systems retry. To restore the legacy "log-only" behaviour, set `BugBunny.configuration.nack_raise = false` globally or pass `nack_raise: false` per request.
223
226
 
224
227
  Unroutable returned messages are handled by a single global callback (see `config.on_return` below). The default handler logs them as `session.broker_return` at `warn` level — nothing is dropped silently.
225
228
 
@@ -249,11 +252,27 @@ BugBunny implementa de forma nativa las [OpenTelemetry semantic conventions for
249
252
  Todos los eventos internos se emiten como logs `key=value` compatibles con Datadog, CloudWatch, ELK y ExisRay.
250
253
 
251
254
  ```
255
+ component=bug_bunny event=producer.publish method=POST path=acct/publish messaging_destination_name=acct_x messaging_routing_key=acct.start.42
256
+ component=bug_bunny event=producer.published method=POST path=acct/publish routing_key=acct.start.42 messaging_message_id=corr-1 duration_s=0.000812
257
+ component=bug_bunny event=producer.confirmed method=POST path=acct/publish routing_key=acct.start.42 publish_duration_s=0.000812 confirm_duration_s=0.012 duration_s=0.013
258
+ component=bug_bunny event=producer.rpc_response_received method=GET path=users/42 duration_s=0.034 messaging_operation=receive
252
259
  component=bug_bunny event=consumer.message_processed status=200 duration_s=0.012 messaging_operation=process controller=NodesController action=show
253
260
  component=bug_bunny event=consumer.execution_error error_class=RuntimeError error_message="..." duration_s=0.003
254
261
  component=bug_bunny event=consumer.connection_error attempt_count=2 retry_in_s=10 error_message="..."
255
262
  ```
256
263
 
264
+ ### Duraciones medidas internamente
265
+
266
+ BugBunny mide y emite duraciones automáticamente — **no es necesario envolver llamadas a `client.publish` con `Process.clock_gettime` en el código de aplicación**. Las unidades siguen las [OpenTelemetry metric semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/metrics/) (`s`, segundos como `Float`).
267
+
268
+ | Evento | Duración | Mide |
269
+ |---|---|---|
270
+ | `producer.published` | `duration_s` | Solo el `basic_publish` (TCP enqueue al broker). |
271
+ | `producer.confirmed` | `publish_duration_s` + `confirm_duration_s` + `duration_s` (total) | Publish + espera de ACK del broker. |
272
+ | `producer.rpc_response_received` | `duration_s` | Round-trip RPC completo (publish + procesamiento remoto + reply). |
273
+ | `consumer.message_processed` | `duration_s` | Procesamiento del mensaje (router + controller + reply). |
274
+ | `consumer.execution_error` | `duration_s` | Tiempo transcurrido hasta el error. |
275
+
257
276
  Las claves sensibles (`password`, `token`, `secret`, `api_key`, `authorization`, etc.) se filtran automáticamente a `[FILTERED]` en toda la salida de logs.
258
277
 
259
278
  ---
@@ -93,9 +93,12 @@ module BugBunny
93
93
  # @option args [Boolean] :mandatory Si `true`, el broker retorna el mensaje si no es ruteable.
94
94
  # Para procesar retornos, configurar {BugBunny.configuration.on_return}.
95
95
  # @option args [Float] :confirm_timeout Segundos a esperar el confirm. `nil` espera indefinidamente.
96
+ # @option args [Boolean] :nack_raise Override per-request del flag
97
+ # `BugBunny.configuration.nack_raise`. Si `nil` (default), se usa la configuración global.
96
98
  # @yield [req] Bloque para configurar el objeto Request.
97
99
  # @return [Hash] `{ 'status' => 202, 'body' => nil }`.
98
100
  # @raise [BugBunny::RequestTimeout] Si `confirmed: true` y el broker no confirma a tiempo.
101
+ # @raise [BugBunny::PublishNacked] Si `confirmed: true`, el broker NACKea, y `nack_raise` resuelto es true.
99
102
  def publish(url, **args)
100
103
  send(url, **args) do |req|
101
104
  req.delivery_mode = args[:confirmed] ? :confirmed : :publish
@@ -175,6 +178,7 @@ module BugBunny
175
178
  def apply_publisher_confirms_args(req, args)
176
179
  req.mandatory = args[:mandatory] if args.key?(:mandatory)
177
180
  req.confirm_timeout = args[:confirm_timeout] if args.key?(:confirm_timeout)
181
+ req.nack_raise = args[:nack_raise] if args.key?(:nack_raise)
178
182
  end
179
183
 
180
184
  # Recupera o crea la Session asociada al slot de conexión dado.
@@ -153,6 +153,15 @@ module BugBunny
153
153
  # }
154
154
  attr_accessor :on_return
155
155
 
156
+ # @return [Boolean] Si `true` (default), {BugBunny::Producer#confirmed} levanta
157
+ # {BugBunny::PublishNacked} cuando el broker NACKea la publicación. Si `false`,
158
+ # el NACK solo se logea como `producer.confirms_nacked` y la llamada retorna
159
+ # `{ 'status' => 202 }` (modo legacy).
160
+ #
161
+ # El valor puede sobreescribirse por request pasando `nack_raise:` en
162
+ # `Client#publish`.
163
+ attr_accessor :nack_raise
164
+
156
165
  # @!endgroup
157
166
 
158
167
  # Inicializa la configuración con valores por defecto seguros.
@@ -194,9 +203,7 @@ module BugBunny
194
203
  @queue_options = {}
195
204
 
196
205
  @consumer_middlewares = ConsumerMiddleware::Stack.new
197
- @rpc_reply_headers = nil
198
- @on_rpc_reply = nil
199
- @on_return = nil
206
+ init_callback_defaults
200
207
  end
201
208
 
202
209
  # Construye la URL de conexión AMQP basada en los atributos configurados.
@@ -223,6 +230,17 @@ module BugBunny
223
230
 
224
231
  private
225
232
 
233
+ # Defaults para callbacks y flags relacionados con publish/RPC.
234
+ # Extraído de {#initialize} para mantener el ABC size dentro de los límites.
235
+ #
236
+ # @return [void]
237
+ def init_callback_defaults
238
+ @rpc_reply_headers = nil
239
+ @on_rpc_reply = nil
240
+ @on_return = nil
241
+ @nack_raise = true
242
+ end
243
+
226
244
  def validate_required!(attr, value, rules)
227
245
  return unless rules[:required]
228
246
  return unless value.nil? || (value.is_a?(String) && value.empty?)
@@ -19,6 +19,37 @@ module BugBunny
19
19
  # Protege contra vulnerabilidades de RCE validando la herencia de las clases enrutadas.
20
20
  class SecurityError < Error; end
21
21
 
22
+ # Error lanzado cuando el broker responde NACK a una publicación en modo `:confirmed`.
23
+ #
24
+ # Un NACK significa que el broker rechazó explícitamente el mensaje (ej: política de
25
+ # confirms interna, disk full, replicación insuficiente). El mensaje no fue aceptado
26
+ # y se considera no entregado — equivalente a un fallo de transporte desde la
27
+ # perspectiva del publisher.
28
+ #
29
+ # Se levanta por default desde {BugBunny::Producer#confirmed}. Para opt-out,
30
+ # configurar `BugBunny.configuration.nack_raise = false` o pasar
31
+ # `nack_raise: false` por request.
32
+ #
33
+ # @example
34
+ # rescue BugBunny::PublishNacked => e
35
+ # e.path # => 'acct.start'
36
+ # e.nacked_count # => 1
37
+ class PublishNacked < Error
38
+ # @return [String] La ruta del request cuyo publish fue NACKeado.
39
+ attr_reader :path
40
+
41
+ # @return [Integer] Cantidad de mensajes NACKeados según `Bunny::Channel#nacked_set`.
42
+ attr_reader :nacked_count
43
+
44
+ # @param path [String] Ruta lógica del request (ej: 'acct.start').
45
+ # @param nacked_count [Integer] Cantidad de NACKs reportados por el broker.
46
+ def initialize(path:, nacked_count:)
47
+ @path = path
48
+ @nacked_count = nacked_count
49
+ super("broker NACK on path=#{path} (nacked=#{nacked_count})")
50
+ end
51
+ end
52
+
22
53
  # ==========================================
23
54
  # Categoría: Errores del Cliente (4xx)
24
55
  # ==========================================
@@ -52,14 +52,24 @@ module BugBunny
52
52
  # A diferencia de {#rpc} (que espera la respuesta de un Consumer remoto), aquí solo se
53
53
  # espera el ACK del propio broker — no hay round-trip al servicio destino.
54
54
  #
55
- # @param request [BugBunny::Request] Request con `mandatory`, `confirm_timeout` y/o `on_return` opcionales.
55
+ # @param request [BugBunny::Request] Request con `mandatory`, `confirm_timeout`, `nack_raise`
56
+ # y/o `on_return` opcionales.
56
57
  # @return [Hash] `{ 'status' => 202, 'body' => nil }` si el broker confirmó la recepción.
57
58
  # @raise [BugBunny::RequestTimeout] Si el broker no confirma dentro de `confirm_timeout` segundos.
59
+ # @raise [BugBunny::PublishNacked] Si el broker NACKea la publicación y `nack_raise` está activo
60
+ # (default `true` — ver {BugBunny::Configuration#nack_raise}).
58
61
  # @raise [BugBunny::CommunicationError] Si el canal AMQP falla durante la publicación o confirm.
59
62
  def confirmed(request)
60
- publish_message(request)
61
- wait_for_confirms!(request)
62
- log_nacks_if_any(request)
63
+ started_at = monotonic_now
64
+ publish_duration = publish_message(request)
65
+
66
+ confirm_started_at = monotonic_now
67
+ acked = wait_for_confirms!(request)
68
+ confirm_duration = duration_s(confirm_started_at)
69
+
70
+ handle_confirm_result(request, acked)
71
+ log_confirmed(request, publish_duration, confirm_duration, started_at)
72
+
63
73
  { 'status' => 202, 'body' => nil }
64
74
  rescue BugBunny::Error
65
75
  raise
@@ -87,6 +97,7 @@ module BugBunny
87
97
  future = Concurrent::IVar.new
88
98
  @pending_requests[cid] = future
89
99
 
100
+ started_at = monotonic_now
90
101
  begin
91
102
  fire(request)
92
103
 
@@ -98,11 +109,7 @@ module BugBunny
98
109
  raise BugBunny::RequestTimeout, "Timeout waiting for RPC: #{request.path} [#{request.method}]" if result.nil?
99
110
 
100
111
  BugBunny.configuration.on_rpc_reply&.call(result[:headers])
101
-
102
- safe_log(:debug, 'producer.rpc_response_received',
103
- messaging_system: 'rabbitmq', messaging_operation: 'receive', messaging_message_id: cid,
104
- response_body: result[:body]&.truncate(500),
105
- response_headers: result[:headers]&.to_json&.truncate(300))
112
+ log_rpc_response_received(request, cid, result, started_at)
106
113
 
107
114
  parse_response(result[:body])
108
115
  ensure
@@ -116,8 +123,11 @@ module BugBunny
116
123
  # Resuelve exchange, serializa payload, logea y publica el mensaje.
117
124
  # Compartido por {#fire} y {#confirmed}.
118
125
  #
126
+ # Emite `producer.publish` antes y `producer.published` después con `duration_s`
127
+ # midiendo solo el `basic_publish` (TCP enqueue al broker, sin esperar ACK).
128
+ #
119
129
  # @param request [BugBunny::Request]
120
- # @return [void]
130
+ # @return [Float] duración del publish en segundos.
121
131
  def publish_message(request)
122
132
  x = @session.exchange(
123
133
  name: request.exchange,
@@ -126,7 +136,43 @@ module BugBunny
126
136
  )
127
137
  payload = serialize_message(request.body)
128
138
  log_request(request, payload)
139
+
140
+ started_at = monotonic_now
129
141
  x.publish(payload, request.amqp_options.merge(routing_key: request.final_routing_key))
142
+ publish_duration = duration_s(started_at)
143
+
144
+ log_published(request, publish_duration)
145
+ publish_duration
146
+ end
147
+
148
+ # @api private
149
+ def log_published(request, publish_duration_s)
150
+ safe_log(:info, 'producer.published',
151
+ method: request.method.to_s.upcase, path: request.path,
152
+ routing_key: request.final_routing_key,
153
+ messaging_message_id: request.correlation_id,
154
+ duration_s: publish_duration_s)
155
+ end
156
+
157
+ # @api private
158
+ def log_rpc_response_received(request, cid, result, started_at)
159
+ safe_log(:info, 'producer.rpc_response_received',
160
+ method: request.method.to_s.upcase, path: request.path,
161
+ messaging_system: 'rabbitmq', messaging_operation: 'receive', messaging_message_id: cid,
162
+ duration_s: duration_s(started_at),
163
+ response_body: result[:body]&.truncate(500),
164
+ response_headers: result[:headers]&.to_json&.truncate(300))
165
+ end
166
+
167
+ # @api private
168
+ def log_confirmed(request, publish_duration_s, confirm_duration_s, started_at)
169
+ safe_log(:info, 'producer.confirmed',
170
+ method: request.method.to_s.upcase, path: request.path,
171
+ routing_key: request.final_routing_key,
172
+ messaging_message_id: request.correlation_id,
173
+ publish_duration_s: publish_duration_s,
174
+ confirm_duration_s: confirm_duration_s,
175
+ duration_s: duration_s(started_at))
130
176
  end
131
177
 
132
178
  # Espera la confirmación del broker con timeout opcional.
@@ -154,20 +200,44 @@ module BugBunny
154
200
  "Timeout (#{timeout}s) waiting for publisher confirms: #{request.path}"
155
201
  end
156
202
 
157
- # Logea las nack-eadas del canal si las hay.
158
- # NACK no es un error fatal: el broker rechazó rutear (ej. confirm policy interna),
159
- # pero el mensaje no se perdió silenciosamente queda en el set para auditoría.
203
+ # Procesa el resultado de `wait_for_confirms`.
204
+ #
205
+ # Si el broker NACKeó (`acked == false`), logea el evento `producer.confirms_nacked`
206
+ # y opcionalmente levanta {BugBunny::PublishNacked} según el flag `nack_raise`
207
+ # resuelto desde el request o la configuración global.
160
208
  #
161
209
  # @param request [BugBunny::Request]
210
+ # @param acked [Boolean] Resultado de `wait_for_confirms` (true = todos los confirms positivos).
211
+ # @raise [BugBunny::PublishNacked] Si hay NACK y `nack_raise?` es true.
162
212
  # @return [void]
163
- def log_nacks_if_any(request)
213
+ def handle_confirm_result(request, acked)
214
+ return if acked
215
+
216
+ count = nacked_count
217
+ safe_log(:warn, 'producer.confirms_nacked', count: count, path: request.path)
218
+ return unless nack_raise?(request)
219
+
220
+ raise BugBunny::PublishNacked.new(path: request.path, nacked_count: count)
221
+ end
222
+
223
+ # Cuenta los delivery tags NACKeados reportados por el canal.
224
+ #
225
+ # @return [Integer]
226
+ def nacked_count
164
227
  ch = @session.channel
165
- return unless ch.respond_to?(:nacked_set)
228
+ return 0 unless ch.respond_to?(:nacked_set)
229
+
230
+ ch.nacked_set&.size || 0
231
+ end
166
232
 
167
- nacked = ch.nacked_set
168
- return if nacked.nil? || nacked.empty?
233
+ # Resuelve el flag `nack_raise` con prioridad request > configuración global.
234
+ #
235
+ # @param request [BugBunny::Request]
236
+ # @return [Boolean]
237
+ def nack_raise?(request)
238
+ return request.nack_raise unless request.nack_raise.nil?
169
239
 
170
- safe_log(:warn, 'producer.confirms_nacked', count: nacked.size, path: request.path)
240
+ BugBunny.configuration.nack_raise
171
241
  end
172
242
 
173
243
  # Registra la petición en el log calculando las opciones de infraestructura.
@@ -27,6 +27,8 @@ module BugBunny
27
27
  # vía {BugBunny.configuration.on_return}.
28
28
  # @attr confirm_timeout [Float, nil] Segundos máximos a esperar el `wait_for_confirms`.
29
29
  # `nil` espera indefinidamente.
30
+ # @attr nack_raise [Boolean, nil] Override per-request del flag global
31
+ # `BugBunny.configuration.nack_raise`. `nil` (default) delega a la configuración global.
30
32
  class Request
31
33
  attr_accessor :body, :headers, :params, :path, :method, :exchange, :exchange_type, :routing_key, :timeout,
32
34
  :delivery_mode, :queue_options
@@ -35,7 +37,7 @@ module BugBunny
35
37
  attr_accessor :exchange_options
36
38
 
37
39
  # Publisher Confirms (delivery_mode = :confirmed)
38
- attr_accessor :mandatory, :confirm_timeout
40
+ attr_accessor :mandatory, :confirm_timeout, :nack_raise
39
41
 
40
42
  # Metadatos AMQP Estándar
41
43
  attr_accessor :app_id, :content_type, :content_encoding, :priority,
@@ -63,6 +65,7 @@ module BugBunny
63
65
  # Defaults para Publisher Confirms (modo :confirmed)
64
66
  @mandatory = false
65
67
  @confirm_timeout = nil
68
+ @nack_raise = nil
66
69
  end
67
70
 
68
71
  # Combina el path con los params como query string.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = '4.12.0'
4
+ VERSION = '4.14.0'
5
5
  end
data/skill/SKILL.md CHANGED
@@ -15,7 +15,7 @@ Skill de conocimiento completo sobre BugBunny. Consultame para cualquier pregunt
15
15
  **Session** — `BugBunny::Session` envuelve canales de Bunny con thread-safety y double-checked locking.
16
16
  **RPC** — Patrón síncrono que usa la pseudo-cola `amq.rabbitmq.reply-to` para respuestas sin crear queues temporales.
17
17
  **Fire-and-Forget** — Patrón asíncrono donde el producer publica y continúa sin esperar respuesta. Retorna `{ 'status' => 202 }`.
18
- **Publisher Confirms** — Extensión de RabbitMQ que confirma al publisher que el broker recibió el mensaje. BugBunny lo expone como `confirmed: true` en `Client#publish`: el publish bloquea hasta `wait_for_confirms`. NACK no es fatal; se logea pero el método retorna 202.
18
+ **Publisher Confirms** — Extensión de RabbitMQ que confirma al publisher que el broker recibió el mensaje. BugBunny lo expone como `confirmed: true` en `Client#publish`: el publish bloquea hasta `wait_for_confirms`. NACK del broker (rechazo explícito) levanta `BugBunny::PublishNacked` por default; configurable via `config.nack_raise = false` o `nack_raise: false` per request para volver al modo log-only.
19
19
  **Mandatory** — Flag de `basic.publish` que pide al broker retornar el mensaje al publisher si no es ruteable a ninguna cola. Se procesa via `basic.return` (no es respuesta del request).
20
20
  **basic.return** — Evento asincrónico que Bunny dispatcha por exchange (`Bunny::Exchange#on_return`). BugBunny registra un handler único por nombre de exchange al resolverlo en `Session#exchange` y lo delega a `Configuration#on_return` o al default que logea.
21
21
  **Resource** — ORM tipo ActiveRecord que mapea operaciones CRUD a llamadas AMQP.
@@ -97,6 +97,37 @@ BugBunny implementa las [OpenTelemetry semantic conventions for messaging](https
97
97
  - **Consumer:** Extrae los campos de los logs estructurados sin mutar los headers originales. Los eventos `consumer.message_received` y `consumer.message_processed` incluyen estos campos automáticamente.
98
98
  - **RPC Reply:** El consumer inyecta los mismos campos en el reply para cerrar el ciclo de traza del lado del cliente.
99
99
 
100
+ ### Eventos de log y duraciones internas
101
+
102
+ BugBunny mide y emite duraciones automáticamente. **No envolver llamadas a `client.publish` con `Process.clock_gettime` en código de aplicación** — duplica el trabajo. Las duraciones siguen las [OpenTelemetry metric semantic conventions](https://opentelemetry.io/docs/specs/semconv/general/metrics/) (`duration_s` como `Float` en segundos).
103
+
104
+ | Evento | Nivel | Emitido por | Campos clave |
105
+ |---|---|---|---|
106
+ | `producer.publish` | INFO | `Producer#publish_message` (pre) | `method`, `path`, `messaging_*` |
107
+ | `producer.publish_payload` | INFO | `Producer#publish_message` | `payload` (truncado), `body_size` |
108
+ | `producer.publish_detail` | DEBUG | `Producer#publish_message` | `exchange_opts` final |
109
+ | `producer.published` | INFO | `Producer#publish_message` (post) | `method`, `path`, `routing_key`, `messaging_message_id`, **`duration_s`** (publish solo) |
110
+ | `producer.confirmed` | INFO | `Producer#confirmed` (post-ACK) | `method`, `path`, `routing_key`, **`publish_duration_s`**, **`confirm_duration_s`**, **`duration_s`** (total) |
111
+ | `producer.confirms_nacked` | WARN | `Producer#confirmed` (NACK) | `count`, `path` |
112
+ | `producer.rpc_waiting` | DEBUG | `Producer#rpc` | `messaging_message_id`, `timeout_s` |
113
+ | `producer.rpc_response_received` | INFO | `Producer#rpc` (reply recibido) | `method`, `path`, **`duration_s`** (round-trip total), `response_body` |
114
+ | `producer.rpc_response_orphaned` | WARN | reply listener | `correlation_id` |
115
+ | `consumer.message_received` | INFO | `Consumer#process_message` | `method`, `path`, `messaging_*` |
116
+ | `consumer.message_processed` | INFO | `Consumer#process_message` (post) | `response_status`, **`duration_s`**, `controller`, `action`, `messaging_*` |
117
+ | `consumer.execution_error` | ERROR | `Consumer#process_message` (rescue) | **`duration_s`**, `error_class`, `error_message` |
118
+ | `consumer.route_not_found` | WARN | `Consumer#process_message` | `method`, `path` |
119
+ | `consumer.connection_error` | ERROR | `Consumer#subscribe` (retry loop) | `attempt_count`, `retry_in_s`, `error_*` |
120
+ | `session.broker_return` | WARN | `Session` (mandatory unrouted) | `reply_code`, `reply_text`, `exchange`, `routing_key` |
121
+
122
+ **Resumen de qué mide cada `duration_s`:**
123
+
124
+ - `producer.published.duration_s` — solo el `basic_publish` (TCP enqueue al broker).
125
+ - `producer.confirmed.publish_duration_s` — el publish.
126
+ - `producer.confirmed.confirm_duration_s` — la espera del ACK del broker (`wait_for_confirms`).
127
+ - `producer.confirmed.duration_s` — total (publish + ACK).
128
+ - `producer.rpc_response_received.duration_s` — round-trip RPC completo (publish + procesamiento remoto + reply).
129
+ - `consumer.message_processed.duration_s` — procesamiento server-side (router + controller + reply).
130
+
100
131
  ---
101
132
 
102
133
  ## API: Configuración Global
@@ -202,7 +233,8 @@ client.publish('events', method: :post, body: { type: 'order.placed' })
202
233
  client.publish('acct.start', exchange: 'acct_x', body: payload,
203
234
  confirmed: true, mandatory: true, confirm_timeout: 0.5)
204
235
  # → { 'status' => 202, 'body' => nil } # broker ACK confirmado
205
- # Si broker NACK: logea `producer.confirms_nacked` y retorna 202 igual (NACK ≠ pérdida).
236
+ # Si broker NACK: logea `producer.confirms_nacked` y raise BugBunny::PublishNacked
237
+ # (default — opt-out con config.nack_raise = false o nack_raise: false per request).
206
238
  # Si timeout: raise BugBunny::RequestTimeout.
207
239
  # Si mandatory + no ruteable: dispara `Configuration#on_return` (default: logea `session.broker_return`).
208
240
  ```
@@ -46,6 +46,7 @@ end
46
46
  | `confirmed` | Boolean | `false` | En `Client#publish`, flipea `delivery_mode` a `:confirmed`. Bloquea hasta `wait_for_confirms`. |
47
47
  | `mandatory` | Boolean | `false` | Pide al broker retornar el mensaje si no es ruteable. Solo útil con `confirmed: true`. |
48
48
  | `confirm_timeout` | Float | `nil` | Segundos máximos a esperar el ACK. `nil` = espera indefinida. Excedido → `BugBunny::RequestTimeout`. |
49
+ | `nack_raise` | Boolean | `nil` | Override per-request de `config.nack_raise`. `nil` = usa flag global. |
49
50
 
50
51
  ## Producer (bajo nivel)
51
52
 
@@ -58,19 +59,22 @@ El `Producer` es usado internamente por el `Client`. Implementa tres patrones de
58
59
  - Reply listener (`basic_consume`) auto-iniciado en el primer RPC.
59
60
  - Double-checked locking mutex para seguridad del listener.
60
61
  - Timeout lanza `BugBunny::RequestTimeout`.
62
+ - **Emite `producer.rpc_response_received` (INFO) con `duration_s` = round-trip total** (publish + procesamiento remoto + reply). No medir en código de aplicación.
61
63
 
62
64
  ### Fire-and-Forget (`Producer#fire`)
63
65
 
64
66
  - Publica en el exchange y retorna `{ 'status' => 202 }` inmediatamente.
65
67
  - Sin confirmación de procesamiento.
68
+ - **Emite `producer.published` (INFO) con `duration_s`** = solo el `basic_publish` (TCP enqueue al broker).
66
69
 
67
70
  ### Confirmed (`Producer#confirmed`)
68
71
 
69
72
  - Publica y bloquea hasta `channel.wait_for_confirms` del broker.
70
73
  - Bunny 2.x **no soporta timeout** nativo en `wait_for_confirms` — BugBunny envuelve la llamada en un hilo auxiliar y usa `Concurrent::IVar#value(timeout)` como reloj. Si `confirm_timeout` expira → `BugBunny::RequestTimeout`.
71
- - NACK del broker no es fatal: se logea `producer.confirms_nacked` con `count` y `path`, y retorna 202 igual.
74
+ - Si `wait_for_confirms` devuelve `false` (broker NACKea), se logea `producer.confirms_nacked` con `count` y `path`. Por default (`config.nack_raise = true`) levanta `BugBunny::PublishNacked` con `path` y `nacked_count`. Para opt-out: `config.nack_raise = false` o pasar `nack_raise: false` per request — en ese caso solo logea y retorna 202.
72
75
  - Si `mandatory: true` y el mensaje no es ruteable, el broker dispara `basic.return`. El handler se atacha vía `Bunny::Exchange#on_return` en `Session#exchange` la primera vez que se resuelve cada exchange (cacheado por nombre, una sola vez por canal) y delega a `Configuration#on_return` o al logger por default.
73
76
  - Errores del canal se envuelven en `BugBunny::CommunicationError`; errores `BugBunny::Error` pre-existentes se propagan sin envolver.
77
+ - **Emite `producer.confirmed` (INFO) con tres duraciones desglosadas**: `publish_duration_s` (TCP enqueue), `confirm_duration_s` (`wait_for_confirms`), `duration_s` (total). Útil para distinguir latencia de red vs latencia de confirm policy del broker.
74
78
 
75
79
  ## Middleware Stack (Client-side, Onion Architecture)
76
80
 
@@ -164,6 +168,7 @@ req.timestamp # Time.now.to_i
164
168
  req.content_type # 'application/json'
165
169
  req.mandatory # Boolean — solo modo :confirmed
166
170
  req.confirm_timeout # Float|nil — solo modo :confirmed
171
+ req.nack_raise # Boolean|nil — override per-request de config.nack_raise (solo modo :confirmed)
167
172
  ```
168
173
 
169
174
  Cuando `mandatory == true`, `Request#amqp_options` inyecta `mandatory: true` en el hash que va a `basic_publish`.
@@ -182,7 +187,10 @@ Producer#confirmed
182
187
 
183
188
  ├──> wait_for_confirms! (espera ACK del broker, con timeout opcional)
184
189
 
185
- └──> log_nacks_if_any (si nacked_set no está vacío → log WARN)
190
+ └──> handle_confirm_result
191
+ ├─ acked == true → return { status: 202 }
192
+ └─ acked == false → log WARN producer.confirms_nacked
193
+ └─ raise BugBunny::PublishNacked (si config.nack_raise || req.nack_raise)
186
194
 
187
195
  Asíncronamente, si el broker no pudo rutear:
188
196
  broker ──basic.return──> Exchange#on_return ──> Session handler ──> Configuration#on_return
@@ -221,7 +229,7 @@ Excepciones del callback se capturan y se logean como `session.on_return_failed`
221
229
  | Auditoría, billing, eventos críticos | `:confirmed` (con `mandatory: true` si es ruteable) |
222
230
  | Request-response síncrono | `:rpc` |
223
231
 
224
- `:confirmed` cuesta un round-trip al broker pero **no** al consumer remoto — más rápido que RPC, con garantía de entrega al broker. NACK del broker es raro (típicamente por confirm policies internas) y no implica pérdida del mensaje.
232
+ `:confirmed` cuesta un round-trip al broker pero **no** al consumer remoto — más rápido que RPC, con garantía de entrega al broker. NACK del broker es raro (típicamente por confirm policies internas, disk full, replicación insuficiente) pero **sí** implica que el mensaje no fue aceptado. Por default BugBunny levanta `BugBunny::PublishNacked` para que el caller pueda escalar (ej: convertir a HTTP 503 y dejar que el sistema upstream reintente). Opt-out con `config.nack_raise = false` o `nack_raise: false` per request.
225
233
 
226
234
  ## Cascada de Configuración (3 niveles)
227
235
 
@@ -8,6 +8,7 @@ StandardError
8
8
  ├── BugBunny::CommunicationError
9
9
  ├── BugBunny::ConfigurationError
10
10
  ├── BugBunny::SecurityError
11
+ ├── BugBunny::PublishNacked
11
12
  ├── BugBunny::ClientError (4xx)
12
13
  │ ├── BugBunny::BadRequest (400)
13
14
  │ ├── BugBunny::NotFound (404)
@@ -37,6 +38,12 @@ StandardError
37
38
  **Cuándo:** El consumer resuelve la clase pero falla la validación `is_a?(BugBunny::Controller)`.
38
39
  **Resolución:** Verificar que el controlador herede de `BugBunny::Controller` y que `config.controller_namespace` sea correcto.
39
40
 
41
+ ### BugBunny::PublishNacked
42
+ **Causa:** El broker NACKea explícitamente una publicación en modo `:confirmed` (`Client#publish(..., confirmed: true)`). Indica que el mensaje no fue aceptado — disk full, replicación insuficiente, confirm policy interna, etc.
43
+ **Cuándo:** `Producer#confirmed` detecta `wait_for_confirms == false`. Se levanta por default (`config.nack_raise = true`).
44
+ **Atributos:** `path` (ruta del request) y `nacked_count` (cantidad de delivery tags NACKeados).
45
+ **Resolución:** Para casos críticos (auditoría, billing, RADIUS accounting), dejar que la excepción bubble para que el caller upstream reintente (ej: HTTP 503 → retry). Para tolerar NACKs (eventos best-effort), `BugBunny.configuration.nack_raise = false` global o `nack_raise: false` per request — en ese caso solo se logea `producer.confirms_nacked`.
46
+
40
47
  ## Errores de Cliente (4xx)
41
48
 
42
49
  ### BugBunny::BadRequest (400)
@@ -162,6 +162,18 @@ RSpec.describe BugBunny::Configuration do
162
162
  end
163
163
  end
164
164
 
165
+ describe 'nack_raise flag' do
166
+ it 'tiene default true (raise PublishNacked en NACK)' do
167
+ expect(BugBunny::Configuration.new.nack_raise).to be(true)
168
+ end
169
+
170
+ it 'acepta false para opt-out (modo legacy)' do
171
+ configure_with(nack_raise: false)
172
+
173
+ expect(BugBunny.configuration.nack_raise).to be(false)
174
+ end
175
+ end
176
+
165
177
  describe '.validate! directamente' do
166
178
  it 'es invocable directamente sobre la instancia' do
167
179
  config = BugBunny::Configuration.new
@@ -95,6 +95,31 @@ RSpec.describe BugBunny::Producer do
95
95
  messaging_destination_name: 'events_x'
96
96
  )
97
97
  end
98
+
99
+ it 'producer.published incluye duration_s y campos de routing' do
100
+ request = BugBunny::Request.new('users')
101
+ request.exchange = 'users_x'
102
+ request.method = :post
103
+ request.correlation_id = 'corr-1'
104
+
105
+ fake_exchange = double('exchange')
106
+ allow(session).to receive(:exchange).and_return(fake_exchange)
107
+ allow(fake_exchange).to receive(:publish)
108
+
109
+ producer.fire(request)
110
+
111
+ published_event = logged_events.find { |e| e[:event] == 'producer.published' }
112
+ expect(published_event).not_to be_nil
113
+ expect(published_event[:level]).to eq(:info)
114
+ expect(published_event[:kwargs]).to include(
115
+ method: 'POST',
116
+ path: 'users',
117
+ routing_key: 'users',
118
+ messaging_message_id: 'corr-1'
119
+ )
120
+ expect(published_event[:kwargs][:duration_s]).to be_a(Numeric)
121
+ expect(published_event[:kwargs][:duration_s]).to be >= 0
122
+ end
98
123
  end
99
124
 
100
125
  describe '#rpc — log events incluyen campos OTel' do
@@ -182,6 +207,28 @@ RSpec.describe BugBunny::Producer do
182
207
  messaging_message_id: 'corr-reply-test'
183
208
  )
184
209
  end
210
+
211
+ it 'producer.rpc_response_received incluye duration_s total' do
212
+ request = BugBunny::Request.new('users')
213
+ request.exchange = 'users_x'
214
+ request.method = :get
215
+
216
+ allow(rpc_producer).to receive(:ensure_reply_listener!)
217
+
218
+ ivar = Concurrent::IVar.new
219
+ allow(Concurrent::IVar).to receive(:new).and_return(ivar)
220
+
221
+ request.correlation_id = 'corr-dur'
222
+ rpc_producer.instance_variable_get(:@pending_requests)['corr-dur'] = ivar
223
+
224
+ Thread.new { ivar.set({ body: '{"ok":true}', headers: {} }) }.join(0.1)
225
+
226
+ rpc_producer.rpc(request)
227
+
228
+ response_event = logged_events.find { |e| e[:event] == 'producer.rpc_response_received' }
229
+ expect(response_event[:kwargs][:duration_s]).to be_a(Numeric)
230
+ expect(response_event[:kwargs][:duration_s]).to be >= 0
231
+ end
185
232
  end
186
233
  end
187
234
 
@@ -245,21 +292,71 @@ RSpec.describe BugBunny::Producer do
245
292
  )
246
293
  end
247
294
 
248
- it 'logea producer.confirms_nacked cuando hay nacks' do
249
- allow(mock_channel).to receive(:nacked_set).and_return(Set.new([1, 2]))
250
-
295
+ it 'NO logea producer.confirms_nacked cuando wait_for_confirms devuelve true' do
251
296
  confirmed_producer.confirmed(build_request)
252
297
 
253
298
  nack_event = logged_events.find { |e| e[:event] == 'producer.confirms_nacked' }
254
- expect(nack_event).not_to be_nil
255
- expect(nack_event[:kwargs]).to include(count: 2, path: 'acct.start')
299
+ expect(nack_event).to be_nil
256
300
  end
257
301
 
258
- it 'NO logea producer.confirms_nacked cuando nacked_set está vacío' do
302
+ it 'logea producer.confirmed con publish_duration_s, confirm_duration_s y duration_s total' do
259
303
  confirmed_producer.confirmed(build_request)
260
304
 
261
- nack_event = logged_events.find { |e| e[:event] == 'producer.confirms_nacked' }
262
- expect(nack_event).to be_nil
305
+ ev = logged_events.find { |e| e[:event] == 'producer.confirmed' }
306
+ expect(ev).not_to be_nil
307
+ expect(ev[:level]).to eq(:info)
308
+ expect(ev[:kwargs]).to include(method: 'POST', path: 'acct.start', routing_key: 'acct.start')
309
+ expect(ev[:kwargs][:publish_duration_s]).to be_a(Numeric)
310
+ expect(ev[:kwargs][:confirm_duration_s]).to be_a(Numeric)
311
+ expect(ev[:kwargs][:duration_s]).to be_a(Numeric)
312
+ end
313
+
314
+ context 'cuando el broker NACKea (wait_for_confirms devuelve false)' do
315
+ before do
316
+ allow(mock_channel).to receive(:wait_for_confirms).and_return(false)
317
+ allow(mock_channel).to receive(:nacked_set).and_return(Set.new([1, 2]))
318
+ end
319
+
320
+ it 'levanta BugBunny::PublishNacked por default (config.nack_raise = true)' do
321
+ expect { confirmed_producer.confirmed(build_request) }.to raise_error(BugBunny::PublishNacked) do |err|
322
+ expect(err.path).to eq('acct.start')
323
+ expect(err.nacked_count).to eq(2)
324
+ end
325
+ end
326
+
327
+ it 'logea producer.confirms_nacked antes de levantar' do
328
+ expect { confirmed_producer.confirmed(build_request) }.to raise_error(BugBunny::PublishNacked)
329
+
330
+ nack_event = logged_events.find { |e| e[:event] == 'producer.confirms_nacked' }
331
+ expect(nack_event).not_to be_nil
332
+ expect(nack_event[:kwargs]).to include(count: 2, path: 'acct.start')
333
+ end
334
+
335
+ it 'no levanta si el request override `nack_raise = false`' do
336
+ req = build_request
337
+ req.nack_raise = false
338
+
339
+ result = confirmed_producer.confirmed(req)
340
+
341
+ expect(result).to eq('status' => 202, 'body' => nil)
342
+ expect(logged_events.find { |e| e[:event] == 'producer.confirms_nacked' }).not_to be_nil
343
+ end
344
+
345
+ it 'no levanta si la configuración global tiene `nack_raise = false`' do
346
+ allow(BugBunny.configuration).to receive(:nack_raise).and_return(false)
347
+
348
+ result = confirmed_producer.confirmed(build_request)
349
+
350
+ expect(result).to eq('status' => 202, 'body' => nil)
351
+ end
352
+
353
+ it 'el override per-request gana sobre la configuración global' do
354
+ allow(BugBunny.configuration).to receive(:nack_raise).and_return(false)
355
+ req = build_request
356
+ req.nack_raise = true
357
+
358
+ expect { confirmed_producer.confirmed(req) }.to raise_error(BugBunny::PublishNacked)
359
+ end
263
360
  end
264
361
 
265
362
  it 'levanta BugBunny::RequestTimeout si wait_for_confirms excede confirm_timeout' do
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: 4.12.0
4
+ version: 4.14.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-05-11 00:00:00.000000000 Z
11
+ date: 2026-05-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bunny
@@ -303,7 +303,7 @@ metadata:
303
303
  homepage_uri: https://github.com/gedera/bug_bunny
304
304
  source_code_uri: https://github.com/gedera/bug_bunny
305
305
  changelog_uri: https://github.com/gedera/bug_bunny/blob/main/CHANGELOG.md
306
- documentation_uri: https://github.com/gedera/bug_bunny/blob/v4.12.0/skill
306
+ documentation_uri: https://github.com/gedera/bug_bunny/blob/v4.14.0/skill
307
307
  post_install_message:
308
308
  rdoc_options: []
309
309
  require_paths: