exis_ray 0.7.2 → 0.9.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: 5dd52ddf30f1d4eaaaad95ed4acde4bd94c8dd4a01b20853f42ddd71887c0876
4
- data.tar.gz: 329163a0bbbd0473e8da70971822c645de763f6a2c99eb4ccf1c973603cd700e
3
+ metadata.gz: c87f049dd7216e8ac33a9844b77cd1c863f9248130b329994002aa654c0c47ca
4
+ data.tar.gz: 1752d432a8437178121b36a0b28293f52f96b99d13a5c40589f312fb8f9b6236
5
5
  SHA512:
6
- metadata.gz: 9eb8bb340dae5b7f423e206ed1fa708caf0b599be0bfc3dd9f1d71209485cf6f2a17a06c9805167cd4988cae20b751f684fda2253d5983efa3c3d9e456e51849
7
- data.tar.gz: e18cf7eaa0962d39591072eb9241df8c950c0d1d1c1786aaff4d84ebc4acd1b6ff993b280f5bedc6175bbabd56a0a75f0e8f92a50b899051a1942d21e91888fc
6
+ metadata.gz: 72f092befb65f8eb03fe851a302ec49ac47fb155a288ed1103923766f0322da6e5704e760d8c6fa7e4865d78666ddc29b7f7c5e486f22120de1c0ff4a072777e
7
+ data.tar.gz: 57e552a85bc42d9cb659d49a429c85131175350542856378d432063b9558f264a636c93d4a25ee57b51717718c5bcb03a2f86fbff3676ce85fedfde4562f5509
data/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [0.9.0] - 2026-05-19
2
+
3
+ ### Correcciones
4
+ - **HTTP entrypoint sin trace header no emitía `root_id`/`source`/`request_id`** (#9): `HttpMiddleware` era el único entrypoint sin fallback de `root_id`. Un servicio que es punto de entrada (no eslabón intermedio de un trace distribuido) emitía log lines sin `root_id` y, por efecto cascada en `JsonFormatter#inject_tracer_context` (`return unless root_id`), sin `source` —campo mandatorio del estándar Wispro—. Ahora `HttpMiddleware` genera un `root_id` fresco cuando no llega trace header, igual que `Sidekiq::ServerMiddleware`/`BugBunny::ConsumerTracingMiddleware`/`TaskMonitor`. Además `JsonFormatter` emite `request_id` fuera del guard de `root_id` (distinto ciclo de vida: UUID v4 de Rails vs formato X-Ray), así un servicio puede correlacionar por request aunque no haya trace context activo.
5
+
6
+ ### Documentación
7
+ - **Artefactos dev-\* (RFC-008/010)**: nuevos `docs/behavior/behavior.md` (RFC-007 — secuencia de hidratación de trace por entrypoint y emisión en logs, cobertura declarada) y `docs/glossary/glossary.md` (RFC-009 — lenguaje ubicuo del bounded context: `root_id`, `trace_id`, `source`, `request_id`, `entrypoint`, ...). `README.md` y `skill/SKILL.md` recompuestos indexando el detalle (indexa, no duplica): índice de artefactos, nota de coexistencia transitoria para capas F2, version-lock declarado y `description` endurecida como contrato de discoverability.
8
+
9
+ ## [0.8.0] - 2026-05-14
10
+
11
+ ### Nuevas funcionalidades
12
+ - **`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.
13
+
14
+ ### Mejoras internas
15
+ - **`.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/.
16
+
1
17
  ## [0.7.2] - 2026-05-11
2
18
 
3
19
  ### Documentación
data/CLAUDE.md CHANGED
@@ -105,12 +105,14 @@ ExisRay.configuration.json_logs? # => true/false
105
105
  | `service` | Siempre |
106
106
  | `service_version` | Siempre (de `config.version` o `config.x.version`) |
107
107
  | `deployment_environment` | Siempre (de `Rails.env`) |
108
+ | `request_id` | Cuando `Tracer.request_id` está presente (independiente de root_id) |
108
109
  | `root_id` | Cuando hay trace context activo |
109
110
  | `trace_id` | Cuando hay trace context activo |
110
- | `source` | Cuando hay trace context activo |
111
+ | `source` | Cuando hay trace context activo (HTTP siempre genera root fresco si no llega header) |
111
112
  | `correlation_id` | Cuando `Current.correlation_id` está presente |
112
113
  | `user_id` | Cuando `Current.user_id` está presente |
113
114
  | `isp_id` | Cuando `Current.isp_id` está presente |
115
+ | `Current.log_fields` (cualquier key) | Si la subclass overrideó el hook (default `{}`) |
114
116
  | `sidekiq_job` | Solo en procesos Sidekiq |
115
117
  | `task` | Solo en procesos TaskMonitor |
116
118
  | `tags` | Solo si hay Rails tagged logging activo |
data/README.md CHANGED
@@ -51,11 +51,24 @@ ExisRay opera en tres capas que se combinan automáticamente:
51
51
 
52
52
  **Flujo de propagación:**
53
53
 
54
- 1. Un request/job/mensaje llega al servicio. El middleware correspondiente hidrata el `Tracer` con el header entrante (o genera un nuevo `root_id` si no trae uno).
55
- 2. `JsonFormatter` inyecta automáticamente `root_id`, `trace_id`, `source` y el contexto de negocio (`user_id`, `isp_id`, `correlation_id`) en cada línea de log.
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.
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
 
59
+ ## Documentación de detalle
60
+
61
+ Artefactos de detalle (RFC-008 — el contrato y el significado viven acá; este README indexa y resume, no duplica):
62
+
63
+ | Capa | Artefacto | Estado |
64
+ |:-----|:----------|:-------|
65
+ | Comportamiento | [`docs/behavior/behavior.md`](docs/behavior/behavior.md) — secuencias de hidratación de trace por entrypoint y emisión en logs | parcial, incremental por PR |
66
+ | Glosario | [`docs/glossary/glossary.md`](docs/glossary/glossary.md) — lenguaje ubicuo (`root_id`, `trace_id`, `source`, `request_id`, `entrypoint`, ...) | sembrado inicial, acreta |
67
+ | Datos | — | n/a (gema sin DB) |
68
+ | Operaciones · Interfaz · Topología | — | F2 `dev-structure`, no implementado |
69
+
70
+ > **Coexistencia transitoria (RFC-008 §2):** las secciones _Cómo funciona_, _Campos auto-inyectados_ y _Referencia del Tracer_ de este README contienen contrato/arquitectura cuyo destino estructural (`docs/interface`, `docs/topology`) es **F2 no implementado**. Permanecen embebidas hasta que esas capas existan; el significado y las secuencias ya migraron a `docs/glossary` y `docs/behavior` y se referencian desde acá.
71
+
59
72
  ## Instalación
60
73
 
61
74
  ```ruby
@@ -151,6 +164,38 @@ end
151
164
  | `Current.user?` / `Current.isp?` | Predicate: true si el ID no es nil |
152
165
  | `Current.correlation_id?` | Predicate: true si está presente (no vacío) |
153
166
 
167
+ #### Hook `log_fields` — inyectar campos custom en cada log
168
+
169
+ `Current.log_fields` es un class method overridable que retorna un Hash de campos
170
+ extra a inyectar en cada log line, junto a `user_id`/`isp_id`/`correlation_id`.
171
+ Cubre tanto **constantes de proceso** (declaradas como frozen constants en la
172
+ subclass) como **valores dinámicos per-request** (leídos de atributos de
173
+ `Current`) — todo en un solo lugar.
174
+
175
+ ```ruby
176
+ class Current < ExisRay::Current
177
+ TENANT_ID = ENV.fetch("TENANT_ID").freeze # static, frozen al boot
178
+ attribute :region # dynamic, per-request
179
+
180
+ def self.log_fields
181
+ { tenant_id: TENANT_ID, region: region }.compact
182
+ end
183
+ end
184
+
185
+ # En un before_action / middleware:
186
+ Current.region = request.headers["X-Region"]
187
+
188
+ # Los logs salen automáticamente con tenant_id y region:
189
+ # {"...":"...", "tenant_id":"42", "region":"us-east-1", "event":"..."}
190
+ ```
191
+
192
+ **Reglas:**
193
+
194
+ - Default `{}` — cero overhead si no se override.
195
+ - `JsonFormatter` filtra claves sensibles del hash retornado (`api_key`, `token`, etc.).
196
+ - Si el override revienta, el formatter lo rescata silenciosamente (logging nunca debe afectar el flujo principal).
197
+ - **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.
198
+
154
199
  ### Reporter (reporte de errores)
155
200
 
156
201
  `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).
@@ -304,12 +349,14 @@ config.logger.formatter = ExisRay::JsonFormatter
304
349
  | `service` | Siempre (nombre de la app Rails en snake_case) |
305
350
  | `service_version` | Siempre (lee de `config.version` o `config.x.version`) |
306
351
  | `deployment_environment` | Siempre (lee de `Rails.env`) |
307
- | `root_id` | Cuando hay trace context activo |
308
- | `trace_id` | Cuando hay trace context activo |
309
- | `source` | Cuando hay trace context activo (`http`, `sidekiq`, `task`, `system`) |
352
+ | `request_id` | Cuando `Tracer.request_id` está presente (independiente del trace context ver [glosario](docs/glossary/glossary.md)) |
353
+ | `root_id` | Siempre en cualquier entrypoint (genera uno fresco si no llega trace header) |
354
+ | `trace_id` | Cuando hay trace header entrante parseado (eslabón intermedio) |
355
+ | `source` | Siempre en cualquier entrypoint (`http`, `sidekiq`, `task`, `system`) |
310
356
  | `correlation_id` | Cuando `Current.correlation_id` está presente |
311
357
  | `user_id` | Cuando `Current.user_id` no es nil |
312
358
  | `isp_id` | Cuando `Current.isp_id` no es nil |
359
+ | `Current.log_fields` (cualquier key) | Si la subclass overrideó el hook y retornó un Hash no vacío |
313
360
  | `sidekiq_job` | Solo en procesos Sidekiq |
314
361
  | `task` | Solo en procesos TaskMonitor |
315
362
  | `tags` | Solo si hay Rails tagged logging activo |
@@ -0,0 +1,75 @@
1
+ # Comportamiento — exis_ray
2
+
3
+ > meta: artefacto · RFC-007 (UML 2.x sequence / BPMN 2.0, render Mermaid) · generado dev-enrich · anclado a commit `00cf803` (v0.8.0 + fix issue #9) · cobertura **parcial incremental**
4
+
5
+ ## 1. Resumen
6
+
7
+ Comportamiento runtime de la trazabilidad distribuida: hidratación del `Tracer` en cada entrypoint y emisión del contexto en cada línea de log. Documentación **incremental** — este artefacto se inició con el PR del issue #9 (flujo de entrypoint HTTP); el resto de flujos se acreta cuando se tocan.
8
+
9
+ ## 2. Cuerpo
10
+
11
+ ### Cobertura (obligatoria — RFC-007 §2.a)
12
+
13
+ | Flujo | Estado | Origen |
14
+ |---|---|---|
15
+ | Hidratación de trace en entrypoint HTTP + emisión en logs | **documentado** | PR issue #9 |
16
+ | Hidratación en entrypoint Sidekiq (server middleware) | **referenciado** (no diagramado) | — |
17
+ | Hidratación en entrypoint BugBunny consumer | **referenciado** (no diagramado) | — |
18
+ | Hidratación en entrypoint TaskMonitor (Rake/Cron) | **referenciado** (no diagramado) | — |
19
+ | Propagación saliente (Faraday / ActiveResource / BugBunny publisher) | **NO documentado** | — |
20
+ | Ciclo de vida RPC reply (BugBunny) | **NO documentado** | — |
21
+
22
+ Ausencia de un flujo en este artefacto ≠ inexistencia del flujo. Acreta por PR.
23
+
24
+ ### Flujo: Hidratación de trace en entrypoint HTTP + emisión en logs
25
+
26
+ Invariante de paridad: los 4 entrypoints (`HttpMiddleware`, `Sidekiq::ServerMiddleware`, `BugBunny::ConsumerTracingMiddleware`, `TaskMonitor`) garantizan un `root_id` aunque no llegue trace header entrante. Antes del fix de issue #9, `HttpMiddleware` era el único sin ese fallback: un servicio que es **punto de entrada** (no eslabón intermedio) emitía logs sin `root_id`, y por efecto cascada sin `source` (campo mandatorio del estándar Wispro).
27
+
28
+ ```mermaid
29
+ sequenceDiagram
30
+ participant Cliente
31
+ participant HM as HttpMiddleware
32
+ participant T as ExisRay::Tracer
33
+ participant App as Rack app / Controller
34
+ participant JF as JsonFormatter
35
+
36
+ Cliente->>HM: request (con o sin trace_header)
37
+ HM->>T: hydrate(trace_id: env[trace_header], source: "http")
38
+ T->>T: created_at, source="http", trace_id=...
39
+ T->>T: parse_trace_id
40
+ alt trace_header presente (eslabón intermedio)
41
+ T->>T: root_id = data["Root"] (parseado del header)
42
+ else sin trace_header (servicio entrypoint)
43
+ T-->>HM: root_id queda nil
44
+ HM->>T: root_id ||= generate_new_root %% fix issue #9 Gap A
45
+ T->>T: root_id = "Root=1-<ts>-<rand>" (fresco)
46
+ end
47
+ HM->>T: request_id = env["action_dispatch.request_id"]
48
+ HM->>HM: ExisRay.sync_correlation_id
49
+ HM->>App: @app.call(env)
50
+ App->>JF: Rails.logger.info "event=..."
51
+ JF->>T: request_id? %% issue #9 Gap C: fuera del guard de root_id
52
+ JF->>JF: payload[:request_id] = T.request_id (si presente)
53
+ JF->>T: root_id?
54
+ alt root_id presente (siempre en entrypoint, post-fix)
55
+ JF->>JF: payload += root_id, trace_id, source, ...
56
+ end
57
+ JF-->>App: línea JSON con source + correlación
58
+ Note over HM,App: rescue StandardError ⇒ @app.call(env) igual<br/>(logging nunca rompe el flujo principal)
59
+ ```
60
+
61
+ Contexto: `JsonFormatter#inject_tracer_context` corta todo su bloque con `return unless root_id` (Gap B — efecto cascada de Gap A). El fix de Gap A (root fresco en `HttpMiddleware`) des-gatea ese bloque y `source` vuelve a emitirse. `request_id` se emite **fuera** de ese guard (Gap C): tiene distinto ciclo de vida que `root_id` (UUID v4 de Rails vs formato X-Ray) y un servicio puede querer correlación por request aunque no haya trace context.
62
+
63
+ ## 3. Inferencias
64
+
65
+ | Afirmación | confidence | a verificar por humano |
66
+ |---|---|---|
67
+ | Los 4 entrypoints comparten la invariante "siempre hay root_id" | `declared` | código de los 4 middlewares lo confirma (issue #9 lo tabula) |
68
+ | `request_id` no se emitía en ninguna línea pre-fix | `declared` | confirmado: `JsonFormatter` solo lo usaba interno vía `clean_request_id` |
69
+ | Orden de emisión (request_id antes del guard de root_id) | `declared` | `json_formatter.rb#inject_tracer_context` post-fix |
70
+
71
+ ## 4. Cobertura y fronteras
72
+
73
+ - **Incremental:** solo el flujo de entrypoint HTTP está diagramado. Sidekiq/BugBunny/TaskMonitor referenciados por paridad pero no diagramados (acretan cuando se toquen).
74
+ - **Fuera de alcance:** estructura de datos (gema sin DB → no aplica), interfaz Ruby pública / topología (capas F2 de `dev-structure`, no implementadas).
75
+ - **Significado de términos** (`root_id`, `source`, etc.) → `docs/glossary/glossary.md`.
@@ -0,0 +1,76 @@
1
+ # Glosario — exis_ray
2
+
3
+ > meta: artefacto · RFC-009 (DDD ubiquitous language / bounded context + ISO 11179) · generado dev-enrich · anclado a commit `00cf803` · cobertura **parcial — sembrado inicial, acreta por PR**
4
+
5
+ ## 1. Resumen
6
+
7
+ Lenguaje ubicuo del bounded context **exis_ray** (observabilidad/trazabilidad del ecosistema Wispro). Significado de cada término **en esta gema**. Gema sin capa de datos (`docs/data` = n/a): el `Binding` apunta al símbolo público estable que *es* el concepto (ISO 11179, materialización no-tabular), no a tablas.
8
+
9
+ ## 2. Cuerpo
10
+
11
+ ## entrypoint
12
+
13
+ Punto donde la ejecución entra al servicio y nace (o se continúa) un contexto de trazabilidad. Hay exactamente 4: HTTP, Sidekiq, BugBunny consumer, TaskMonitor. **Invariante:** todo entrypoint garantiza un `root_id` — si no llega trace header entrante, genera uno fresco. Un servicio que es entrypoint sin trace upstream (ej. collector/ingest) igual emite logs correlacionables y con `source`.
14
+
15
+ **Binding:** `ExisRay::HttpMiddleware`, `ExisRay::Sidekiq::ServerMiddleware`, `ExisRay::BugBunny::ConsumerTracingMiddleware`, `ExisRay::TaskMonitor`.
16
+
17
+ ## root_id
18
+
19
+ Identificador raíz de un trace distribuido (formato AWS X-Ray: `1-<ts_hex>-<rand_hex>`, prefijado `Root=`). **Constante a lo largo de toda la cadena** de servicios: nace en el primer entrypoint y se propaga sin cambiar. Es el campo de correlación primario de logs.
20
+
21
+ **Binding:** `ExisRay::Tracer.root_id`.
22
+
23
+ ## trace_id
24
+
25
+ Header de traza entrante completo, sin parsear: `Root=...;Self=...;CalledFrom=...;TotalTimeSoFar=...ms`. Presente solo cuando el servicio es **eslabón intermedio** (recibió header upstream). Un entrypoint sin trace upstream tiene `root_id` (fresco) pero no `trace_id`.
26
+
27
+ **Binding:** `ExisRay::Tracer.trace_id`.
28
+
29
+ ## self_id
30
+
31
+ Identificador del span del servicio actual dentro del trace. Se genera por servicio y viaja en el header saliente como `Self=`.
32
+
33
+ **Binding:** `ExisRay::Tracer.self_id`.
34
+
35
+ ## source
36
+
37
+ Entrypoint de ejecución que originó la línea de log. Valores válidos: `http`, `sidekiq`, `task`, `system`. **Campo mandatorio** del estándar Wispro de logging — toda línea debe tenerlo. Se emite dentro del bloque de tracer context, que está gateado por `root_id`; por eso la invariante "todo entrypoint garantiza root_id" es lo que hace que `source` nunca falte.
38
+
39
+ **Binding:** `ExisRay::Tracer.source`.
40
+
41
+ ## request_id
42
+
43
+ UUID v4 del request HTTP, provisto por `ActionDispatch::RequestId` (`env["action_dispatch.request_id"]`). **Distinto ciclo de vida que `root_id`**: identificador por-request de Rails, no formato X-Ray, no constante a lo largo de la cadena. Se emite en logs fuera del guard de `root_id` — un servicio puede querer correlación por request aunque no haya trace context activo.
44
+
45
+ **Binding:** `ExisRay::Tracer.request_id`.
46
+
47
+ ## correlation_id
48
+
49
+ ID de correlación de negocio compuesto `ServiceName;RootID`, sincronizado al `Current` configurado por la app host. Liga logs de negocio con el trace.
50
+
51
+ **Binding:** `ExisRay::Tracer.correlation_id`, `ExisRay::Current#correlation_id`.
52
+
53
+ ## trace context
54
+
55
+ Conjunto thread-safe de atributos de trazabilidad activos en la request/job actual (`root_id`, `trace_id`, `self_id`, `source`, `request_id`, `created_at`, ...). Vive en `ActiveSupport::CurrentAttributes`; se hidrata en el entrypoint y se resetea al finalizar. "Hay trace context activo" ≡ `root_id` presente.
56
+
57
+ **Binding:** `ExisRay::Tracer` (subclase de `ActiveSupport::CurrentAttributes`).
58
+
59
+ ## propagation header
60
+
61
+ Header saliente que lleva el trace al siguiente servicio (`X-Amzn-Trace-Id` por default): `Root=...;Self=...;CalledFrom=<service>;TotalTimeSoFar=<acc>ms`. Lo genera `Tracer.generate_trace_header`. Distinto del header **entrante** (formato Rack `HTTP_X_AMZN_TRACE_ID`).
62
+
63
+ **Binding:** `ExisRay::Tracer.generate_trace_header`, `ExisRay.configuration.propagation_trace_header`.
64
+
65
+ ## 3. Inferencias
66
+
67
+ | Término | confidence | a verificar |
68
+ |---|---|---|
69
+ | Significado de negocio de cada término | `inferred` (LLM ancló al código; significado lo confirma humano) | revisar que la prosa refleje el uso real en servicios consumidores |
70
+ | `trace context activo ≡ root_id presente` | `declared` | `JsonFormatter#inject_tracer_context` lo implementa literal |
71
+
72
+ ## 4. Cobertura y fronteras
73
+
74
+ - **Sembrado inicial** disparado por PR issue #9; cubre los términos del núcleo trace/log. Acreta por PR (ausencia ≠ inexistencia).
75
+ - **Términos no cubiertos aún:** `Reporter`/Sentry context, `total_time_so_far`, `called_from`, `log_fields`, `sidekiq_job`, `task`.
76
+ - **Frontera:** comportamiento/secuencias → `docs/behavior/behavior.md`. Estructura de datos → n/a (gema sin DB).
@@ -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
@@ -13,6 +13,13 @@ module ExisRay
13
13
  trace_id: env[ExisRay.configuration.trace_header],
14
14
  source: "http"
15
15
  )
16
+ # Si la request no trae trace header entrante (servicio que es punto de
17
+ # entrada, no eslabón intermedio de un trace distribuido), generamos un
18
+ # root_id fresco igual que el resto de entrypoints
19
+ # (Sidekiq::ServerMiddleware, BugBunny::ConsumerTracingMiddleware,
20
+ # TaskMonitor). Sin esto root_id queda nil y JsonFormatter dropea todo
21
+ # el bloque de tracer, incluido `source` (campo mandatorio). Ver issue #9.
22
+ ExisRay::Tracer.root_id ||= ExisRay::Tracer.send(:generate_new_root)
16
23
  ExisRay::Tracer.request_id = env["action_dispatch.request_id"]
17
24
  ExisRay.sync_correlation_id
18
25
 
@@ -102,6 +102,12 @@ module ExisRay
102
102
  # @param payload [Hash] El diccionario del log donde se insertarán los datos.
103
103
  # @return [void]
104
104
  def inject_tracer_context(payload)
105
+ # `request_id` (UUID v4 de Rails) tiene distinto ciclo de vida que
106
+ # `root_id` (formato X-Ray). Se emite fuera del guard de `root_id`:
107
+ # un servicio puede querer correlación por request_id aunque no haya
108
+ # trace context activo. Ver issue #9 (Gap C).
109
+ payload[:request_id] = ExisRay::Tracer.request_id if ExisRay::Tracer.request_id
110
+
105
111
  return unless ExisRay::Tracer.root_id
106
112
 
107
113
  payload[:root_id] = ExisRay::Tracer.root_id
@@ -122,9 +128,28 @@ module ExisRay
122
128
  payload[:user_id] = curr.user_id if curr.respond_to?(:user_id) && !curr.user_id.nil?
123
129
  payload[:isp_id] = curr.isp_id if curr.respond_to?(:isp_id) && !curr.isp_id.nil?
124
130
 
125
- return unless curr.respond_to?(:correlation_id) && curr.correlation_id
131
+ payload[:correlation_id] = curr.correlation_id if curr.respond_to?(:correlation_id) && curr.correlation_id
132
+
133
+ inject_log_fields(payload, curr)
134
+ end
135
+
136
+ # Mergea el resultado de `Current.log_fields` al payload, aplicando el mismo
137
+ # filtrado de claves sensibles que el resto del formatter. Si la subclass del
138
+ # host overrideó el hook y revienta, el error se traga: logging nunca debe
139
+ # afectar el flujo principal.
140
+ #
141
+ # @param payload [Hash]
142
+ # @param curr [Class] La clase Current configurada por la app host.
143
+ # @return [void]
144
+ def inject_log_fields(payload, curr)
145
+ return unless curr.respond_to?(:log_fields)
126
146
 
127
- payload[:correlation_id] = curr.correlation_id
147
+ fields = curr.log_fields
148
+ return if fields.nil? || fields.empty?
149
+
150
+ payload.merge!(filter_sensitive_hash(fields))
151
+ rescue StandardError
152
+ nil
128
153
  end
129
154
 
130
155
  # 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.2"
5
+ VERSION = "0.9.0"
6
6
  end
data/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: exis-ray
3
- description: Skill de conocimiento completo sobre ExisRay, la capa de observabilidad y trazabilidad distribuida del ecosistema Wispro. Consultame para integración, arquitectura, API, errores y antipatrones.
3
+ description: Conocimiento completo de ExisRay, la capa de observabilidad y trazabilidad distribuida del ecosistema Wispro (logging JSON estructurado, trace context AWS X-Ray, propagación entre servicios). ACTIVAR cuando un servicio o gema Rails integra/configura ExisRay, emite o debuggea logs JSON estructurados, propaga trace context (header X-Amzn-Trace-Id) entre HTTP/Sidekiq/BugBunny, usa ExisRay::Tracer/Current/Reporter/JsonFormatter/TaskMonitor, edita el initializer de ExisRay, o resuelve errores/antipatrones de logging-trazabilidad Wispro.
4
4
  ---
5
5
 
6
6
  # ExisRay Expert
@@ -9,6 +9,14 @@ Observabilidad y trazabilidad distribuida para microservicios Rails (AWS X-Ray c
9
9
 
10
10
  Para el complemento del estándar de logging Wispro (regla Data First, mapeo OpenTelemetry, ciclo de vida de jobs/requests), ver `references/standard.md`.
11
11
 
12
+ ### Artefactos de detalle (RFC-008)
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`):
15
+
16
+ - [`docs/behavior/behavior.md`](../docs/behavior/behavior.md) — secuencias de hidratación de trace por entrypoint + emisión en logs (parcial, incremental).
17
+ - [`docs/glossary/glossary.md`](../docs/glossary/glossary.md) — lenguaje ubicuo del bounded context (`root_id`, `trace_id`, `source`, `request_id`, `entrypoint`, ...).
18
+ - Datos = n/a (gema sin DB). Operaciones/Interfaz/Topología = F2 `dev-structure`, no implementado: contrato Ruby permanece embebido abajo (coexistencia transitoria RFC-008 §2).
19
+
12
20
  ---
13
21
 
14
22
  ## Glosario
@@ -67,11 +75,11 @@ ExisRay unifica trazabilidad distribuida, logging estructurado JSON, contexto de
67
75
 
68
76
  ### Flujo runtime (HTTP request)
69
77
 
70
- 1. `HttpMiddleware` lee `trace_header` del env Rack, llama `Tracer.hydrate(trace_id:, source: "http")`
78
+ 1. `HttpMiddleware` lee `trace_header` del env Rack, llama `Tracer.hydrate(trace_id:, source: "http")`. **Si no llega header** (servicio entrypoint, no eslabón intermedio) genera un `root_id` fresco — paridad con `Sidekiq::ServerMiddleware`/`BugBunny::ConsumerTracingMiddleware`/`TaskMonitor`. Captura `request_id` de `action_dispatch.request_id`. Secuencia detallada: `docs/behavior/behavior.md`.
71
79
  2. `Tracer.parse_trace_id` extrae `root_id`, `self_id`, `called_from`, `total_time_so_far`
72
80
  3. `ExisRay.sync_correlation_id` asigna `Tracer.correlation_id` a `Current.correlation_id`
73
81
  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`. 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.
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.
75
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.*`).
76
84
  7. En llamadas salientes, `FaradayMiddleware`/`ActiveResourceInstrumentation` inyectan `propagation_trace_header` con `Tracer.generate_trace_header`
77
85
  8. Al finalizar, `ActiveSupport::CurrentAttributes` hace reset automatico
@@ -163,6 +171,27 @@ Current.correlation_id? # => true si correlation_id es present?
163
171
 
164
172
  Los setters auto-sincronizan con `ActiveResource::Base.headers` y `PaperTrail.request` cuando estan definidos.
165
173
 
174
+ #### Hook `log_fields` — inyectar campos custom en cada log
175
+
176
+ 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 `{}`.
177
+
178
+ ```ruby
179
+ class Current < ExisRay::Current
180
+ TENANT_ID = ENV.fetch("TENANT_ID").freeze # static, frozen al boot
181
+ attribute :region # dynamic, per-request
182
+
183
+ def self.log_fields
184
+ { tenant_id: TENANT_ID, region: region }.compact
185
+ end
186
+ end
187
+ ```
188
+
189
+ Reglas:
190
+
191
+ - `JsonFormatter` filtra claves sensibles del hash retornado (mismo regex que el resto del formatter).
192
+ - Si el override revienta, el formatter rescata silenciosamente (logging no afecta flujo principal).
193
+ - 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.
194
+
166
195
  ### ExisRay::Reporter (clase base abstracta)
167
196
 
168
197
  ```ruby
@@ -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
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe ExisRay::HttpMiddleware do
6
+ subject(:middleware) { described_class.new(app) }
7
+
8
+ let(:app) { ->(_env) { [200, {}, ["ok"]] } }
9
+ let(:trace_header) { ExisRay.configuration.trace_header }
10
+
11
+ after { ExisRay::Tracer.reset }
12
+
13
+ describe "#call" do
14
+ context "cuando la request NO trae trace header entrante (entrypoint, issue #9 Gap A)" do
15
+ let(:env) { { "action_dispatch.request_id" => "req-uuid-123" } }
16
+
17
+ it "genera un root_id fresco igual que los demás entrypoints" do
18
+ middleware.call(env)
19
+
20
+ expect(ExisRay::Tracer.root_id).to match(/\ARoot=1-/)
21
+ end
22
+
23
+ it "setea source=http (des-gatea el bloque de tracer en JsonFormatter, Gap B)" do
24
+ middleware.call(env)
25
+
26
+ expect(ExisRay::Tracer.source).to eq("http")
27
+ end
28
+
29
+ it "setea request_id desde el env de Rails" do
30
+ middleware.call(env)
31
+
32
+ expect(ExisRay::Tracer.request_id).to eq("req-uuid-123")
33
+ end
34
+ end
35
+
36
+ context "cuando la request trae trace header entrante (eslabón intermedio)" do
37
+ let(:env) do
38
+ {
39
+ trace_header => "Root=1-5759b146-abc123;Self=1-5759b146-def456;CalledFrom=upstream",
40
+ "action_dispatch.request_id" => "req-uuid-456"
41
+ }
42
+ end
43
+
44
+ it "respeta el root_id entrante y no genera uno nuevo" do
45
+ middleware.call(env)
46
+
47
+ expect(ExisRay::Tracer.root_id).to eq("1-5759b146-abc123")
48
+ end
49
+
50
+ it "setea source=http y request_id" do
51
+ middleware.call(env)
52
+
53
+ expect(ExisRay::Tracer.source).to eq("http")
54
+ expect(ExisRay::Tracer.request_id).to eq("req-uuid-456")
55
+ end
56
+ end
57
+
58
+ it "invoca la app y retorna su respuesta" do
59
+ expect(middleware.call({})).to eq([200, {}, ["ok"]])
60
+ end
61
+
62
+ it "no rompe el flujo principal si la hidratación falla" do
63
+ allow(ExisRay::Tracer).to receive(:hydrate).and_raise(StandardError)
64
+
65
+ expect(middleware.call({})).to eq([200, {}, ["ok"]])
66
+ end
67
+ end
68
+ end
@@ -15,6 +15,7 @@ RSpec.describe ExisRay::JsonFormatter do
15
15
  def self.service_name = "test-service"
16
16
  def self.root_id = nil
17
17
  def self.trace_id = nil
18
+ def self.request_id = nil
18
19
  end)
19
20
 
20
21
  allow(ExisRay).to receive(:current_class).and_return(nil)
@@ -261,6 +262,91 @@ RSpec.describe ExisRay::JsonFormatter do
261
262
  expect(result).not_to have_key("user_id")
262
263
  end
263
264
  end
265
+
266
+ describe "inyeccion de Current.log_fields" do
267
+ # Mock estilo ActiveSupport::CurrentAttributes que combina los atributos canónicos
268
+ # con un hook log_fields. Por defecto retorna {}, los tests lo redefinen vía stub.
269
+ let(:current_class_double) do
270
+ Class.new do
271
+ class << self
272
+ attr_accessor :user_id, :isp_id, :correlation_id, :_log_fields
273
+
274
+ def respond_to_missing?(method, *)
275
+ %i[user_id isp_id correlation_id log_fields].include?(method) || super
276
+ end
277
+ end
278
+
279
+ def self.log_fields
280
+ _log_fields || {}
281
+ end
282
+ end
283
+ end
284
+
285
+ before do
286
+ allow(ExisRay).to receive(:current_class).and_return(current_class_double)
287
+ end
288
+
289
+ it "inyecta los campos retornados por log_fields en el payload" do
290
+ current_class_double._log_fields = { tenant_id: "42", region: "us-east-1" }
291
+
292
+ result = call("event=boot")
293
+
294
+ # tenant_id="42" se castea a Integer por filter_sensitive_hash (igual que cualquier otro KV)
295
+ expect(result).to include("tenant_id" => 42, "region" => "us-east-1", "event" => "boot")
296
+ end
297
+
298
+ it "no agrega keys cuando log_fields retorna hash vacío" do
299
+ current_class_double._log_fields = {}
300
+
301
+ result = call("event=boot")
302
+
303
+ expect(result).not_to have_key("tenant_id")
304
+ end
305
+
306
+ it "no agrega keys cuando log_fields retorna nil" do
307
+ current_class_double._log_fields = nil
308
+
309
+ result = call("event=boot")
310
+
311
+ expect(result.keys).to contain_exactly("time", "level", "severity_number", "service", "event")
312
+ end
313
+
314
+ it "el mensaje del developer pisa log_fields (override por call site)" do
315
+ current_class_double._log_fields = { tenant_id: "from-current" }
316
+
317
+ result = call("event=override tenant_id=from-message")
318
+
319
+ expect(result["tenant_id"]).to eq("from-message")
320
+ end
321
+
322
+ it "filtra claves sensibles" do
323
+ current_class_double._log_fields = { api_key: "leaked", tenant_id: "42" }
324
+
325
+ result = call("event=boot")
326
+
327
+ expect(result["api_key"]).to eq("[FILTERED]")
328
+ expect(result["tenant_id"]).to eq(42)
329
+ end
330
+
331
+ it "no rompe el formatter si la subclass overrideó log_fields con un método que revienta" do
332
+ allow(current_class_double).to receive(:log_fields).and_raise(StandardError, "boom")
333
+
334
+ expect { call("event=boot") }.not_to raise_error
335
+ result = call("event=boot")
336
+ expect(result["event"]).to eq("boot")
337
+ end
338
+
339
+ it "no falla si Current configurado no implementa log_fields (backwards compat)" do
340
+ legacy_current = Class.new do
341
+ class << self
342
+ attr_accessor :user_id, :isp_id, :correlation_id
343
+ end
344
+ end
345
+ allow(ExisRay).to receive(:current_class).and_return(legacy_current)
346
+
347
+ expect { call("event=boot") }.not_to raise_error
348
+ end
349
+ end
264
350
  end
265
351
 
266
352
  describe "#kv_string? (privado)" do
@@ -321,6 +407,62 @@ RSpec.describe ExisRay::JsonFormatter do
321
407
  end
322
408
  end
323
409
 
410
+ describe "inyección de contexto de tracer (issue #9)" do
411
+ def stub_tracer(root_id:, request_id:, source:, trace_id: nil)
412
+ stub_const("ExisRay::Tracer", Module.new do
413
+ define_singleton_method(:service_name) { "test-service" }
414
+ define_singleton_method(:root_id) { root_id }
415
+ define_singleton_method(:trace_id) { trace_id }
416
+ define_singleton_method(:request_id) { request_id }
417
+ define_singleton_method(:source) { source }
418
+ define_singleton_method(:sidekiq_job) { nil }
419
+ define_singleton_method(:task) { nil }
420
+ end)
421
+ end
422
+
423
+ context "cuando hay trace context activo (root_id presente)" do
424
+ before { stub_tracer(root_id: "1-abc-def", request_id: "uuid-1", source: "http", trace_id: "Root=1-abc-def") }
425
+
426
+ it "emite root_id, trace_id, source y request_id" do
427
+ result = call("event=acct.received")
428
+
429
+ expect(result).to include(
430
+ "root_id" => "1-abc-def",
431
+ "trace_id" => "Root=1-abc-def",
432
+ "source" => "http",
433
+ "request_id" => "uuid-1"
434
+ )
435
+ end
436
+ end
437
+
438
+ context "cuando NO hay trace context (root_id nil) pero sí request_id (Gap C)" do
439
+ before { stub_tracer(root_id: nil, request_id: "uuid-2", source: "http") }
440
+
441
+ it "emite request_id aunque root_id esté ausente" do
442
+ result = call("event=acct.received")
443
+
444
+ expect(result["request_id"]).to eq("uuid-2")
445
+ end
446
+
447
+ it "no emite root_id ni trace_id" do
448
+ result = call("event=acct.received")
449
+
450
+ expect(result).not_to have_key("root_id")
451
+ expect(result).not_to have_key("trace_id")
452
+ end
453
+ end
454
+
455
+ context "cuando no hay request_id" do
456
+ before { stub_tracer(root_id: "1-x-y", request_id: nil, source: "http") }
457
+
458
+ it "no incluye la clave request_id" do
459
+ result = call("event=acct.received")
460
+
461
+ expect(result).not_to have_key("request_id")
462
+ end
463
+ end
464
+ end
465
+
324
466
  describe "#filter_sensitive_hash (privado)" do
325
467
  it "filtra claves sensibles en el nivel raíz" do
326
468
  result = formatter.send(:filter_sensitive_hash, { user: "gabriel", password: "secret" })
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.2
4
+ version: 0.9.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-11 00:00:00.000000000 Z
11
+ date: 2026-05-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -51,6 +51,8 @@ files:
51
51
  - LICENSE.txt
52
52
  - README.md
53
53
  - Rakefile
54
+ - docs/behavior/behavior.md
55
+ - docs/glossary/glossary.md
54
56
  - lib/exis_ray.rb
55
57
  - lib/exis_ray/active_resource_instrumentation.rb
56
58
  - lib/exis_ray/bug_bunny/consumer_tracing_middleware.rb
@@ -75,6 +77,7 @@ files:
75
77
  - skills.yml
76
78
  - spec/exis_ray/configuration_spec.rb
77
79
  - spec/exis_ray/current_spec.rb
80
+ - spec/exis_ray/http_middleware_spec.rb
78
81
  - spec/exis_ray/json_formatter_spec.rb
79
82
  - spec/exis_ray/log_subscriber_spec.rb
80
83
  - spec/exis_ray/reporter_spec.rb
@@ -87,8 +90,8 @@ licenses:
87
90
  metadata:
88
91
  homepage_uri: https://github.com/gedera/exis_ray
89
92
  source_code_uri: https://github.com/gedera/exis_ray
90
- changelog_uri: https://github.com/gedera/exis_ray/blob/v0.7.2/CHANGELOG.md
91
- documentation_uri: https://github.com/gedera/exis_ray/blob/v0.7.2/skill
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
92
95
  post_install_message:
93
96
  rdoc_options: []
94
97
  require_paths: