exis_ray 0.3.2 → 0.3.3

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: 1fd6ad63299e12bd9398e3ccc0436ee48d70f21705cfbdfac492fc97a8fda5af
4
- data.tar.gz: 6cea90be719f5d1b85a9dee5cd281ba24cde652b76f6e16b1f07aa3da72ea255
3
+ metadata.gz: 1a158ad57fc2696906e60524f77c37652db5e84029c833cf9f33d1526f3aa91c
4
+ data.tar.gz: 428a2aafeaa742329aed3b39c6b86741af0b36b6e0f33ab037ae7fbf64911cbe
5
5
  SHA512:
6
- metadata.gz: cbb1b60e287ba40f586c8404d4e5c9914b318090cffb1d0fc9d12ac8c27498004b019190eaaf33cfe35bb63927a016dc26a5973990401e32f0a6b4be69e7b43d
7
- data.tar.gz: 806578528228d964297e68109f75f6485f901bc22919e04d22cb2a794348d9687f9f1c235441756e245f14cd60eaad41b3898c4fc4ed21328c7e3a023dce8fd1
6
+ metadata.gz: 07de5f2488be3ba52d8ad663bb43535b5979aa15db6a25dea2523e2453ec5aff9d7225ce02428711b71c7592410a53c1827d3dee2a0cc064fb98cd734d790057
7
+ data.tar.gz: 157d67c0de961846d7a62441adc4cc8fdfcea70df8e1a5b716fa0699555e4ab7ffd26bd74acadfef331a8ffbca477e3bfc6f49bb54af8874941ee4d643cdad6e
data/CHANGELOG.md CHANGED
@@ -1,3 +1,25 @@
1
+ ## [0.3.3] - 2026-03-23
2
+
3
+ ### Fixed
4
+ - **`ActiveResourceInstrumentation` header incorrecto:** Se corrigió el uso de `trace_header` (formato Rack, ej: `HTTP_X_AMZN_TRACE_ID`) para requests salientes. Ahora se usa `propagation_trace_header` (formato HTTP, ej: `X-Amzn-Trace-Id`), igual que `FaradayMiddleware`. Antes, los microservicios downstream recibían un header con nombre incorrecto y la traza distribuida no se propagaba.
5
+ - **Header injection en `Current` setters:** Los valores asignados a `user_id=`, `isp_id=` y `correlation_id=` ahora son sanitizados antes de escribirse en `ActiveResource::Base.headers`. Se eliminan caracteres CRLF (`\r\n`) para prevenir HTTP header injection hacia otros microservicios.
6
+ - **`Reporter` exponía datos sensibles a Sentry:** `build_from_current` enviaba `user.as_json` completo (incluyendo `password_digest`, tokens, etc.) al contexto de Sentry. Ahora el comportamiento por defecto es enviar solo `{ id: }`. Se exponen los hooks `sentry_user_context` y `sentry_isp_context` para que la app host controle qué campos incluir.
7
+ - **Reloj de pared en cálculo de duración:** `Tracer.created_at` y `current_duration_ms` usaban `Time.now` (afectado por NTP/leap seconds). Ahora usan `Process.clock_gettime(Process::CLOCK_MONOTONIC)` en todos los puntos de asignación (`HttpMiddleware`, `Sidekiq::ServerMiddleware`, `TaskMonitor`) y en el cálculo de duración.
8
+ - **`generate_new_root` ignoraba sufijos no numéricos:** El sufijo del pod/hostname se convertía con `.to_i`, retornando siempre `0` para strings alfanuméricos (ej: `"worker01"` → `00000000`). Ahora se codifican los bytes del string directamente a hex, preservando la unicidad del identificador.
9
+ - **`JsonFormatter#parse_kv_string` crash con quote suelto:** Un valor de un solo caracter `"` provocaba que `value[1..-2]` retornara `nil` y `.gsub` explotara con `NoMethodError`. Corregido con `|| ""` como fallback.
10
+ - **Pérdida silenciosa de mensaje en `JsonFormatter`:** Si `kv_string?` detectaba un string como kv pero `parse_kv_string` no extraía ningún par (ej: `"key="`), el mensaje original desaparecía del JSON sin ir al campo `message`. Ahora cae al fallback correctamente.
11
+ - **`TaskMonitor#log_event` podía enmascarar excepciones del negocio:** Si el logger fallaba dentro de `log_event`, su excepción reemplazaba la excepción original de la tarea. Ahora `log_event` está protegido con `rescue StandardError` interno.
12
+ - **`current_class` y `reporter_class` resueltos dos veces en `ensure`:** En `Sidekiq::ServerMiddleware` y `TaskMonitor`, el bloque `ensure` llamaba `safe_constantize` dos veces por clase. Ahora se asigna a variable local antes del `ensure`.
13
+
14
+ ### Changed
15
+ - **`sentry_user_context` y `sentry_isp_context` como hooks públicos en `Reporter`:** La subclase puede sobreescribir estos métodos para definir exactamente qué atributos del modelo se envían a Sentry, sin exponer datos sensibles por defecto.
16
+
17
+ ### Performance
18
+ - **Memoización de `current_class` y `reporter_class` en producción:** En entornos con `cache_classes=true`, la resolución vía `safe_constantize` se ejecuta una sola vez. En desarrollo se sigue resolviendo por request para respetar el reloading de Zeitwerk.
19
+ - **Regex de `JsonFormatter` extraídas a constantes:** `KV_DETECT_RE` y `KV_PARSE_RE` son ahora constantes de clase, eliminando recompilaciones innecesarias en cada línea de log.
20
+ - **`Current#user` e `Current#isp` usan sentinel para cachear `nil`:** Si `find_by` no encuentra el registro, el resultado se cachea con un objeto sentinel `NOT_FOUND`. Las llamadas subsiguientes retornan `nil` sin consultar la DB.
21
+ - **`Sidekiq::ClientMiddleware` resuelve `current_class` una sola vez** por job encolado en lugar de cuatro veces.
22
+
1
23
  ## [0.3.2] - 2026-03-23
2
24
 
3
25
  ### Added
data/README.md CHANGED
@@ -102,6 +102,26 @@ class Choto < ExisRay::Reporter
102
102
  end
103
103
  ```
104
104
 
105
+ #### Controlling what user/ISP data is sent to Sentry
106
+
107
+ By default, ExisRay only sends `{ id: }` to Sentry for both user and ISP contexts. This is intentional — sending `user.as_json` without restrictions would expose sensitive attributes (`password_digest`, `reset_password_token`, etc.) to a third-party service.
108
+
109
+ To include additional fields, override the hooks in your subclass:
110
+
111
+ ```ruby
112
+ class Choto < ExisRay::Reporter
113
+ def self.sentry_user_context(current)
114
+ { id: current.user_id, email: current.user&.email, role: current.user&.role }
115
+ end
116
+
117
+ def self.sentry_isp_context(current)
118
+ { id: current.isp_id, name: current.isp&.name }
119
+ end
120
+ end
121
+ ```
122
+
123
+ > **Important:** never include attributes like `password_digest`, tokens, or secrets in these methods.
124
+
105
125
  ### 3. Hydrate Context (Controller)
106
126
 
107
127
  In your `ApplicationController`, verify the incoming request and set the context. ExisRay handles the Trace ID automatically, you just handle the Business Logic.
@@ -25,8 +25,9 @@ module ExisRay
25
25
  # Generamos el string propagable: "Root=...;Parent=...;Sampled=..."
26
26
  trace_header_value = ExisRay::Tracer.generate_trace_header
27
27
 
28
- # Buscamos la key configurada (ej: 'HTTP_X_AMZN_TRACE_ID' o custom)
29
- trace_header_key = ExisRay.configuration.trace_header
28
+ # Usamos el header de propagación (formato HTTP, ej: 'X-Amzn-Trace-Id'),
29
+ # NO el trace_header que es formato Rack (HTTP_X_AMZN_TRACE_ID) para lectura entrante.
30
+ trace_header_key = ExisRay.configuration.propagation_trace_header
30
31
 
31
32
  # Retornamos un nuevo hash combinado (merge) para no mutar el original por error
32
33
  original_headers.merge(trace_header_key => trace_header_value)
@@ -6,10 +6,14 @@ module ExisRay
6
6
  class Current < ActiveSupport::CurrentAttributes
7
7
  attribute :user_id, :isp_id, :correlation_id
8
8
 
9
+ # Sentinel para distinguir "no consultado" de "consultado y no encontrado".
10
+ # Evita re-queries a la DB cuando el objeto no existe.
11
+ NOT_FOUND = Object.new.freeze
12
+
9
13
  # Callback nativo de Rails: Se ejecuta automáticamente al llamar a Current.reset
10
14
  resets do
11
- @user = nil
12
- @isp = nil
15
+ @user = NOT_FOUND
16
+ @isp = NOT_FOUND
13
17
 
14
18
  if defined?(PaperTrail)
15
19
  PaperTrail.request.whodunnit = nil
@@ -28,7 +32,7 @@ module ExisRay
28
32
  def user_id=(id)
29
33
  super
30
34
  if defined?(ActiveResource::Base)
31
- ActiveResource::Base.headers['UserId'] = id.to_s
35
+ ActiveResource::Base.headers['UserId'] = sanitize_header_value(id)
32
36
  end
33
37
  if defined?(PaperTrail)
34
38
  PaperTrail.request.whodunnit = id
@@ -37,9 +41,9 @@ module ExisRay
37
41
 
38
42
  def isp_id=(id)
39
43
  super
40
- @isp = nil # Invalida cache
44
+ @isp = NOT_FOUND # Invalida cache
41
45
  if defined?(ActiveResource::Base)
42
- ActiveResource::Base.headers['IspId'] = id.to_s
46
+ ActiveResource::Base.headers['IspId'] = sanitize_header_value(id)
43
47
  end
44
48
  end
45
49
 
@@ -51,7 +55,7 @@ module ExisRay
51
55
  end
52
56
 
53
57
  if defined?(ActiveResource::Base)
54
- ActiveResource::Base.headers['CorrelationId'] = id.to_s
58
+ ActiveResource::Base.headers['CorrelationId'] = sanitize_header_value(id)
55
59
  end
56
60
 
57
61
  if defined?(PaperTrail)
@@ -68,33 +72,35 @@ module ExisRay
68
72
  # Estos métodos asumen que la app host tiene modelos ::User e ::Isp
69
73
 
70
74
  def user=(object)
71
- @user = object
75
+ @user = object || NOT_FOUND
72
76
  self.user_id = object&.id
73
77
  end
74
78
 
75
79
  def user
76
- return @user if defined?(@user) && @user
80
+ @user = NOT_FOUND unless defined?(@user)
81
+ return nil if @user.equal?(NOT_FOUND) && !user_id
77
82
 
78
- if user_id && defined?(::User) && ::User.respond_to?(:find_by)
79
- @user = ::User.find_by(id: user_id)
80
- else
81
- nil
83
+ if @user.equal?(NOT_FOUND)
84
+ @user = (defined?(::User) && ::User.respond_to?(:find_by) ? ::User.find_by(id: user_id) : nil) || NOT_FOUND
82
85
  end
86
+
87
+ @user.equal?(NOT_FOUND) ? nil : @user
83
88
  end
84
89
 
85
90
  def isp=(object)
86
- @isp = object
91
+ @isp = object || NOT_FOUND
87
92
  self.isp_id = object&.id
88
93
  end
89
94
 
90
95
  def isp
91
- return @isp if defined?(@isp) && @isp
96
+ @isp = NOT_FOUND unless defined?(@isp)
97
+ return nil if @isp.equal?(NOT_FOUND) && !isp_id
92
98
 
93
- if isp_id && defined?(::Isp) && ::Isp.respond_to?(:find_by)
94
- @isp = ::Isp.find_by(id: isp_id)
95
- else
96
- nil
99
+ if @isp.equal?(NOT_FOUND)
100
+ @isp = (defined?(::Isp) && ::Isp.respond_to?(:find_by) ? ::Isp.find_by(id: isp_id) : nil) || NOT_FOUND
97
101
  end
102
+
103
+ @isp.equal?(NOT_FOUND) ? nil : @isp
98
104
  end
99
105
 
100
106
  def user?
@@ -108,5 +114,14 @@ module ExisRay
108
114
  def correlation_id?
109
115
  correlation_id.present?
110
116
  end
117
+
118
+ private
119
+
120
+ # Elimina caracteres CRLF para prevenir HTTP header injection.
121
+ # Un valor con "\r\n" en un header de ActiveResource podría inyectar
122
+ # headers arbitrarios en requests hacia otros microservicios.
123
+ def sanitize_header_value(value)
124
+ value.to_s.gsub(/[\r\n]/, '')
125
+ end
111
126
  end
112
127
  end
@@ -8,7 +8,7 @@ module ExisRay
8
8
 
9
9
  def call(env)
10
10
  # 1. Hidratar Infraestructura
11
- ExisRay::Tracer.created_at = Time.now.utc.to_f
11
+ ExisRay::Tracer.created_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
12
12
  ExisRay::Tracer.source = "http"
13
13
 
14
14
  trace_header_key = ExisRay.configuration.trace_header
@@ -15,10 +15,16 @@ module ExisRay
15
15
  # Automáticamente inyecta el contexto de trazabilidad ({ExisRay::Tracer})
16
16
  # y el contexto de negocio ({ExisRay::Current}) en cada línea de log.
17
17
  class JsonFormatter < ::Logger::Formatter
18
- # Solución al NoMethodError: Garantiza que el formateador sea compatible
18
+ # Solución al NoMethodError: Garantiza que el formateador sea compatible
19
19
  # con el wrapper de ActiveSupport::TaggedLogging de Rails.
20
20
  include ActiveSupport::TaggedLogging::Formatter if defined?(ActiveSupport::TaggedLogging::Formatter)
21
21
 
22
+ # Detecta si un string comienza con al menos un par key=value.
23
+ KV_DETECT_RE = /\A\w+=/
24
+
25
+ # Extrae pares key=value de un string. Soporta valores sin espacios o entre comillas dobles.
26
+ KV_PARSE_RE = /(\w+)=("(?:[^"\\]|\\.)*"|\S+)/
27
+
22
28
  # Procesa un mensaje de log y lo formatea como una cadena estructurada en JSON.
23
29
  #
24
30
  # @param severity [String] El nivel de severidad del log (ej. "INFO", "ERROR", "DEBUG").
@@ -99,7 +105,8 @@ module ExisRay
99
105
  if msg.is_a?(Hash)
100
106
  payload.merge!(msg)
101
107
  elsif msg.is_a?(String) && kv_string?(msg)
102
- payload.merge!(parse_kv_string(msg))
108
+ parsed = parse_kv_string(msg)
109
+ parsed.empty? ? payload[:message] = msg : payload.merge!(parsed)
103
110
  else
104
111
  payload[:message] = msg.to_s
105
112
  end
@@ -111,7 +118,7 @@ module ExisRay
111
118
  # @param str [String]
112
119
  # @return [Boolean]
113
120
  def kv_string?(str)
114
- str.match?(/\A\w+=/)
121
+ str.match?(KV_DETECT_RE)
115
122
  end
116
123
 
117
124
  # Parsea un string con formato key=value y retorna un Hash.
@@ -121,8 +128,8 @@ module ExisRay
121
128
  # @return [Hash]
122
129
  def parse_kv_string(str)
123
130
  result = {}
124
- str.scan(/(\w+)=("(?:[^"\\]|\\.)*"|\S+)/) do |key, value|
125
- result[key.to_sym] = value.start_with?('"') ? value[1..-2].gsub('\\"', '"') : value
131
+ str.scan(KV_PARSE_RE) do |key, value|
132
+ result[key.to_sym] = value.start_with?('"') ? (value[1..-2] || "").gsub('\\"', '"') : value
126
133
  end
127
134
  result
128
135
  end
@@ -172,16 +172,37 @@ module ExisRay
172
172
  add_tags(isp_id: klass.isp_id) if klass.respond_to?(:isp_id?) && klass.isp_id?
173
173
 
174
174
  if klass.respond_to?(:user) && klass.user.present?
175
- user_json = klass.user.respond_to?(:as_json) ? klass.user.as_json : { id: klass.user_id }
176
- add_context(user: user_json)
175
+ add_context(user: sentry_user_context(klass))
177
176
  end
178
177
 
179
178
  if klass.respond_to?(:isp) && klass.isp.present?
180
- isp_json = klass.isp.respond_to?(:as_json) ? klass.isp.as_json : { id: klass.isp_id }
181
- add_context(isp: isp_json)
179
+ add_context(isp: sentry_isp_context(klass))
182
180
  end
183
181
  end
184
182
 
183
+ # Hook para que la app host controle qué datos del usuario se envían a Sentry.
184
+ # Por defecto solo se envía el ID para evitar exponer datos sensibles (password_digest, tokens, etc.).
185
+ #
186
+ # @example Sobreescribir en la subclase del Reporter de la app
187
+ # def self.sentry_user_context(current)
188
+ # { id: current.user_id, email: current.user&.email }
189
+ # end
190
+ #
191
+ # @param current [Class] La clase Current resuelta.
192
+ # @return [Hash]
193
+ def self.sentry_user_context(current)
194
+ { id: current.user_id }
195
+ end
196
+
197
+ # Hook para que la app host controle qué datos del ISP se envían a Sentry.
198
+ # Por defecto solo se envía el ID.
199
+ #
200
+ # @param current [Class] La clase Current resuelta.
201
+ # @return [Hash]
202
+ def self.sentry_isp_context(current)
203
+ { id: current.isp_id }
204
+ end
205
+
185
206
  private_class_method :build_from_current, :build_from_tracer
186
207
  end
187
208
  end
@@ -14,11 +14,11 @@ module ExisRay
14
14
 
15
15
  # 2. Inyectamos el contexto de negocio (Current)
16
16
  # Esto permite saber qué Usuario o ISP disparó el job.
17
- if ExisRay.current_class.present?
17
+ if (curr = ExisRay.current_class).present?
18
18
  context = {}
19
- context[:user_id] = ExisRay.current_class.user_id if ExisRay.current_class.respond_to?(:user_id)
20
- context[:isp_id] = ExisRay.current_class.isp_id if ExisRay.current_class.respond_to?(:isp_id)
21
- context[:correlation_id] = ExisRay.current_class.correlation_id if ExisRay.current_class.respond_to?(:correlation_id)
19
+ context[:user_id] = curr.user_id if curr.respond_to?(:user_id)
20
+ context[:isp_id] = curr.isp_id if curr.respond_to?(:isp_id)
21
+ context[:correlation_id] = curr.correlation_id if curr.respond_to?(:correlation_id)
22
22
 
23
23
  job['exis_ray_context'] = context
24
24
  end
@@ -32,8 +32,10 @@ module ExisRay
32
32
  ensure
33
33
  # Limpieza vital en Sidekiq para evitar fugas de contexto entre jobs en el mismo hilo.
34
34
  ExisRay::Tracer.reset
35
- ExisRay.current_class&.reset if ExisRay.current_class.respond_to?(:reset)
36
- ExisRay.reporter_class&.reset if ExisRay.reporter_class.respond_to?(:reset)
35
+ current = ExisRay.current_class
36
+ reporter = ExisRay.reporter_class
37
+ current.reset if current&.respond_to?(:reset)
38
+ reporter.reset if reporter&.respond_to?(:reset)
37
39
  end
38
40
 
39
41
  private
@@ -44,7 +46,7 @@ module ExisRay
44
46
  # @param job [Hash] Payload de Sidekiq.
45
47
  # @return [void]
46
48
  def hydrate_tracer(worker, job)
47
- ExisRay::Tracer.created_at = Time.now.utc.to_f
49
+ ExisRay::Tracer.created_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
50
  ExisRay::Tracer.sidekiq_job = worker.class.name
49
51
  ExisRay::Tracer.source = "sidekiq"
50
52
 
@@ -41,8 +41,10 @@ module ExisRay
41
41
  ensure
42
42
  # Limpieza centralizada obligatoria para evitar filtraciones de memoria o contexto
43
43
  ExisRay::Tracer.reset
44
- ExisRay.current_class&.reset if ExisRay.current_class.respond_to?(:reset)
45
- ExisRay.reporter_class&.reset if ExisRay.reporter_class.respond_to?(:reset)
44
+ current = ExisRay.current_class
45
+ reporter = ExisRay.reporter_class
46
+ current.reset if current&.respond_to?(:reset)
47
+ reporter.reset if reporter&.respond_to?(:reset)
46
48
  end
47
49
 
48
50
  # --- Métodos Privados ---
@@ -55,7 +57,7 @@ module ExisRay
55
57
  ExisRay::Tracer.task = task_name.to_s
56
58
  ExisRay::Tracer.source = "task"
57
59
  ExisRay::Tracer.request_id = SecureRandom.uuid
58
- ExisRay::Tracer.created_at = Time.now.utc.to_f
60
+ ExisRay::Tracer.created_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
59
61
 
60
62
  pod_id = get_pod_identifier
61
63
  ExisRay::Tracer.root_id = ExisRay::Tracer.send(:generate_new_root, pod_id)
@@ -93,6 +95,8 @@ module ExisRay
93
95
  else
94
96
  Rails.logger.send(level, "[ExisRay] #{message}")
95
97
  end
98
+ rescue StandardError
99
+ # El logger nunca debe interrumpir el flujo principal de la tarea.
96
100
  end
97
101
 
98
102
  private_class_method :get_pod_identifier, :setup_tracer, :execute_with_optional_tags, :log_event
@@ -59,7 +59,7 @@ module ExisRay
59
59
  # @return [Integer] Duración en ms.
60
60
  def self.current_duration_ms
61
61
  return 0 unless created_at
62
- ((Time.now.utc.to_f - created_at) * 1000).round
62
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - created_at) * 1000).round
63
63
  end
64
64
 
65
65
  # Construye el header de trazabilidad para enviar al siguiente servicio.
@@ -89,7 +89,10 @@ module ExisRay
89
89
  timestamp_hex = Time.now.to_i.to_s(16)
90
90
 
91
91
  if suffix_id.present?
92
- suffix_hex = suffix_id.to_i.to_s(16).rjust(8, '0')
92
+ # Codificamos los bytes del string a hex para preservar unicidad
93
+ # independientemente de si el sufijo es numérico o alfanumérico.
94
+ # Ej: "worker01" → "776f726b657230 31", "abc" → "616263"
95
+ suffix_hex = suffix_id.to_s.bytes.map { |b| b.to_s(16).rjust(2, '0') }.join.first(8).rjust(8, '0')
93
96
  unique_part = SecureRandom.hex(8) + suffix_hex
94
97
  else
95
98
  unique_part = SecureRandom.hex(12)
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ExisRay
4
4
  # Versión actual de la gema.
5
- VERSION = "0.3.2"
5
+ VERSION = "0.3.3"
6
6
  end
data/lib/exis_ray.rb CHANGED
@@ -60,7 +60,8 @@ module ExisRay
60
60
  # --- Helpers Centralizados de Resolución de Clases ---
61
61
 
62
62
  # Resuelve y retorna la clase configurada para manejar el contexto de negocio (Current).
63
- # Convierte el String configurado (ej: 'Current') en la clase real constante.
63
+ # En producción (cache_classes=true) memoiza el resultado para evitar safe_constantize
64
+ # en cada request. En desarrollo siempre resuelve para soportar el reloading de Zeitwerk.
64
65
  #
65
66
  # @return [Class, nil] La clase constante (ej: Current) o nil si no se encuentra/configura.
66
67
  def current_class
@@ -69,12 +70,15 @@ module ExisRay
69
70
  klass_name = configuration.current_class
70
71
  return nil unless klass_name.present?
71
72
 
72
- # Si es String, lo convertimos a constante de forma segura.
73
- klass_name.is_a?(String) ? klass_name.safe_constantize : klass_name
73
+ if cache_classes?
74
+ @current_class_cache ||= resolve_class(klass_name)
75
+ else
76
+ resolve_class(klass_name)
77
+ end
74
78
  end
75
79
 
76
80
  # Resuelve y retorna la clase configurada para el reporte de errores (Reporter).
77
- # Convierte el String configurado (ej: 'Choto') en la clase real constante.
81
+ # En producción memoiza el resultado. En desarrollo siempre resuelve.
78
82
  #
79
83
  # @return [Class, nil] La clase constante (ej: Choto) o nil si no se encuentra/configura.
80
84
  def reporter_class
@@ -83,7 +87,21 @@ module ExisRay
83
87
  klass_name = configuration.reporter_class
84
88
  return nil unless klass_name.present?
85
89
 
90
+ if cache_classes?
91
+ @reporter_class_cache ||= resolve_class(klass_name)
92
+ else
93
+ resolve_class(klass_name)
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def resolve_class(klass_name)
86
100
  klass_name.is_a?(String) ? klass_name.safe_constantize : klass_name
87
101
  end
102
+
103
+ def cache_classes?
104
+ defined?(Rails) && Rails.application.config.cache_classes
105
+ end
88
106
  end
89
107
  end
@@ -82,6 +82,10 @@ RSpec.describe ExisRay::JsonFormatter do
82
82
  expect(result["msg"]).to eq('dijo "hola"')
83
83
  end
84
84
 
85
+ it "no crashea con un quote suelto como valor (string malformado)" do
86
+ expect { call('key="') }.not_to raise_error
87
+ end
88
+
85
89
  it "soporta múltiples pares con tipos de valor variados" do
86
90
  result = call("component=orders event=invoice_generated duration_ms=42.5 retries=0")
87
91
 
@@ -94,6 +98,14 @@ RSpec.describe ExisRay::JsonFormatter do
94
98
  end
95
99
  end
96
100
 
101
+ it "cae a message si el string parece kv pero no produce ningún par" do
102
+ result = call("key=")
103
+
104
+ expect(result["message"]).to eq("key=")
105
+ expect(result).not_to have_key("key")
106
+ end
107
+ end
108
+
97
109
  context "cuando el mensaje es un String libre (sin formato key=value)" do
98
110
  it "asigna el string completo al campo message" do
99
111
  result = call("algo salió mal")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exis_ray
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriel Edera