ez_logs_agent 0.1.3

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.
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "request_store"
4
+
5
+ module EzLogsAgent
6
+ # Manages actor context for the current request.
7
+ #
8
+ # Actor represents "who triggered this action" - the logged-in user.
9
+ #
10
+ # Actor schema:
11
+ # {
12
+ # id: String, # REQUIRED - stable identifier (e.g., user.id)
13
+ # label: String # optional - human-readable display (e.g., user.email)
14
+ # }
15
+ #
16
+ # Usage:
17
+ # # Configured via actor_from_request hook in EzLogsAgent.configure
18
+ module Actor
19
+ class << self
20
+ # Get the current actor for this request
21
+ # @return [Hash, nil] Current actor or nil if not set
22
+ def current
23
+ RequestStore.store[:ez_logs_actor]
24
+ rescue => e
25
+ EzLogsAgent::Logger.debug("[Actor] current failed: #{e.message}")
26
+ nil
27
+ end
28
+
29
+ # Set the current actor for this request
30
+ # Validates and sanitizes the actor before storing
31
+ # @param actor [Hash, nil] Actor to set
32
+ # @return [Hash, nil] The sanitized actor that was stored
33
+ def current=(actor)
34
+ validated = ActorValidator.sanitize(actor)
35
+
36
+ # Log debug message if actor was invalid (user hook misconfiguration)
37
+ if actor && validated.nil?
38
+ EzLogsAgent::Logger.debug("[Actor] Invalid actor structure ignored: #{actor.inspect}")
39
+ end
40
+
41
+ RequestStore.store[:ez_logs_actor] = validated
42
+ validated
43
+ rescue => e
44
+ EzLogsAgent::Logger.debug("[Actor] current= failed: #{e.message}")
45
+ nil
46
+ end
47
+
48
+ # Clear the current actor
49
+ # @return [void]
50
+ def clear
51
+ RequestStore.store[:ez_logs_actor] = nil
52
+ rescue => e
53
+ EzLogsAgent::Logger.debug("[Actor] clear failed: #{e.message}")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EzLogsAgent
4
+ # Validates and sanitizes actor data structures.
5
+ #
6
+ # Actor schema:
7
+ # {
8
+ # id: String, # REQUIRED, stable identifier
9
+ # label: String | nil, # optional, human-readable display
10
+ # kind: String | nil # optional, one of human|agent|system|hybrid
11
+ # }
12
+ #
13
+ # This module ensures actors conform to the expected structure
14
+ # before being stored in event context.
15
+ module ActorValidator
16
+ # Valid actor_kind values — keep in sync with the server's Event enum
17
+ # and with `EzLogsAgent::UserAgentDetector` output. Anything outside
18
+ # this set is dropped on sanitize.
19
+ VALID_KINDS = %w[human agent system hybrid].freeze
20
+ class << self
21
+ # Check if an actor structure is valid
22
+ # @param actor [Hash, nil] Actor hash to validate
23
+ # @return [Boolean] true if valid (including nil), false otherwise
24
+ def valid?(actor)
25
+ # nil actor is valid (means "unknown provenance")
26
+ return true if actor.nil?
27
+
28
+ # Must be a Hash
29
+ return false unless actor.is_a?(Hash)
30
+
31
+ # id is required
32
+ id = actor[:id] || actor["id"]
33
+ return false if id.nil? || id.to_s.empty?
34
+
35
+ true
36
+ end
37
+
38
+ # Sanitize actor structure to ensure consistent format
39
+ # Returns nil for invalid actors
40
+ # @param actor [Hash, nil] Actor hash to sanitize
41
+ # @return [Hash, nil] Sanitized actor or nil
42
+ def sanitize(actor)
43
+ return nil unless valid?(actor)
44
+ return nil if actor.nil?
45
+
46
+ id = actor[:id] || actor["id"]
47
+ label = actor[:label] || actor["label"]
48
+ kind = actor[:kind] || actor["kind"]
49
+
50
+ result = { id: id.to_s }
51
+ result[:label] = label.to_s if label
52
+ result[:kind] = kind.to_s if kind && VALID_KINDS.include?(kind.to_s)
53
+
54
+ result
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'monitor'
4
+
5
+ module EzLogsAgent
6
+ # Thread-safe in-memory event buffer.
7
+ # Stores events until flushed. Drops oldest when full.
8
+ # Never raises exceptions to host application.
9
+ class Buffer
10
+ @queue = []
11
+ @monitor = Monitor.new
12
+
13
+ class << self
14
+ # Add event to buffer. Drops oldest if full.
15
+ # @param event [Hash] Event hash from EventBuilder
16
+ # @return [void]
17
+ def push(event)
18
+ @monitor.synchronize do
19
+ if @queue.size >= max_size
20
+ @queue.shift
21
+ log_warn("[Buffer] Buffer full (#{max_size}), dropped oldest event")
22
+ end
23
+
24
+ @queue.push(event)
25
+ end
26
+ rescue => error
27
+ log_error("[Buffer] push failed: #{error.message}")
28
+ # Fail open: discard event, never crash host
29
+ end
30
+
31
+ # Flush all buffered events and clear buffer atomically.
32
+ # @return [Array<Hash>] Array of events (empty if buffer was empty)
33
+ def flush
34
+ @monitor.synchronize do
35
+ events = @queue.dup
36
+ @queue.clear
37
+ events
38
+ end
39
+ rescue => error
40
+ log_error("[Buffer] flush failed: #{error.message}")
41
+ []
42
+ end
43
+
44
+ # Current number of buffered events.
45
+ # @return [Integer]
46
+ def size
47
+ @monitor.synchronize { @queue.size }
48
+ rescue => error
49
+ log_error("[Buffer] size failed: #{error.message}")
50
+ 0
51
+ end
52
+
53
+ # Clear all buffered events.
54
+ # @return [void]
55
+ def clear
56
+ @monitor.synchronize { @queue.clear }
57
+ rescue => error
58
+ log_error("[Buffer] clear failed: #{error.message}")
59
+ # Best effort, ignore failures
60
+ end
61
+
62
+ private
63
+
64
+ def max_size
65
+ EzLogsAgent.configuration.buffer_size
66
+ rescue
67
+ 100 # Defensive fallback if configuration unavailable
68
+ end
69
+
70
+ def log_warn(message)
71
+ EzLogsAgent::Logger.warn(message) if defined?(EzLogsAgent::Logger)
72
+ rescue
73
+ # Logging must never crash the buffer
74
+ end
75
+
76
+ def log_error(message)
77
+ EzLogsAgent::Logger.error(message) if defined?(EzLogsAgent::Logger)
78
+ rescue
79
+ # Logging must never crash the buffer
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EzLogsAgent
4
+ module Capturers
5
+ # ActiveJob hooks for correlation propagation and job execution capture.
6
+ #
7
+ # This capturer provides first-class ActiveJob support with two responsibilities:
8
+ #
9
+ # 1. **Enqueue-Time (Correlation Propagation)**:
10
+ # - Uses `before_enqueue` callback to inject correlation_id into job
11
+ # - Preserves causal chain: HTTP → Job → Job
12
+ #
13
+ # 2. **Execution-Time (Job Capture)**:
14
+ # - Uses `around_perform` callback to capture job execution as background_job events
15
+ # - Restores correlation_id from job
16
+ # - Measures duration and captures success/failure outcome
17
+ #
18
+ # == Serialization Support
19
+ #
20
+ # ActiveJob's metadata hash is NOT automatically serialized. This capturer
21
+ # overrides `serialize` and `deserialize` to persist the correlation_id
22
+ # across job serialization (required for Async adapter and other adapters
23
+ # that serialize jobs).
24
+ #
25
+ # == Sidekiq Adapter Detection
26
+ #
27
+ # If the job's queue adapter is Sidekiq, this capturer:
28
+ # - STILL propagates correlation at enqueue-time
29
+ # - SKIPS execution capture (defers to Sidekiq server middleware)
30
+ #
31
+ # This prevents double events when Sidekiq is the adapter.
32
+ #
33
+ # == Installation
34
+ #
35
+ # This capturer is automatically installed by Railtie when ActiveJob
36
+ # is detected and `capture_jobs = true`.
37
+ #
38
+ # For manual installation (if not using Rails):
39
+ #
40
+ # EzLogsAgent::Capturers::ActiveJobCapturer.install
41
+ #
42
+ class ActiveJobCapturer
43
+ @serialization_installed = false
44
+
45
+ class << self
46
+ # Installs ActiveJob hooks for correlation propagation and job capture.
47
+ #
48
+ # This method is idempotent and can be called multiple times safely.
49
+ #
50
+ # @return [void]
51
+ def install
52
+ return unless defined?(ActiveJob)
53
+
54
+ install_serialization_hooks unless @serialization_installed
55
+
56
+ ActiveJob::Base.before_enqueue do |job|
57
+ ActiveJobCapturer.propagate_correlation(job)
58
+ end
59
+
60
+ ActiveJob::Base.around_perform do |job, block|
61
+ ActiveJobCapturer.capture_execution(job, block)
62
+ end
63
+
64
+ EzLogsAgent::Logger.debug("[ActiveJobCapturer] Hooks installed")
65
+ rescue StandardError => e
66
+ EzLogsAgent::Logger.error("[ActiveJobCapturer] Installation failed: #{e.class} - #{e.message}")
67
+ end
68
+
69
+ # Propagates correlation_id from current context into job.
70
+ #
71
+ # Runs at enqueue-time (when job is scheduled).
72
+ # Stores correlation in `ezlogs_correlation_id` attribute which
73
+ # survives serialization/deserialization.
74
+ #
75
+ # @param job [ActiveJob::Base] The job being enqueued
76
+ # @return [void]
77
+ def propagate_correlation(job)
78
+ correlation_id = EzLogsAgent::Correlation.current
79
+ return unless correlation_id && !correlation_id.empty?
80
+
81
+ job.ezlogs_correlation_id = correlation_id
82
+ rescue StandardError => e
83
+ EzLogsAgent::Logger.error("[ActiveJobCapturer] Correlation propagation failed: #{e.class} - #{e.message}")
84
+ end
85
+
86
+ # Captures job execution as a background_job event.
87
+ #
88
+ # Runs at execution-time (when job executes).
89
+ # Skips capture if job uses Sidekiq adapter (prevents double events).
90
+ #
91
+ # IMPORTANT: When jobs run inline (Async adapter, perform_now, test adapter),
92
+ # they execute in the same thread as the caller (HTTP request or parent job).
93
+ # We must save and restore the previous correlation to avoid clearing the
94
+ # outer context's correlation. This applies to:
95
+ # - Development with Async adapter
96
+ # - Production with perform_now calls
97
+ # - Test environments
98
+ # - Any synchronous job execution
99
+ #
100
+ # @param job [ActiveJob::Base] The job being executed
101
+ # @param block [Proc] The job execution block
102
+ # @return [Object] The result of the job execution
103
+ def capture_execution(job, block)
104
+ return block.call unless EzLogsAgent.configuration.capture_jobs
105
+
106
+ if sidekiq_adapter?(job)
107
+ EzLogsAgent::Logger.debug("[ActiveJobCapturer] Skipping capture (Sidekiq adapter)")
108
+ return block.call
109
+ end
110
+
111
+ if excluded_job_class?(job)
112
+ EzLogsAgent::Logger.debug("[ActiveJobCapturer] Skipping capture (excluded job class: #{job.class.name})")
113
+ return block.call
114
+ end
115
+
116
+ # Save the previous correlation (from HTTP middleware or parent job)
117
+ # so we can restore it after the job completes
118
+ previous_correlation = EzLogsAgent::Correlation.current
119
+
120
+ # Use job's propagated correlation, fall back to current context, or generate new
121
+ correlation_id = extract_correlation(job) || previous_correlation || EzLogsAgent::Correlation.generate
122
+ EzLogsAgent::Correlation.current = correlation_id
123
+
124
+ start_time = Time.now
125
+ result = block.call
126
+ duration_ms = ((Time.now - start_time) * 1000).to_i
127
+
128
+ capture_success(job, correlation_id, duration_ms, start_time)
129
+ result
130
+ rescue StandardError => error
131
+ capture_failure(job, correlation_id, error, start_time)
132
+ raise
133
+ ensure
134
+ # Restore previous correlation instead of unconditionally clearing.
135
+ # This is critical for inline jobs that run in the same thread as the caller.
136
+ if previous_correlation
137
+ EzLogsAgent::Correlation.current = previous_correlation
138
+ else
139
+ EzLogsAgent::Correlation.clear
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ # Installs serialization hooks on ActiveJob::Base to persist correlation_id
146
+ # across job serialization/deserialization.
147
+ #
148
+ # This is required because ActiveJob's metadata hash is NOT automatically
149
+ # serialized. We override `serialize` and `deserialize` to include our
150
+ # correlation_id in the job data.
151
+ #
152
+ # @return [void]
153
+ def install_serialization_hooks
154
+ return if @serialization_installed
155
+
156
+ ActiveJob::Base.class_eval do
157
+ attr_accessor :ezlogs_correlation_id
158
+ end
159
+
160
+ ActiveJob::Base.prepend(Module.new do
161
+ def serialize
162
+ super.merge("ezlogs_correlation_id" => @ezlogs_correlation_id)
163
+ end
164
+
165
+ def deserialize(job_data)
166
+ super
167
+ @ezlogs_correlation_id = job_data["ezlogs_correlation_id"]
168
+ end
169
+ end)
170
+
171
+ @serialization_installed = true
172
+ EzLogsAgent::Logger.debug("[ActiveJobCapturer] Serialization hooks installed")
173
+ rescue StandardError => e
174
+ EzLogsAgent::Logger.error("[ActiveJobCapturer] Failed to install serialization hooks: #{e.class} - #{e.message}")
175
+ end
176
+
177
+ # Checks if job uses Sidekiq adapter.
178
+ #
179
+ # @param job [ActiveJob::Base] The job instance
180
+ # @return [Boolean] true if Sidekiq adapter, false otherwise
181
+ def sidekiq_adapter?(job)
182
+ return false unless defined?(ActiveJob::QueueAdapters::SidekiqAdapter)
183
+
184
+ job.class.queue_adapter.is_a?(ActiveJob::QueueAdapters::SidekiqAdapter)
185
+ rescue StandardError
186
+ false
187
+ end
188
+
189
+ # Checks if job class is in the excluded list.
190
+ #
191
+ # @param job [ActiveJob::Base] The job instance
192
+ # @return [Boolean] true if excluded, false otherwise
193
+ def excluded_job_class?(job)
194
+ job_class_name = job.class.name
195
+ EzLogsAgent.configuration.all_excluded_job_classes.include?(job_class_name)
196
+ rescue StandardError
197
+ false
198
+ end
199
+
200
+ # Extracts correlation_id from job.
201
+ #
202
+ # @param job [ActiveJob::Base] The job instance
203
+ # @return [String, nil] The correlation_id if present
204
+ def extract_correlation(job)
205
+ return nil unless job.respond_to?(:ezlogs_correlation_id)
206
+
207
+ correlation = job.ezlogs_correlation_id
208
+ correlation if correlation && !correlation.empty?
209
+ rescue StandardError
210
+ nil
211
+ end
212
+
213
+ # Captures successful job execution.
214
+ #
215
+ # @param job [ActiveJob::Base] The job instance
216
+ # @param correlation_id [String] The correlation ID
217
+ # @param duration_ms [Integer] Job execution duration in milliseconds
218
+ # @param start_time [Time] Job start time
219
+ # @return [void]
220
+ def capture_success(job, correlation_id, duration_ms, start_time)
221
+ event = EzLogsAgent::EventBuilder.build(
222
+ source_type: :background_job,
223
+ source_data: extract_job_data(job),
224
+ outcome: :success,
225
+ correlation_id: correlation_id,
226
+ duration_ms: duration_ms,
227
+ timestamp: start_time
228
+ )
229
+
230
+ EzLogsAgent::Buffer.push(event)
231
+ rescue StandardError => e
232
+ EzLogsAgent::Logger.error("[ActiveJobCapturer] Failed to capture success event: #{e.class} - #{e.message}")
233
+ end
234
+
235
+ # Captures failed job execution.
236
+ #
237
+ # @param job [ActiveJob::Base] The job instance
238
+ # @param correlation_id [String] The correlation ID
239
+ # @param error [StandardError] The error that caused the failure
240
+ # @param start_time [Time] Job start time
241
+ # @return [void]
242
+ def capture_failure(job, correlation_id, error, start_time)
243
+ event = EzLogsAgent::EventBuilder.build(
244
+ source_type: :background_job,
245
+ source_data: extract_job_data(job),
246
+ outcome: :failure,
247
+ correlation_id: correlation_id,
248
+ error_message: "#{error.class}: #{error.message}",
249
+ timestamp: start_time
250
+ )
251
+
252
+ EzLogsAgent::Buffer.push(event)
253
+ rescue StandardError => e
254
+ EzLogsAgent::Logger.error("[ActiveJobCapturer] Failed to capture failure event: #{e.class} - #{e.message}")
255
+ end
256
+
257
+ # Extracts relevant job data for event source_data.
258
+ #
259
+ # @param job [ActiveJob::Base] The job instance
260
+ # @return [Hash] Job metadata for source_data
261
+ def extract_job_data(job)
262
+ {
263
+ job_class: job.class.name,
264
+ queue: job.queue_name,
265
+ arguments: extract_arguments(job)
266
+ }.compact
267
+ end
268
+
269
+ # Pull sanitized arguments off the job. We prefer
270
+ # `job.serialize["arguments"]` (the JSON-safe form that survives
271
+ # Redis/Sidekiq) over `job.arguments` (live Ruby objects, can
272
+ # include AR records). Sanitizer collapses non-primitive top-
273
+ # level values to "[Object]" so giant graphs never ship.
274
+ #
275
+ # Returns nil when no arguments are present so `.compact` drops
276
+ # the field entirely — keeps the source_data clean for jobs
277
+ # like health-checks that take no args.
278
+ def extract_arguments(job)
279
+ serialized = serialize_safely(job)
280
+ raw = serialized && serialized["arguments"]
281
+ raw = job.arguments if raw.nil? && job.respond_to?(:arguments)
282
+ return nil if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
283
+
284
+ EzLogsAgent::Sanitizer.sanitize_args(raw)
285
+ rescue StandardError => e
286
+ EzLogsAgent::Logger.debug("[ActiveJobCapturer] argument extraction failed: #{e.class}: #{e.message}")
287
+ nil
288
+ end
289
+
290
+ def serialize_safely(job)
291
+ return nil unless job.respond_to?(:serialize)
292
+
293
+ job.serialize
294
+ rescue StandardError
295
+ nil
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end