ez_logs_agent 0.1.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.
data/RELEASING.md ADDED
@@ -0,0 +1,55 @@
1
+ # Release Checklist
2
+
3
+ Steps to release a new version of `ez_logs_agent`.
4
+
5
+ ---
6
+
7
+ ## Pre-Release
8
+
9
+ - [ ] All tests passing: `bundle exec rspec`
10
+ - [ ] Code style clean: `bundle exec rubocop` (if configured)
11
+ - [ ] README.md reviewed and accurate
12
+ - [ ] CHANGELOG.md updated with new version
13
+ - [ ] Version bumped in `lib/ez_logs_agent/version.rb`
14
+ - [ ] Gemspec metadata reviewed (summary, description, homepage)
15
+ - [ ] No uncommitted changes: `git status`
16
+
17
+ ---
18
+
19
+ ## Release
20
+
21
+ ```bash
22
+ # Build the gem
23
+ gem build ez_logs_agent.gemspec
24
+
25
+ # Verify the gem contents
26
+ gem specification ez_logs_agent-X.Y.Z.gem
27
+
28
+ # Push to RubyGems (requires authentication)
29
+ gem push ez_logs_agent-X.Y.Z.gem
30
+
31
+ # Tag the release
32
+ git tag vX.Y.Z
33
+ git push origin vX.Y.Z
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Post-Release
39
+
40
+ - [ ] Verify gem is available: `gem info ez_logs_agent -r`
41
+ - [ ] Create GitHub release with changelog notes
42
+ - [ ] Monitor for issues or bug reports
43
+ - [ ] Update CURRENT_STATE.md if needed
44
+
45
+ ---
46
+
47
+ ## Version Numbering
48
+
49
+ This project follows [Semantic Versioning](https://semver.org/):
50
+
51
+ - **MAJOR** (X.0.0): Breaking changes to public API
52
+ - **MINOR** (0.X.0): New features, backwards compatible
53
+ - **PATCH** (0.0.X): Bug fixes, backwards compatible
54
+
55
+ Pre-1.0: API is not yet stable. Minor version bumps may include breaking changes.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -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,51 @@
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
+ # }
11
+ #
12
+ # This module ensures actors conform to the expected structure
13
+ # before being stored in event context.
14
+ module ActorValidator
15
+ class << self
16
+ # Check if an actor structure is valid
17
+ # @param actor [Hash, nil] Actor hash to validate
18
+ # @return [Boolean] true if valid (including nil), false otherwise
19
+ def valid?(actor)
20
+ # nil actor is valid (means "unknown provenance")
21
+ return true if actor.nil?
22
+
23
+ # Must be a Hash
24
+ return false unless actor.is_a?(Hash)
25
+
26
+ # id is required
27
+ id = actor[:id] || actor["id"]
28
+ return false if id.nil? || id.to_s.empty?
29
+
30
+ true
31
+ end
32
+
33
+ # Sanitize actor structure to ensure consistent format
34
+ # Returns nil for invalid actors
35
+ # @param actor [Hash, nil] Actor hash to sanitize
36
+ # @return [Hash, nil] Sanitized actor or nil
37
+ def sanitize(actor)
38
+ return nil unless valid?(actor)
39
+ return nil if actor.nil?
40
+
41
+ id = actor[:id] || actor["id"]
42
+ label = actor[:label] || actor["label"]
43
+
44
+ result = { id: id.to_s }
45
+ result[:label] = label.to_s if label
46
+
47
+ result
48
+ end
49
+ end
50
+ end
51
+ 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,270 @@
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
+ }.compact
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end