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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +57 -0
- data/CONFIGURATION.md +752 -0
- data/FAQ.md +574 -0
- data/LICENSE.txt +21 -0
- data/QUICKSTART.md +390 -0
- data/README.md +1021 -0
- data/RELEASING.md +55 -0
- data/Rakefile +8 -0
- data/lib/ez_logs_agent/actor.rb +57 -0
- data/lib/ez_logs_agent/actor_validator.rb +51 -0
- data/lib/ez_logs_agent/buffer.rb +83 -0
- data/lib/ez_logs_agent/capturers/active_job_capturer.rb +270 -0
- data/lib/ez_logs_agent/capturers/database_capturer.rb +467 -0
- data/lib/ez_logs_agent/capturers/job_capturer.rb +238 -0
- data/lib/ez_logs_agent/configuration.rb +186 -0
- data/lib/ez_logs_agent/configuration_validator.rb +139 -0
- data/lib/ez_logs_agent/correlation.rb +40 -0
- data/lib/ez_logs_agent/event_builder.rb +281 -0
- data/lib/ez_logs_agent/flush_scheduler.rb +99 -0
- data/lib/ez_logs_agent/logger.rb +62 -0
- data/lib/ez_logs_agent/middleware/http_request.rb +1094 -0
- data/lib/ez_logs_agent/railtie.rb +353 -0
- data/lib/ez_logs_agent/resource_extractor.rb +172 -0
- data/lib/ez_logs_agent/retry_sender.rb +120 -0
- data/lib/ez_logs_agent/transport.rb +91 -0
- data/lib/ez_logs_agent/version.rb +5 -0
- data/lib/ez_logs_agent.rb +42 -0
- data/lib/generators/ez_logs_agent/install/install_generator.rb +94 -0
- data/lib/generators/ez_logs_agent/install/templates/ez_logs_agent.rb.tt +128 -0
- data/lib/tasks/ez_logs_agent.rake +110 -0
- data/script/publish-to-public.sh +113 -0
- data/sig/ez_logs_agent.rbs +4 -0
- metadata +178 -0
|
@@ -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
|