bug_bunny 4.9.1 → 4.10.1

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: c70dd01d8d666fe56dbb95bc925ee431133cc0c342dc113a0775a4dddd4ce135
4
- data.tar.gz: 2917d1105969a8173a8b9f70944d9b5839122a56185a5334db0e1c3a04594d2c
3
+ metadata.gz: 1e1a5c8c069bdf7634c57b1df16aa226d5abc8fd6e864596d273431a680a1b55
4
+ data.tar.gz: 49b7cf39e53fe8c3f3b90331cfc4f171c8769264356ba1c1d4a767bf27b868ee
5
5
  SHA512:
6
- metadata.gz: 241b7e0e684aa866d98940a2a3bc587ed13cf1da65c5708ff983c439078c40d823f13cac54ee8bf6d735dda138050c598c8cbf06759605523b8b2207de0e8e4d
7
- data.tar.gz: 0b4f509d46dbe3ebd43bf8421bd9938bd2aa5d68ca23e639c3c1d232d5ad9d41b55cec0a4a0096cf8481e46c5d06163ce959d0f0167636d9084bf5d8aa3c9ba2
6
+ metadata.gz: '082acc0c03183a7d077381563ab9da50657e7468006334b5cfc2d00c8442d603285d38d0f557d67793c833fd6c4d5133c82b3cbbe5048f7f89721c19082971b9'
7
+ data.tar.gz: 9ee3d5a73a1e339d13241093bedfc9dfe212e2a77fb655f3fe21f1378b02d5e10c86417e12fe637eaa1f9591be3ae1e7c2a72d6ed3dac5ee3007f05d1ee7159f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [4.10.1] - 2026-04-08
4
+
5
+ ### Correcciones
6
+ - Corregir route matching 404: el path del cliente ahora se normaliza antes de pasarlo a `RouteSet#recognize`. Antes, `URI.parse("http://dummy/#{path}")` prependead un `/` extra al path, causando que rutas existentes no hicieran match. Ahora se usa `path.gsub(%r{^/|/$}, '')` antes del recognize. — @Gabriel
7
+
3
8
  ## [4.9.1] - 2026-04-06
4
9
 
5
10
  ### Correcciones
@@ -176,23 +176,25 @@ module BugBunny
176
176
 
177
177
  safe_log(:info, 'consumer.message_received', method: http_method, path: path,
178
178
  routing_key: delivery_info.routing_key, **otel_fields)
179
- safe_log(:debug, 'consumer.message_received_body', body: body.truncate(200))
179
+ safe_log(:info, 'consumer.message_received_body', body: body&.truncate(500), body_size: body&.size || 0)
180
180
 
181
181
  # ===================================================================
182
182
  # 3. Ruteo Declarativo
183
183
  # ===================================================================
184
- uri = URI.parse("http://dummy/#{path}")
184
+ normalized_path = path.gsub(%r{^/|/$}, '')
185
+
186
+ uri = URI.parse("http://dummy/#{normalized_path}")
185
187
 
186
188
  # Extraemos query params (ej. /nodes?status=active)
187
189
  query_params = uri.query ? Rack::Utils.parse_nested_query(uri.query) : {}
188
190
  query_params = query_params.with_indifferent_access if defined?(ActiveSupport::HashWithIndifferentAccess)
189
191
 
190
192
  # Le preguntamos al motor de rutas global quién debe manejar esto
191
- route_info = BugBunny.routes.recognize(http_method, uri.path)
193
+ route_info = BugBunny.routes.recognize(http_method, normalized_path)
192
194
 
193
195
  if route_info.nil?
194
- safe_log(:warn, 'consumer.route_not_found', method: http_method, path: uri.path)
195
- handle_fatal_error(properties, 404, 'Not Found', "No route matches [#{http_method}] \"/#{uri.path}\"")
196
+ safe_log(:warn, 'consumer.route_not_found', method: http_method, path: normalized_path)
197
+ handle_fatal_error(properties, 404, 'Not Found', "No route matches [#{http_method}] \"#{normalized_path}\"")
196
198
  session.channel.reject(delivery_info.delivery_tag, false)
197
199
  return
198
200
  end
@@ -256,7 +258,7 @@ module BugBunny
256
258
  rescue StandardError => e
257
259
  safe_log(:error, 'consumer.execution_error', duration_s: duration_s(start_time), **exception_metadata(e))
258
260
  safe_log(:debug, 'consumer.execution_error_backtrace', backtrace: e.backtrace.first(5).join(' | '))
259
- handle_fatal_error(properties, 500, 'Internal Server Error', e.message)
261
+ handle_fatal_error(properties, 500, 'Internal Server Error', e.message, e)
260
262
  session.channel.reject(delivery_info.delivery_tag, false)
261
263
  end
262
264
 
@@ -267,7 +269,12 @@ module BugBunny
267
269
  # @param correlation_id [String] ID para correlacionar la respuesta con la petición original.
268
270
  # @return [void]
269
271
  def reply(payload, reply_to, correlation_id)
270
- safe_log(:debug, 'consumer.rpc_reply', reply_to: reply_to, messaging_message_id: correlation_id)
272
+ safe_log(:info, 'consumer.rpc_reply',
273
+ reply_to: reply_to,
274
+ messaging_message_id: correlation_id,
275
+ response_status: payload[:status],
276
+ response_body: payload[:body]&.to_json&.truncate(500),
277
+ response_body_size: payload[:body]&.to_json&.size || 0)
271
278
  otel_headers = BugBunny::OTel.messaging_headers(
272
279
  operation: 'publish',
273
280
  destination: '',
@@ -287,14 +294,20 @@ module BugBunny
287
294
  # Maneja errores fatales asegurando que el cliente reciba una respuesta.
288
295
  # Evita que el cliente RPC se quede esperando hasta el timeout.
289
296
  #
297
+ # @param properties [Bunny::MessageProperties] Headers y propiedades AMQP.
298
+ # @param status [Integer] Código de estado HTTP.
299
+ # @param error_title [String] Título del error.
300
+ # @param detail [String] Detalle del error.
301
+ # @param exception [StandardError, nil] Excepción original (para status 500).
290
302
  # @api private
291
- def handle_fatal_error(properties, status, error_title, detail)
303
+ def handle_fatal_error(properties, status, error_title, detail, exception = nil)
292
304
  return unless properties.reply_to
293
305
 
294
- error_payload = {
295
- status: status,
296
- body: { error: error_title, detail: detail }
297
- }
306
+ body = { error: error_title, detail: detail }
307
+
308
+ body[:bug_bunny_exception] = BugBunny::RemoteError.serialize(exception) if status == 500 && exception
309
+
310
+ error_payload = { status: status, body: body }
298
311
  reply(error_payload, properties.reply_to, properties.correlation_id)
299
312
  end
300
313
 
@@ -224,7 +224,12 @@ module BugBunny
224
224
  {
225
225
  status: 500,
226
226
  headers: response_headers,
227
- body: { error: 'Internal Server Error', detail: exception.message, type: exception.class.name }
227
+ body: {
228
+ error: 'Internal Server Error',
229
+ detail: exception.message,
230
+ type: exception.class.name,
231
+ bug_bunny_exception: BugBunny::RemoteError.serialize(exception)
232
+ }
228
233
  }
229
234
  end
230
235
 
@@ -50,6 +50,14 @@ module BugBunny
50
50
  # Pasamos el body crudo; UnprocessableEntity lo procesará en exception.rb
51
51
  raise BugBunny::UnprocessableEntity, body
52
52
  when 500..599
53
+ if body.is_a?(Hash) && body['bug_bunny_exception']
54
+ exception_data = body['bug_bunny_exception']
55
+ raise BugBunny::RemoteError.new(
56
+ exception_data['class'],
57
+ exception_data['message'],
58
+ exception_data['backtrace'] || []
59
+ )
60
+ end
53
61
  raise BugBunny::InternalServerError, format_error_message(body)
54
62
  else
55
63
  handle_unknown_error(status, body)
@@ -89,7 +89,9 @@ module BugBunny
89
89
  BugBunny.configuration.on_rpc_reply&.call(result[:headers])
90
90
 
91
91
  safe_log(:debug, 'producer.rpc_response_received',
92
- messaging_system: 'rabbitmq', messaging_operation: 'receive', messaging_message_id: cid)
92
+ messaging_system: 'rabbitmq', messaging_operation: 'receive', messaging_message_id: cid,
93
+ response_body: result[:body]&.truncate(500),
94
+ response_headers: result[:headers]&.to_json&.truncate(300))
93
95
 
94
96
  parse_response(result[:body])
95
97
  ensure
@@ -125,7 +127,12 @@ module BugBunny
125
127
  safe_log(:info, 'producer.publish', method: verb, path: target, **otel_fields)
126
128
  safe_log(:debug, 'producer.publish_detail', messaging_destination_name: request.exchange,
127
129
  exchange_opts: final_x_opts)
128
- safe_log(:debug, 'producer.publish_payload', payload: payload.truncate(300)) if payload.is_a?(String)
130
+ return unless payload.is_a?(String)
131
+
132
+ safe_log(:info, 'producer.publish_payload',
133
+ payload: payload.truncate(500),
134
+ payload_class: payload.class.name,
135
+ body_size: request.body.nil? ? 0 : request.body.size)
129
136
  end
130
137
 
131
138
  # Serializa el mensaje para su transporte.
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BugBunny
4
+ # Error 500 especial que propagationa información de una excepción remota.
5
+ #
6
+ # Cuando un controller levanta una excepción no manejada en el worker, esta clase
7
+ # permite al llamador RPC acceder a:
8
+ # - La clase original de la excepción (ej: ActiveRecord::RecordNotFound)
9
+ # - El mensaje original
10
+ # - El backtrace completo para debugging
11
+ #
12
+ # Mantiene compatibilidad hacia atrás: si la respuesta no contiene
13
+ # bug_bunny_exception, se comporta como un InternalServerError común.
14
+ class RemoteError < ServerError
15
+ # @return [String] La clase de la excepción remota (ej: 'ActiveRecord::RecordNotFound').
16
+ attr_reader :original_class
17
+
18
+ # @return [String] El mensaje original de la excepción.
19
+ attr_reader :original_message
20
+
21
+ # @return [Array<String>] El backtrace original de la excepción.
22
+ attr_reader :original_backtrace
23
+
24
+ # Serializa una excepción para transmitirse como parte de la respuesta.
25
+ #
26
+ # @param exception [StandardError] La excepción a serializar.
27
+ # @param max_lines [Integer] Máximo de líneas del backtrace (default 25).
28
+ # @return [Hash] Estructura con class, message y backtrace.
29
+ def self.serialize(exception, max_lines: 25)
30
+ {
31
+ class: exception.class.name,
32
+ message: exception.message,
33
+ backtrace: exception.backtrace&.first(max_lines) || []
34
+ }
35
+ end
36
+
37
+ # Inicializa la excepción remota propagada desde el worker.
38
+ #
39
+ # @param original_class [String] Nombre completo de la clase de la excepción.
40
+ # @param message [String] Mensaje de la excepción.
41
+ # @param backtrace [Array<String>] Stack trace completo.
42
+ def initialize(original_class, message, backtrace)
43
+ @original_class = original_class
44
+ @original_message = message
45
+ @original_backtrace = backtrace || []
46
+ super(message)
47
+ set_backtrace(backtrace || [])
48
+ end
49
+
50
+ # @return [String] Representación legible de la excepción.
51
+ def to_s
52
+ "#{self.class.name}(#{original_class}): #{message}"
53
+ end
54
+ end
55
+ end
@@ -401,6 +401,17 @@ module BugBunny
401
401
  attributes['id'] || @extra_attributes['id'] || @extra_attributes['ID'] || @extra_attributes['Id'] || @extra_attributes['_id']
402
402
  end
403
403
 
404
+ # Representación legible del recurso.
405
+ # Muestra solo ID y atributos principales, sin detalles de infraestructura.
406
+ #
407
+ # @return [String]
408
+ def inspect
409
+ infra_keys = %w[routing_key exchange exchange_type exchange_options queue_options _id]
410
+ attrs = @extra_attributes.merge(attributes).reject { |k, _| infra_keys.include?(k) || k == 'id' }
411
+ attr_str = attrs.first(5).map { |k, v| "#{k}=#{v.inspect}" }.join(' ')
412
+ "#<#{self.class.name} id=#{id.inspect} persisted=#{@persisted}#{" #{attr_str}" unless attr_str.empty?}>"
413
+ end
414
+
404
415
  def id=(value)
405
416
  if self.class.attribute_names.include?('id')
406
417
  super
@@ -461,6 +472,7 @@ module BugBunny
461
472
  # @return [Boolean]
462
473
  def destroy
463
474
  return false unless persisted?
475
+ return false unless id
464
476
 
465
477
  run_callbacks(:destroy) do
466
478
  path = "#{self.class.resource_name}/#{id}"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BugBunny
4
- VERSION = '4.9.1'
4
+ VERSION = '4.10.1'
5
5
  end
data/lib/bug_bunny.rb CHANGED
@@ -4,6 +4,7 @@ require 'bunny'
4
4
  require 'logger'
5
5
  require_relative 'bug_bunny/version'
6
6
  require_relative 'bug_bunny/exception'
7
+ require_relative 'bug_bunny/remote_error'
7
8
  require_relative 'bug_bunny/configuration'
8
9
  require_relative 'bug_bunny/observability'
9
10
  require_relative 'bug_bunny/otel'
data/skill/SKILL.md CHANGED
@@ -230,6 +230,10 @@ No registrar consumer middlewares durante la ejecución de `call()`. El stack to
230
230
  **Causa:** El mensaje intenta ejecutar un controlador que no hereda de `BugBunny::Controller`.
231
231
  **Resolución:** Verificar la jerarquía de controladores y que `config.controller_namespace` coincida.
232
232
 
233
+ ### BugBunny::RouteNotFoundError (404)
234
+ **Causa:** El path del mensaje no coincide con ninguna ruta registrada. El path debe estar normalizado (sin slashes iniciales/trailing).
235
+ **Resolución:** Verificar que el cliente envíe el path sin leading/trailing slashes (ej: `users/42`, no `/users/42/`).
236
+
233
237
  ### BugBunny::UnprocessableEntity (422)
234
238
  **Causa:** Fallo de validación en el servicio remoto.
235
239
  **Resolución:** `resource.save` devuelve `false`. Acceder a `resource.errors` o `rescue` con `e.error_messages`.
@@ -21,13 +21,14 @@ consumer = BugBunny::Consumer.subscribe(
21
21
  2. Extrae campos **OTel messaging** del mensaje para logs estructurados (sin mutar headers).
22
22
  3. Valida que el mensaje tenga header `type` (path).
23
23
  4. Parsea el método HTTP de headers (`x-http-method` o `method`).
24
- 5. Emite log `consumer.message_received` con campos OTel (`messaging_operation: 'process'`).
25
- 6. Reconoce la ruta con `BugBunny.routes.recognize(method, path)`.
26
- 7. Resuelve el controlador validando herencia de `BugBunny::Controller`.
27
- 8. Ejecuta consumer middlewares controller callbacks → acción.
28
- 9. Responde via `reply_to` si está presente (RPC), inyectando campos OTel (`messaging_operation: 'publish'`).
29
- 10. Emite log `consumer.message_processed` con campos OTel y duraciones.
30
- 11. Hace `ack` del mensaje. En caso de error, `reject`.
24
+ 5. **Normaliza el path**: remueve slashes iniciales/trailing (`path.gsub(%r{^/|/$}, '')`).
25
+ 6. Emite log `consumer.message_received` con campos OTel (`messaging_operation: 'process'`).
26
+ 7. Reconoce la ruta con `BugBunny.routes.recognize(method, normalized_path)`.
27
+ 8. Resuelve el controlador validando herencia de `BugBunny::Controller`.
28
+ 9. Ejecuta consumer middlewares controller callbacks acción.
29
+ 10. Responde via `reply_to` si está presente (RPC), inyectando campos OTel (`messaging_operation: 'publish'`).
30
+ 11. Emite log `consumer.message_processed` con campos OTel y duraciones.
31
+ 12. Hace `ack` del mensaje. En caso de error, `reject`.
31
32
 
32
33
  ## Observability: OTel Fields
33
34
 
data/skills.lock CHANGED
@@ -1,5 +1,5 @@
1
1
  ---
2
- synced_at: '2026-04-06 14:08:54'
2
+ synced_at: '2026-04-08 09:13:30'
3
3
  skills:
4
4
  - name: agent-review
5
5
  scope: local
@@ -110,4 +110,48 @@ RSpec.describe BugBunny::Resource, :integration do
110
110
  end
111
111
  end
112
112
  end
113
+
114
+ describe '#inspect' do
115
+ let(:node) { SpecNode.new(id: '123', name: 'test-node', status: 'active', ip: '192.168.1.1', port: 8080) }
116
+
117
+ it 'muestra id y persisted' do
118
+ expect(node.inspect).to include('id="123"')
119
+ expect(node.inspect).to include('persisted=false')
120
+ end
121
+
122
+ it 'muestra atributos principales sin detalles de infraestructura' do
123
+ result = node.inspect
124
+
125
+ expect(result).to include('name="test-node"')
126
+ expect(result).to include('status="active"')
127
+ expect(result).to include('ip="192.168.1.1"')
128
+ expect(result).to include('port=8080')
129
+ expect(result).not_to include('routing_key')
130
+ expect(result).not_to include('exchange')
131
+ end
132
+
133
+ it 'filtra atributos de infraestructura cuando están presentes' do
134
+ node.routing_key = 'radius_1'
135
+ node.exchange = 'test_exchange'
136
+ node.exchange_type = 'topic'
137
+ node.exchange_options = { durable: true }
138
+ node.persisted = true
139
+
140
+ result = node.inspect
141
+
142
+ expect(result).not_to include('routing_key')
143
+ expect(result).not_to include('exchange')
144
+ expect(result).not_to include('exchange_type')
145
+ expect(result).not_to include('exchange_options')
146
+ expect(result).to include('persisted=true')
147
+ end
148
+
149
+ it 'limita a 5 atributos principales' do
150
+ node = SpecNode.new(id: '1', a: '1', b: '2', c: '3', d: '4', e: '5', f: '6')
151
+
152
+ result = node.inspect
153
+
154
+ expect(result.scan('=').length).to be <= 7 # id + persisted + max 5 attrs
155
+ end
156
+ end
113
157
  end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe BugBunny::Routing::Route do
6
+ describe '#match? and #extract_params — path normalization' do
7
+ let(:route) { described_class.new('GET', 'services/otaigccd59q0k7kxb1h193go2/restart', to: 'services#restart') }
8
+
9
+ it 'matches path without leading slash' do
10
+ expect(route.match?('GET', 'services/otaigccd59q0k7kxb1h193go2/restart')).to be true
11
+ end
12
+
13
+ it 'matches path with leading slash' do
14
+ expect(route.match?('GET', '/services/otaigccd59q0k7kxb1h193go2/restart')).to be true
15
+ end
16
+
17
+ it 'matches path with trailing slash' do
18
+ expect(route.match?('GET', 'services/otaigccd59q0k7kxb1h193go2/restart/')).to be true
19
+ end
20
+
21
+ it 'matches path with leading and trailing slash' do
22
+ expect(route.match?('GET', '/services/otaigccd59q0k7kxb1h193go2/restart/')).to be true
23
+ end
24
+
25
+ it 'extracts params from path without slash' do
26
+ params = route.extract_params('services/otaigccd59q0k7kxb1h193go2/restart')
27
+ expect(params).to eq({})
28
+ end
29
+
30
+ it 'extracts params from path with slash' do
31
+ params = route.extract_params('/services/otaigccd59q0k7kxb1h193go2/restart')
32
+ expect(params).to eq({})
33
+ end
34
+
35
+ context 'route with dynamic segment' do
36
+ let(:route_with_id) { described_class.new('GET', 'nodes/:id', to: 'nodes#show') }
37
+
38
+ it 'extracts params from path without slash' do
39
+ params = route_with_id.extract_params('nodes/123')
40
+ expect(params).to eq({ 'id' => '123' })
41
+ end
42
+
43
+ it 'extracts params from path with slash' do
44
+ params = route_with_id.extract_params('/nodes/123')
45
+ expect(params).to eq({ 'id' => '123' })
46
+ end
47
+ end
48
+
49
+ context 'route defined with leading slash' do
50
+ let(:route_with_leading_slash) do
51
+ described_class.new('GET', '/services/otaigccd59q0k7kxb1h193go2/restart', to: 'services#restart')
52
+ end
53
+
54
+ it 'matches path without slash' do
55
+ expect(route_with_leading_slash.match?('GET', 'services/otaigccd59q0k7kxb1h193go2/restart')).to be true
56
+ end
57
+
58
+ it 'matches path with slash' do
59
+ expect(route_with_leading_slash.match?('GET', '/services/otaigccd59q0k7kxb1h193go2/restart')).to be true
60
+ end
61
+ end
62
+ end
63
+
64
+ describe 'RouteSet#recognize path normalization' do
65
+ let(:route_set) { BugBunny::Routing::RouteSet.new }
66
+
67
+ before do
68
+ route_set.draw do
69
+ get 'services/otaigccd59q0k7kxb1h193go2/restart', to: 'services#restart'
70
+ end
71
+ end
72
+
73
+ it 'recognizes path without slash' do
74
+ result = route_set.recognize('GET', 'services/otaigccd59q0k7kxb1h193go2/restart')
75
+ expect(result).not_to be_nil
76
+ expect(result[:controller]).to eq('services')
77
+ expect(result[:action]).to eq('restart')
78
+ end
79
+
80
+ it 'recognizes path with leading slash' do
81
+ result = route_set.recognize('GET', '/services/otaigccd59q0k7kxb1h193go2/restart')
82
+ expect(result).not_to be_nil
83
+ expect(result[:controller]).to eq('services')
84
+ expect(result[:action]).to eq('restart')
85
+ end
86
+
87
+ it 'returns nil for non-matching path' do
88
+ result = route_set.recognize('GET', 'services/nonexistent')
89
+ expect(result).to be_nil
90
+ end
91
+ end
92
+
93
+ describe 'Consumer path handling — URI parsing edge case' do
94
+ let(:channel) { BunnyMocks::FakeChannel.new(true) }
95
+ let(:connection) { BunnyMocks::FakeConnection.new(true, channel) }
96
+
97
+ let(:mock_channel) do
98
+ ch = double('channel')
99
+ allow(ch).to receive(:reject)
100
+ allow(ch).to receive(:ack)
101
+ allow(ch).to receive(:open?).and_return(true)
102
+ default_ex = double('default_exchange')
103
+ allow(default_ex).to receive(:publish)
104
+ allow(ch).to receive(:default_exchange).and_return(default_ex)
105
+ ch
106
+ end
107
+
108
+ let(:mock_session) do
109
+ s = instance_double(BugBunny::Session)
110
+ allow(s).to receive(:exchange).and_return(double('exchange'))
111
+ allow(s).to receive(:queue).and_return(double('queue'))
112
+ allow(s).to receive(:close)
113
+ allow(s).to receive(:channel).and_return(mock_channel)
114
+ s
115
+ end
116
+
117
+ let(:test_consumer) do
118
+ c = BugBunny::Consumer.new(connection)
119
+ c.instance_variable_set(:@session, mock_session)
120
+ c
121
+ end
122
+
123
+ let(:delivery_info) do
124
+ double('delivery_info',
125
+ exchange: 'events_x',
126
+ routing_key: 'users.created',
127
+ delivery_tag: 'delivery-tag-1',
128
+ redelivered?: false)
129
+ end
130
+
131
+ let(:properties) do
132
+ double('properties',
133
+ type: 'services/otaigccd59q0k7kxb1h193go2/restart',
134
+ headers: { 'x-http-method' => 'GET' },
135
+ correlation_id: 'corr-abc-123',
136
+ reply_to: nil,
137
+ content_type: 'application/json')
138
+ end
139
+
140
+ let(:logged_events) { [] }
141
+
142
+ before do
143
+ allow(test_consumer).to receive(:safe_log) do |level, event, **kwargs|
144
+ logged_events << { level: level, event: event, kwargs: kwargs }
145
+ end
146
+ allow(test_consumer).to receive(:handle_fatal_error)
147
+ end
148
+
149
+ it 'logs route_not_found with normalized path (without extra slash)' do
150
+ allow(BugBunny.routes).to receive(:recognize).and_return(nil)
151
+
152
+ test_consumer.send(:process_message, delivery_info, properties, '{}')
153
+
154
+ route_not_found_event = logged_events.find { |e| e[:event] == 'consumer.route_not_found' }
155
+ expect(route_not_found_event).not_to be_nil
156
+ expect(route_not_found_event[:kwargs][:path]).to eq('services/otaigccd59q0k7kxb1h193go2/restart')
157
+ end
158
+
159
+ context 'when properties.type has leading slash' do
160
+ let(:properties) do
161
+ double('properties',
162
+ type: '/services/otaigccd59q0k7kxb1h193go2/restart',
163
+ headers: { 'x-http-method' => 'GET' },
164
+ correlation_id: 'corr-abc-123',
165
+ reply_to: nil,
166
+ content_type: 'application/json')
167
+ end
168
+
169
+ it 'logs route_not_found with normalized path (stripped leading slash)' do
170
+ allow(BugBunny.routes).to receive(:recognize).and_return(nil)
171
+
172
+ test_consumer.send(:process_message, delivery_info, properties, '{}')
173
+
174
+ route_not_found_event = logged_events.find { |e| e[:event] == 'consumer.route_not_found' }
175
+ expect(route_not_found_event).not_to be_nil
176
+ expect(route_not_found_event[:kwargs][:path]).to eq('services/otaigccd59q0k7kxb1h193go2/restart')
177
+ end
178
+ end
179
+ end
180
+ end
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.9.1
4
+ version: 4.10.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - gabix
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-07 00:00:00.000000000 Z
11
+ date: 2026-04-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bunny
@@ -249,6 +249,7 @@ files:
249
249
  - lib/bug_bunny/otel.rb
250
250
  - lib/bug_bunny/producer.rb
251
251
  - lib/bug_bunny/railtie.rb
252
+ - lib/bug_bunny/remote_error.rb
252
253
  - lib/bug_bunny/request.rb
253
254
  - lib/bug_bunny/resource.rb
254
255
  - lib/bug_bunny/routing/route.rb
@@ -289,6 +290,7 @@ files:
289
290
  - spec/unit/producer_spec.rb
290
291
  - spec/unit/request_spec.rb
291
292
  - spec/unit/resource_attributes_spec.rb
293
+ - spec/unit/route_spec.rb
292
294
  - spec/unit/session_spec.rb
293
295
  - test/integration/infrastructure_test.rb
294
296
  - test/integration/manual_client_test.rb
@@ -300,7 +302,7 @@ metadata:
300
302
  homepage_uri: https://github.com/gedera/bug_bunny
301
303
  source_code_uri: https://github.com/gedera/bug_bunny
302
304
  changelog_uri: https://github.com/gedera/bug_bunny/blob/main/CHANGELOG.md
303
- documentation_uri: https://github.com/gedera/bug_bunny/blob/v4.9.1/skill
305
+ documentation_uri: https://github.com/gedera/bug_bunny/blob/v4.10.1/skill
304
306
  post_install_message:
305
307
  rdoc_options: []
306
308
  require_paths: