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.
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EzLogsAgent
4
+ # Configuration for the EzLogsAgent.
5
+ #
6
+ # == Noise Filtering Overview
7
+ #
8
+ # EzLogsAgent captures events but filters out noise at the agent level.
9
+ # Each capturer has its own exclusion mechanism:
10
+ #
11
+ # === HTTP Requests (middleware/http_request.rb)
12
+ # - excluded_paths: URL paths to ignore (supports * wildcard for prefix match)
13
+ # - DEFAULT_EXCLUDED_EXTENSIONS: Static file extensions (.js, .css, .png, etc.)
14
+ # - excluded_graphql_operations: GraphQL operations to skip (introspection, etc.)
15
+ #
16
+ # === Database Callbacks (capturers/database_capturer.rb)
17
+ # - excluded_tables: Table names to ignore (Rails internals, job queues, etc.)
18
+ # - IGNORED_ATTRIBUTES: Technical fields (created_at, lock_version, etc.)
19
+ # - SENSITIVE_PATTERNS: Attributes containing passwords, tokens, secrets
20
+ #
21
+ # === Background Jobs (capturers/job_capturer.rb)
22
+ # - excluded_job_classes: Job class names to ignore (health checks, etc.)
23
+ #
24
+ # == Server-Side vs Agent-Side Filtering
25
+ #
26
+ # The filtering philosophy:
27
+ # - Agent filters NOISE (introspection, assets, health checks, Rails internals)
28
+ # - Server classifies SIGNIFICANCE (reads vs writes) for UI filtering
29
+ #
30
+ # This means GraphQL queries and GET requests ARE captured by the agent,
31
+ # but the server classifies them as "background" so users can toggle visibility.
32
+ #
33
+ class Configuration
34
+ attr_accessor :server_url
35
+ attr_accessor :project_token
36
+ attr_accessor :capture_http
37
+ attr_accessor :capture_jobs
38
+ attr_accessor :capture_database
39
+ attr_accessor :excluded_paths
40
+ attr_accessor :excluded_tables
41
+ attr_accessor :excluded_job_classes
42
+ attr_accessor :excluded_graphql_operations
43
+ attr_accessor :excluded_graphql_variable_keys
44
+ attr_accessor :buffer_size
45
+ attr_accessor :retry_attempts
46
+ attr_accessor :send_interval
47
+ attr_accessor :log_level
48
+
49
+ # Actor extraction hook for HTTP requests (optional)
50
+ # Must be a callable (lambda/proc) that accepts (request, controller)
51
+ # and returns { kind:, id:, label:, metadata: } or nil
52
+ attr_accessor :actor_from_request
53
+
54
+ # Display name field mapping for database records (optional)
55
+ # Maps model class names to attribute names used for human-readable display
56
+ #
57
+ # Example:
58
+ # config.display_name_for = {
59
+ # "User" => :email,
60
+ # "Product" => :name,
61
+ # "Order" => :number
62
+ # }
63
+ #
64
+ # IMPORTANT: Only use direct attributes, not associations.
65
+ # Associations will trigger database queries and should be avoided.
66
+ #
67
+ # If not configured for a model, falls back to: name → title → number → "##{id}"
68
+ attr_accessor :display_name_for
69
+
70
+ # Default paths excluded from HTTP capture - common Rails noise
71
+ DEFAULT_EXCLUDED_PATHS = [
72
+ "/rails/active_storage*", # File uploads/downloads
73
+ "/assets*", # Asset pipeline
74
+ "/packs*", # Webpacker assets
75
+ "/vite*", # Vite assets
76
+ "/health*", # Health checks
77
+ "/up", # Rails 7.1+ health check
78
+ "/alive", # Kubernetes liveness probe
79
+ "/ready", # Kubernetes readiness probe
80
+ "/metrics", # Prometheus metrics endpoint
81
+ "/favicon.ico", # Browser favicon
82
+ "/*.hot-update.*", # Hot module replacement
83
+ "/.well-known*", # Well-known URIs (security.txt, etc.)
84
+ "/robots.txt", # Search engine crawler config
85
+ "/sitemap.xml", # Sitemap for crawlers
86
+ "/cable*", # ActionCable WebSocket connections
87
+ "/sidekiq", # Sidekiq Web UI dashboard root (the conventional mount)
88
+ "/sidekiq/*", # Sidekiq Web UI sub-paths (auto-poll noise)
89
+ # Authentication pages - not meaningful business actions
90
+ # Use */path* patterns to match auth routes anywhere (e.g., /admin/logout)
91
+ "*/sign_in*", # Devise and common sign in (matches /users/sign_in, /admin/sign_in)
92
+ "*/sign_out*", # Devise and common sign out
93
+ "*/login*", # Common auth pattern (matches /login, /admin/login)
94
+ "*/logout*", # Common auth pattern (matches /logout, /admin/logout)
95
+ "/users/password*", # Devise password reset/edit
96
+ "/session*" # Common auth pattern
97
+ ].freeze
98
+
99
+ # Default file extensions excluded from HTTP capture - static assets
100
+ # These are matched against the path suffix regardless of directory
101
+ DEFAULT_EXCLUDED_EXTENSIONS = %w[
102
+ .js .css .map
103
+ .png .jpg .jpeg .gif .svg .ico .webp
104
+ .woff .woff2 .ttf .eot .otf
105
+ ].freeze
106
+
107
+ # Default tables excluded from database capture - Rails internal tables
108
+ DEFAULT_EXCLUDED_TABLES = [
109
+ "schema_migrations",
110
+ "ar_internal_metadata",
111
+ "sessions", # ActiveRecord session store (plural)
112
+ "session", # ActiveRecord session store (singular)
113
+ "active_storage_blobs", # ActiveStorage internals
114
+ "active_storage_attachments",
115
+ "active_storage_variant_records",
116
+ "solid_queue_jobs", # SolidQueue internals
117
+ "solid_queue_scheduled_executions",
118
+ "solid_queue_ready_executions",
119
+ "solid_queue_claimed_executions",
120
+ "solid_queue_blocked_executions",
121
+ "solid_queue_failed_executions",
122
+ "solid_queue_pauses",
123
+ "solid_queue_processes",
124
+ "solid_queue_semaphores",
125
+ "solid_queue_recurring_tasks",
126
+ "solid_queue_recurring_executions",
127
+ "solid_cache_entries", # SolidCache internals
128
+ "solid_cable_messages" # SolidCable internals
129
+ ].freeze
130
+
131
+ # Default job classes excluded from background job capture - infrastructure/health check jobs
132
+ DEFAULT_EXCLUDED_JOB_CLASSES = [
133
+ "SidekiqAlive::Worker", # Sidekiq health check
134
+ "SolidQueue::CleanupJob", # SolidQueue maintenance
135
+ "SolidQueue::RecurringJob" # SolidQueue scheduler internals
136
+ ].freeze
137
+
138
+ # Default GraphQL operations excluded from capture - introspection and IDE queries
139
+ # Supports exact match and prefix match (patterns ending with *)
140
+ DEFAULT_EXCLUDED_GRAPHQL_OPERATIONS = [
141
+ "IntrospectionQuery", # Standard IDE introspection query
142
+ "__*" # All introspection fields (__schema, __type, etc.)
143
+ ].freeze
144
+
145
+ DEFAULT_SERVER_URL = "https://app.ezlogs.io"
146
+
147
+ def initialize
148
+ @server_url = DEFAULT_SERVER_URL
149
+ @project_token = nil
150
+ @capture_http = true
151
+ @capture_jobs = true
152
+ @capture_database = true
153
+ @excluded_paths = [] # User-defined; combined with DEFAULT_EXCLUDED_PATHS
154
+ @excluded_tables = [] # User-defined; combined with DEFAULT_EXCLUDED_TABLES
155
+ @excluded_job_classes = [] # User-defined; combined with DEFAULT_EXCLUDED_JOB_CLASSES
156
+ @excluded_graphql_operations = [] # User-defined; combined with DEFAULT_EXCLUDED_GRAPHQL_OPERATIONS
157
+ @excluded_graphql_variable_keys = [] # User-defined; additional sensitive variable key patterns to filter
158
+ @buffer_size = 10_000 # Increased for high-volume workloads (job-heavy apps)
159
+ @retry_attempts = 3
160
+ @send_interval = 3 # More frequent sends for better throughput
161
+ @log_level = :warn
162
+ @actor_from_request = nil # Not configured by default
163
+ @display_name_for = {} # Not configured by default
164
+ end
165
+
166
+ # Returns all excluded paths (defaults + user-configured)
167
+ def all_excluded_paths
168
+ DEFAULT_EXCLUDED_PATHS + (@excluded_paths || [])
169
+ end
170
+
171
+ # Returns all excluded tables (defaults + user-configured)
172
+ def all_excluded_tables
173
+ DEFAULT_EXCLUDED_TABLES + (@excluded_tables || [])
174
+ end
175
+
176
+ # Returns all excluded job classes (defaults + user-configured)
177
+ def all_excluded_job_classes
178
+ DEFAULT_EXCLUDED_JOB_CLASSES + (@excluded_job_classes || [])
179
+ end
180
+
181
+ # Returns all excluded GraphQL operations (defaults + user-configured)
182
+ def all_excluded_graphql_operations
183
+ DEFAULT_EXCLUDED_GRAPHQL_OPERATIONS + (@excluded_graphql_operations || [])
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EzLogsAgent
4
+ # Validates EzLogsAgent configuration and provides actionable error messages.
5
+ #
6
+ # Used at boot-time by Railtie and by the test_connection rake task
7
+ # to ensure configuration is valid before attempting to capture or send events.
8
+ #
9
+ # Validation philosophy:
10
+ # - Only validate what will cause hard failures
11
+ # - Provide clear, actionable error messages
12
+ # - Warnings for optional but recommended config
13
+ # - Never crash the host application
14
+ #
15
+ class ConfigurationValidator
16
+ # Result of configuration validation
17
+ #
18
+ # @attr_reader [Array<String>] errors Critical configuration errors
19
+ # @attr_reader [Array<String>] warnings Non-critical configuration warnings
20
+ ValidationResult = Struct.new(:errors, :warnings) do
21
+ def valid?
22
+ errors.empty?
23
+ end
24
+
25
+ def has_warnings?
26
+ warnings.any?
27
+ end
28
+ end
29
+
30
+ # Validates the given configuration
31
+ #
32
+ # @param config [EzLogsAgent::Configuration] Configuration to validate
33
+ # @return [ValidationResult] Validation result with errors and warnings
34
+ def self.validate(config)
35
+ errors = []
36
+ warnings = []
37
+
38
+ # Required: server_url must be set
39
+ if config.server_url.nil? || config.server_url.to_s.strip.empty?
40
+ errors << "server_url is required. Set it in config/initializers/ez_logs_agent.rb"
41
+ elsif !valid_url?(config.server_url)
42
+ errors << "server_url must be a valid URL (got: #{config.server_url})"
43
+ end
44
+
45
+ # Optional but recommended: project_token
46
+ if config.project_token.nil? || config.project_token.to_s.strip.empty?
47
+ warnings << "project_token is not set. Authentication may fail if the server requires it."
48
+ end
49
+
50
+ # Validate numeric fields
51
+ if config.buffer_size && (!config.buffer_size.is_a?(Integer) || config.buffer_size <= 0)
52
+ errors << "buffer_size must be a positive integer (got: #{config.buffer_size})"
53
+ end
54
+
55
+ if config.retry_attempts && (!config.retry_attempts.is_a?(Integer) || config.retry_attempts < 0)
56
+ errors << "retry_attempts must be a non-negative integer (got: #{config.retry_attempts})"
57
+ end
58
+
59
+ if config.send_interval && (!config.send_interval.is_a?(Numeric) || config.send_interval <= 0)
60
+ errors << "send_interval must be a positive number (got: #{config.send_interval})"
61
+ end
62
+
63
+ # Validate log_level
64
+ if config.log_level && !valid_log_level?(config.log_level)
65
+ errors << "log_level must be one of: :debug, :info, :warn, :error (got: #{config.log_level})"
66
+ end
67
+
68
+ # Validate boolean fields
69
+ unless [true, false].include?(config.capture_http)
70
+ errors << "capture_http must be true or false (got: #{config.capture_http})"
71
+ end
72
+
73
+ unless [true, false].include?(config.capture_jobs)
74
+ errors << "capture_jobs must be true or false (got: #{config.capture_jobs})"
75
+ end
76
+
77
+ unless [true, false].include?(config.capture_database)
78
+ errors << "capture_database must be true or false (got: #{config.capture_database})"
79
+ end
80
+
81
+ # Validate array fields
82
+ if config.excluded_paths && !config.excluded_paths.is_a?(Array)
83
+ errors << "excluded_paths must be an array (got: #{config.excluded_paths.class})"
84
+ end
85
+
86
+ if config.excluded_tables && !config.excluded_tables.is_a?(Array)
87
+ errors << "excluded_tables must be an array (got: #{config.excluded_tables.class})"
88
+ end
89
+
90
+ if config.excluded_job_classes && !config.excluded_job_classes.is_a?(Array)
91
+ errors << "excluded_job_classes must be an array (got: #{config.excluded_job_classes.class})"
92
+ end
93
+
94
+ if config.excluded_graphql_operations && !config.excluded_graphql_operations.is_a?(Array)
95
+ errors << "excluded_graphql_operations must be an array (got: #{config.excluded_graphql_operations.class})"
96
+ end
97
+
98
+ # Validate actor_from_request is callable if present
99
+ if config.actor_from_request && !config.actor_from_request.respond_to?(:call)
100
+ errors << "actor_from_request must be a callable (lambda/proc) or nil"
101
+ end
102
+
103
+ # Validate display_name_for is a hash if present
104
+ if config.display_name_for && !config.display_name_for.is_a?(Hash)
105
+ errors << "display_name_for must be a hash (got: #{config.display_name_for.class})"
106
+ end
107
+
108
+ # Warning if all capture flags are disabled
109
+ if !config.capture_http && !config.capture_jobs && !config.capture_database
110
+ warnings << "All capture flags are disabled. No events will be captured."
111
+ end
112
+
113
+ ValidationResult.new(errors, warnings)
114
+ end
115
+
116
+ # Checks if the given string is a valid URL
117
+ #
118
+ # @param url [String] URL to validate
119
+ # @return [Boolean] true if valid URL
120
+ def self.valid_url?(url)
121
+ return false unless url.is_a?(String)
122
+
123
+ uri = URI.parse(url)
124
+ uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
125
+ rescue URI::InvalidURIError
126
+ false
127
+ end
128
+
129
+ # Checks if the given log level is valid
130
+ #
131
+ # @param level [Symbol] Log level to validate
132
+ # @return [Boolean] true if valid log level
133
+ def self.valid_log_level?(level)
134
+ %i[debug info warn error].include?(level)
135
+ end
136
+
137
+ private_class_method :valid_url?, :valid_log_level?
138
+ end
139
+ end
@@ -0,0 +1,40 @@
1
+ require "securerandom"
2
+ require "request_store"
3
+
4
+ module EzLogsAgent
5
+ module Correlation
6
+ STORE_KEY = :ez_logs_correlation_id
7
+
8
+ # Generate a new unique correlation ID
9
+ # Format: ezl_<timestamp_ms>_<random_hex_8>
10
+ #
11
+ # @return [String] A new correlation ID
12
+ def self.generate
13
+ timestamp_ms = (Time.now.to_f * 1000).to_i
14
+ random_hex = SecureRandom.hex(4) # 8 chars
15
+ "ezl_#{timestamp_ms}_#{random_hex}"
16
+ end
17
+
18
+ # Get the current correlation ID (or nil)
19
+ #
20
+ # @return [String, nil] The current correlation ID or nil if not set
21
+ def self.current
22
+ RequestStore.store[STORE_KEY]
23
+ end
24
+
25
+ # Set the current correlation ID
26
+ #
27
+ # @param value [String, nil] The correlation ID to store
28
+ # @return [String, nil] The stored value
29
+ def self.current=(value)
30
+ RequestStore.store[STORE_KEY] = value
31
+ end
32
+
33
+ # Clear the current correlation ID
34
+ #
35
+ # @return [void]
36
+ def self.clear
37
+ RequestStore.store.delete(STORE_KEY)
38
+ end
39
+ end
40
+ end
@@ -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