ExisRay
Capa de observabilidad y trazabilidad distribuida para microservicios Rails del ecosistema Wispro. Unifica tracing (AWS X-Ray compatible), logging JSON estructurado, propagación de contexto de negocio y reporte de errores en una sola gema.
Features
-
Distributed Tracing — Parseo, generación y propagación automática de headers
X-Amzn-Trace-Id. - Structured JSON Logging — Logs HTTP, Sidekiq y Rake en formato JSON single-line con contexto inyectado.
-
Context Propagation —
user_id,isp_idycorrelation_idviajan automáticamente entre servicios. - Error Reporting — Wrapper de Sentry que enriquece cada evento con trace context e identidad de negocio.
- Auto-instrumentación — HTTP, Sidekiq, ActiveResource y BugBunny se configuran automáticamente via Railtie.
- Compatibilidad — Ruby >= 2.6, Rails 6, 7 y 8.
Cómo funciona
ExisRay opera en tres capas que se combinan automáticamente:
┌─────────────────────────────────┐
│ App Host (Rails) │
│ Current ← user_id, isp_id │
│ Reporter ← Sentry context │
└──────────────┬──────────────────┘
│
┌──────────────────────────┼──────────────────────────┐
│ │ │
┌────────▼────────┐ ┌─────────▼─────────┐ ┌─────────▼─────────┐
│ HTTP Request │ │ Sidekiq Job │ │ BugBunny Msg │
│ HttpMiddleware │ │ ServerMiddleware │ │ ConsumerTracing │
└────────┬────────┘ └─────────┬─────────┘ └─────────┬─────────┘
│ │ │
└──────────────────────────┼──────────────────────────┘
│
▼
┌─────────────────────┐
│ ExisRay::Tracer │
│ (CurrentAttributes) │
│ │
│ root_id, trace_id, │
│ source, self_id, │
│ called_from, │
│ total_time_so_far │
└──────────┬──────────┘
│
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
JsonFormatter FaradayMiddleware PublisherTracing
(logs + context) (HTTP saliente) (RabbitMQ saliente)
Flujo de propagación:
- Un request/job/mensaje llega al servicio. El middleware correspondiente hidrata el
Tracercon el header entrante (o genera un nuevoroot_idsi no trae uno). -
JsonFormatterinyecta automáticamenteroot_id,trace_id,sourcey el contexto de negocio (user_id,isp_id,correlation_id) en cada línea de log. - 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 elroot_idoriginal, elself_iddel servicio actual, elCalledFromy el tiempo acumulado. - El servicio destino repite desde el paso 1. El
root_idse mantiene constante a lo largo de toda la cadena.
Instalación
gem "exis_ray"Quick Start
Configuración mínima para un servicio Rails nuevo:
# 1. Configurar ExisRay (config/initializers/exis_ray.rb)
ExisRay.configure do |config|
config.log_format = Rails.env.production? ? :json : :text
config.current_class = "Current"
config.reporter_class = "Reporter"
end
# 2. Crear Current (app/models/current.rb)
class Current < ExisRay::Current
# ExisRay provee: user_id, isp_id, correlation_id
# Agregar atributos específicos de la app:
attribute :permissions
end
# 3. Crear Reporter (app/models/reporter.rb)
class Reporter < ExisRay::Reporter
end
# 4. Hidratar el Current en cada request (app/controllers/application_controller.rb)
class ApplicationController < ActionController::Base
before_action :set_context
private
def set_context
Current.user = current_user if current_user
Current.isp_id = request.headers["X-Isp-Id"]
end
endCon esto, ExisRay auto-instrumenta HTTP y Sidekiq (si está presente). Los logs en producción salen en JSON con trace context completo.
Configuración
# config/initializers/exis_ray.rb
ExisRay.configure do |config|
# Header entrante (formato Rack). Default: "HTTP_X_AMZN_TRACE_ID"
config.trace_header = "HTTP_X_AMZN_TRACE_ID"
# Header saliente (formato HTTP). Default: "X-Amzn-Trace-Id"
config.propagation_trace_header = "X-Amzn-Trace-Id"
# Clases de la app host (strings para evitar problemas de autoloading)
config.current_class = "Current" # hereda de ExisRay::Current
config.reporter_class = "Reporter" # hereda de ExisRay::Reporter
# Formato de logs: :text (default) o :json
config.log_format = Rails.env.production? ? :json : :text
# Subclase de LogSubscriber para campos HTTP extra (opcional)
# config.log_subscriber_class = "MyLogSubscriber"
endClases de la App Host
Current (contexto de negocio)
ExisRay::Current extiende ActiveSupport::CurrentAttributes y provee tres atributos base: user_id, isp_id y correlation_id. Al asignarlos, propaga automáticamente a PaperTrail (whodunnit), ActiveResource (headers) y Reporter (tags de Sentry).
# app/models/current.rb
class Current < ExisRay::Current
# Atributos heredados: user_id, isp_id, correlation_id
# Helpers heredados: user, user=, isp, isp=, user?, isp?, correlation_id?
#
# Agregar atributos específicos de la app:
attribute :permissions
endHelpers disponibles:
| Método | Descripción |
|---|---|
Current.user = object |
Asigna user_id desde object.id
|
Current.user |
Lazy-loads ::User.find_by(id: user_id) (cacheado por request) |
Current.isp = object |
Asigna isp_id desde object.id
|
Current.isp |
Lazy-loads ::Isp.find_by(id: isp_id) (cacheado por request) |
Current.user? / Current.isp?
|
Predicate: true si el ID no es nil |
Current.correlation_id? |
Predicate: true si está presente (no vacío) |
Reporter (reporte de errores)
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).
# app/models/reporter.rb
class Reporter < ExisRay::Reporter
# Hook opcional para contexto adicional en Sentry
def self.build_custom_context
add_tags(plan: ExisRay.current_class.isp&.plan)
end
# Hook opcional para controlar qué datos del usuario se envían a Sentry.
# Default: solo { id: current.user_id }
def self.sentry_user_context(current)
{ id: current.user_id, email: current.user&.email }
end
endAPI pública:
# Reportar un mensaje
Reporter.report("algo inesperado", context: { order_id: 123 }, tags: { severity: "high" })
# Reportar una excepción
Reporter.exception(error, context: { order_id: 123 }, fingerprint: ["order-failure"])
# Acumular contexto durante el request (se envía con el próximo report/exception)
Reporter.add_context(order: { id: 123, total: 500 })
Reporter.add_tags(feature: "checkout")
Reporter.add_fingerprint("checkout-error")Integraciones
HTTP (automático)
El Railtie inserta ExisRay::HttpMiddleware después de ActionDispatch::RequestId. Hidrata el Tracer con el header entrante y sincroniza el correlation_id en cada request. No requiere configuración.
Sidekiq (automático)
El Railtie registra client y server middleware. El trace context se propaga automáticamente entre enqueuer y worker. El JsonFormatter se aplica al logger de Sidekiq si json_logs? está activo. No requiere cambios en los workers.
BugBunny — Publisher (manual)
Agregar PublisherTracing al middleware stack del cliente:
# BugBunny::Client
client = BugBunny::Client.new(pool: pool) do |stack|
stack.use ExisRay::BugBunny::PublisherTracing
end
# BugBunny::Resource (se hereda a subclases)
class ApplicationResource < BugBunny::Resource
client_middleware do |stack|
stack.use ExisRay::BugBunny::PublisherTracing
end
endBugBunny — Consumer (automático)
El Railtie registra ConsumerTracingMiddleware en el consumer middleware stack, más los hooks RPC (rpc_reply_headers y on_rpc_reply) para propagación completa en llamadas síncronas.
Faraday (manual)
conn = Faraday.new(url: "https://api.internal") do |f|
f.use ExisRay::FaradayMiddleware
endActiveResource (automático)
El Railtie prepend ActiveResourceInstrumentation a ActiveResource::Base. Inyecta el propagation_trace_header en cada request saliente.
TaskMonitor (Rake/Cron)
Genera un contexto de trazabilidad para tareas que no tienen request HTTP entrante:
task generate_invoices: :environment do
ExisRay::TaskMonitor.run("billing:generate_invoices") do
InvoiceService.process_all
end
endTaskMonitor genera un root_id nuevo, configura Reporter y Current, loguea task_started/task_finished con duration_s y outcome, y limpia el contexto al finalizar. Si el bloque lanza una excepción, la registra como outcome=failed con exception.type, exception.message y exception.stacktrace (OTel) y la re-lanza.
JSON Logging
Con log_format: :json, ExisRay::JsonFormatter reemplaza el formatter de Rails y emite cada línea como JSON single-line con contexto inyectado automáticamente:
{"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"}Los mensajes con formato key=value se parsean y elevan al root del JSON. Los valores numéricos se castean automáticamente:
Rails.logger.info "component=billing event=invoice_created invoice_id=123 total=45.50"
# => {"time":"...","level":"INFO","service":"...","root_id":"...","component":"billing","event":"invoice_created","invoice_id":123,"total":45.5}Los mensajes tipo Hash (usados internamente por LogSubscriber) se mergean directamente. Los mensajes de texto libre se asignan a la clave body:
Rails.logger.info "Algo pasó sin formato KV"
# => {"time":"...","level":"INFO","service":"...","body":"Algo pasó sin formato KV"}En modo :text, ExisRay inyecta el trace_id o root_id como tag de Rails (config.log_tags) y no modifica el formatter.
Campos auto-inyectados
JsonFormatter inyecta estos campos automáticamente en cada línea. Nunca los incluyas manualmente en tus logs:
| Campo | Condición |
|---|---|
time |
Siempre (UTC ISO 8601) |
level |
Siempre |
severity_number |
Siempre (OTel SeverityNumber: DEBUG=5, INFO=9, WARN=13, ERROR=17, FATAL=21) |
service |
Siempre (nombre de la app Rails en snake_case) |
service_version |
Siempre (lee de config.version o config.x.version) |
deployment_environment |
Siempre (lee de Rails.env) |
root_id |
Cuando hay trace context activo |
trace_id |
Cuando hay trace context activo |
source |
Cuando hay trace context activo (http, sidekiq, task, system) |
correlation_id |
Cuando Current.correlation_id está presente |
user_id |
Cuando Current.user_id no es nil |
isp_id |
Cuando Current.isp_id no es nil |
sidekiq_job |
Solo en procesos Sidekiq |
task |
Solo en procesos TaskMonitor |
tags |
Solo si hay Rails tagged logging activo |
LogSubscriber inyecta además estos campos en los logs de requests HTTP:
| Campo | Descripción |
|---|---|
http_status |
Código HTTP (Integer). Antes status, renombrado en v0.6.0 |
http_route |
Template de ruta (ej: /users/:id). Resolución via route.defaults
|
user_agent_original |
Header User-Agent del request |
server_address |
Hostname sin puerto del header Host
|
exception.type |
Clase de la excepción (cuando el request falla) |
exception.message |
Mensaje de la excepción |
exception.stacktrace |
Primeras 20 líneas del backtrace |
Filtrado de claves sensibles
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).
LogSubscriber custom
Para inyectar campos extra en los logs de requests HTTP, crear una subclase de ExisRay::LogSubscriber:
# app/subscribers/my_log_subscriber.rb
class MyLogSubscriber < ExisRay::LogSubscriber
def self.extra_fields(event)
{ user_agent: event.payload[:headers]["HTTP_USER_AGENT"] }
end
end
# config/initializers/exis_ray.rb
ExisRay.configure do |config|
config.log_subscriber_class = "MyLogSubscriber"
endReferencia del Tracer
ExisRay::Tracer es el componente central. Extiende ActiveSupport::CurrentAttributes para thread-safety y se resetea automáticamente al final de cada request/job.
Atributos
| Atributo | Tipo | Descripción |
|---|---|---|
trace_id |
String |
Header completo parseado (Root=...;Self=...;CalledFrom=...) |
root_id |
String |
ID raíz, constante a lo largo de toda la cadena de servicios |
self_id |
String |
ID del span del servicio que generó el header |
called_from |
String |
Nombre del servicio que envió el request |
total_time_so_far |
Integer |
Tiempo acumulado en ms desde el inicio de la cadena |
source |
String |
Entrypoint: http, sidekiq, task, system
|
request_id |
String |
UUID del request (Rails ActionDispatch::RequestId) |
created_at |
Float |
Timestamp monotónico del inicio del contexto |
sidekiq_job |
String |
Nombre del job Sidekiq (solo en workers) |
task |
String |
Nombre de la tarea (solo en TaskMonitor) |
Métodos públicos
# Hidratar el Tracer (usado internamente por los middlewares)
ExisRay::Tracer.hydrate(trace_id: header_string, source: "http")
# Generar header de propagación para el siguiente servicio
ExisRay::Tracer.generate_trace_header
# => "Root=1-abc123-...;Self=1-def456-...;CalledFrom=wispro_agent;TotalTimeSoFar=42ms"
# Duración del request actual
ExisRay::Tracer.current_duration_s # => 0.0452 (Float, segundos)
ExisRay::Tracer.current_duration_ms # => 45 (Integer, milisegundos)
# Formatear duración human-readable
ExisRay::Tracer.format_duration(0.007) # => "7.0ms"
ExisRay::Tracer.format_duration(1.25) # => "1.25s"
ExisRay::Tracer.format_duration(125.0) # => "2 minutes 5 seconds"
# Nombre del servicio (snake_case del module parent de la app Rails)
ExisRay::Tracer.service_name # => "wispro_agent"
# Correlation ID compuesto
ExisRay::Tracer.correlation_id # => "wispro_agent;1-65f...abc"
# Sincronizar correlation_id al Current configurado
ExisRay.sync_correlation_idLicencia
Disponible como open source bajo los términos de la MIT License.