exis_ray 0.7.1 → 0.8.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: 91d6964520e83c2088af45357edf601358d2ffefbff4861ef15e1abaf09a36bc
4
- data.tar.gz: d276b0753bd7b956662a3f6e1d61cb51d707a9729496f17b8bd4a3ca1bc7fba4
3
+ metadata.gz: 95101aa6f502c3c839070a556ab984244493d9f8bbabaad2b3b4e2e83b19cc67
4
+ data.tar.gz: af2097e279144370005dd457effbeaf961a11938f191a9aa30053efe717f9b12
5
5
  SHA512:
6
- metadata.gz: 4b382e4fcd8171db8771f9f5f8d2238fdf2cd17b2ac4e953c54d06248eab390c321bf37579b954e3965bb7092c0959dcfae999782200ea8cbee487ca6c74b314
7
- data.tar.gz: a6da50e40e16e4560d6c52e6443b36d2c7ffef5263940b73a3696d7411817ef5990cd26bf625141984694b7d2f824b22fdd88ba2e9072762b721b9e1718a3eaf
6
+ metadata.gz: 62e1bf2fe943f02976ba69b73ba9d76e698f93f6b9d1284a02354c01c4ab4d5b18411212da27ffe0ea315616184dfce0d3b66dc9a871c6cebbb50320656f5342
7
+ data.tar.gz: 10cf92356274c0da04446c96a109f298eb2c94a8eacb3787762e9785cf42915b877304161f70aeb6ac8478598b29c6aca127057a0db8d7f089f29b046a9bd954
data/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.8.0] - 2026-05-14
2
+
3
+ ### Nuevas funcionalidades
4
+ - **`Current.log_fields` hook** (#6, #8): class method overridable en `ExisRay::Current` para inyectar campos custom en cada log line. Cubre con un solo mecanismo tanto constantes de proceso (frozen constants en la subclass) como valores dinámicos per-request (atributos de Current). `JsonFormatter` filtra claves sensibles del hash retornado y rescata silenciosamente si el override revienta. Útil para servicios multi-tenant donde se necesita inyectar `tenant_id`, `region`, `stack_id`, etc.
5
+
6
+ ### Mejoras internas
7
+ - **`.rubocop.yml` relajado para destrabar CI**: los thresholds default venían bloqueando todos los PRs recientes. Excluido `Metrics/BlockLength` en specs y railtie, deshabilitado `Lint/SuppressedException` (el estándar Wispro requiere `rescue StandardError` vacíos en logging), thresholds más realistas para Metrics en lib/.
8
+
9
+ ## [0.7.2] - 2026-05-11
10
+
11
+ ### Documentación
12
+ - **Warning sobre `ActiveSupport::TaggedLogging` con JSON logging:** `TaggedLogging` antepone tags como texto raw antes del formatter, rompiendo el JSON (`[req_id] {...}`). `JsonFormatter` ya inyecta `request_id`/`trace_id` como campos JSON — usar ambos genera salida inválida. Documentada la config correcta del logger en `production.rb` (README + SKILL FAQ).
13
+ - **Skill improvements (issue #4):** (1) Clarificado el criterio auto-inyectado vs manual — el formatter solo conoce contexto de ejecución (Tracer/Current), por eso `component` y `event` deben aportarse manualmente. (2) Agregados ejemplos concretos de output JSON lado-a-lado con el input KV/Hash. (3) Completada la lista de campos default de `LogSubscriber` (17 campos con tipos y condiciones, antes truncado con "etc."). (4) Documentados los 3 modos de input del formatter (KV / Hash / string libre) con ejemplos de cada uno.
14
+
1
15
  ## [0.7.1] - 2026-04-06
2
16
 
3
17
  ### Fixed
data/CLAUDE.md CHANGED
@@ -111,6 +111,7 @@ ExisRay.configuration.json_logs? # => true/false
111
111
  | `correlation_id` | Cuando `Current.correlation_id` está presente |
112
112
  | `user_id` | Cuando `Current.user_id` está presente |
113
113
  | `isp_id` | Cuando `Current.isp_id` está presente |
114
+ | `Current.log_fields` (cualquier key) | Si la subclass overrideó el hook (default `{}`) |
114
115
  | `sidekiq_job` | Solo en procesos Sidekiq |
115
116
  | `task` | Solo en procesos TaskMonitor |
116
117
  | `tags` | Solo si hay Rails tagged logging activo |
data/README.md CHANGED
@@ -151,6 +151,38 @@ end
151
151
  | `Current.user?` / `Current.isp?` | Predicate: true si el ID no es nil |
152
152
  | `Current.correlation_id?` | Predicate: true si está presente (no vacío) |
153
153
 
154
+ #### Hook `log_fields` — inyectar campos custom en cada log
155
+
156
+ `Current.log_fields` es un class method overridable que retorna un Hash de campos
157
+ extra a inyectar en cada log line, junto a `user_id`/`isp_id`/`correlation_id`.
158
+ Cubre tanto **constantes de proceso** (declaradas como frozen constants en la
159
+ subclass) como **valores dinámicos per-request** (leídos de atributos de
160
+ `Current`) — todo en un solo lugar.
161
+
162
+ ```ruby
163
+ class Current < ExisRay::Current
164
+ TENANT_ID = ENV.fetch("TENANT_ID").freeze # static, frozen al boot
165
+ attribute :region # dynamic, per-request
166
+
167
+ def self.log_fields
168
+ { tenant_id: TENANT_ID, region: region }.compact
169
+ end
170
+ end
171
+
172
+ # En un before_action / middleware:
173
+ Current.region = request.headers["X-Region"]
174
+
175
+ # Los logs salen automáticamente con tenant_id y region:
176
+ # {"...":"...", "tenant_id":"42", "region":"us-east-1", "event":"..."}
177
+ ```
178
+
179
+ **Reglas:**
180
+
181
+ - Default `{}` — cero overhead si no se override.
182
+ - `JsonFormatter` filtra claves sensibles del hash retornado (`api_key`, `token`, etc.).
183
+ - Si el override revienta, el formatter lo rescata silenciosamente (logging nunca debe afectar el flujo principal).
184
+ - **Precedencia**: los campos canónicos del Tracer (`trace_id`, `root_id`, etc.) y las keys del propio mensaje del developer (ej. `Rails.logger.info "tenant_id=99"`) pisan `log_fields`. No sirve para overrideear campos canónicos, solo para agregar nuevos.
185
+
154
186
  ### Reporter (reporte de errores)
155
187
 
156
188
  `ExisRay::Reporter` es un wrapper de Sentry que enriquece automáticamente cada evento con el trace context del `Tracer` y el contexto de negocio del `Current`. Soporta Sentry SDK moderno y legacy (Raven/Session).
@@ -252,22 +284,46 @@ Con `log_format: :json`, `ExisRay::JsonFormatter` reemplaza el formatter de Rail
252
284
  {"time":"2026-04-01T14:30:00Z","level":"INFO","severity_number":9,"service":"wispro_agent","service_version":"1.2.3","deployment_environment":"production","root_id":"1-65f...abc","trace_id":"Root=1-65f...;Self=...","source":"http","user_id":42,"isp_id":10,"component":"exis_ray","event":"http_request","method":"GET","path":"/api/v1/users","http_route":"/api/v1/users","http_status":200,"duration_s":0.0452,"user_agent_original":"Mozilla/5.0","server_address":"api.example.com"}
253
285
  ```
254
286
 
255
- Los mensajes con formato `key=value` se parsean y elevan al root del JSON. Los valores numéricos se castean automáticamente:
287
+ El formatter acepta tres tipos de mensaje. Los tres producen output JSON equivalente; elegí el que sea más legible para tu caso:
256
288
 
257
289
  ```ruby
290
+ # 1. String KV — one-liner rápido, valores numéricos se castean
258
291
  Rails.logger.info "component=billing event=invoice_created invoice_id=123 total=45.50"
259
- # => {"time":"...","level":"INFO","service":"...","root_id":"...","component":"billing","event":"invoice_created","invoice_id":123,"total":45.5}
260
- ```
261
292
 
262
- Los mensajes tipo Hash (usados internamente por `LogSubscriber`) se mergean directamente. Los mensajes de texto libre se asignan a la clave `body`:
293
+ # 2. Hash style payloads complejos o con valores nested
294
+ Rails.logger.info(component: "billing", event: "invoice_created",
295
+ invoice: { id: 123, total: 45.50 })
263
296
 
264
- ```ruby
297
+ # 3. String libre — fallback, va a la clave `body`
265
298
  Rails.logger.info "Algo pasó sin formato KV"
266
299
  # => {"time":"...","level":"INFO","service":"...","body":"Algo pasó sin formato KV"}
267
300
  ```
268
301
 
302
+ Los mensajes Hash también son la forma que usa internamente `LogSubscriber` para emitir el cierre de cada request HTTP.
303
+
304
+ > **Nota:** `component` (módulo de negocio) y `event` (qué pasó) **no** son auto-inyectados — los aporta el call site. El formatter solo conoce el contexto de **ejecución** (quién, de dónde, con qué identidad), no el contexto del **lugar del código** que loguea.
305
+
269
306
  En modo `:text`, ExisRay inyecta el `trace_id` o `root_id` como tag de Rails (`config.log_tags`) y no modifica el formatter.
270
307
 
308
+ ### Configuración del logger en producción
309
+
310
+ Para logs JSON limpios sin códigos ANSI ni texto extra, configurar el logger así:
311
+
312
+ ```ruby
313
+ # config/environments/production.rb
314
+ config.colorize_logging = false
315
+ config.logger = ActiveSupport::Logger.new(STDOUT)
316
+ config.logger.formatter = ExisRay::JsonFormatter
317
+ ```
318
+
319
+ **No usar `ActiveSupport::TaggedLogging`** — agrega tags como texto al inicio de cada línea antes del formatter, lo cual rompe el JSON:
320
+
321
+ ```
322
+ [request_id] {"time":"...","level":"INFO",...} # ← texto antes del JSON (INVALIDO)
323
+ ```
324
+
325
+ `ExisRay::JsonFormatter` ya inyecta los tags (`request_id`, `trace_id`, etc.) como campos JSON, así que `TaggedLogging` es redundante e incompatible.
326
+
271
327
  ### Campos auto-inyectados
272
328
 
273
329
  `JsonFormatter` inyecta estos campos automáticamente en cada línea. **Nunca** los incluyas manualmente en tus logs:
@@ -286,21 +342,34 @@ En modo `:text`, ExisRay inyecta el `trace_id` o `root_id` como tag de Rails (`c
286
342
  | `correlation_id` | Cuando `Current.correlation_id` está presente |
287
343
  | `user_id` | Cuando `Current.user_id` no es nil |
288
344
  | `isp_id` | Cuando `Current.isp_id` no es nil |
345
+ | `Current.log_fields` (cualquier key) | Si la subclass overrideó el hook y retornó un Hash no vacío |
289
346
  | `sidekiq_job` | Solo en procesos Sidekiq |
290
347
  | `task` | Solo en procesos TaskMonitor |
291
348
  | `tags` | Solo si hay Rails tagged logging activo |
292
349
 
293
- `LogSubscriber` inyecta además estos campos en los logs de requests HTTP:
294
-
295
- | Campo | Descripción |
296
- |:------|:------------|
297
- | `http_status` | Código HTTP (Integer). Antes `status`, renombrado en v0.6.0 |
298
- | `http_route` | Template de ruta (ej: `/users/:id`). Resolución via `route.defaults` |
299
- | `user_agent_original` | Header `User-Agent` del request |
300
- | `server_address` | Hostname sin puerto del header `Host` |
301
- | `exception.type` | Clase de la excepción (cuando el request falla) |
302
- | `exception.message` | Mensaje de la excepción |
303
- | `exception.stacktrace` | Primeras 20 líneas del backtrace |
350
+ `LogSubscriber` inyecta además estos campos en cada log de cierre de request HTTP. **Nunca duplicarlos** en logs manuales:
351
+
352
+ | Campo | Tipo | Notas |
353
+ |:------|:-----|:------|
354
+ | `component` | String | Siempre `"exis_ray"` |
355
+ | `event` | String | Siempre `"http_request"` |
356
+ | `method` | String | Verbo HTTP |
357
+ | `path` | String | URL concreta del request |
358
+ | `http_route` | String | Template (ej: `/users/:id`). Baja cardinalidad para dashboards |
359
+ | `format` | Symbol/String | `html`, `json`, etc. |
360
+ | `controller` | String | Class name del controller |
361
+ | `action` | String | Nombre del action |
362
+ | `http_status` | Integer | Status HTTP final. Antes `status`, renombrado en v0.6.0 |
363
+ | `duration_s` | Float | Segundos (Rails reporta ms, se convierte), redondeo 4 decimales |
364
+ | `duration_human` | String | Legible: `"42.5ms"`, `"1.25s"`, `"2 minutes 5 seconds"` |
365
+ | `view_runtime_s` | Float\|nil | Solo si Rails lo reporta |
366
+ | `db_runtime_s` | Float\|nil | Solo si ActiveRecord lo reporta |
367
+ | `user_agent_original` | String | Header `User-Agent` |
368
+ | `server_address` | String | Hostname sin puerto del header `Host` |
369
+ | `error_class`, `error_message` | String | Solo en fallo (legacy) |
370
+ | `exception.type`, `exception.message`, `exception.stacktrace` | String | Solo en fallo (OTel; stack limitado a 20 líneas) |
371
+
372
+ Severity del log: `ERROR` si `http_status >= 500`, sino `INFO`.
304
373
 
305
374
  ### Filtrado de claves sensibles
306
375
 
@@ -8,6 +8,30 @@ module ExisRay
8
8
  class Current < ActiveSupport::CurrentAttributes
9
9
  attribute :user_id, :isp_id, :correlation_id
10
10
 
11
+ # Hook overridable por la subclass de la app host. Retorna un Hash de campos
12
+ # extra a inyectar en cada log line, junto a `user_id`/`isp_id`/`correlation_id`.
13
+ #
14
+ # Pensado para cubrir tanto constantes de proceso (declaradas como `freeze`-d
15
+ # constants en la subclass) como valores dinámicos per-request (leídos de
16
+ # atributos de Current). El JsonFormatter invoca este método en cada log y
17
+ # mergea el resultado al payload — luego de los campos canónicos pero antes
18
+ # de las keys del propio mensaje del developer (que ganan por override).
19
+ #
20
+ # @example Constantes de proceso + valores per-request combinados
21
+ # class Current < ExisRay::Current
22
+ # TENANT_ID = ENV.fetch("TENANT_ID").freeze
23
+ # attribute :region
24
+ #
25
+ # def self.log_fields
26
+ # { tenant_id: TENANT_ID, region: region }.compact
27
+ # end
28
+ # end
29
+ #
30
+ # @return [Hash] Pares clave/valor a inyectar. Default `{}`.
31
+ def self.log_fields
32
+ {}
33
+ end
34
+
11
35
  # Callback nativo de Rails: Se ejecuta automáticamente al llamar a Current.reset
12
36
  resets do
13
37
  @user_object = nil
@@ -122,9 +122,28 @@ module ExisRay
122
122
  payload[:user_id] = curr.user_id if curr.respond_to?(:user_id) && !curr.user_id.nil?
123
123
  payload[:isp_id] = curr.isp_id if curr.respond_to?(:isp_id) && !curr.isp_id.nil?
124
124
 
125
- return unless curr.respond_to?(:correlation_id) && curr.correlation_id
125
+ payload[:correlation_id] = curr.correlation_id if curr.respond_to?(:correlation_id) && curr.correlation_id
126
126
 
127
- payload[:correlation_id] = curr.correlation_id
127
+ inject_log_fields(payload, curr)
128
+ end
129
+
130
+ # Mergea el resultado de `Current.log_fields` al payload, aplicando el mismo
131
+ # filtrado de claves sensibles que el resto del formatter. Si la subclass del
132
+ # host overrideó el hook y revienta, el error se traga: logging nunca debe
133
+ # afectar el flujo principal.
134
+ #
135
+ # @param payload [Hash]
136
+ # @param curr [Class] La clase Current configurada por la app host.
137
+ # @return [void]
138
+ def inject_log_fields(payload, curr)
139
+ return unless curr.respond_to?(:log_fields)
140
+
141
+ fields = curr.log_fields
142
+ return if fields.nil? || fields.empty?
143
+
144
+ payload.merge!(filter_sensitive_hash(fields))
145
+ rescue StandardError
146
+ nil
128
147
  end
129
148
 
130
149
  # Inyecta cualquier etiqueta nativa (tags) de Rails que esté presente en el hilo actual.
@@ -44,16 +44,12 @@ module ExisRay
44
44
  # y safe_constantize puede fallar aún con eager_load=true.
45
45
  if (name = ExisRay.configuration.current_class).present?
46
46
  klass = name.safe_constantize
47
- if klass && !klass.<=(ExisRay::Current)
48
- raise "ExisRay: current_class '#{name}' does not inherit from ExisRay::Current"
49
- end
47
+ raise "ExisRay: current_class '#{name}' does not inherit from ExisRay::Current" if klass && !klass.<=(ExisRay::Current)
50
48
  end
51
49
 
52
50
  if (name = ExisRay.configuration.reporter_class).present?
53
51
  klass = name.safe_constantize
54
- if klass && !klass.<=(ExisRay::Reporter)
55
- raise "ExisRay: reporter_class '#{name}' does not inherit from ExisRay::Reporter"
56
- end
52
+ raise "ExisRay: reporter_class '#{name}' does not inherit from ExisRay::Reporter" if klass && !klass.<=(ExisRay::Reporter)
57
53
  end
58
54
 
59
55
  # Aplicamos el formateador JSON globalmente al logger ya instanciado de Rails
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ExisRay
4
4
  # Versión actual de la gema.
5
- VERSION = "0.7.1"
5
+ VERSION = "0.8.0"
6
6
  end
data/skill/SKILL.md CHANGED
@@ -71,8 +71,8 @@ ExisRay unifica trazabilidad distribuida, logging estructurado JSON, contexto de
71
71
  2. `Tracer.parse_trace_id` extrae `root_id`, `self_id`, `called_from`, `total_time_so_far`
72
72
  3. `ExisRay.sync_correlation_id` asigna `Tracer.correlation_id` a `Current.correlation_id`
73
73
  4. Controller ejecuta `before_action` para setear `Current.user_id`, `Current.isp_id`
74
- 5. `JsonFormatter` intercepta cada `Rails.logger.*` e inyecta automaticamente: `time`, `level`, `severity_number`, `service`, `service_version`, `deployment_environment`, `root_id`, `trace_id`, `source`, `user_id`, `isp_id`, `correlation_id`
75
- 6. `LogSubscriber` emite un unico Hash al finalizar el request (method, path, http_status, http_route, duration_s, user_agent_original, server_address, etc.)
74
+ 5. `JsonFormatter` intercepta cada `Rails.logger.*` e inyecta automaticamente: `time`, `level`, `severity_number`, `service`, `service_version`, `deployment_environment`, `root_id`, `trace_id`, `source`, `user_id`, `isp_id`, `correlation_id`. El developer aporta `component` (modulo de negocio) y `event` (que paso); estos NO son auto-inyectados porque dependen del call site, no del contexto de ejecucion.
75
+ 6. `LogSubscriber` emite un unico Hash al finalizar el request con campos default (`component`, `event`, `method`, `path`, `http_route`, `format`, `controller`, `action`, `http_status`, `duration_s`, `duration_human`, `view_runtime_s`, `db_runtime_s`, `user_agent_original`, `server_address`, y en error `error_class`/`error_message`/`exception.*`).
76
76
  7. En llamadas salientes, `FaradayMiddleware`/`ActiveResourceInstrumentation` inyectan `propagation_trace_header` con `Tracer.generate_trace_header`
77
77
  8. Al finalizar, `ActiveSupport::CurrentAttributes` hace reset automatico
78
78
 
@@ -163,6 +163,27 @@ Current.correlation_id? # => true si correlation_id es present?
163
163
 
164
164
  Los setters auto-sincronizan con `ActiveResource::Base.headers` y `PaperTrail.request` cuando estan definidos.
165
165
 
166
+ #### Hook `log_fields` — inyectar campos custom en cada log
167
+
168
+ Class method overridable que retorna un Hash de campos extra para JsonFormatter. Cubre tanto **constantes de proceso** (frozen constants en la subclass) como **valores dinámicos per-request** (atributos de Current) en un solo lugar. Default `{}`.
169
+
170
+ ```ruby
171
+ class Current < ExisRay::Current
172
+ TENANT_ID = ENV.fetch("TENANT_ID").freeze # static, frozen al boot
173
+ attribute :region # dynamic, per-request
174
+
175
+ def self.log_fields
176
+ { tenant_id: TENANT_ID, region: region }.compact
177
+ end
178
+ end
179
+ ```
180
+
181
+ Reglas:
182
+
183
+ - `JsonFormatter` filtra claves sensibles del hash retornado (mismo regex que el resto del formatter).
184
+ - Si el override revienta, el formatter rescata silenciosamente (logging no afecta flujo principal).
185
+ - Precedencia: campos canónicos del Tracer y keys del mensaje del developer pisan `log_fields` en colisión. Solo sirve para agregar fields nuevos, no para overrideear los canónicos.
186
+
166
187
  ### ExisRay::Reporter (clase base abstracta)
167
188
 
168
189
  ```ruby
@@ -212,6 +233,32 @@ end
212
233
 
213
234
  ### ExisRay::LogSubscriber
214
235
 
236
+ Reemplaza Lograge. Se suscribe a `process_action.action_controller` y emite un Hash por request HTTP. Severity es `ERROR` si `http_status >= 500`, sino `INFO`.
237
+
238
+ **Campos default emitidos** (mergeados al payload JSON; nunca duplicarlos manualmente):
239
+
240
+ | Campo | Tipo | Notas |
241
+ |:------|:-----|:------|
242
+ | `component` | String | Siempre `"exis_ray"` |
243
+ | `event` | String | Siempre `"http_request"` |
244
+ | `method` | String | Verbo HTTP |
245
+ | `path` | String | URL concreta del request |
246
+ | `http_route` | String | Template (ej: `/users/:id`). Baja cardinalidad para dashboards |
247
+ | `format` | Symbol/String | `html`, `json`, etc. |
248
+ | `controller` | String | Class name del controller |
249
+ | `action` | String | Nombre del action |
250
+ | `http_status` | Integer | Status HTTP final |
251
+ | `duration_s` | Float | Segundos (Rails reporta ms, se convierte), redondeo 4 decimales |
252
+ | `duration_human` | String | Legible: `"42.5ms"`, `"1.25s"`, `"2 minutes 5 seconds"` |
253
+ | `view_runtime_s` | Float\|nil | Solo si Rails lo reporta |
254
+ | `db_runtime_s` | Float\|nil | Solo si ActiveRecord lo reporta |
255
+ | `user_agent_original` | String | Header `User-Agent` |
256
+ | `server_address` | String | Hostname sin puerto (de `Host` header) |
257
+ | `error_class`, `error_message` | String | Solo en fallo (legacy) |
258
+ | `exception.type`, `exception.message`, `exception.stacktrace` | String | Solo en fallo (OTel; stack limitado a 20 lineas) |
259
+
260
+ Para inyectar campos extra, sobreescribir `extra_fields`:
261
+
215
262
  ```ruby
216
263
  class MyLogSubscriber < ExisRay::LogSubscriber
217
264
  def self.extra_fields(event)
@@ -227,12 +274,41 @@ ExisRay.configure { |c| c.log_subscriber_class = "MyLogSubscriber" }
227
274
 
228
275
  Se asigna automaticamente a `Rails.logger.formatter` cuando `log_format: :json`. Acepta tres tipos de mensaje:
229
276
 
230
- - **Hash**: merge directo al payload JSON
231
- - **String KV** (`"event=foo bar=baz"`): parsea pares y los eleva al root del JSON
232
- - **String libre**: asigna al campo `body`
277
+ - **Hash**: merge directo al payload JSON. Util para payloads complejos o con valores nested.
278
+ - **String KV** (`"event=foo bar=baz"`): parsea pares y los eleva al root del JSON. Util para one-liners rapidos.
279
+ - **String libre**: asigna al campo `body` (OTel log body).
233
280
 
234
281
  Casteo automatico: integers, floats, objetos JSON (`{...}`, `[...]`). Filtra claves sensibles (`password|secret|token|api_key|auth`) a `[FILTERED]`. Fallback a JSON minimo si el formateo falla.
235
282
 
283
+ #### Criterio auto-inyectado vs manual
284
+
285
+ - **Auto-inyectado** (formatter conoce desde `Tracer`/`Current`): contexto de **ejecucion** — quien hace el request, de donde viene, en que servicio, con que identidad.
286
+ - **Manual** (lo aporta cada `Rails.logger.*`): contexto del **call site** — que modulo (`component`) y que paso (`event`). El formatter no puede saber esto sin recorrer el stack en cada log.
287
+
288
+ Por eso `component` y `event` jamas se auto-inyectan, aunque el estandar Wispro los exija.
289
+
290
+ #### Ejemplos: KV vs Hash producen output equivalente
291
+
292
+ ```ruby
293
+ # KV string — one-liner rapido
294
+ Rails.logger.info("component=billing event=invoice_paid invoice_id=42 total=199.99")
295
+
296
+ # Hash style — payloads complejos / nested
297
+ Rails.logger.info(component: "billing", event: "invoice_paid",
298
+ invoice: { id: 42, total: 199.99 })
299
+
300
+ # String libre — fallback, va a `body`
301
+ Rails.logger.info("usuario hizo click")
302
+ ```
303
+
304
+ #### Output JSON resultante (mismo para KV y Hash del ejemplo)
305
+
306
+ ```json
307
+ {"time":"2026-05-11T09:15:00.123Z","level":"INFO","severity_number":9,"service":"box_radius_manager","service_version":"1.2.3","deployment_environment":"production","root_id":"1-abc","trace_id":"Root=1-abc;Self=...","source":"http","user_id":42,"isp_id":10,"correlation_id":"box_radius_manager;1-abc","component":"billing","event":"invoice_paid","invoice_id":42,"total":199.99}
308
+ ```
309
+
310
+ Los campos hasta `correlation_id` los inyecta el formatter automaticamente. De `component` en adelante son los campos del mensaje del developer.
311
+
236
312
  ### Middlewares de propagacion
237
313
 
238
314
  ```ruby
@@ -290,6 +366,20 @@ Agrega `f.use ExisRay::FaradayMiddleware` al stack de Faraday. Solo inyecta head
290
366
  **P: Puedo usar ExisRay sin JSON logging?**
291
367
  Si. Con `log_format: :text` (default), ExisRay inyecta el `root_id` como tag de Rails via `config.log_tags`. El JsonFormatter y LogSubscriber no se activan.
292
368
 
369
+ **P: Por que no usar `ActiveSupport::TaggedLogging` con JSON logging?**
370
+ `TaggedLogging` agrega tags como texto plano al inicio de cada línea **antes** del formatter, lo cual rompe el JSON:
371
+ ```
372
+ [request_id] {"time":"...","level":"INFO",...} # ← texto antes del JSON
373
+ ```
374
+ `ExisRay::JsonFormatter` ya inyecta los tags (`request_id`, `trace_id`, etc.) como campos JSON. Usar ambos genera JSON inválido.
375
+
376
+ Configuración correcta en production.rb:
377
+ ```ruby
378
+ config.colorize_logging = false
379
+ config.logger = ActiveSupport::Logger.new(STDOUT)
380
+ config.logger.formatter = ExisRay::JsonFormatter
381
+ ```
382
+
293
383
  **P: Que pasa con los logs de Sidekiq (el propio logger de Sidekiq)?**
294
384
  Si `json_logs?` es true, el Railtie asigna `Sidekiq.logger.formatter = ExisRay::JsonFormatter.new`, asi los logs internos de Sidekiq tambien salen en JSON.
295
385
 
@@ -112,4 +112,38 @@ RSpec.describe ExisRay::Current do
112
112
  TestCurrent.correlation_id = "corr-1"
113
113
  end
114
114
  end
115
+
116
+ describe ".log_fields hook" do
117
+ it "retorna un Hash vacío por defecto" do
118
+ expect(TestCurrent.log_fields).to eq({})
119
+ end
120
+
121
+ it "permite que la subclass overridee el método para inyectar campos custom" do
122
+ stub_const("TenantCurrent", Class.new(ExisRay::Current) do
123
+ def self.log_fields
124
+ { tenant_id: "42", region: "us-east-1" }
125
+ end
126
+ end)
127
+
128
+ expect(TenantCurrent.log_fields).to eq(tenant_id: "42", region: "us-east-1")
129
+ end
130
+
131
+ it "soporta combinar constantes de proceso con atributos per-request" do
132
+ stub_const("MixedCurrent", Class.new(ExisRay::Current) do
133
+ attribute :region
134
+
135
+ STATIC_TENANT = "tenant-42"
136
+
137
+ def self.log_fields
138
+ { tenant_id: STATIC_TENANT, region: region }.compact
139
+ end
140
+ end)
141
+ MixedCurrent.region = "us-east-1"
142
+
143
+ expect(MixedCurrent.log_fields).to eq(tenant_id: "tenant-42", region: "us-east-1")
144
+
145
+ MixedCurrent.reset
146
+ expect(MixedCurrent.log_fields).to eq(tenant_id: "tenant-42")
147
+ end
148
+ end
115
149
  end
@@ -261,6 +261,91 @@ RSpec.describe ExisRay::JsonFormatter do
261
261
  expect(result).not_to have_key("user_id")
262
262
  end
263
263
  end
264
+
265
+ describe "inyeccion de Current.log_fields" do
266
+ # Mock estilo ActiveSupport::CurrentAttributes que combina los atributos canónicos
267
+ # con un hook log_fields. Por defecto retorna {}, los tests lo redefinen vía stub.
268
+ let(:current_class_double) do
269
+ Class.new do
270
+ class << self
271
+ attr_accessor :user_id, :isp_id, :correlation_id, :_log_fields
272
+
273
+ def respond_to_missing?(method, *)
274
+ %i[user_id isp_id correlation_id log_fields].include?(method) || super
275
+ end
276
+ end
277
+
278
+ def self.log_fields
279
+ _log_fields || {}
280
+ end
281
+ end
282
+ end
283
+
284
+ before do
285
+ allow(ExisRay).to receive(:current_class).and_return(current_class_double)
286
+ end
287
+
288
+ it "inyecta los campos retornados por log_fields en el payload" do
289
+ current_class_double._log_fields = { tenant_id: "42", region: "us-east-1" }
290
+
291
+ result = call("event=boot")
292
+
293
+ # tenant_id="42" se castea a Integer por filter_sensitive_hash (igual que cualquier otro KV)
294
+ expect(result).to include("tenant_id" => 42, "region" => "us-east-1", "event" => "boot")
295
+ end
296
+
297
+ it "no agrega keys cuando log_fields retorna hash vacío" do
298
+ current_class_double._log_fields = {}
299
+
300
+ result = call("event=boot")
301
+
302
+ expect(result).not_to have_key("tenant_id")
303
+ end
304
+
305
+ it "no agrega keys cuando log_fields retorna nil" do
306
+ current_class_double._log_fields = nil
307
+
308
+ result = call("event=boot")
309
+
310
+ expect(result.keys).to contain_exactly("time", "level", "severity_number", "service", "event")
311
+ end
312
+
313
+ it "el mensaje del developer pisa log_fields (override por call site)" do
314
+ current_class_double._log_fields = { tenant_id: "from-current" }
315
+
316
+ result = call("event=override tenant_id=from-message")
317
+
318
+ expect(result["tenant_id"]).to eq("from-message")
319
+ end
320
+
321
+ it "filtra claves sensibles" do
322
+ current_class_double._log_fields = { api_key: "leaked", tenant_id: "42" }
323
+
324
+ result = call("event=boot")
325
+
326
+ expect(result["api_key"]).to eq("[FILTERED]")
327
+ expect(result["tenant_id"]).to eq(42)
328
+ end
329
+
330
+ it "no rompe el formatter si la subclass overrideó log_fields con un método que revienta" do
331
+ allow(current_class_double).to receive(:log_fields).and_raise(StandardError, "boom")
332
+
333
+ expect { call("event=boot") }.not_to raise_error
334
+ result = call("event=boot")
335
+ expect(result["event"]).to eq("boot")
336
+ end
337
+
338
+ it "no falla si Current configurado no implementa log_fields (backwards compat)" do
339
+ legacy_current = Class.new do
340
+ class << self
341
+ attr_accessor :user_id, :isp_id, :correlation_id
342
+ end
343
+ end
344
+ allow(ExisRay).to receive(:current_class).and_return(legacy_current)
345
+
346
+ expect { call("event=boot") }.not_to raise_error
347
+ end
348
+ end
264
349
  end
265
350
 
266
351
  describe "#kv_string? (privado)" do
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exis_ray
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.1
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabriel Edera
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-07 00:00:00.000000000 Z
11
+ date: 2026-05-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -87,8 +87,8 @@ licenses:
87
87
  metadata:
88
88
  homepage_uri: https://github.com/gedera/exis_ray
89
89
  source_code_uri: https://github.com/gedera/exis_ray
90
- changelog_uri: https://github.com/gedera/exis_ray/blob/v0.7.1/CHANGELOG.md
91
- documentation_uri: https://github.com/gedera/exis_ray/blob/v0.7.1/skill
90
+ changelog_uri: https://github.com/gedera/exis_ray/blob/v0.8.0/CHANGELOG.md
91
+ documentation_uri: https://github.com/gedera/exis_ray/blob/v0.8.0/skill
92
92
  post_install_message:
93
93
  rdoc_options: []
94
94
  require_paths: