exis_ray 0.1.0 → 0.2.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: 10abd6a535e59a6d883a669567f45450fdbf694163699c2d5fb3cab4436a6ba9
4
- data.tar.gz: c64fa72c61bc02921264714ec334ceaa8aee37b6507ebc42e9936f1f2eca86b5
3
+ metadata.gz: 72c313e5194f8252623ac026d98ab510c8ad50c7f4b7c7d8f362005c14d6d3e5
4
+ data.tar.gz: 51b8c985ab03bff4389784477e9c5eb5f27e81138766e50356414b2e88121b68
5
5
  SHA512:
6
- metadata.gz: c3bda3764d45ce3de886ed7a48d79df083c62bd48e84ac42e832f77bd736d35d333a723167809b8db6d1aceb21a6b9dd3c9ae42d7b359b9a6b4bb6c78da0b5c1
7
- data.tar.gz: 8e7b43e3fea6235d93365f89efa180a870785cbfb3329a380563aee4f69334c16d8703599104e12c746c5b06a1ee8af527bbccac19595e492ff172f0b85a3390
6
+ metadata.gz: 5bd0a72ee15676c2074306af62a71098230b30831f49d9b678bb02dd853528a687db9e2d354d0fa34f3bcf758e0bf63fbd5512a949963e256528817749163fda
7
+ data.tar.gz: 485e39a4b8bf41a01a099d5a75c472f77683ac4a98bb1dcfa109e9abd16be0532930f78bebfee0a94beeb2a725cbae0a3696eabafaa070b0aae2a4cd24b13937
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-03-12
4
+
5
+ ### Added
6
+ - **Structured JSON Logging:** Introduced a centralized `ExisRay::JsonFormatter` that intercepts all application logs (HTTP, Sidekiq, and Rake tasks) and formats them into context-rich, single-line JSON objects.
7
+ - Added `lograge` as a core dependency to condense standard Rails multi-line HTTP logs into single events.
8
+ - Introduced the `config.log_format` configuration option (accepts `:text` or `:json`).
9
+ - Added native support for `ActiveSupport::TaggedLogging`. Standard Rails tags (`config.log_tags`) are now automatically intercepted and injected as a `"tags"` array within the JSON payload.
10
+ - Added support for merging Lograge's `custom_options` directly into the JSON root.
11
+ - Expanded `README.md` with an "Advanced Logging Guide", detailing environment-specific setup and customization strategies.
12
+
13
+ ### Changed
14
+ - Refactored `Sidekiq::ServerMiddleware` and `TaskMonitor` to intelligently adapt their logging behavior based on the active `log_format`, preventing redundant tagging in JSON mode.
15
+ - Shifted the `Railtie` logging configuration to execute `after: :load_config_initializers`. This guarantees the host application's initializers are fully loaded before ExisRay determines the log format.
16
+ - Enforced strict RuboCop compliance (`Style/StringLiterals` -> double quotes) across all internal classes, methods, and documentation examples.
17
+
18
+ ### Fixed
19
+ - Fixed `NoMethodError: undefined method 'current_tags'` by ensuring `JsonFormatter` correctly includes the `ActiveSupport::TaggedLogging::Formatter` interface.
20
+ - Resolved a race condition during Rails boot where `lograge` failed to initialize if the gem configuration was set via an initializer.
21
+
3
22
  ## [0.1.0] - 2025-12-23
4
23
 
5
24
  - Initial release
data/README.md CHANGED
@@ -1,17 +1,18 @@
1
1
  # ExisRay
2
2
 
3
- **ExisRay** is a robust observability framework designed for Ruby on Rails microservices. It unifies **Distributed Tracing** (AWS X-Ray compatible), **Business Context Propagation**, and **Error Reporting** into a single, cohesive gem.
3
+ **ExisRay** is a robust observability framework designed for Ruby on Rails microservices. It unifies **Distributed Tracing** (AWS X-Ray compatible), **Business Context Propagation**, **Error Reporting**, and **Structured JSON Logging** into a single, cohesive gem.
4
4
 
5
5
  It acts as the backbone of your architecture, ensuring that every request, background task (Sidekiq/Cron), log line, and external API call carries the necessary context to debug issues across a distributed system.
6
6
 
7
7
  ## 🚀 Features
8
8
 
9
9
  * **Distributed Tracing:** Automatically parses, generates, and propagates Trace headers (compatible with AWS ALB `X-Amzn-Trace-Id`).
10
- * **Unified Logging:** Injects the global `Root ID` into every Rails log line automatically, making Kibana/CloudWatch filtering effortless.
10
+ * **Structured JSON Logging:** Optionally unifies all your application logs (HTTP requests, Sidekiq jobs, and Rake tasks) into a clean, single-line JSON format, perfect for Datadog, ELK, or CloudWatch.
11
+ * **Unified Tagging:** If JSON logging is disabled, it automatically injects the global `Root ID` into every Rails log line.
11
12
  * **Context Management:** Thread-safe storage for business identity (`User`, `ISP`, `CorrelationId`) with automatic cleanup.
12
13
  * **Error Reporting:** A wrapper for Sentry (Legacy & Modern SDKs) that enriches errors with the full trace and business context.
13
- * **Sidekiq Integration:** Automatic context propagation (User/ISP/Trace) between the Enqueuer and the Worker.
14
- * **Task Monitor:** A specialized monitor for Rake/Cron tasks to initialize traces where no HTTP request exists.
14
+ * **Sidekiq Integration:** Automatic context propagation (User/ISP/Trace) and log formatting between the Enqueuer and the Worker.
15
+ * **Task Monitor:** A specialized monitor for Rake/Cron tasks to initialize traces and format logs where no HTTP request exists.
15
16
  * **HTTP Clients:** Automatically patches `ActiveResource` and provides middleware for `Faraday`.
16
17
 
17
18
  ---
@@ -21,7 +22,7 @@ It acts as the backbone of your architecture, ensuring that every request, backg
21
22
  Add this line to your application's Gemfile:
22
23
 
23
24
  ```ruby
24
- gem 'exis_ray'
25
+ gem "exis_ray"
25
26
  ```
26
27
 
27
28
  And then execute:
@@ -30,6 +31,8 @@ And then execute:
30
31
  $ bundle install
31
32
  ```
32
33
 
34
+ *(Note: ExisRay automatically installs and configures `lograge` as a dependency to handle HTTP log condensation).*
35
+
33
36
  ---
34
37
 
35
38
  ## ⚙️ Configuration
@@ -42,18 +45,24 @@ Create an initializer to configure the behavior. This is crucial to link ExisRay
42
45
  ExisRay.configure do |config|
43
46
  # 1. Trace Header (Incoming)
44
47
  # The HTTP header used to read the Trace ID from the Load Balancer (Rack format).
45
- # Default: 'HTTP_X_AMZN_TRACE_ID' (AWS Standard).
46
- config.trace_header = 'HTTP_X_WP_TRACE_ID'
48
+ # Default: "HTTP_X_AMZN_TRACE_ID" (AWS Standard).
49
+ config.trace_header = "HTTP_X_WP_TRACE_ID"
47
50
 
48
51
  # 2. Propagation Header (Outgoing)
49
52
  # The header sent to downstream services via ActiveResource/Faraday.
50
- config.propagation_trace_header = 'X-Wp-Trace-Id'
53
+ config.propagation_trace_header = "X-Wp-Trace-Id"
51
54
 
52
55
  # 3. Dynamic Classes (Required)
53
56
  # Link your app's specific classes to the gem.
54
57
  # We use Strings to avoid "uninitialized constant" errors during boot.
55
- config.current_class = 'Current' # Your Context Model
56
- config.reporter_class = 'Choto' # Your Sentry Wrapper
58
+ config.current_class = "Current" # Your Context Model
59
+ config.reporter_class = "Choto" # Your Sentry Wrapper
60
+
61
+ # 4. Log Format (New!)
62
+ # Choose the output format for your application logs.
63
+ # Options: :text (default Rails behavior) or :json (Structured logging).
64
+ # Pro-tip: Make it dynamic based on the environment!
65
+ config.log_format = Rails.env.production? ? :json : :text
57
66
  end
58
67
  ```
59
68
 
@@ -71,7 +80,7 @@ Inherit from `ExisRay::Current` to manage your global state. This class handles
71
80
  class Current < ExisRay::Current
72
81
  # Add app-specific attributes here
73
82
  attribute :billing_cycle, :permissions
74
-
83
+
75
84
  # ExisRay provides: user_id, isp_id, correlation_id
76
85
  end
77
86
  ```
@@ -107,10 +116,7 @@ def set_exis_ray_context
107
116
  Current.user = current_user if current_user
108
117
 
109
118
  # 2. ISP Context (e.g., from Headers)
110
- Current.isp_id = request.headers['X-Isp-Id']
111
-
112
- # Note: Setting these automatically prepares headers for ActiveResource
113
- # and tags for Sentry.
119
+ Current.isp_id = request.headers["X-Isp-Id"]
114
120
  end
115
121
  ```
116
122
 
@@ -118,33 +124,30 @@ end
118
124
 
119
125
  ## 🛠 Usage Scenarios
120
126
 
121
- ### A. Automatic Sidekiq Integration
127
+ ### A. Structured JSON Logging
128
+
129
+ When `config.log_format = :json` is enabled, ExisRay transforms all your application outputs into single-line, context-rich JSON objects.
130
+
131
+ **HTTP Requests (via Lograge):**
132
+ ```json
133
+ {"time":"2026-03-12T14:30:00Z","level":"INFO","service":"App-HTTP","root_id":"Root=1-65a...bc","trace_id":"Root=1-65a...bc;Self=...","request_id":"9876-abcd-...","user_id":42,"isp_id":10,"method":"GET","path":"/api/v1/users","format":"html","controller":"UsersController","action":"index","status":200,"duration":45.2,"view":20.1,"db":15.0}
134
+ ```
135
+
136
+ **Sidekiq Jobs & Rake Tasks:**
137
+ ```json
138
+ {"time":"2026-03-12T14:31:00Z","level":"INFO","service":"Sidekiq-HardWorker","root_id":"Root=1-65a...bc","user_id":42,"message":"[ExisRay] Processing payment..."}
139
+ ```
140
+
141
+ ### B. Automatic Sidekiq Integration
122
142
 
123
143
  If `Sidekiq` is present, ExisRay automatically configures Client and Server middlewares. **No code changes are required in your workers.**
124
144
 
125
145
  **How it works:**
126
146
  1. **Enqueue:** When you call `Worker.perform_async`, the current `Trace ID` and `Current` attributes are injected into the job payload.
127
147
  2. **Process:** When the worker executes, `Current` is hydrated with the original data.
128
- 3. **Logs:** Sidekiq logs will show the same `[Root=...]` ID as the web request.
148
+ 3. **Logs:** Sidekiq logs will automatically include the propagated `Root ID` and Business Context (either as text tags or structured JSON).
129
149
 
130
- ```ruby
131
- # Controller
132
- def create
133
- # Trace ID: A, User: 42
134
- HardWorker.perform_async(100)
135
- end
136
-
137
- # Worker
138
- class HardWorker
139
- include Sidekiq::Worker
140
- def perform(amount)
141
- puts Current.user_id # => 42 (Restored!)
142
- Rails.logger.info "Processing" # => [Root=A] Processing...
143
- end
144
- end
145
- ```
146
-
147
- ### B. Background Tasks (Cron/Rake)
150
+ ### C. Background Tasks (Cron/Rake)
148
151
 
149
152
  For Rake tasks or Cron jobs (where no HTTP request exists), use `ExisRay::TaskMonitor`. It generates a fresh `Root ID`.
150
153
 
@@ -152,14 +155,13 @@ For Rake tasks or Cron jobs (where no HTTP request exists), use `ExisRay::TaskMo
152
155
 
153
156
  ```ruby
154
157
  task generate_invoices: :environment do
155
- ExisRay::TaskMonitor.run('billing:generate_invoices') do
156
- # Logs are tagged: [Root=1-65a...bc] [ExisRay] Starting task...
158
+ ExisRay::TaskMonitor.run("billing:generate_invoices") do
157
159
  InvoiceService.process_all
158
160
  end
159
161
  end
160
162
  ```
161
163
 
162
- ### C. HTTP Clients
164
+ ### D. HTTP Clients
163
165
 
164
166
  ExisRay ensures traceability across microservices.
165
167
 
@@ -172,7 +174,7 @@ If `ActiveResource` is detected, ExisRay automatically patches it. All outgoing
172
174
  For Faraday, you must explicitly add the middleware:
173
175
 
174
176
  ```ruby
175
- conn = Faraday.new(url: '[https://api.internal](https://api.internal)') do |f|
177
+ conn = Faraday.new(url: "[https://api.internal](https://api.internal)") do |f|
176
178
  f.use ExisRay::FaradayMiddleware
177
179
  f.adapter Faraday.default_adapter
178
180
  end
@@ -180,11 +182,74 @@ end
180
182
 
181
183
  ---
182
184
 
185
+ ## 📋 Advanced Logging Guide
186
+
187
+ ExisRay's `JsonFormatter` is designed to be highly extensible. Here is how you can get the most out of your observability stack.
188
+
189
+ ### 1. Environment Best Practices
190
+
191
+ For the best developer experience, we recommend using standard text logs in development and structured JSON logs in production.
192
+
193
+ **File:** `config/environments/production.rb`
194
+ ```ruby
195
+ Rails.application.configure do
196
+ # Force Rails to log to STDOUT so Docker/Swarm can collect the JSON output
197
+ if ENV["RAILS_LOG_TO_STDOUT"].present?
198
+ logger = ActiveSupport::Logger.new(STDOUT)
199
+ logger.formatter = config.log_formatter
200
+ config.logger = ActiveSupport::TaggedLogging.new(logger)
201
+ end
202
+
203
+ # Set an appropriate log level to avoid disk/network saturation
204
+ config.log_level = :info
205
+ end
206
+ ```
207
+
208
+ ### 2. Extending JSON with Rails Tags (`config.log_tags`)
209
+
210
+ If you are using native Rails tags, ExisRay will automatically capture them and group them into a `"tags"` array within your JSON log. This prevents the JSON structure from breaking.
211
+
212
+ **File:** `config/environments/production.rb`
213
+ ```ruby
214
+ # Add custom tags to your HTTP requests
215
+ config.log_tags = [
216
+ :uuid,
217
+ ->(request) { request.headers["X-Custom-Header"] }
218
+ ]
219
+ ```
220
+ **Output:**
221
+ ```json
222
+ {"time":"2026-03-12T14:30:00Z","service":"App-HTTP","tags":["abcd-1234-uuid","CustomValue"],"method":"GET","status":200}
223
+ ```
224
+
225
+ ### 3. Extending JSON with Custom Properties (Lograge)
226
+
227
+ If you prefer to inject key-value pairs directly into the root of the JSON (which is highly recommended for querying in Datadog, Kibana, etc.), you can leverage Lograge's `custom_options` in your host application.
228
+
229
+ ExisRay will flawlessly merge these attributes with the core Trace ID and Business Context.
230
+
231
+ **File:** `config/environments/production.rb`
232
+ ```ruby
233
+ config.lograge.custom_options = lambda do |event|
234
+ {
235
+ ip_address: event.payload[:ip],
236
+ browser: event.payload[:user_agent]
237
+ }
238
+ end
239
+ ```
240
+ **Output:**
241
+ ```json
242
+ {"time":"2026-03-12T14:30:00Z","service":"App-HTTP","root_id":"Root=1-65a...","method":"GET","ip_address":"192.168.1.1","browser":"Chrome","status":200}
243
+ ```
244
+
245
+ ---
246
+
183
247
  ## 🏗 Architecture
184
248
 
185
249
  * **`ExisRay::Tracer`**: The infrastructure layer. Handles AWS X-Ray format parsing and ID generation.
186
250
  * **`ExisRay::Current`**: The business layer. Manages domain identity (`User`, `ISP`).
187
251
  * **`ExisRay::Reporter`**: The observability layer. Bridges the gap between your app and Sentry.
252
+ * **`ExisRay::JsonFormatter`**: The central logging engine. Intercepts HTTP, Sidekiq, and Tasks to output clean JSON.
188
253
  * **`ExisRay::TaskMonitor`**: The entry point for non-HTTP processes.
189
254
 
190
255
  ## License
@@ -25,12 +25,26 @@ module ExisRay
25
25
  # @example 'Current'
26
26
  attr_accessor :current_class
27
27
 
28
+ # @!attribute [rw] log_format
29
+ # @return [Symbol] El formato en el que se emitirán los logs de la aplicación.
30
+ # Puede ser `:text` (comportamiento por defecto de Rails) o `:json` (formato estructurado).
31
+ # @example :json
32
+ attr_accessor :log_format
33
+
28
34
  # Inicializa la configuración con valores por defecto compatibles con AWS X-Ray.
29
35
  def initialize
30
36
  @trace_header = 'HTTP_X_AMZN_TRACE_ID'
31
37
  @propagation_trace_header = 'X-Amzn-Trace-Id'
32
38
  @reporter_class = 'Reporter'
33
39
  @current_class = 'Current'
40
+ @log_format = :text
41
+ end
42
+
43
+ # Indica si la aplicación está configurada para emitir logs en formato estructurado (JSON).
44
+ #
45
+ # @return [Boolean] `true` si `log_format` es `:json`, `false` en caso contrario.
46
+ def json_logs?
47
+ @log_format == :json
34
48
  end
35
49
  end
36
50
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "json"
5
+ require "active_support/tagged_logging"
6
+
7
+ module ExisRay
8
+ # Formateador global que intercepta todos los logs de la aplicación y los emite en formato JSON.
9
+ #
10
+ # Esta clase hereda de `Logger::Formatter` y tiene la responsabilidad unificada
11
+ # de estandarizar la salida para peticiones HTTP (procesadas previamente vía Lograge),
12
+ # trabajos en segundo plano (Sidekiq), tareas programadas (Rake/Cron) y cualquier
13
+ # mensaje arbitrario enviado explícitamente a `Rails.logger`.
14
+ #
15
+ # Automáticamente inyecta el contexto de trazabilidad ({ExisRay::Tracer})
16
+ # y el contexto de negocio ({ExisRay::Current}) en cada línea de log.
17
+ class JsonFormatter < ::Logger::Formatter
18
+ # Solución al NoMethodError: Garantiza que el formateador sea compatible
19
+ # con el wrapper de ActiveSupport::TaggedLogging de Rails.
20
+ include ActiveSupport::TaggedLogging::Formatter if defined?(ActiveSupport::TaggedLogging::Formatter)
21
+
22
+ # Procesa un mensaje de log y lo formatea como una cadena estructurada en JSON.
23
+ #
24
+ # @param severity [String] El nivel de severidad del log (ej. "INFO", "ERROR", "DEBUG").
25
+ # @param timestamp [Time] La marca de tiempo en la que se generó el log.
26
+ # @param _progname [String, nil] El nombre del programa o aplicación (ignorado aquí).
27
+ # @param msg [String, Hash, Object] El mensaje a registrar. Puede ser un Hash (inyectado por Lograge) o un String.
28
+ # @return [String] Una cadena en formato JSON terminada con un salto de línea (\n).
29
+ def call(severity, timestamp, _progname, msg)
30
+ payload = {
31
+ time: timestamp.utc.iso8601,
32
+ level: severity,
33
+ service: ExisRay::Tracer.service_name
34
+ }
35
+
36
+ inject_tracer_context(payload)
37
+ inject_business_context(payload)
38
+ inject_current_tags(payload)
39
+ process_message(payload, msg)
40
+
41
+ # Compactamos para eliminar claves con valores nulos (nil) y generamos el JSON
42
+ "#{payload.compact.to_json}\n"
43
+ end
44
+
45
+ private
46
+
47
+ # Inyecta los identificadores de trazabilidad distribuida en el payload.
48
+ #
49
+ # @param payload [Hash] El diccionario del log donde se insertarán los datos.
50
+ # @return [void]
51
+ def inject_tracer_context(payload)
52
+ return unless ExisRay::Tracer.root_id
53
+
54
+ payload[:root_id] = ExisRay::Tracer.root_id
55
+ payload[:trace_id] = ExisRay::Tracer.trace_id if ExisRay::Tracer.trace_id
56
+ end
57
+
58
+ # Inyecta el contexto de negocio (ID de usuario, ISP, ID de correlación) en el payload.
59
+ #
60
+ # @param payload [Hash] El diccionario del log donde se insertarán los datos.
61
+ # @return [void]
62
+ def inject_business_context(payload)
63
+ curr = ExisRay.current_class
64
+ return unless curr
65
+
66
+ payload[:user_id] = curr.user_id if curr.respond_to?(:user_id) && curr.user_id
67
+ payload[:isp_id] = curr.isp_id if curr.respond_to?(:isp_id) && curr.isp_id
68
+
69
+ if curr.respond_to?(:correlation_id) && curr.correlation_id
70
+ payload[:correlation_id] = curr.correlation_id
71
+ end
72
+ end
73
+
74
+ # Inyecta cualquier etiqueta nativa (tags) de Rails que esté presente en el hilo actual.
75
+ #
76
+ # @param payload [Hash] El diccionario base del log.
77
+ # @return [void]
78
+ def inject_current_tags(payload)
79
+ if respond_to?(:current_tags) && current_tags.any?
80
+ payload[:tags] = current_tags
81
+ end
82
+ end
83
+
84
+ # Procesa el cuerpo del mensaje recibido y lo fusiona con el payload.
85
+ #
86
+ # Si el mensaje es un `Hash` (como el que nos pasará Lograge para peticiones HTTP),
87
+ # se hace un merge directo. Si es texto plano u otro objeto, se asigna a la clave `:message`.
88
+ #
89
+ # @param payload [Hash] El diccionario base del log.
90
+ # @param msg [String, Hash, Object] El mensaje original recibido por el logger.
91
+ # @return [void]
92
+ def process_message(payload, msg)
93
+ if msg.is_a?(Hash)
94
+ payload.merge!(msg)
95
+ else
96
+ payload[:message] = msg.to_s
97
+ end
98
+ end
99
+ end
100
+ end
@@ -1,43 +1,62 @@
1
- require 'rails/railtie'
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require "lograge" # Requerido globalmente para que su propio Railtie se registre en el boot
2
5
 
3
6
  module ExisRay
4
- # Integración automática con Rails.
5
- # Carga middlewares HTTP, tags de logs e integraciones (Sidekiq/ActiveResource).
7
+ # Integración automática de la gema con el ecosistema de Ruby on Rails.
8
+ #
9
+ # Se encarga de inyectar middlewares, configurar la estrategia de logging
10
+ # (texto plano o JSON estructurado) e instrumentar dependencias externas
11
+ # como Sidekiq y ActiveResource durante la fase de inicialización (`boot`) de la app.
6
12
  class Railtie < ::Rails::Railtie
7
13
  # 1. Middleware HTTP
14
+ # Intercepta las peticiones entrantes para hidratar el Tracer.
8
15
  initializer "exis_ray.configure_middleware" do |app|
9
- require 'exis_ray/http_middleware'
16
+ require "exis_ray/http_middleware"
10
17
  app.middleware.insert_after ActionDispatch::RequestId, ExisRay::HttpMiddleware
11
18
  end
12
19
 
13
- # 2. Logs Tags
14
- initializer "exis_ray.configure_log_tags" do |app|
15
- app.config.log_tags ||= []
16
- app.config.log_tags << proc {
17
- if ExisRay::Tracer.trace_id.present?
18
- ExisRay::Tracer.trace_id
19
- elsif ExisRay::Tracer.root_id.present?
20
- ExisRay::Tracer.root_id
21
- else
22
- nil
20
+ # 2. Configuración de Estrategia de Logging (Lograge y Tags)
21
+ # CLAVE: Usamos `after: :load_config_initializers` para garantizar que la app
22
+ # ya haya leído `config/initializers/exis_ray.rb` antes de tomar esta decisión.
23
+ initializer "exis_ray.configure_logging", after: :load_config_initializers do |app|
24
+ if ExisRay.configuration.json_logs?
25
+ app.config.lograge.enabled = true
26
+ app.config.lograge.formatter = Lograge::Formatters::Raw.new
27
+ else
28
+ # Comportamiento legacy: Text Plain Tags
29
+ app.config.log_tags ||= []
30
+ app.config.log_tags << proc do
31
+ ExisRay::Tracer.trace_id.presence || ExisRay::Tracer.root_id.presence
23
32
  end
24
- }
33
+ end
25
34
  end
26
35
 
27
- # 3. Integraciones Post-Boot
36
+ # 3. Integraciones Post-Boot y Forzado de Formateadores
37
+ # Se ejecuta una vez que las gemas y el entorno de Rails están completamente cargados.
28
38
  config.after_initialize do
29
- # ActiveResource
39
+ # Aplicamos el formateador JSON globalmente al logger ya instanciado de Rails
40
+ if ExisRay.configuration.json_logs? && Rails.logger
41
+ Rails.logger.formatter = ExisRay::JsonFormatter.new
42
+ Rails.logger.info({ message: "[ExisRay] JSON Logging unificado activado." })
43
+ end
44
+
45
+ # --- Instrumentación de ActiveResource ---
30
46
  if defined?(ActiveResource::Base)
31
- require 'exis_ray/active_resource_instrumentation'
47
+ require "exis_ray/active_resource_instrumentation"
32
48
  ActiveResource::Base.send(:prepend, ExisRay::ActiveResourceInstrumentation)
33
- Rails.logger.info "[ExisRay] ActiveResource instrumentado."
49
+
50
+ log_message(
51
+ text: "[ExisRay] ActiveResource instrumentado.",
52
+ json: { message: "[ExisRay] ActiveResource instrumentado." }
53
+ )
34
54
  end
35
55
 
36
- # Sidekiq
37
- # Usamos ::Sidekiq para referirnos a la Gema Global y no al módulo local ExisRay::Sidekiq
56
+ # --- Instrumentación de Sidekiq ---
38
57
  if defined?(::Sidekiq)
39
- require 'exis_ray/sidekiq/client_middleware'
40
- require 'exis_ray/sidekiq/server_middleware'
58
+ require "exis_ray/sidekiq/client_middleware"
59
+ require "exis_ray/sidekiq/server_middleware"
41
60
 
42
61
  ::Sidekiq.configure_client do |config|
43
62
  config.client_middleware do |chain|
@@ -53,7 +72,27 @@ module ExisRay
53
72
  chain.prepend ExisRay::Sidekiq::ServerMiddleware
54
73
  end
55
74
  end
56
- Rails.logger.info "[ExisRay] Sidekiq Middleware integrado."
75
+
76
+ # Sidekiq maneja su propio logger. Lo forzamos a usar nuestra estructura JSON.
77
+ if ExisRay.configuration.json_logs? && ::Sidekiq.logger
78
+ ::Sidekiq.logger.formatter = ExisRay::JsonFormatter.new
79
+ Rails.logger.info({ message: "[ExisRay] Sidekiq Middleware y JsonFormatter integrados." })
80
+ else
81
+ Rails.logger.info "[ExisRay] Sidekiq Middleware integrado."
82
+ end
83
+ end
84
+ end
85
+
86
+ # Helper interno para imprimir logs de inicialización respetando el formato elegido.
87
+ #
88
+ # @param text [String] El mensaje para el formato texto.
89
+ # @param json [Hash] El payload para el formato JSON.
90
+ # @return [void]
91
+ def self.log_message(text:, json:)
92
+ if ExisRay.configuration.json_logs?
93
+ Rails.logger.info(json)
94
+ else
95
+ Rails.logger.info(text)
57
96
  end
58
97
  end
59
98
  end
@@ -1,102 +1,98 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ExisRay
2
4
  module Sidekiq
3
5
  # Middleware de Servidor para Sidekiq.
4
- # Se ejecuta alrededor de cada trabajo (job) procesado por un Worker.
5
6
  #
6
- # Su responsabilidad es:
7
- # 1. Recuperar el Trace ID y contexto (User, ISP) inyectados por el cliente.
8
- # 2. Configurar el entorno (Tracer, Current, Reporter).
9
- # 3. Limpiar todo al finalizar para no contaminar el thread (Thread Pooling).
7
+ # Se ejecuta envolviendo cada trabajo (job) procesado por un Worker.
8
+ #
9
+ # Responsabilidades:
10
+ # 1. Recuperar el Trace ID y contexto de negocio (User, ISP) inyectados por el cliente.
11
+ # 2. Hidratar el entorno local (Tracer, Current, Reporter).
12
+ # 3. Limpiar absolutamente todo al finalizar para no contaminar el Thread Pool de Sidekiq.
10
13
  class ServerMiddleware
11
- # Intercepta la ejecución del job.
14
+ # Intercepta la ejecución del job en el servidor Sidekiq.
12
15
  #
13
16
  # @param worker [Object] La instancia del worker que procesará el job.
14
- # @param job [Hash] El payload del trabajo (contiene argumentos y metadatos).
15
- # @param queue [String] El nombre de la cola.
16
- def call(worker, job, queue)
17
- # 1. Hidratación de Infraestructura (Tracer)
17
+ # @param job [Hash] El payload del trabajo (contiene argumentos y metadatos inyectados).
18
+ # @param _queue [String] El nombre de la cola (ignorado).
19
+ # @yield Ejecuta el bloque que procesa el job real.
20
+ # @return [void]
21
+ def call(worker, job, _queue)
18
22
  hydrate_tracer(worker, job)
19
-
20
- # 2. Hidratación de Negocio (Current Class configurada)
21
23
  hydrate_current(job)
22
-
23
- # 3. Configuración de Reporte (Sentry/Reporter Class configurada)
24
24
  setup_reporter(worker)
25
25
 
26
- # 4. Ejecución con Logs Taggeados
27
- # Inyectamos el Root ID en los logs de Rails para correlacionarlos con Sidekiq.
28
- tags = [ExisRay::Tracer.root_id]
29
-
30
- if Rails.logger.respond_to?(:tagged)
31
- Rails.logger.tagged(*tags) { yield }
26
+ # Ejecución adaptativa de logs
27
+ if !ExisRay.configuration.json_logs? && Rails.logger.respond_to?(:tagged)
28
+ Rails.logger.tagged(ExisRay::Tracer.root_id) { yield }
32
29
  else
33
30
  yield
34
31
  end
35
-
36
32
  ensure
37
- # 5. Limpieza Total (Vital en Sidekiq)
38
- # Sidekiq reutiliza threads. Si no limpiamos, el contexto de un job
39
- # (ej: usuario actual) podría filtrarse al siguiente job.
33
+ # Limpieza vital en Sidekiq para evitar fugas de contexto entre jobs en el mismo hilo.
40
34
  ExisRay::Tracer.reset
41
-
42
- # Limpieza usando los helpers centralizados (sin hardcodear Current)
43
35
  ExisRay.current_class&.reset if ExisRay.current_class.respond_to?(:reset)
44
36
  ExisRay.reporter_class&.reset if ExisRay.reporter_class.respond_to?(:reset)
45
37
  end
46
38
 
47
39
  private
48
40
 
49
- # Configura el Tracer con el ID recibido o genera uno nuevo.
41
+ # Configura el Tracer con el ID recibido en el payload o genera uno nuevo si no existe.
42
+ #
43
+ # @param worker [Object] Instancia del worker.
44
+ # @param job [Hash] Payload de Sidekiq.
45
+ # @return [void]
50
46
  def hydrate_tracer(worker, job)
51
47
  ExisRay::Tracer.created_at = Time.now.utc.to_f
52
48
  ExisRay::Tracer.service_name = "Sidekiq-#{worker.class.name}"
53
49
 
54
- if job['exis_ray_trace']
55
- # Continuidad: Usamos la traza que viene del cliente (Web/Cron)
56
- ExisRay::Tracer.trace_id = job['exis_ray_trace']
50
+ if job["exis_ray_trace"]
51
+ # Continuidad: Usamos la traza propagada desde el cliente (Web/Cron)
52
+ ExisRay::Tracer.trace_id = job["exis_ray_trace"]
57
53
  ExisRay::Tracer.parse_trace_id
58
54
  else
59
- # Origen: El job nació aquí (ej: desde consola o trigger externo sin contexto)
55
+ # Origen: El job nació directamente aquí sin contexto previo
60
56
  ExisRay::Tracer.root_id = ExisRay::Tracer.send(:generate_new_root)
61
57
  end
62
58
  end
63
59
 
64
- # Hidrata la clase Current configurada con los datos del payload.
60
+ # Hidrata la clase Current configurada con los datos de negocio del payload.
61
+ #
62
+ # @param job [Hash] Payload de Sidekiq.
63
+ # @return [void]
65
64
  def hydrate_current(job)
66
- # Obtenemos la clase dinámica (ej: Current)
67
65
  klass = ExisRay.current_class
66
+ return unless klass && job["exis_ray_context"]
68
67
 
69
- # Salimos si no hay clase configurada o no hay contexto en el job
70
- return unless klass && job['exis_ray_context']
71
-
72
- ctx = job['exis_ray_context']
68
+ ctx = job["exis_ray_context"]
73
69
 
74
- # Asignación segura usando la clase dinámica
75
- klass.user_id = ctx['user_id'] if ctx['user_id'] && klass.respond_to?(:user_id=)
76
- klass.isp_id = ctx['isp_id'] if ctx['isp_id'] && klass.respond_to?(:isp_id=)
70
+ klass.user_id = ctx["user_id"] if ctx["user_id"] && klass.respond_to?(:user_id=)
71
+ klass.isp_id = ctx["isp_id"] if ctx["isp_id"] && klass.respond_to?(:isp_id=)
77
72
 
78
- if ctx['correlation_id'] && klass.respond_to?(:correlation_id=)
79
- klass.correlation_id = ctx['correlation_id']
73
+ if ctx["correlation_id"] && klass.respond_to?(:correlation_id=)
74
+ klass.correlation_id = ctx["correlation_id"]
80
75
  end
81
76
  end
82
77
 
83
- # Configura tags y nombres de transacción en el Reporter.
78
+ # Configura etiquetas y nombres de transacción en el Reporter (Sentry).
79
+ #
80
+ # @param worker [Object] Instancia del worker.
81
+ # @return [void]
84
82
  def setup_reporter(worker)
85
83
  klass = ExisRay.reporter_class
86
84
  return unless klass
87
85
 
88
- # Nombre de transacción para Sentry: "Sidekiq/HardWorker"
89
86
  if klass.respond_to?(:transaction_name=)
90
87
  klass.transaction_name = "Sidekiq/#{worker.class.name}"
91
88
  end
92
89
 
93
- # Tags adicionales de infraestructura Sidekiq
94
- if klass.respond_to?(:add_tags)
95
- klass.add_tags(
96
- sidekiq_queue: worker.class.get_sidekiq_options['queue'],
97
- retry_count: worker.respond_to?(:retry_count) ? worker.retry_count : 0
98
- )
99
- end
90
+ return unless klass.respond_to?(:add_tags)
91
+
92
+ klass.add_tags(
93
+ sidekiq_queue: worker.class.get_sidekiq_options["queue"],
94
+ retry_count: worker.respond_to?(:retry_count) ? worker.retry_count : 0
95
+ )
100
96
  end
101
97
  end
102
98
  end
@@ -1,44 +1,58 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ExisRay
2
- # Wrapper para monitorear tareas en segundo plano (Rake/Cron).
4
+ # Wrapper para monitorear tareas en segundo plano (Rake/Cron) o scripts aislados.
5
+ #
6
+ # Esta clase inicializa un contexto de trazabilidad simulado (ya que no hay
7
+ # una petición HTTP entrante) generando un nuevo `Root ID`. Luego, configura
8
+ # el reporte de errores y el contexto global antes de ejecutar el bloque provisto.
3
9
  module TaskMonitor
4
- # Ejecuta un bloque dentro de un contexto monitoreado.
5
- # @param task_name [String] Nombre identificador (ej: 'billing:generate').
10
+ # Ejecuta un bloque de código dentro de un contexto monitoreado por ExisRay.
11
+ #
12
+ # @param task_name [String, Symbol] Nombre identificador de la tarea (ej: "billing:generate").
13
+ # @yield El bloque de código que representa la lógica de la tarea.
14
+ # @raise [StandardError] Re-lanza cualquier excepción ocurrida tras registrarla.
15
+ # @return [void]
6
16
  def self.run(task_name)
7
17
  setup_tracer(task_name)
8
18
 
9
- short_name = task_name.to_s.split(':').last
19
+ short_name = task_name.to_s.split(":").last
10
20
 
11
- # Configurar Reporter
21
+ # Configurar Reporter (Sentry u otro)
12
22
  if (rep = ExisRay.reporter_class) && rep.respond_to?(:transaction_name=)
13
23
  rep.transaction_name = short_name
14
24
  rep.add_tags(service: :cron, task: short_name) if rep.respond_to?(:add_tags)
15
25
  end
16
26
 
17
- # Configurar Current
27
+ # Configurar Current Attributes
18
28
  if (curr = ExisRay.current_class) && curr.respond_to?(:correlation_id=)
19
29
  curr.correlation_id = ExisRay::Tracer.correlation_id
20
30
  end
21
31
 
22
- # Logs con Root ID
23
- tags = [ExisRay::Tracer.root_id]
24
- Rails.logger.tagged(*tags) do
25
- Rails.logger.info "[ExisRay] Iniciando tarea: #{task_name}"
26
- yield
27
- Rails.logger.info "[ExisRay] Finalizada con éxito."
28
- end
32
+ log_event(:info, "Iniciando tarea: #{task_name}", task: task_name, status: "started")
29
33
 
34
+ # Bloque de ejecución con o sin tags dependiendo de la configuración
35
+ execute_with_optional_tags { yield }
36
+
37
+ log_event(:info, "Finalizada con éxito.", task: task_name, status: "success")
30
38
  rescue StandardError => e
31
- Rails.logger.error "[ExisRay] Falló la tarea #{task_name}: #{e.message}"
39
+ log_event(:error, "Falló la tarea #{task_name}: #{e.message}", task: task_name, status: "failed", error: e.message)
32
40
  raise e
33
41
  ensure
34
- # Limpieza centralizada
42
+ # Limpieza centralizada obligatoria para evitar filtraciones de memoria o contexto
35
43
  ExisRay::Tracer.reset
36
44
  ExisRay.current_class&.reset if ExisRay.current_class.respond_to?(:reset)
37
45
  ExisRay.reporter_class&.reset if ExisRay.reporter_class.respond_to?(:reset)
38
46
  end
39
47
 
48
+ # --- Métodos Privados ---
49
+
50
+ # Inicializa el Tracer con datos específicos de la tarea y el entorno.
51
+ #
52
+ # @param task_name [String, Symbol] El nombre de la tarea en ejecución.
53
+ # @return [void]
40
54
  def self.setup_tracer(task_name)
41
- ExisRay::Tracer.service_name = task_name.to_s.gsub(':', '-').camelize
55
+ ExisRay::Tracer.service_name = task_name.to_s.tr(":", "-").camelize
42
56
  ExisRay::Tracer.request_id = SecureRandom.uuid
43
57
  ExisRay::Tracer.created_at = Time.now.utc.to_f
44
58
 
@@ -46,10 +60,40 @@ module ExisRay
46
60
  ExisRay::Tracer.root_id = ExisRay::Tracer.send(:generate_new_root, pod_id)
47
61
  end
48
62
 
63
+ # Obtiene un identificador único del contenedor o máquina actual.
64
+ #
65
+ # @return [String] El sufijo del hostname o "local" por defecto.
49
66
  def self.get_pod_identifier
50
- (ENV['HOSTNAME'] || 'local').split('-').last.to_s
67
+ (ENV["HOSTNAME"] || "local").split("-").last.to_s
68
+ end
69
+
70
+ # Ejecuta el bloque inyectando tags de Rails solo si estamos en modo texto.
71
+ # En modo JSON, JsonFormatter ya se encarga de inyectar el root_id.
72
+ #
73
+ # @yield El bloque de ejecución de la tarea.
74
+ # @return [void]
75
+ def self.execute_with_optional_tags
76
+ if !ExisRay.configuration.json_logs? && Rails.logger.respond_to?(:tagged)
77
+ Rails.logger.tagged(ExisRay::Tracer.root_id) { yield }
78
+ else
79
+ yield
80
+ end
81
+ end
82
+
83
+ # Envía un evento al logger de Rails adaptándose al formato configurado.
84
+ #
85
+ # @param level [Symbol] Nivel de severidad (:info, :error).
86
+ # @param message [String] Mensaje en texto plano para el modo clásico.
87
+ # @param payload [Hash] Datos estructurados para el modo JSON.
88
+ # @return [void]
89
+ def self.log_event(level, message, **payload)
90
+ if ExisRay.configuration.json_logs?
91
+ Rails.logger.send(level, payload.merge(message: "[ExisRay] #{message}"))
92
+ else
93
+ Rails.logger.send(level, "[ExisRay] #{message}")
94
+ end
51
95
  end
52
96
 
53
- private_class_method :get_pod_identifier, :setup_tracer
97
+ private_class_method :get_pod_identifier, :setup_tracer, :execute_with_optional_tags, :log_event
54
98
  end
55
99
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ExisRay
4
4
  # Versión actual de la gema.
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
data/lib/exis_ray.rb CHANGED
@@ -13,6 +13,7 @@ require "exis_ray/task_monitor"
13
13
  require "exis_ray/http_middleware"
14
14
  require "exis_ray/current"
15
15
  require "exis_ray/reporter"
16
+ require "exis_ray/json_formatter"
16
17
 
17
18
  # Integraciones Opcionales
18
19
  # Solo cargamos el middleware de Faraday si la gema está presente en el sistema.
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.1.0
4
+ version: 0.2.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-02-13 00:00:00.000000000 Z
11
+ date: 2026-03-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '6.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: lograge
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0.11'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0.11'
41
55
  description: Gema que gestiona el contexto de request, logs y propagación de headers.
42
56
  email:
43
57
  - gab.edera@gmail.com
@@ -55,6 +69,7 @@ files:
55
69
  - lib/exis_ray/current.rb
56
70
  - lib/exis_ray/faraday_middleware.rb
57
71
  - lib/exis_ray/http_middleware.rb
72
+ - lib/exis_ray/json_formatter.rb
58
73
  - lib/exis_ray/railtie.rb
59
74
  - lib/exis_ray/reporter.rb
60
75
  - lib/exis_ray/sidekiq/client_middleware.rb