exis_ray 0.9.0 → 0.11.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: c87f049dd7216e8ac33a9844b77cd1c863f9248130b329994002aa654c0c47ca
4
- data.tar.gz: 1752d432a8437178121b36a0b28293f52f96b99d13a5c40589f312fb8f9b6236
3
+ metadata.gz: e474f1122d88cba11551996f1fe73b01da92f28dcea658e6eb473e51277e838e
4
+ data.tar.gz: 708af8ed6b2ece0cf5e78f74543135ceb4e83d8e2e51788e980847c7675d586e
5
5
  SHA512:
6
- metadata.gz: 72f092befb65f8eb03fe851a302ec49ac47fb155a288ed1103923766f0322da6e5704e760d8c6fa7e4865d78666ddc29b7f7c5e486f22120de1c0ff4a072777e
7
- data.tar.gz: 57e552a85bc42d9cb659d49a429c85131175350542856378d432063b9558f264a636c93d4a25ee57b51717718c5bcb03a2f86fbff3676ce85fedfde4562f5509
6
+ metadata.gz: 2e314e520ab390a510953c78c8774c816e30a9fd814e9bbd36eb2e874e5ed81abbaeb91a7f0abea350c46f4783f8f93c176209a08a15a4691de2c13551534135
7
+ data.tar.gz: 478cfe36c1b7f5e60c37de8c6db1e86c239a13a2ec2c8a3bd6797b455cc27dd0ed7f3960c02e29049e0979c648a7ab7857982d84664ae43647dfb2af5cb99f16
data/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## [0.11.0] - 2026-05-26
2
+
3
+ ### Nuevas funcionalidades
4
+ - **`config.emit_legacy_exception_keys`** (#15): nuevo flag de `Configuration` (default `true`) que controla la emisión de las keys legacy `error_class`/`error_message` junto a `exception.type`/`exception.message`. Bajado a `false`, `ExisRay::LogSubscriber` (logs HTTP) y `ExisRay::TaskMonitor` (logs de tasks) emiten solo las keys OTel v1.0 (`exception.*`), reduciendo bytes y ruido downstream cuando los consumers (dashboards, alertas, queries) ya migraron. Mantiene `true` como default durante la ventana de transición — se flipea a `false` en v0.12.0 y se remueve el flag en v1.0 cuando los legacy se eliminen definitivamente.
5
+ - **`url.path` (OTel v1.0) + `config.emit_legacy_path_key`** (#15): `ExisRay::LogSubscriber` empieza a emitir `url.path` (nombre canónico OTel) en todos los logs HTTP. El alias legacy `path` se sigue emitiendo durante la ventana de transición y queda gateado por `config.emit_legacy_path_key` (default `true`). Cuando los consumers (queries, dashboards, alertas) hayan migrado a `url.path`, bajar el flag a `false` para suprimir el alias. Se flipea a `false` en v0.12.0 y se remueve el flag en v1.0.
6
+ - **Doc: `url.path` vs `http_route` no se deduplican** (#15): el caso reportado de "valores idénticos en endpoints estáticos" no es un bug — `url.path` es la URL **concreta** (alta cardinalidad), `http_route` es el **template** (baja cardinalidad). Cumplen roles distintos en dashboards/queries downstream; siguen emitiéndose siempre los dos. Documentado en `README.md` y `skill/SKILL.md` (subsección "Migración OTel — `path` → `url.path`" + nota explicativa de la coexistencia semántica).
7
+
8
+ ## [0.10.0] - 2026-05-25
9
+
10
+ ### Documentación
11
+ - **`SKILL.md`/`README` describían la auto-inyección como incondicional** (#11): la prosa de "Flujo runtime" en `SKILL.md` y "Flujo de propagación" en `README.md` listaba los campos auto-inyectados (`root_id`, `trace_id`, `source`, `request_id`, ...) sin desglosar los guards. `JsonFormatter#inject_tracer_context` corta su bloque con `return unless Tracer.root_id`, y `request_id` se emite **fuera** de ese guard (distinto ciclo de vida — fix de #9). Sin esa precisión, un implementador en `box_radius_manager` concluyó (apoyándose en la doc) que "`root_id` ya cubre la trazabilidad, `request_id` es redundante" y removió un workaround necesario; en su entrypoint sin trace header `root_id` era nil → logs sin `request_id`/`source`/`root_id`. Agregada subsección "Condiciones de emisión por campo" en `SKILL.md` con tabla "campo → condición → entrypoint que la garantiza", callout en el README arriba de "Campos auto-inyectados", y enlace cruzado a `docs/behavior/behavior.md` + `docs/glossary/glossary.md` (que ya tenían el detalle).
12
+
13
+ ### Correcciones
14
+ - **Clave `task` duplicada en JSON cuando `TaskMonitor.run` loguea lifecycle** (#12): `TaskMonitor` emitía `task=#{task_name}` en los KV strings de `task_started`/`task_finished` (3 sites: `task_monitor.rb:32,41,48`); `JsonFormatter#inject_tracer_context` ya inyecta la misma clave desde `Tracer.task` con Symbol — al colapsar Symbol/String en `JSON.generate`, `json` emitía `warning: detected duplicate key "task"` y la línea entera va a romper en `json` 3.0. **Fix B** (origen): los KV de lifecycle ya no emiten `task=...`; la clave viene exclusivamente del formatter vía `Tracer.task`. **Fix A** (defensa en profundidad): `JsonFormatter` normaliza todas las claves del payload a String antes de `JSON.generate`, deduplicando con precedencia "última inserción gana" (developer payload pisa contexto canónico inyectado por Tracer, igual que `log_fields`). Cubre futuros casos análogos Tracer ↔ developer KV sobre la misma clave.
15
+
1
16
  ## [0.9.0] - 2026-05-19
2
17
 
3
18
  ### Correcciones
data/CLAUDE.md CHANGED
@@ -178,6 +178,7 @@ ExisRay.configure do |config|
178
178
  config.propagation_trace_header = 'X-Amzn-Trace-Id'
179
179
  config.current_class = 'Current'
180
180
  config.reporter_class = 'Reporter'
181
+ config.emit_legacy_exception_keys = true # default true; pasar a false cuando consumers usen exception.*
181
182
  end
182
183
 
183
184
  # BugBunny publisher — debe agregarse manualmente al cliente
data/README.md CHANGED
@@ -52,7 +52,7 @@ ExisRay opera en tres capas que se combinan automáticamente:
52
52
  **Flujo de propagación:**
53
53
 
54
54
  1. Un request/job/mensaje llega al servicio. El middleware correspondiente hidrata el `Tracer` con el header entrante. **Todo entrypoint garantiza un `root_id`**: si no llega trace header (servicio que es punto de entrada, no eslabón intermedio), genera uno fresco. En HTTP además captura el `request_id` de Rails.
55
- 2. `JsonFormatter` inyecta automáticamente `root_id`, `trace_id`, `source`, `request_id` y el contexto de negocio (`user_id`, `isp_id`, `correlation_id`) en cada línea de log. Como el entrypoint siempre asegura `root_id`, `source` (campo mandatorio) nunca falta.
55
+ 2. `JsonFormatter` inyecta `root_id`, `trace_id`, `source`, `request_id` y el contexto de negocio (`user_id`, `isp_id`, `correlation_id`) en cada línea de log. **Cada campo tiene un guard:** `root_id`/`trace_id`/`source`/`task`/`sidekiq_job` salen solo si `Tracer.root_id` está presente; `request_id` se emite fuera de ese guard (distinto ciclo de vida). Como todo entrypoint asegura `root_id` (genera uno fresco si no llega header), `source` (mandatorio) nunca falta. Detalle por campo: tabla [Campos auto-inyectados](#campos-auto-inyectados) más abajo.
56
56
  3. Cuando el servicio llama a otro servicio (HTTP, Sidekiq, RabbitMQ), el middleware de salida genera un nuevo header con `Tracer.generate_trace_header`, que incluye el `root_id` original, el `self_id` del servicio actual, el `CalledFrom` y el tiempo acumulado.
57
57
  4. El servicio destino repite desde el paso 1. El `root_id` se mantiene constante a lo largo de toda la cadena.
58
58
 
@@ -339,7 +339,9 @@ config.logger.formatter = ExisRay::JsonFormatter
339
339
 
340
340
  ### Campos auto-inyectados
341
341
 
342
- `JsonFormatter` inyecta estos campos automáticamente en cada línea. **Nunca** los incluyas manualmente en tus logs:
342
+ `JsonFormatter` inyecta estos campos automáticamente en cada línea. **Nunca** los incluyas manualmente en tus logs.
343
+
344
+ > **No es incondicional.** `root_id`/`trace_id`/`source`/`task`/`sidekiq_job` están gateados por `inject_tracer_context`'s `return unless Tracer.root_id`. Los 4 entrypoints (HTTP, Sidekiq server, BugBunny consumer, TaskMonitor) garantizan el `root_id` (fresco si no llega header), por eso `source` (mandatorio) nunca falta — pero si el código loguea fuera de un entrypoint (boot, código inicializador, hilos sueltos), esos campos pueden faltar. `request_id` se emite fuera de ese guard y tiene distinto ciclo de vida.
343
345
 
344
346
  | Campo | Condición |
345
347
  |:------|:----------|
@@ -368,8 +370,9 @@ config.logger.formatter = ExisRay::JsonFormatter
368
370
  | `component` | String | Siempre `"exis_ray"` |
369
371
  | `event` | String | Siempre `"http_request"` |
370
372
  | `method` | String | Verbo HTTP |
371
- | `path` | String | URL concreta del request |
372
- | `http_route` | String | Template (ej: `/users/:id`). Baja cardinalidad para dashboards |
373
+ | `url.path` | String | URL concreta del request (OTel v1.0). Siempre presente |
374
+ | `path` | String | Alias legacy de `url.path`. Solo si `config.emit_legacy_path_key` (default `true`, deprecado) |
375
+ | `http_route` | String | Template (ej: `/users/:id`). Baja cardinalidad para dashboards. En endpoints sin params coincide en valor con `url.path` — la dupe es semánticamente esperada (concrete vs template) |
373
376
  | `format` | Symbol/String | `html`, `json`, etc. |
374
377
  | `controller` | String | Class name del controller |
375
378
  | `action` | String | Nombre del action |
@@ -380,11 +383,37 @@ config.logger.formatter = ExisRay::JsonFormatter
380
383
  | `db_runtime_s` | Float\|nil | Solo si ActiveRecord lo reporta |
381
384
  | `user_agent_original` | String | Header `User-Agent` |
382
385
  | `server_address` | String | Hostname sin puerto del header `Host` |
383
- | `error_class`, `error_message` | String | Solo en fallo (legacy) |
386
+ | `error_class`, `error_message` | String | Solo en fallo y si `config.emit_legacy_exception_keys` (default `true`, deprecadas) |
384
387
  | `exception.type`, `exception.message`, `exception.stacktrace` | String | Solo en fallo (OTel; stack limitado a 20 líneas) |
385
388
 
386
389
  Severity del log: `ERROR` si `http_status >= 500`, sino `INFO`.
387
390
 
391
+ ### Migración OTel — `error_class`/`error_message` → `exception.*`
392
+
393
+ Durante la ventana de transición OTel v1.0, `LogSubscriber` y `TaskMonitor` emiten ambos sets de keys en cada log de error. Cuando todos los consumers (dashboards, queries, alertas) hayan migrado a `exception.type`/`exception.message`/`exception.stacktrace`, desactivar los legacy:
394
+
395
+ ```ruby
396
+ ExisRay.configure do |c|
397
+ c.emit_legacy_exception_keys = false # default: true
398
+ end
399
+ ```
400
+
401
+ Roadmap: default `false` en v0.12.0; flag y legacy keys removidos en v1.0.
402
+
403
+ ### Migración OTel — `path` → `url.path`
404
+
405
+ `LogSubscriber` emite `url.path` (nombre OTel v1.0) en todos los logs HTTP. Durante la ventana de transición emite también `path` (alias legacy Wispro). Cuando las queries/dashboards/alertas migren a `url.path`, desactivar el legacy:
406
+
407
+ ```ruby
408
+ ExisRay.configure do |c|
409
+ c.emit_legacy_path_key = false # default: true
410
+ end
411
+ ```
412
+
413
+ Roadmap: default `false` en v0.12.0; flag y key `path` removidos en v1.0.
414
+
415
+ > **Sobre `url.path` vs `http_route`:** son semánticamente distintos — `url.path` es la URL **concreta** (alta cardinalidad, ej. `/users/42`), `http_route` es el **template** matcheado (baja cardinalidad, ej. `/users/:id`). En endpoints sin params coinciden en valor (ej. `/up`), pero ambos se siguen emitiendo porque cumplen roles distintos en dashboards/queries downstream. La dupe en valor es esperada, no es un bug.
416
+
388
417
  ### Filtrado de claves sensibles
389
418
 
390
419
  Las claves que matcheen `/password|pass|passwd|secret|token|api_key|auth/i` se reemplazan automáticamente por `[FILTERED]`, tanto en strings KV como en Hashes (incluyendo anidados).
@@ -52,6 +52,25 @@ module ExisRay
52
52
  # @example 'production'
53
53
  attr_accessor :deployment_environment
54
54
 
55
+ # @!attribute [rw] emit_legacy_exception_keys
56
+ # @return [Boolean] Si true, emite `error_class`/`error_message` junto a `exception.type`/`exception.message`.
57
+ # Default `true` durante la ventana de transición OTel v1.0. Setear a `false` cuando todos los
58
+ # consumers (dashboards, alertas, queries) hayan migrado a `exception.*` para reducir bytes
59
+ # y ruido en logs.
60
+ # Aplica a `ExisRay::LogSubscriber` (logs HTTP) y `ExisRay::TaskMonitor` (logs de tasks).
61
+ # @example false
62
+ attr_accessor :emit_legacy_exception_keys
63
+
64
+ # @!attribute [rw] emit_legacy_path_key
65
+ # @return [Boolean] Si true, `ExisRay::LogSubscriber` emite `path` (nombre legacy Wispro)
66
+ # junto a `url.path` (OTel v1.0). Default `true` durante la ventana de transición.
67
+ # Setear a `false` cuando los consumers (queries, dashboards, alertas) hayan migrado a
68
+ # `url.path` para reducir bytes y ruido en logs.
69
+ # Nota: `url.path` y `http_route` son semánticamente distintos (URL concreta vs template);
70
+ # coinciden como string solo en endpoints sin params — la dupe es esperada y no se elimina.
71
+ # @example false
72
+ attr_accessor :emit_legacy_path_key
73
+
55
74
  # Inicializa la configuración con valores por defecto compatibles con AWS X-Ray.
56
75
  def initialize
57
76
  @trace_header = "HTTP_X_AMZN_TRACE_ID"
@@ -62,6 +81,8 @@ module ExisRay
62
81
  @log_subscriber_class = nil
63
82
  @service_version = default_service_version
64
83
  @deployment_environment = default_deployment_environment
84
+ @emit_legacy_exception_keys = true
85
+ @emit_legacy_path_key = true
65
86
  end
66
87
 
67
88
  # Lee la versión del servicio desde la configuración de Rails.
@@ -61,13 +61,29 @@ module ExisRay
61
61
  inject_current_tags(payload)
62
62
  process_message(payload, msg)
63
63
 
64
- "#{JSON.generate(payload.compact, { ascii_only: false })}\n"
64
+ "#{JSON.generate(normalize_keys(payload), { ascii_only: false })}\n"
65
65
  rescue StandardError
66
66
  fallback_message(severity, timestamp, msg)
67
67
  end
68
68
 
69
69
  private
70
70
 
71
+ # Normaliza todas las claves del payload a strings y deduplica con precedencia
72
+ # "última inserción gana" (developer payload pisa contexto canónico inyectado
73
+ # antes, igual que `log_fields`). Previene el warning `duplicate key` que `json`
74
+ # emite cuando coexisten `:task` (Symbol, vía Tracer) y `"task"` (String, vía
75
+ # KV string developer) — `json` 3.0 lo va a transformar en error.
76
+ #
77
+ # @param payload [Hash]
78
+ # @return [Hash{String => Object}]
79
+ def normalize_keys(payload)
80
+ payload.each_with_object({}) do |(key, value), result|
81
+ next if value.nil?
82
+
83
+ result[key.to_s] = value
84
+ end
85
+ end
86
+
71
87
  # @return [String, nil]
72
88
  def service_version
73
89
  ExisRay.configuration.service_version
@@ -98,7 +98,7 @@ module ExisRay
98
98
  component: "exis_ray",
99
99
  event: "http_request",
100
100
  method: payload[:method],
101
- path: payload[:path],
101
+ "url.path": payload[:path],
102
102
  http_route: extract_http_route(payload),
103
103
  format: payload[:format],
104
104
  controller: payload[:controller],
@@ -112,6 +112,8 @@ module ExisRay
112
112
  server_address: extract_server_address(headers["HTTP_HOST"])
113
113
  }
114
114
 
115
+ data[:path] = payload[:path] if ExisRay.configuration.emit_legacy_path_key
116
+
115
117
  data.merge!(exception_data)
116
118
  data.merge!(self.class.extra_fields(event))
117
119
  data.compact
@@ -129,15 +131,16 @@ module ExisRay
129
131
  end
130
132
 
131
133
  # Extrae los datos de excepción para logs OTel.
132
- # Emite tanto los campos legacy (`error_class`/`error_message`) como los OTel
133
- # (`exception.type`/`exception.message`/`exception.stacktrace`) durante la ventana
134
- # de transición definida en el plan "Breaking changes OTel v1.0".
134
+ # Siempre emite `exception.type`/`exception.message`/`exception.stacktrace` (OTel v1.0).
135
+ # Emite además los campos legacy (`error_class`/`error_message`) cuando
136
+ # `ExisRay.configuration.emit_legacy_exception_keys` es `true` (default durante la
137
+ # ventana de transición). Setear a `false` cuando los consumers hayan migrado.
135
138
  #
136
139
  # El stacktrace se toma de `payload[:exception_object]` (expuesto por Rails), no
137
140
  # de `payload[:exception]` que es solo `[class_name, message]`.
138
141
  #
139
142
  # @param payload [Hash] Payload completo del notification de Rails.
140
- # @return [Hash] Campos exception.* y error_class/error_message con keys symbol.
143
+ # @return [Hash] Campos exception.* (y error_class/error_message si el flag está activo).
141
144
  def extract_exception_data(payload)
142
145
  exception_info = payload[:exception]
143
146
  return {} unless exception_info
@@ -146,12 +149,15 @@ module ExisRay
146
149
  exception_message = exception_info.last
147
150
 
148
151
  data = {
149
- error_class: exception_class,
150
- error_message: exception_message,
151
152
  "exception.type": exception_class,
152
153
  "exception.message": exception_message
153
154
  }
154
155
 
156
+ if ExisRay.configuration.emit_legacy_exception_keys
157
+ data[:error_class] = exception_class
158
+ data[:error_message] = exception_message
159
+ end
160
+
155
161
  exception_object = payload[:exception_object]
156
162
  if exception_object.respond_to?(:backtrace) && exception_object.backtrace
157
163
  data[:"exception.stacktrace"] = exception_object.backtrace.take(20).join("\n")
@@ -29,7 +29,7 @@ module ExisRay
29
29
  curr.correlation_id = ExisRay::Tracer.correlation_id
30
30
  end
31
31
 
32
- log_event(:info, "component=exis_ray event=task_started task=#{task_name} outcome=started")
32
+ log_event(:info, "component=exis_ray event=task_started outcome=started")
33
33
 
34
34
  # Bloque de ejecución con o sin tags dependiendo de la configuración
35
35
  execute_with_optional_tags(&block)
@@ -38,18 +38,16 @@ module ExisRay
38
38
  human_time = ExisRay::Tracer.format_duration(duration_s)
39
39
 
40
40
  log_event(:info,
41
- "component=exis_ray event=task_finished task=#{task_name} " \
41
+ "component=exis_ray event=task_finished " \
42
42
  "outcome=success duration_s=#{duration_s} duration_human=\"#{human_time}\"")
43
43
  rescue StandardError => e
44
44
  duration_s = ExisRay::Tracer.current_duration_s
45
45
  human_time = ExisRay::Tracer.format_duration(duration_s)
46
46
 
47
47
  log_event(:error,
48
- "component=exis_ray event=task_finished task=#{task_name} " \
48
+ "component=exis_ray event=task_finished " \
49
49
  "outcome=failed duration_s=#{duration_s} duration_human=\"#{human_time}\" " \
50
- "error_class=#{e.class} error_message=#{e.message.inspect} " \
51
- "exception.type=#{e.class} exception.message=#{e.message.inspect} " \
52
- "exception.stacktrace=#{format_stacktrace(e.backtrace)}")
50
+ "#{format_exception_fields(e)}")
53
51
  raise e
54
52
  ensure
55
53
  # Limpieza centralizada obligatoria para evitar filtraciones de memoria o contexto
@@ -124,7 +122,26 @@ module ExisRay
124
122
  '""'
125
123
  end
126
124
 
125
+ # Construye los campos KV de excepción para el log de `task_finished` failed.
126
+ # Siempre incluye `exception.type` / `exception.message` / `exception.stacktrace` (OTel v1.0).
127
+ # Incluye además `error_class` / `error_message` cuando
128
+ # `ExisRay.configuration.emit_legacy_exception_keys` es `true` (default durante la ventana
129
+ # de transición).
130
+ #
131
+ # @param error [Exception]
132
+ # @return [String]
133
+ def self.format_exception_fields(error)
134
+ message_quoted = error.message.inspect
135
+ stack = format_stacktrace(error.backtrace)
136
+
137
+ parts = []
138
+ parts << "error_class=#{error.class} error_message=#{message_quoted}" if ExisRay.configuration.emit_legacy_exception_keys
139
+ parts << "exception.type=#{error.class} exception.message=#{message_quoted} " \
140
+ "exception.stacktrace=#{stack}"
141
+ parts.join(" ")
142
+ end
143
+
127
144
  private_class_method :pod_identifier, :setup_tracer, :execute_with_optional_tags,
128
- :log_event, :format_stacktrace
145
+ :log_event, :format_stacktrace, :format_exception_fields
129
146
  end
130
147
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ExisRay
4
4
  # Versión actual de la gema.
5
- VERSION = "0.9.0"
5
+ VERSION = "0.11.0"
6
6
  end
data/skill/SKILL.md CHANGED
@@ -11,7 +11,7 @@ Para el complemento del estándar de logging Wispro (regla Data First, mapeo Ope
11
11
 
12
12
  ### Artefactos de detalle (RFC-008)
13
13
 
14
- Este SKILL.md **resume e indexa**; el contrato y el significado de detalle viven en `docs/<capa>/`. **Version-lock por construcción:** `gemspec.files` empaqueta `docs/**` en el mismo tag que este `SKILL.md`; los links son rutas relativas dentro del paquete del release (nunca rama/`HEAD`/URL flotante). Contrato resumido anclado a **v0.8.0 + fix issue #9** (commit `00cf803`):
14
+ Este SKILL.md **resume e indexa**; el contrato y el significado de detalle viven en `docs/<capa>/`. **Version-lock por construcción:** `gemspec.files` empaqueta `docs/**` en el mismo tag que este `SKILL.md`; los links son rutas relativas dentro del paquete del release (nunca rama/`HEAD`/URL flotante). Contrato resumido anclado a **v0.10.0** (post fix issues #9, #11, #12):
15
15
 
16
16
  - [`docs/behavior/behavior.md`](../docs/behavior/behavior.md) — secuencias de hidratación de trace por entrypoint + emisión en logs (parcial, incremental).
17
17
  - [`docs/glossary/glossary.md`](../docs/glossary/glossary.md) — lenguaje ubicuo del bounded context (`root_id`, `trace_id`, `source`, `request_id`, `entrypoint`, ...).
@@ -79,8 +79,8 @@ ExisRay unifica trazabilidad distribuida, logging estructurado JSON, contexto de
79
79
  2. `Tracer.parse_trace_id` extrae `root_id`, `self_id`, `called_from`, `total_time_so_far`
80
80
  3. `ExisRay.sync_correlation_id` asigna `Tracer.correlation_id` a `Current.correlation_id`
81
81
  4. Controller ejecuta `before_action` para setear `Current.user_id`, `Current.isp_id`
82
- 5. `JsonFormatter` intercepta cada `Rails.logger.*` e inyecta automaticamente: `time`, `level`, `severity_number`, `service`, `service_version`, `deployment_environment`, `request_id` (fuera del guard de `root_id` distinto ciclo de vida), `root_id`, `trace_id`, `source`, `user_id`, `isp_id`, `correlation_id`. Como el entrypoint siempre garantiza `root_id`, `source` (mandatorio) nunca falta. 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.
83
- 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.*`).
82
+ 5. `JsonFormatter` intercepta cada `Rails.logger.*` e inyecta el contexto de ejecucion en cada linea. **No es incondicional:** cada campo tiene un guard especifico (ver tabla "Condiciones de emision" mas abajo). En particular `inject_tracer_context` corta el bloque `root_id`/`trace_id`/`source`/`task`/`sidekiq_job` con `return unless Tracer.root_id` la invariante "todo entrypoint garantiza `root_id`" es lo que hace que `source` (mandatorio) nunca falte. `request_id` se emite **fuera** de ese guard (distinto ciclo de vida que `root_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.
83
+ 6. `LogSubscriber` emite un unico Hash al finalizar el request con campos default (`component`, `event`, `method`, `url.path` siempre + `path` si `config.emit_legacy_path_key`, `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 `exception.type`/`exception.message`/`exception.stacktrace` siempre + `error_class`/`error_message` si `config.emit_legacy_exception_keys`).
84
84
  7. En llamadas salientes, `FaradayMiddleware`/`ActiveResourceInstrumentation` inyectan `propagation_trace_header` con `Tracer.generate_trace_header`
85
85
  8. Al finalizar, `ActiveSupport::CurrentAttributes` hace reset automatico
86
86
 
@@ -100,6 +100,8 @@ ExisRay.configure do |config|
100
100
  config.log_subscriber_class = "MyLogSubscriber" # String|nil, default nil
101
101
  config.service_version = "1.2.3" # String|nil, default: Rails config.version o config.x.version
102
102
  config.deployment_environment = "production" # String|nil, default: Rails.env
103
+ config.emit_legacy_exception_keys = true # Boolean, default true (ventana transicion OTel v1.0)
104
+ config.emit_legacy_path_key = true # Boolean, default true (ventana transicion `path` -> `url.path` OTel v1.0)
103
105
  end
104
106
 
105
107
  ExisRay.configuration.json_logs? # => true si log_format == :json
@@ -233,8 +235,9 @@ ExisRay::TaskMonitor.run("billing:generate_invoices") do
233
235
  InvoiceService.process_all
234
236
  end
235
237
  # Genera root_id propio, loguea task_started/task_finished con outcome y duration_s.
236
- # En caso de error emite: error_class, error_message (legacy) + exception.type,
237
- # exception.message, exception.stacktrace (OTel, limitado a 20 lineas).
238
+ # En caso de error emite: exception.type, exception.message, exception.stacktrace
239
+ # (OTel, limitado a 20 lineas) + error_class/error_message si
240
+ # config.emit_legacy_exception_keys es true (default durante ventana de transicion).
238
241
  # Re-lanza excepciones despues de loguearlas.
239
242
  # Hace reset de Tracer, Current y Reporter en ensure.
240
243
  ```
@@ -250,8 +253,9 @@ Reemplaza Lograge. Se suscribe a `process_action.action_controller` y emite un H
250
253
  | `component` | String | Siempre `"exis_ray"` |
251
254
  | `event` | String | Siempre `"http_request"` |
252
255
  | `method` | String | Verbo HTTP |
253
- | `path` | String | URL concreta del request |
254
- | `http_route` | String | Template (ej: `/users/:id`). Baja cardinalidad para dashboards |
256
+ | `url.path` | String | URL concreta del request (OTel v1.0). Siempre presente |
257
+ | `path` | String | Alias legacy de `url.path`. Solo si `config.emit_legacy_path_key` (default `true`, deprecado) |
258
+ | `http_route` | String | Template (ej: `/users/:id`). Baja cardinalidad para dashboards. En endpoints sin params coincide en valor con `url.path` — dupe semanticamente esperada (concrete vs template) |
255
259
  | `format` | Symbol/String | `html`, `json`, etc. |
256
260
  | `controller` | String | Class name del controller |
257
261
  | `action` | String | Nombre del action |
@@ -262,7 +266,7 @@ Reemplaza Lograge. Se suscribe a `process_action.action_controller` y emite un H
262
266
  | `db_runtime_s` | Float\|nil | Solo si ActiveRecord lo reporta |
263
267
  | `user_agent_original` | String | Header `User-Agent` |
264
268
  | `server_address` | String | Hostname sin puerto (de `Host` header) |
265
- | `error_class`, `error_message` | String | Solo en fallo (legacy) |
269
+ | `error_class`, `error_message` | String | Solo en fallo y si `config.emit_legacy_exception_keys` (default `true`, deprecadas) |
266
270
  | `exception.type`, `exception.message`, `exception.stacktrace` | String | Solo en fallo (OTel; stack limitado a 20 lineas) |
267
271
 
268
272
  Para inyectar campos extra, sobreescribir `extra_fields`:
@@ -295,6 +299,26 @@ Casteo automatico: integers, floats, objetos JSON (`{...}`, `[...]`). Filtra cla
295
299
 
296
300
  Por eso `component` y `event` jamas se auto-inyectan, aunque el estandar Wispro los exija.
297
301
 
302
+ #### Condiciones de emision por campo
303
+
304
+ La auto-inyeccion **no es incondicional**: cada campo tiene un guard. `inject_tracer_context` corta su bloque con `return unless Tracer.root_id`, asi que `root_id`/`trace_id`/`source`/`task`/`sidekiq_job` solo se emiten cuando hay trace context activo. Los 4 entrypoints (HTTP, Sidekiq server, BugBunny consumer, TaskMonitor) garantizan ese `root_id` (fresco si no llega header), por eso `source` (mandatorio) nunca falta en una linea originada por un entrypoint. `request_id` se emite **fuera** del guard de `root_id` — distinto ciclo de vida.
305
+
306
+ | Campo | Condicion de emision | Entrypoint que la garantiza |
307
+ |:------|:---------------------|:----------------------------|
308
+ | `time`, `level`, `severity_number`, `service`, `service_version`, `deployment_environment` | Siempre (no depende de Tracer/Current) | — |
309
+ | `request_id` | `Tracer.request_id` presente. **Fuera del guard de `root_id`** (issue #9 Gap C): distinto ciclo de vida (UUID v4 de Rails vs formato X-Ray). | HTTP (via `ActionDispatch::RequestId`). Otros entrypoints solo si la app lo setea explicitamente. |
310
+ | `root_id` | `Tracer.root_id` presente. **Gatea todo el bloque de tracer context.** | Los 4 entrypoints garantizan `root_id` fresco si no llega trace header (issue #9 Gap A). |
311
+ | `source` | `Tracer.source` presente **y** `root_id` presente (esta dentro del bloque gateado). | Idem `root_id`. Como `source` es mandatorio del estandar Wispro, la invariante "todo entrypoint garantiza `root_id`" es lo que evita que falte. |
312
+ | `trace_id` | `Tracer.trace_id` presente **y** `root_id` presente. Solo cuando el servicio es **eslabon intermedio** (recibio trace header upstream). | — (entrypoint que no recibe header tiene `root_id` fresco pero `trace_id` nil). |
313
+ | `sidekiq_job` | `Tracer.sidekiq_job` presente **y** `root_id` presente. | Sidekiq `ServerMiddleware`. |
314
+ | `task` | `Tracer.task` presente **y** `root_id` presente. | `TaskMonitor.run`. |
315
+ | `correlation_id` | `Current.correlation_id` presente. | `ExisRay.sync_correlation_id` (HTTP middleware lo llama; otros entrypoints solo si la app lo invoca). |
316
+ | `user_id`, `isp_id` | `Current.<attr>` no nil. | Lo setea la app (login, before_actions, etc.). |
317
+ | `Current.log_fields` (cualquier key) | La subclass de `Current` overrideo el hook y retorno un Hash no vacio. | — |
318
+ | `tags` | Rails tagged logging activo. **Antipatron con JSON** (rompe el formato) — ver FAQ. | — |
319
+
320
+ Para el detalle por entrypoint (que setea que y cuando), ver [`docs/behavior/behavior.md`](../docs/behavior/behavior.md). Para el significado de cada campo, [`docs/glossary/glossary.md`](../docs/glossary/glossary.md).
321
+
298
322
  #### Ejemplos: KV vs Hash producen output equivalente
299
323
 
300
324
  ```ruby
data/skills.yml CHANGED
@@ -2,48 +2,34 @@ mcps:
2
2
  - github
3
3
  - clickup
4
4
  skills:
5
- skill-manager:
6
- repo: sequre/ai_knowledge
7
- scope: local
8
5
  yard:
9
6
  repo: sequre/ai_knowledge
10
- scope: local
11
7
  quality-code:
12
8
  repo: sequre/ai_knowledge
13
- scope: local
14
9
  gem-release:
15
10
  repo: sequre/ai_knowledge
16
- scope: local
17
- skill-builder:
11
+ dev-structure:
18
12
  repo: sequre/ai_knowledge
19
- scope: local
20
- ai-reports:
13
+ dev-compose:
21
14
  repo: sequre/ai_knowledge
22
- scope: local
23
- environment:
24
- space_id: "${AI_REPORTS_SPACE_ID}"
25
- bug_reports_list_id: "${AI_REPORTS_BUG_REPORTS_LIST_ID}"
26
- improvements_list_id: "${AI_REPORTS_IMPROVEMENTS_LIST_ID}"
27
- agent-review:
15
+ dev-enrich:
28
16
  repo: sequre/ai_knowledge
29
- scope: local
30
- environment:
31
- space_id: "${AGENT_REVIEW_SAPCE_ID}"
32
- list_id: "${AGENT_LIST_ID}"
33
- action-plan:
17
+ skill-feedback:
18
+ repo: sequre/ai_knowledge
19
+ agent-issue:
20
+ repo: sequre/ai_knowledge
21
+ dev-flow:
22
+ repo: sequre/ai_knowledge
23
+ matrix-element:
34
24
  repo: sequre/ai_knowledge
35
25
  environment:
36
- space_id: "${AGENT_REVIEW_SAPCE_ID}"
37
- list_id: "${AGENT_ACTION_PLAN_LIST_ID}"
26
+ homeserver: "https://matrix.cloud.wispro.co"
27
+ auth_token: "${MATRIX_AUTH_TOKEN}"
28
+ rooms:
29
+ agents: "!VCHwQXgmXdyhhhPhoz:matrix.cloud.wispro.co"
38
30
  documentation-writer:
39
31
  repo: github/awesome-copilot
40
32
  path: skills/documentation-writer
41
- scope: local
42
- find-skills:
43
- repo: vercel-labs/skills
44
- path: skills/find-skills
45
- scope: local
46
33
  opentelemetry:
47
34
  repo: bobmatnyc/claude-mpm-skills
48
35
  path: universal/observability/opentelemetry
49
- scope: local
@@ -37,6 +37,14 @@ RSpec.describe ExisRay::Configuration do
37
37
  it "deployment_environment por defecto es nil fuera de Rails" do
38
38
  expect(config.deployment_environment).to be_nil
39
39
  end
40
+
41
+ it "emit_legacy_exception_keys por defecto es true (ventana de transición OTel)" do
42
+ expect(config.emit_legacy_exception_keys).to be true
43
+ end
44
+
45
+ it "emit_legacy_path_key por defecto es true (ventana de transición OTel url.path)" do
46
+ expect(config.emit_legacy_path_key).to be true
47
+ end
40
48
  end
41
49
 
42
50
  describe "resource attributes OTel" do
@@ -158,5 +166,19 @@ RSpec.describe ExisRay::Configuration do
158
166
 
159
167
  expect(config.log_subscriber_class).to eq("MyLogSubscriber")
160
168
  end
169
+
170
+ it "permite cambiar emit_legacy_exception_keys" do
171
+ config = described_class.new
172
+ config.emit_legacy_exception_keys = false
173
+
174
+ expect(config.emit_legacy_exception_keys).to be false
175
+ end
176
+
177
+ it "permite cambiar emit_legacy_path_key" do
178
+ config = described_class.new
179
+ config.emit_legacy_path_key = false
180
+
181
+ expect(config.emit_legacy_path_key).to be false
182
+ end
161
183
  end
162
184
  end
@@ -463,6 +463,72 @@ RSpec.describe ExisRay::JsonFormatter do
463
463
  end
464
464
  end
465
465
 
466
+ describe "dedup de claves Symbol/String (issue #12)" do
467
+ def stub_tracer_with_task(task_value)
468
+ stub_const("ExisRay::Tracer", Module.new do
469
+ define_singleton_method(:service_name) { "test-service" }
470
+ define_singleton_method(:root_id) { "1-abc-def" }
471
+ define_singleton_method(:trace_id) { nil }
472
+ define_singleton_method(:request_id) { nil }
473
+ define_singleton_method(:source) { "task" }
474
+ define_singleton_method(:sidekiq_job) { nil }
475
+ define_singleton_method(:task) { task_value }
476
+ end)
477
+ end
478
+
479
+ it "no emite warning de `duplicate key` cuando KV string y Tracer setean la misma key" do
480
+ stub_tracer_with_task("billing:invoices")
481
+
482
+ warning = nil
483
+ original_warn = Warning.method(:warn)
484
+ Warning.singleton_class.define_method(:warn) { |msg, **| warning = msg }
485
+
486
+ begin
487
+ call("event=task_started task=billing:invoices outcome=started")
488
+ ensure
489
+ Warning.singleton_class.define_method(:warn, original_warn)
490
+ end
491
+
492
+ expect(warning).to be_nil
493
+ end
494
+
495
+ it "developer payload (KV string) pisa al contexto canónico inyectado por Tracer" do
496
+ stub_tracer_with_task("billing:invoices")
497
+
498
+ result = call("event=foo task=override outcome=success")
499
+
500
+ expect(result["task"]).to eq("override")
501
+ end
502
+
503
+ it "emite la key como String aunque internamente fuese Symbol" do
504
+ stub_tracer_with_task("billing:invoices")
505
+
506
+ json_str = formatter.call(severity, timestamp, progname, "event=task_started outcome=started")
507
+
508
+ # Si quedaran ambas (`:task` y `"task"`) el JSON tendría dos veces la key.
509
+ expect(json_str.scan(/"task"\s*:/).size).to eq(1)
510
+ expect(JSON.parse(json_str)["task"]).to eq("billing:invoices")
511
+ end
512
+
513
+ it "no incluye keys con valor nil en el output" do
514
+ stub_const("ExisRay::Tracer", Module.new do
515
+ define_singleton_method(:service_name) { "test-service" }
516
+ define_singleton_method(:root_id) { nil }
517
+ define_singleton_method(:trace_id) { nil }
518
+ define_singleton_method(:request_id) { nil }
519
+ define_singleton_method(:source) { nil }
520
+ define_singleton_method(:sidekiq_job) { nil }
521
+ define_singleton_method(:task) { nil }
522
+ end)
523
+
524
+ result = call("event=foo")
525
+
526
+ expect(result).not_to have_key("root_id")
527
+ expect(result).not_to have_key("task")
528
+ expect(result).not_to have_key("source")
529
+ end
530
+ end
531
+
466
532
  describe "#filter_sensitive_hash (privado)" do
467
533
  it "filtra claves sensibles en el nivel raíz" do
468
534
  result = formatter.send(:filter_sensitive_hash, { user: "gabriel", password: "secret" })
@@ -151,6 +151,33 @@ RSpec.describe ExisRay::LogSubscriber do
151
151
  expect(data[:server_address]).to eq("api.example.com")
152
152
  end
153
153
 
154
+ it "emite url.path (OTel v1.0) siempre con la URL concreta" do
155
+ event = build_event(payload: base_payload)
156
+
157
+ data = subscriber.send(:build_payload, event)
158
+
159
+ expect(data[:"url.path"]).to eq("/users/42")
160
+ end
161
+
162
+ it "emite path (legacy Wispro) junto a url.path cuando emit_legacy_path_key=true (default)" do
163
+ event = build_event(payload: base_payload)
164
+
165
+ data = subscriber.send(:build_payload, event)
166
+
167
+ expect(data[:path]).to eq("/users/42")
168
+ expect(data[:"url.path"]).to eq("/users/42")
169
+ end
170
+
171
+ it "omite path cuando emit_legacy_path_key=false (solo url.path)" do
172
+ allow(ExisRay.configuration).to receive(:emit_legacy_path_key).and_return(false)
173
+ event = build_event(payload: base_payload)
174
+
175
+ data = subscriber.send(:build_payload, event)
176
+
177
+ expect(data).not_to have_key(:path)
178
+ expect(data[:"url.path"]).to eq("/users/42")
179
+ end
180
+
154
181
  # Helper: construye un mock de ActionDispatch::Journey::Route con la API REAL
155
182
  # que usa el código de producción (route.defaults, route.path.spec, route.verb).
156
183
  # Esto evita el anti-patrón anterior de stubear una API inventada.
@@ -268,6 +295,22 @@ RSpec.describe ExisRay::LogSubscriber do
268
295
  expect(data[:error_message]).to eq("something went wrong")
269
296
  end
270
297
 
298
+ it "omite error_class/error_message cuando emit_legacy_exception_keys=false" do
299
+ allow(ExisRay.configuration).to receive(:emit_legacy_exception_keys).and_return(false)
300
+
301
+ event = build_event(payload: base_payload.merge(
302
+ status: nil,
303
+ exception: ["RuntimeError", "something went wrong"]
304
+ ))
305
+
306
+ data = subscriber.send(:build_payload, event)
307
+
308
+ expect(data[:"exception.type"]).to eq("RuntimeError")
309
+ expect(data[:"exception.message"]).to eq("something went wrong")
310
+ expect(data).not_to have_key(:error_class)
311
+ expect(data).not_to have_key(:error_message)
312
+ end
313
+
271
314
  it "inyecta exception.stacktrace desde payload[:exception_object]" do
272
315
  exception_object = RuntimeError.new("boom")
273
316
  exception_object.set_backtrace(["a.rb:1", "b.rb:2", "c.rb:3"])
@@ -49,7 +49,22 @@ RSpec.describe ExisRay::TaskMonitor do
49
49
  expect(start_log[0]).to eq(:info)
50
50
  expect(start_log[1]).to include("outcome=started")
51
51
  expect(start_log[1]).to include("event=task_started")
52
- expect(start_log[1]).to include("task=billing:invoices")
52
+ end
53
+
54
+ it "no emite `task=` en el KV (la clave viene del formatter vía Tracer.task)" do
55
+ captured_task = nil
56
+ described_class.run("billing:invoices") { captured_task = ExisRay::Tracer.task }
57
+
58
+ # task_name vive en el Tracer durante la ejecución del bloque
59
+ expect(captured_task).to eq("billing:invoices")
60
+
61
+ # Los KV de lifecycle NO incluyen `task=...` — evita la duplicación con
62
+ # `JsonFormatter#inject_tracer_context` (issue #12).
63
+ lifecycle = captured_logs.map { |_, msg| msg }.select { |m| m.include?("event=task_") }
64
+ expect(lifecycle).not_to be_empty
65
+ lifecycle.each do |msg|
66
+ expect(msg).not_to match(/\btask=/)
67
+ end
53
68
  end
54
69
 
55
70
  it "loguea task_finished con outcome=success (breaking rename v0.6.0)" do
@@ -94,6 +109,21 @@ RSpec.describe ExisRay::TaskMonitor do
94
109
  expect(finish_log[1]).to include("exception.stacktrace=")
95
110
  end
96
111
 
112
+ it "omite error_class/error_message cuando emit_legacy_exception_keys=false" do
113
+ allow(ExisRay.configuration).to receive(:emit_legacy_exception_keys).and_return(false)
114
+
115
+ expect do
116
+ described_class.run("task:boom") { raise "kaboom" }
117
+ end.to raise_error(RuntimeError, "kaboom")
118
+
119
+ finish_log = captured_logs.find { |lvl, msg| lvl == :error && msg.include?("task_finished") }
120
+ expect(finish_log[1]).not_to include("error_class=")
121
+ expect(finish_log[1]).not_to include("error_message=")
122
+ expect(finish_log[1]).to include("exception.type=RuntimeError")
123
+ expect(finish_log[1]).to include("exception.message=")
124
+ expect(finish_log[1]).to include("exception.stacktrace=")
125
+ end
126
+
97
127
  it "resetea el Tracer incluso en caso de error" do
98
128
  begin
99
129
  described_class.run("task:boom") { raise "kaboom" }
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.9.0
4
+ version: 0.11.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-05-19 00:00:00.000000000 Z
11
+ date: 2026-05-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -90,8 +90,8 @@ licenses:
90
90
  metadata:
91
91
  homepage_uri: https://github.com/gedera/exis_ray
92
92
  source_code_uri: https://github.com/gedera/exis_ray
93
- changelog_uri: https://github.com/gedera/exis_ray/blob/v0.9.0/CHANGELOG.md
94
- documentation_uri: https://github.com/gedera/exis_ray/blob/v0.9.0/skill
93
+ changelog_uri: https://github.com/gedera/exis_ray/blob/v0.11.0/CHANGELOG.md
94
+ documentation_uri: https://github.com/gedera/exis_ray/blob/v0.11.0/skill
95
95
  post_install_message:
96
96
  rdoc_options: []
97
97
  require_paths: