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,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module EzLogsAgent
6
+ # EventBuilder constructs valid Event hashes according to the Event Structure contract.
7
+ #
8
+ # This is a pure, side-effect-free builder that:
9
+ # - Validates required fields
10
+ # - Validates enum values
11
+ # - Enforces conditional rules (outcome ↔ error_message)
12
+ # - Sanitizes sensitive data
13
+ # - Returns a valid Event hash
14
+ #
15
+ # EventBuilder does NOT:
16
+ # - Buffer or send Events
17
+ # - Generate correlation IDs
18
+ # - Interact with Rails or ActiveRecord
19
+ # - Mutate global state
20
+ #
21
+ # @see docs/agent/event_structure.md for complete specification
22
+ module EventBuilder
23
+ # Sensitive keys that should be filtered from source_data and context
24
+ SENSITIVE_KEYS = %w[password token secret api_key credit_card].freeze
25
+
26
+ # Valid source types
27
+ VALID_SOURCE_TYPES = %w[http_request background_job database_callback].freeze
28
+
29
+ # Valid outcome values
30
+ VALID_OUTCOMES = %w[success failure].freeze
31
+
32
+ # Builds a valid Event hash
33
+ #
34
+ # @param source_type [String, Symbol] Event source type (http_request, background_job, database_callback)
35
+ # @param source_data [Hash] Technical details about the event source
36
+ # @param outcome [String, Symbol] Event outcome (success, failure)
37
+ # @param correlation_id [String, nil] Optional correlation identifier
38
+ # @param resource_ids [Array<Hash>] Optional array of resource identifiers
39
+ # @param context [Hash, nil] Optional human-readable context
40
+ # @param duration_ms [Integer, nil] Optional operation duration in milliseconds
41
+ # @param error_message [String, nil] Optional error message (required if outcome is failure)
42
+ # @param timestamp [String, Time, nil] Optional timestamp (defaults to current time)
43
+ #
44
+ # @return [Hash] Valid Event hash
45
+ # @raise [ArgumentError] If validation fails
46
+ #
47
+ # @example Building an HTTP request event
48
+ # EventBuilder.build(
49
+ # source_type: "http_request",
50
+ # source_data: { method: "POST", path: "/api/users", status_code: 201 },
51
+ # outcome: "success",
52
+ # duration_ms: 124
53
+ # )
54
+ #
55
+ def self.build(
56
+ source_type:,
57
+ source_data:,
58
+ outcome:,
59
+ correlation_id: nil,
60
+ resource_ids: [],
61
+ context: nil,
62
+ duration_ms: nil,
63
+ error_message: nil,
64
+ timestamp: nil
65
+ )
66
+ # Validate and normalize inputs
67
+ validated_source_type = validate_source_type!(source_type)
68
+ validated_source_data = validate_source_data!(source_data)
69
+ validated_outcome = validate_outcome!(outcome)
70
+ validated_timestamp = validate_timestamp!(timestamp)
71
+ validated_resource_ids = validate_resource_ids!(resource_ids)
72
+ validated_context = validate_context!(context)
73
+ validated_duration_ms = validate_duration_ms!(duration_ms)
74
+
75
+ # Validate conditional rules
76
+ validate_outcome_error_consistency!(validated_outcome, error_message)
77
+
78
+ # Sanitize sensitive data
79
+ sanitized_source_data = sanitize_hash(validated_source_data)
80
+ sanitized_context = validated_context.nil? ? nil : sanitize_hash(validated_context)
81
+
82
+ # Build and return Event hash
83
+ {
84
+ timestamp: validated_timestamp,
85
+ source_type: validated_source_type,
86
+ source_data: sanitized_source_data,
87
+ outcome: validated_outcome,
88
+ correlation_id: correlation_id,
89
+ resource_ids: validated_resource_ids,
90
+ context: sanitized_context,
91
+ duration_ms: validated_duration_ms,
92
+ error_message: error_message
93
+ }
94
+ end
95
+
96
+ # Validates source_type field
97
+ # @param source_type [String, Symbol]
98
+ # @return [String] Validated source type string
99
+ # @raise [ArgumentError]
100
+ def self.validate_source_type!(source_type)
101
+ raise ArgumentError, "source_type is required" if source_type.nil?
102
+
103
+ # Normalize symbol to string
104
+ normalized = source_type.to_s
105
+
106
+ unless VALID_SOURCE_TYPES.include?(normalized)
107
+ raise ArgumentError,
108
+ "source_type must be one of #{VALID_SOURCE_TYPES.join(', ')}, got: #{normalized}"
109
+ end
110
+
111
+ normalized
112
+ end
113
+ private_class_method :validate_source_type!
114
+
115
+ # Validates source_data field
116
+ # @param source_data [Hash]
117
+ # @return [Hash] Validated source data
118
+ # @raise [ArgumentError]
119
+ def self.validate_source_data!(source_data)
120
+ raise ArgumentError, "source_data is required" if source_data.nil?
121
+ raise ArgumentError, "source_data must be a Hash, got: #{source_data.class}" unless source_data.is_a?(Hash)
122
+
123
+ source_data
124
+ end
125
+ private_class_method :validate_source_data!
126
+
127
+ # Validates outcome field
128
+ # @param outcome [String, Symbol]
129
+ # @return [String] Validated outcome string
130
+ # @raise [ArgumentError]
131
+ def self.validate_outcome!(outcome)
132
+ raise ArgumentError, "outcome is required" if outcome.nil?
133
+
134
+ # Normalize symbol to string
135
+ normalized = outcome.to_s
136
+
137
+ unless VALID_OUTCOMES.include?(normalized)
138
+ raise ArgumentError,
139
+ "outcome must be one of #{VALID_OUTCOMES.join(', ')}, got: #{normalized}"
140
+ end
141
+
142
+ normalized
143
+ end
144
+ private_class_method :validate_outcome!
145
+
146
+ # Validates and normalizes timestamp
147
+ # @param timestamp [String, Time, nil]
148
+ # @return [String] ISO 8601 timestamp string
149
+ # @raise [ArgumentError]
150
+ def self.validate_timestamp!(timestamp)
151
+ return Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ") if timestamp.nil?
152
+
153
+ case timestamp
154
+ when String
155
+ # Validate ISO 8601 format by attempting to parse
156
+ begin
157
+ Time.iso8601(timestamp)
158
+ rescue ArgumentError
159
+ raise ArgumentError, "timestamp must be valid ISO 8601 format, got: #{timestamp}"
160
+ end
161
+ timestamp
162
+ when Time
163
+ timestamp.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
164
+ else
165
+ raise ArgumentError, "timestamp must be a String or Time, got: #{timestamp.class}"
166
+ end
167
+ end
168
+ private_class_method :validate_timestamp!
169
+
170
+ # Validates resource_ids field
171
+ # @param resource_ids [Array]
172
+ # @return [Array] Validated resource_ids array
173
+ # @raise [ArgumentError]
174
+ def self.validate_resource_ids!(resource_ids)
175
+ raise ArgumentError, "resource_ids must be an Array, got: #{resource_ids.class}" unless resource_ids.is_a?(Array)
176
+
177
+ resource_ids.each_with_index do |resource, index|
178
+ unless resource.is_a?(Hash)
179
+ raise ArgumentError,
180
+ "resource_ids[#{index}] must be a Hash, got: #{resource.class}"
181
+ end
182
+
183
+ unless resource.key?(:resource_type) && resource.key?(:resource_id)
184
+ raise ArgumentError,
185
+ "resource_ids[#{index}] must have :resource_type and :resource_id keys, got: #{resource.keys.inspect}"
186
+ end
187
+
188
+ unless resource[:resource_type].is_a?(String)
189
+ raise ArgumentError,
190
+ "resource_ids[#{index}][:resource_type] must be a String, got: #{resource[:resource_type].class}"
191
+ end
192
+
193
+ unless resource[:resource_id].is_a?(String)
194
+ raise ArgumentError,
195
+ "resource_ids[#{index}][:resource_id] must be a String, got: #{resource[:resource_id].class}"
196
+ end
197
+ end
198
+
199
+ resource_ids
200
+ end
201
+ private_class_method :validate_resource_ids!
202
+
203
+ # Validates context field
204
+ # @param context [Hash, nil]
205
+ # @return [Hash, nil] Validated context
206
+ # @raise [ArgumentError]
207
+ def self.validate_context!(context)
208
+ return nil if context.nil?
209
+
210
+ unless context.is_a?(Hash)
211
+ raise ArgumentError, "context must be a Hash or nil, got: #{context.class}"
212
+ end
213
+
214
+ context
215
+ end
216
+ private_class_method :validate_context!
217
+
218
+ # Validates duration_ms field
219
+ # @param duration_ms [Integer, nil]
220
+ # @return [Integer, nil] Validated duration_ms
221
+ # @raise [ArgumentError]
222
+ def self.validate_duration_ms!(duration_ms)
223
+ return nil if duration_ms.nil?
224
+
225
+ unless duration_ms.is_a?(Integer)
226
+ raise ArgumentError, "duration_ms must be an Integer, got: #{duration_ms.class}"
227
+ end
228
+
229
+ if duration_ms.negative?
230
+ raise ArgumentError, "duration_ms must be >= 0, got: #{duration_ms}"
231
+ end
232
+
233
+ duration_ms
234
+ end
235
+ private_class_method :validate_duration_ms!
236
+
237
+ # Validates outcome and error_message consistency
238
+ # @param outcome [String]
239
+ # @param error_message [String, nil]
240
+ # @raise [ArgumentError]
241
+ def self.validate_outcome_error_consistency!(outcome, error_message)
242
+ if outcome == "failure"
243
+ if error_message.nil? || error_message.empty?
244
+ raise ArgumentError, "error_message is required when outcome is 'failure'"
245
+ end
246
+ elsif outcome == "success"
247
+ unless error_message.nil?
248
+ raise ArgumentError, "error_message must be nil when outcome is 'success'"
249
+ end
250
+ end
251
+ end
252
+ private_class_method :validate_outcome_error_consistency!
253
+
254
+ # Recursively sanitizes a hash by filtering sensitive keys
255
+ # @param hash [Hash]
256
+ # @return [Hash] Sanitized hash with sensitive values replaced
257
+ def self.sanitize_hash(hash)
258
+ hash.each_with_object({}) do |(key, value), result|
259
+ if sensitive_key?(key)
260
+ result[key] = "[FILTERED]"
261
+ elsif value.is_a?(Hash)
262
+ result[key] = sanitize_hash(value)
263
+ elsif value.is_a?(Array)
264
+ result[key] = value.map { |item| item.is_a?(Hash) ? sanitize_hash(item) : item }
265
+ else
266
+ result[key] = value
267
+ end
268
+ end
269
+ end
270
+ private_class_method :sanitize_hash
271
+
272
+ # Checks if a key is sensitive (case-insensitive substring match)
273
+ # @param key [String, Symbol]
274
+ # @return [Boolean]
275
+ def self.sensitive_key?(key)
276
+ key_str = key.to_s.downcase
277
+ SENSITIVE_KEYS.any? { |sensitive| key_str.include?(sensitive) }
278
+ end
279
+ private_class_method :sensitive_key?
280
+ end
281
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EzLogsAgent
4
+ # FlushScheduler orchestrates periodic flushing of buffered events to the server.
5
+ #
6
+ # Responsibilities:
7
+ # - Start a single background thread that wakes up every `send_interval` seconds
8
+ # - Flush Buffer and send events via RetrySender
9
+ # - Shut down cleanly when requested
10
+ #
11
+ # Non-responsibilities:
12
+ # - Does NOT retry (RetrySender handles that)
13
+ # - Does NOT interpret Transport results
14
+ # - Does NOT manage Rails lifecycle (Railtie handles that)
15
+ # - Does NOT mutate or inspect events
16
+ class FlushScheduler
17
+ class << self
18
+ # Start the background flush thread
19
+ # Idempotent: safe to call multiple times
20
+ def start
21
+ return if running?
22
+
23
+ @stop_requested = false
24
+ @thread = Thread.new { run_loop }
25
+
26
+ Logger.debug("[FlushScheduler] Started")
27
+ rescue => error
28
+ Logger.error("[FlushScheduler] Failed to start: #{error.class} - #{error.message}")
29
+ end
30
+
31
+ # Stop the background flush thread
32
+ # Blocks until thread exits
33
+ # Idempotent: safe to call multiple times
34
+ def stop
35
+ return unless running?
36
+
37
+ @stop_requested = true
38
+ @thread.join if @thread
39
+
40
+ Logger.debug("[FlushScheduler] Stopped")
41
+ rescue => error
42
+ Logger.error("[FlushScheduler] Failed to stop: #{error.class} - #{error.message}")
43
+ end
44
+
45
+ # Check if the scheduler is currently running
46
+ def running?
47
+ @thread&.alive? == true
48
+ end
49
+
50
+ private
51
+
52
+ # Background loop: sleep → flush → send → repeat
53
+ def run_loop
54
+ loop do
55
+ break if @stop_requested
56
+
57
+ sleep_interval
58
+ break if @stop_requested
59
+
60
+ flush_and_send
61
+ end
62
+ rescue => error
63
+ Logger.error("[FlushScheduler] Loop crashed: #{error.class} - #{error.message}")
64
+ end
65
+
66
+ # Sleep for configured interval
67
+ def sleep_interval
68
+ interval = send_interval
69
+ sleep(interval)
70
+ rescue => error
71
+ Logger.error("[FlushScheduler] Sleep failed: #{error.message}")
72
+ sleep(5) # Defensive fallback
73
+ end
74
+
75
+ # Flush buffer and send events
76
+ def flush_and_send
77
+ events = Buffer.flush
78
+ return if events.nil? || events.empty?
79
+
80
+ RetrySender.send(events)
81
+ rescue => error
82
+ Logger.error("[FlushScheduler] Flush and send failed: #{error.class} - #{error.message}")
83
+ end
84
+
85
+ # Get send_interval from configuration with defensive fallback
86
+ def send_interval
87
+ interval = EzLogsAgent.configuration.send_interval
88
+ return 5 if interval.nil?
89
+ return 5 if !interval.is_a?(Numeric)
90
+ return 5 if interval <= 0
91
+
92
+ interval
93
+ rescue => error
94
+ Logger.error("[FlushScheduler] Failed to read send_interval: #{error.message}")
95
+ 5
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EzLogsAgent
4
+ # Minimal, defensive logging utility for the gem.
5
+ # Delegates to Rails.logger when available, falls back to STDERR.
6
+ # Never raises exceptions, never crashes the host application.
7
+ module Logger
8
+ LOG_LEVELS = {
9
+ debug: 0,
10
+ info: 1,
11
+ warn: 2,
12
+ error: 3
13
+ }.freeze
14
+
15
+ class << self
16
+ def debug(message)
17
+ log(:debug, message)
18
+ end
19
+
20
+ def info(message)
21
+ log(:info, message)
22
+ end
23
+
24
+ def warn(message)
25
+ log(:warn, message)
26
+ end
27
+
28
+ def error(message)
29
+ log(:error, message)
30
+ end
31
+
32
+ private
33
+
34
+ def log(level, message)
35
+ # Early return if this log level should not be logged
36
+ return unless should_log?(level)
37
+
38
+ prefixed_message = "[EzLogsAgent] #{message}"
39
+
40
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
41
+ Rails.logger.public_send(level, prefixed_message)
42
+ else
43
+ # Fallback to stderr when Rails.logger is not available
44
+ # Note: should_log? already checked above, so this respects log_level
45
+ $stderr.puts("[#{level.upcase}] #{prefixed_message}")
46
+ end
47
+ rescue => e
48
+ # Defensive: logging must never crash the host application.
49
+ # Silently swallow any logging errors.
50
+ nil
51
+ end
52
+
53
+ def should_log?(level)
54
+ configured_level = EzLogsAgent.configuration.log_level
55
+ LOG_LEVELS[level] >= LOG_LEVELS[configured_level]
56
+ rescue => e
57
+ # If we can't determine log level, allow logging (fail open).
58
+ true
59
+ end
60
+ end
61
+ end
62
+ end