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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +80 -0
- data/LICENSE.txt +21 -0
- data/README.md +1023 -0
- data/lib/ez_logs_agent/actor.rb +57 -0
- data/lib/ez_logs_agent/actor_validator.rb +58 -0
- data/lib/ez_logs_agent/buffer.rb +83 -0
- data/lib/ez_logs_agent/capturers/active_job_capturer.rb +300 -0
- data/lib/ez_logs_agent/capturers/database_capturer.rb +467 -0
- data/lib/ez_logs_agent/capturers/job_capturer.rb +261 -0
- data/lib/ez_logs_agent/configuration.rb +184 -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 +992 -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/sanitizer.rb +150 -0
- data/lib/ez_logs_agent/transport.rb +91 -0
- data/lib/ez_logs_agent/user_agent_detector.rb +51 -0
- data/lib/ez_logs_agent/version.rb +5 -0
- data/lib/ez_logs_agent.rb +44 -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
- metadata +172 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EzLogsAgent
|
|
4
|
+
# Canonical sanitizer for "user-supplied data we're about to ship over
|
|
5
|
+
# the wire": HTTP params, GraphQL variables, and ActiveJob/Sidekiq
|
|
6
|
+
# arguments.
|
|
7
|
+
#
|
|
8
|
+
# Rules:
|
|
9
|
+
# - Sensitive keys (password / token / secret / api_key / credit_card,
|
|
10
|
+
# plus user-configured patterns) are replaced with "[FILTERED]".
|
|
11
|
+
# - Nested objects recurse up to MAX_NESTING_DEPTH (3); anything deeper
|
|
12
|
+
# collapses to "[Object]" so we never serialize unbounded graphs.
|
|
13
|
+
# - Arrays: small primitive arrays pass through; large ones (>5)
|
|
14
|
+
# collapse to a preview-plus-count string; arrays of objects are
|
|
15
|
+
# sanitized element-by-element with the same depth budget.
|
|
16
|
+
# - Non-primitive Ruby objects (anything not String/Numeric/Bool/nil)
|
|
17
|
+
# become "[Object]". This protects us from accidentally serializing
|
|
18
|
+
# ActiveRecord instances or other big graphs that found their way
|
|
19
|
+
# into a job argument.
|
|
20
|
+
#
|
|
21
|
+
# The module is pure (no I/O, no state), so it's safe to call from
|
|
22
|
+
# any thread.
|
|
23
|
+
module Sanitizer
|
|
24
|
+
# Default sensitive-key patterns. Matched case-insensitively as
|
|
25
|
+
# SUBSTRINGS of the key, so `customer_password` matches `password`.
|
|
26
|
+
SENSITIVE_PATTERNS = %w[
|
|
27
|
+
password passwd pwd
|
|
28
|
+
token access_token refresh_token api_token auth_token
|
|
29
|
+
secret api_secret client_secret
|
|
30
|
+
api_key apikey private_key privatekey secret_key secretkey
|
|
31
|
+
credential auth authorization
|
|
32
|
+
encrypted encrypted_data
|
|
33
|
+
ssn social_security
|
|
34
|
+
credit_card card_number cvv cvc
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
# Hard ceiling for nested object recursion. Deeper structures
|
|
38
|
+
# collapse to the literal string "[Object]".
|
|
39
|
+
MAX_NESTING_DEPTH = 3
|
|
40
|
+
|
|
41
|
+
# Threshold above which an array is summarized instead of inlined.
|
|
42
|
+
# Below this size, primitive arrays are shipped verbatim; arrays
|
|
43
|
+
# of objects are mapped element-by-element.
|
|
44
|
+
MAX_ARRAY_DISPLAY_SIZE = 5
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
# Sanitize a single key/value pair. Public entry point used by
|
|
48
|
+
# HTTP-param and job-arg sanitization.
|
|
49
|
+
#
|
|
50
|
+
# @param key [String, Symbol] Variable / parameter name.
|
|
51
|
+
# @param value [Object] Variable / parameter value.
|
|
52
|
+
# @param depth [Integer] Current recursion depth.
|
|
53
|
+
# @return [Object] Sanitized value (may be the original primitive).
|
|
54
|
+
def sanitize_value(key, value, depth = 0)
|
|
55
|
+
return "[FILTERED]" if sensitive_key?(key)
|
|
56
|
+
return sanitize_nested_object(value, depth) if value.is_a?(Hash)
|
|
57
|
+
return sanitize_array_value(value, depth) if value.is_a?(Array)
|
|
58
|
+
return value if primitive?(value)
|
|
59
|
+
|
|
60
|
+
# Anything else (AR records, dates, custom objects) collapses to
|
|
61
|
+
# a placeholder so we never accidentally serialize a huge graph.
|
|
62
|
+
"[Object]"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Sanitize an ordered list of job arguments (positional). Returns
|
|
66
|
+
# an Array with each element sanitized as if its index were the
|
|
67
|
+
# key (no sensitive-key match for integers — only the nested
|
|
68
|
+
# structure matters at the top level).
|
|
69
|
+
#
|
|
70
|
+
# @param args [Array] Job arguments array.
|
|
71
|
+
# @return [Array] Sanitized arguments array.
|
|
72
|
+
def sanitize_args(args)
|
|
73
|
+
return [] unless args.is_a?(Array)
|
|
74
|
+
|
|
75
|
+
# Top-level array uses the same array-rules so giant arg lists
|
|
76
|
+
# truncate to a preview-with-count rather than ship verbatim.
|
|
77
|
+
sanitize_array_value(args, 0)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check whether a key matches a sensitive pattern. Public so the
|
|
81
|
+
# HTTP middleware can short-circuit early on identical keys.
|
|
82
|
+
#
|
|
83
|
+
# @param key [String, Symbol]
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def sensitive_key?(key)
|
|
86
|
+
key_lower = key.to_s.downcase
|
|
87
|
+
return true if SENSITIVE_PATTERNS.any? { |pattern| key_lower.include?(pattern) }
|
|
88
|
+
|
|
89
|
+
user_patterns = EzLogsAgent.configuration.excluded_graphql_variable_keys || []
|
|
90
|
+
user_patterns.any? { |pattern| key_lower.include?(pattern.to_s.downcase) }
|
|
91
|
+
rescue
|
|
92
|
+
# Defensive: when in doubt, treat as sensitive.
|
|
93
|
+
true
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def sanitize_nested_object(hash, depth)
|
|
99
|
+
return "[Object]" if depth >= MAX_NESTING_DEPTH
|
|
100
|
+
return {} if hash.empty?
|
|
101
|
+
|
|
102
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
103
|
+
result[key] = sanitize_value(key, value, depth + 1)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def sanitize_array_value(array, depth)
|
|
108
|
+
return [] if array.empty?
|
|
109
|
+
|
|
110
|
+
all_primitives = array.all? { |item| primitive?(item) }
|
|
111
|
+
|
|
112
|
+
if all_primitives
|
|
113
|
+
if array.size <= MAX_ARRAY_DISPLAY_SIZE
|
|
114
|
+
array
|
|
115
|
+
else
|
|
116
|
+
preview = array.first(MAX_ARRAY_DISPLAY_SIZE)
|
|
117
|
+
"#{preview}... (#{array.size} total)"
|
|
118
|
+
end
|
|
119
|
+
else
|
|
120
|
+
if array.size <= MAX_ARRAY_DISPLAY_SIZE
|
|
121
|
+
array.map { |item| sanitize_array_item(item, depth) }
|
|
122
|
+
else
|
|
123
|
+
preview = array.first(MAX_ARRAY_DISPLAY_SIZE).map { |item| sanitize_array_item(item, depth) }
|
|
124
|
+
{ "_truncated" => true, "_count" => array.size, "_preview" => preview }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def sanitize_array_item(item, depth)
|
|
130
|
+
if primitive?(item)
|
|
131
|
+
item
|
|
132
|
+
elsif item.is_a?(Hash)
|
|
133
|
+
sanitize_nested_object(item, depth + 1)
|
|
134
|
+
elsif item.is_a?(Array)
|
|
135
|
+
sanitize_array_value(item, depth + 1)
|
|
136
|
+
else
|
|
137
|
+
"[Object]"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def primitive?(value)
|
|
142
|
+
value.nil? ||
|
|
143
|
+
value.is_a?(String) ||
|
|
144
|
+
value.is_a?(Numeric) ||
|
|
145
|
+
value.is_a?(TrueClass) ||
|
|
146
|
+
value.is_a?(FalseClass)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module EzLogsAgent
|
|
8
|
+
# HTTP Transport Client
|
|
9
|
+
#
|
|
10
|
+
# Lowest-level component that sends events to the EzLogs server.
|
|
11
|
+
# Performs a single HTTP POST request with no retries or backoff.
|
|
12
|
+
#
|
|
13
|
+
# Responsibilities:
|
|
14
|
+
# - Serialize events to JSON
|
|
15
|
+
# - POST to server endpoint
|
|
16
|
+
# - Return :success or :failure
|
|
17
|
+
#
|
|
18
|
+
# Does NOT:
|
|
19
|
+
# - Retry failed requests
|
|
20
|
+
# - Implement backoff logic
|
|
21
|
+
# - Read from Buffer
|
|
22
|
+
# - Call Buffer.flush
|
|
23
|
+
# - Raise exceptions to host app
|
|
24
|
+
class Transport
|
|
25
|
+
class << self
|
|
26
|
+
# Send events to the server
|
|
27
|
+
#
|
|
28
|
+
# @param events [Array<Hash>] Array of event hashes
|
|
29
|
+
# @return [Symbol] :success if 2xx response, :failure otherwise
|
|
30
|
+
def send(events)
|
|
31
|
+
return :success if events.nil? || events.empty?
|
|
32
|
+
|
|
33
|
+
response = post_events(events)
|
|
34
|
+
classify_response(response)
|
|
35
|
+
rescue => error
|
|
36
|
+
Logger.error("[Transport] send failed: #{error.class} - #{error.message}")
|
|
37
|
+
:failure
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def post_events(events)
|
|
43
|
+
uri = build_uri
|
|
44
|
+
http = build_http_client(uri)
|
|
45
|
+
request = build_request(uri, events)
|
|
46
|
+
|
|
47
|
+
http.request(request)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build_uri
|
|
51
|
+
server_url = EzLogsAgent.configuration.server_url
|
|
52
|
+
raise "server_url not configured" if server_url.nil? || server_url.empty?
|
|
53
|
+
|
|
54
|
+
URI.parse("#{server_url}/api/events")
|
|
55
|
+
rescue => error
|
|
56
|
+
raise "Invalid server_url: #{error.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_http_client(uri)
|
|
60
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
61
|
+
http.use_ssl = (uri.scheme == "https")
|
|
62
|
+
http.open_timeout = 5
|
|
63
|
+
http.read_timeout = 10
|
|
64
|
+
http
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_request(uri, events)
|
|
68
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
69
|
+
request["Content-Type"] = "application/json"
|
|
70
|
+
|
|
71
|
+
token = EzLogsAgent.configuration.project_token
|
|
72
|
+
request["Authorization"] = "Bearer #{token}" if token
|
|
73
|
+
|
|
74
|
+
request.body = JSON.generate({ events: events })
|
|
75
|
+
request
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def classify_response(response)
|
|
79
|
+
status = response.code.to_i
|
|
80
|
+
|
|
81
|
+
if status >= 200 && status < 300
|
|
82
|
+
Logger.debug("[Transport] send succeeded (#{status})")
|
|
83
|
+
:success
|
|
84
|
+
else
|
|
85
|
+
Logger.error("[Transport] send failed (#{status})")
|
|
86
|
+
:failure
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EzLogsAgent
|
|
4
|
+
# Detects whether an HTTP request originated from an AI agent client
|
|
5
|
+
# (Claude Desktop, ChatGPT, Cursor, Windsurf, …) by inspecting the
|
|
6
|
+
# User-Agent string.
|
|
7
|
+
#
|
|
8
|
+
# Behavior is intentionally narrow: returns vendor metadata only when
|
|
9
|
+
# the UA starts with one of the known LLM-client prefixes. Anything
|
|
10
|
+
# else returns nil — we never *infer* agency from weak signals.
|
|
11
|
+
#
|
|
12
|
+
# The Next.js agent ships an identical detector
|
|
13
|
+
# (`src/user-agent-detector.ts`) so both wire-format emitters classify
|
|
14
|
+
# the same UA the same way.
|
|
15
|
+
module UserAgentDetector
|
|
16
|
+
# Patterns are matched case-insensitively against the start of the
|
|
17
|
+
# User-Agent string. New vendors must be added in BOTH agents.
|
|
18
|
+
PATTERNS = [
|
|
19
|
+
{ prefix: "claude-", vendor: "claude", label: "Claude" },
|
|
20
|
+
{ prefix: "anthropic-", vendor: "claude", label: "Claude" },
|
|
21
|
+
{ prefix: "gpt-", vendor: "openai", label: "ChatGPT" },
|
|
22
|
+
{ prefix: "chatgpt-", vendor: "openai", label: "ChatGPT" },
|
|
23
|
+
{ prefix: "openai-", vendor: "openai", label: "ChatGPT" },
|
|
24
|
+
{ prefix: "cursor-", vendor: "cursor", label: "Cursor" },
|
|
25
|
+
{ prefix: "windsurf-", vendor: "windsurf", label: "Windsurf" }
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# Classify a User-Agent string.
|
|
29
|
+
#
|
|
30
|
+
# @param user_agent [String, nil] Raw User-Agent header value.
|
|
31
|
+
# @return [Hash, nil] `{ kind: "agent", vendor:, label:, suggested_id: }`
|
|
32
|
+
# when the UA matches a known LLM client; nil otherwise.
|
|
33
|
+
def self.classify(user_agent)
|
|
34
|
+
return nil if user_agent.nil? || user_agent.empty?
|
|
35
|
+
|
|
36
|
+
ua = user_agent.downcase
|
|
37
|
+
match = PATTERNS.find { |p| ua.start_with?(p[:prefix]) }
|
|
38
|
+
return nil unless match
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
kind: "agent",
|
|
42
|
+
vendor: match[:vendor],
|
|
43
|
+
label: match[:label],
|
|
44
|
+
suggested_id: "agent:#{match[:vendor]}"
|
|
45
|
+
}
|
|
46
|
+
rescue
|
|
47
|
+
# Defensive: classifier must never crash the middleware.
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ez_logs_agent/version"
|
|
4
|
+
require_relative "ez_logs_agent/configuration"
|
|
5
|
+
require_relative "ez_logs_agent/configuration_validator"
|
|
6
|
+
require_relative "ez_logs_agent/logger"
|
|
7
|
+
require_relative "ez_logs_agent/correlation"
|
|
8
|
+
require_relative "ez_logs_agent/actor_validator"
|
|
9
|
+
require_relative "ez_logs_agent/actor"
|
|
10
|
+
require_relative "ez_logs_agent/user_agent_detector"
|
|
11
|
+
require_relative "ez_logs_agent/sanitizer"
|
|
12
|
+
require_relative "ez_logs_agent/event_builder"
|
|
13
|
+
require_relative "ez_logs_agent/resource_extractor"
|
|
14
|
+
require_relative "ez_logs_agent/buffer"
|
|
15
|
+
require_relative "ez_logs_agent/transport"
|
|
16
|
+
require_relative "ez_logs_agent/retry_sender"
|
|
17
|
+
require_relative "ez_logs_agent/flush_scheduler"
|
|
18
|
+
require_relative "ez_logs_agent/middleware/http_request"
|
|
19
|
+
require_relative "ez_logs_agent/capturers/job_capturer"
|
|
20
|
+
require_relative "ez_logs_agent/capturers/active_job_capturer"
|
|
21
|
+
require_relative "ez_logs_agent/capturers/database_capturer"
|
|
22
|
+
|
|
23
|
+
# Load Railtie only when Rails is present
|
|
24
|
+
require_relative "ez_logs_agent/railtie" if defined?(Rails::Railtie)
|
|
25
|
+
|
|
26
|
+
module EzLogsAgent
|
|
27
|
+
class Error < StandardError; end
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
attr_writer :configuration
|
|
31
|
+
|
|
32
|
+
def configuration
|
|
33
|
+
@configuration ||= Configuration.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def configure
|
|
37
|
+
yield(configuration)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def reset_configuration!
|
|
41
|
+
@configuration = Configuration.new
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators/base"
|
|
4
|
+
|
|
5
|
+
module EzLogsAgent
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
desc "Creates an EzLogsAgent initializer with all configuration options"
|
|
11
|
+
|
|
12
|
+
def show_welcome_message
|
|
13
|
+
say "\n"
|
|
14
|
+
say "=" * 70, :green
|
|
15
|
+
say " EzLogs Agent Installation", :green
|
|
16
|
+
say "=" * 70, :green
|
|
17
|
+
say "\n"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def detect_environment
|
|
21
|
+
@sidekiq_detected = defined?(Sidekiq)
|
|
22
|
+
@activejob_detected = defined?(ActiveJob)
|
|
23
|
+
@activerecord_detected = defined?(ActiveRecord)
|
|
24
|
+
@devise_detected = defined?(Devise)
|
|
25
|
+
|
|
26
|
+
say "Detected Components:", :cyan
|
|
27
|
+
rails_version = defined?(Rails::VERSION::STRING) ? Rails::VERSION::STRING : "unknown"
|
|
28
|
+
say " • Rails #{rails_version}"
|
|
29
|
+
say " • Sidekiq #{Sidekiq::VERSION}", :green if @sidekiq_detected
|
|
30
|
+
say " • ActiveJob", :green if @activejob_detected && !@sidekiq_detected
|
|
31
|
+
say " • ActiveRecord", :green if @activerecord_detected
|
|
32
|
+
say " • Devise", :green if @devise_detected
|
|
33
|
+
say "\n"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def create_initializer_file
|
|
37
|
+
initializer_path = File.join(destination_root, "config/initializers/ez_logs_agent.rb")
|
|
38
|
+
|
|
39
|
+
if File.exist?(initializer_path)
|
|
40
|
+
say "⚠️ Initializer already exists at config/initializers/ez_logs_agent.rb", :yellow
|
|
41
|
+
say " Skipping creation to avoid overwriting your existing configuration.", :yellow
|
|
42
|
+
say "\n"
|
|
43
|
+
return
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
template "ez_logs_agent.rb.tt", "config/initializers/ez_logs_agent.rb"
|
|
47
|
+
say "\n"
|
|
48
|
+
say "✅ Created config/initializers/ez_logs_agent.rb", :green
|
|
49
|
+
say "\n"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def show_next_steps
|
|
53
|
+
say "=" * 70, :cyan
|
|
54
|
+
say " Next Steps", :cyan
|
|
55
|
+
say "=" * 70, :cyan
|
|
56
|
+
say "\n"
|
|
57
|
+
|
|
58
|
+
say "1. Get your API key from EzLogs:", :bold
|
|
59
|
+
say " → Log in to your EzLogs dashboard"
|
|
60
|
+
say " → Go to Settings → API Keys"
|
|
61
|
+
say " → Create a new API key for this application\n\n"
|
|
62
|
+
|
|
63
|
+
say "2. Configure the agent:", :bold
|
|
64
|
+
say " → Open config/initializers/ez_logs_agent.rb"
|
|
65
|
+
say " → Set your server_url (e.g., https://app.ezlogs.io)"
|
|
66
|
+
say " → Set your project_token (the API key from step 1)\n\n"
|
|
67
|
+
|
|
68
|
+
if @devise_detected
|
|
69
|
+
say "3. Enable actor tracking (optional but recommended):", :bold
|
|
70
|
+
say " → Uncomment the actor_from_request block"
|
|
71
|
+
say " → The Devise example is already configured for you\n\n"
|
|
72
|
+
else
|
|
73
|
+
say "3. Enable actor tracking (optional):", :bold
|
|
74
|
+
say " → Uncomment the actor_from_request block in the initializer"
|
|
75
|
+
say " → Customize it for your authentication system\n\n"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
say "4. Test the connection:", :bold
|
|
79
|
+
say " → Run: rails ez_logs_agent:test_connection"
|
|
80
|
+
say " → This verifies your configuration is correct\n\n"
|
|
81
|
+
|
|
82
|
+
say "5. Restart your Rails server:", :bold
|
|
83
|
+
say " → The agent will start capturing events automatically"
|
|
84
|
+
say " → Check your EzLogs dashboard to see activity\n\n"
|
|
85
|
+
|
|
86
|
+
say "=" * 70, :cyan
|
|
87
|
+
say "\n"
|
|
88
|
+
|
|
89
|
+
say "Need help? Check the README or visit https://docs.ezlogs.io", :cyan
|
|
90
|
+
say "\n"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# EzLogsAgent Configuration
|
|
4
|
+
#
|
|
5
|
+
# All options below show their default values.
|
|
6
|
+
# Uncomment and change only the options you need to customize.
|
|
7
|
+
|
|
8
|
+
EzLogsAgent.configure do |config|
|
|
9
|
+
# =============================================================================
|
|
10
|
+
# REQUIRED SETTINGS
|
|
11
|
+
# =============================================================================
|
|
12
|
+
|
|
13
|
+
# URL of the EzLogs server (required)
|
|
14
|
+
# Default: nil (must be set for events to be sent)
|
|
15
|
+
# config.server_url = "https://your-ezlogs-server.com"
|
|
16
|
+
|
|
17
|
+
# API key for authentication (required)
|
|
18
|
+
# Get this from your EzLogs dashboard: Settings → API Keys
|
|
19
|
+
# Default: nil
|
|
20
|
+
# config.project_token = "your-api-key-here"
|
|
21
|
+
|
|
22
|
+
# =============================================================================
|
|
23
|
+
# CAPTURE SETTINGS
|
|
24
|
+
# =============================================================================
|
|
25
|
+
|
|
26
|
+
# Capture HTTP requests
|
|
27
|
+
# Default: true
|
|
28
|
+
# config.capture_http = true
|
|
29
|
+
|
|
30
|
+
# Capture background jobs (Sidekiq/ActiveJob)
|
|
31
|
+
# Default: true
|
|
32
|
+
# config.capture_jobs = true
|
|
33
|
+
|
|
34
|
+
# Capture database changes via model callbacks
|
|
35
|
+
# Default: true
|
|
36
|
+
# config.capture_database = true
|
|
37
|
+
|
|
38
|
+
# =============================================================================
|
|
39
|
+
# EXCLUSIONS
|
|
40
|
+
# =============================================================================
|
|
41
|
+
|
|
42
|
+
# Additional paths to exclude from HTTP capture (added to built-in defaults)
|
|
43
|
+
# Built-in: /rails/active_storage*, /assets*, /packs*, /vite*, /health*, /up, /favicon.ico
|
|
44
|
+
# config.excluded_paths = ["/admin*", "/internal*"]
|
|
45
|
+
|
|
46
|
+
# Additional tables to exclude from database capture (added to built-in defaults)
|
|
47
|
+
# Built-in: schema_migrations, ar_internal_metadata, sessions, session, active_storage_*, solid_queue_*, etc.
|
|
48
|
+
# config.excluded_tables = ["audit_logs", "versions"]
|
|
49
|
+
|
|
50
|
+
# Additional job classes to exclude from background job capture (added to built-in defaults)
|
|
51
|
+
# Built-in: SidekiqAlive::Worker, SolidQueue::CleanupJob, SolidQueue::RecurringJob
|
|
52
|
+
# config.excluded_job_classes = ["MyApp::HealthCheckJob", "MyApp::MetricsJob"]
|
|
53
|
+
|
|
54
|
+
# Additional GraphQL operations to exclude from capture (added to built-in defaults)
|
|
55
|
+
# Built-in: IntrospectionQuery, __* (all introspection fields like __schema, __type)
|
|
56
|
+
# Supports exact match and prefix match (patterns ending with *)
|
|
57
|
+
# config.excluded_graphql_operations = ["GetCurrentUser", "Internal*"]
|
|
58
|
+
|
|
59
|
+
# =============================================================================
|
|
60
|
+
# PERFORMANCE & RELIABILITY
|
|
61
|
+
# =============================================================================
|
|
62
|
+
|
|
63
|
+
# Max events in memory before oldest are dropped
|
|
64
|
+
# Default: 10000 (optimized for high-volume workloads)
|
|
65
|
+
# config.buffer_size = 10000
|
|
66
|
+
|
|
67
|
+
# Retry attempts for failed sends
|
|
68
|
+
# Default: 3
|
|
69
|
+
# config.retry_attempts = 3
|
|
70
|
+
|
|
71
|
+
# Seconds between automatic flushes
|
|
72
|
+
# Default: 3 (more frequent sends for better throughput)
|
|
73
|
+
# config.send_interval = 3
|
|
74
|
+
|
|
75
|
+
# =============================================================================
|
|
76
|
+
# LOGGING
|
|
77
|
+
# =============================================================================
|
|
78
|
+
|
|
79
|
+
# Agent log level (:debug, :info, :warn, :error)
|
|
80
|
+
# Default: :warn
|
|
81
|
+
# Set to :error in production to minimize output
|
|
82
|
+
# config.log_level = :warn
|
|
83
|
+
|
|
84
|
+
# =============================================================================
|
|
85
|
+
# DISPLAY NAMES (What Resource Was Affected?)
|
|
86
|
+
# =============================================================================
|
|
87
|
+
|
|
88
|
+
# Configure display name resolution per model
|
|
89
|
+
# When a database callback fires, the agent resolves a human-readable name
|
|
90
|
+
# for the affected record using the configured field.
|
|
91
|
+
#
|
|
92
|
+
# Fallback chain: configured field → name → title → number → nil
|
|
93
|
+
#
|
|
94
|
+
# Example:
|
|
95
|
+
# config.display_name_for = {
|
|
96
|
+
# "User" => :email, # Use email field for User models
|
|
97
|
+
# "Product" => :name, # Use name field for Product models
|
|
98
|
+
# "Order" => :number # Use number field for Order models
|
|
99
|
+
# }
|
|
100
|
+
#
|
|
101
|
+
# This enables action titles like:
|
|
102
|
+
# - "User updated 'john@example.com'" instead of just "User updated"
|
|
103
|
+
# - "Product created 'Premium Widget'" instead of just "Product created"
|
|
104
|
+
#
|
|
105
|
+
# IMPORTANT: Only use direct attributes, not associations (would trigger DB queries)
|
|
106
|
+
|
|
107
|
+
# =============================================================================
|
|
108
|
+
# ACTOR CONTEXT (Who Triggered This?)
|
|
109
|
+
# =============================================================================
|
|
110
|
+
|
|
111
|
+
# Optional: Extract actor (user identity) from HTTP requests
|
|
112
|
+
# This enables "who triggered this action?" tracking in EzLogs
|
|
113
|
+
#
|
|
114
|
+
# The hook receives (env, controller) and should return:
|
|
115
|
+
# { id: String, label: String } or nil if actor cannot be determined
|
|
116
|
+
#
|
|
117
|
+
# Example for Devise:
|
|
118
|
+
# config.actor_from_request = ->(env, controller) {
|
|
119
|
+
# return nil unless controller.respond_to?(:current_user)
|
|
120
|
+
# user = controller.current_user
|
|
121
|
+
# return nil unless user
|
|
122
|
+
#
|
|
123
|
+
# {
|
|
124
|
+
# id: user.id.to_s,
|
|
125
|
+
# label: user.email
|
|
126
|
+
# }
|
|
127
|
+
# }
|
|
128
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :ez_logs_agent do
|
|
4
|
+
desc "Test connection to EzLogs server"
|
|
5
|
+
task test_connection: :environment do
|
|
6
|
+
require "ez_logs_agent"
|
|
7
|
+
|
|
8
|
+
puts "\n🔍 EzLogs Agent Connection Test\n\n"
|
|
9
|
+
|
|
10
|
+
# Step 1: Validate configuration
|
|
11
|
+
puts "Step 1: Validating configuration..."
|
|
12
|
+
result = EzLogsAgent::ConfigurationValidator.validate(EzLogsAgent.configuration)
|
|
13
|
+
|
|
14
|
+
if result.errors.any?
|
|
15
|
+
puts "❌ Configuration validation failed:\n"
|
|
16
|
+
result.errors.each do |error|
|
|
17
|
+
puts " - #{error}"
|
|
18
|
+
end
|
|
19
|
+
puts "\n"
|
|
20
|
+
exit 1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if result.warnings.any?
|
|
24
|
+
puts "⚠️ Configuration warnings:\n"
|
|
25
|
+
result.warnings.each do |warning|
|
|
26
|
+
puts " - #{warning}"
|
|
27
|
+
end
|
|
28
|
+
puts "\n"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
puts "✅ Configuration is valid\n\n"
|
|
32
|
+
|
|
33
|
+
# Step 2: Show configuration summary
|
|
34
|
+
config = EzLogsAgent.configuration
|
|
35
|
+
puts "Configuration Summary:"
|
|
36
|
+
puts " Server URL: #{config.server_url}"
|
|
37
|
+
puts " Project Token: #{config.project_token ? '***' + config.project_token[-4..-1] : '(not set)'}"
|
|
38
|
+
puts " Capture HTTP: #{config.capture_http}"
|
|
39
|
+
puts " Capture Jobs: #{config.capture_jobs}"
|
|
40
|
+
puts " Capture Database: #{config.capture_database}"
|
|
41
|
+
puts "\n"
|
|
42
|
+
|
|
43
|
+
# Step 2: Test connection
|
|
44
|
+
puts "Step 2: Testing connection to server..."
|
|
45
|
+
|
|
46
|
+
begin
|
|
47
|
+
uri = URI.parse(config.server_url)
|
|
48
|
+
uri.path = "/api/events" if uri.path.empty?
|
|
49
|
+
|
|
50
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
51
|
+
http.use_ssl = uri.scheme == "https"
|
|
52
|
+
http.open_timeout = 5
|
|
53
|
+
http.read_timeout = 10
|
|
54
|
+
|
|
55
|
+
# Send a test event
|
|
56
|
+
test_event = {
|
|
57
|
+
kind: "test",
|
|
58
|
+
correlation_id: "test_connection_#{Time.now.to_i}",
|
|
59
|
+
occurred_at: Time.now.utc.iso8601(3),
|
|
60
|
+
context: {
|
|
61
|
+
message: "EzLogs Agent connection test",
|
|
62
|
+
source: "rake ez_logs_agent:test_connection"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
67
|
+
request["Content-Type"] = "application/json"
|
|
68
|
+
request["Authorization"] = "Bearer #{config.project_token}" if config.project_token
|
|
69
|
+
request.body = { events: [test_event] }.to_json
|
|
70
|
+
|
|
71
|
+
response = http.request(request)
|
|
72
|
+
|
|
73
|
+
case response.code.to_i
|
|
74
|
+
when 200..299
|
|
75
|
+
puts "✅ Connection successful (HTTP #{response.code})"
|
|
76
|
+
puts " Test event accepted by server\n\n"
|
|
77
|
+
puts "✅ All checks passed! EzLogs Agent is configured correctly.\n\n"
|
|
78
|
+
exit 0
|
|
79
|
+
when 401
|
|
80
|
+
puts "❌ Authentication failed (HTTP 401)"
|
|
81
|
+
puts " Check your project_token in config/initializers/ez_logs_agent.rb\n\n"
|
|
82
|
+
exit 1
|
|
83
|
+
when 404
|
|
84
|
+
puts "❌ Server endpoint not found (HTTP 404)"
|
|
85
|
+
puts " Check your server_url: #{config.server_url}\n\n"
|
|
86
|
+
exit 1
|
|
87
|
+
else
|
|
88
|
+
puts "❌ Server responded with HTTP #{response.code}"
|
|
89
|
+
puts " Response: #{response.body[0..200]}\n\n"
|
|
90
|
+
exit 1
|
|
91
|
+
end
|
|
92
|
+
rescue SocketError => e
|
|
93
|
+
puts "❌ Could not resolve host: #{e.message}"
|
|
94
|
+
puts " Check your server_url: #{config.server_url}\n\n"
|
|
95
|
+
exit 1
|
|
96
|
+
rescue Errno::ECONNREFUSED => e
|
|
97
|
+
puts "❌ Connection refused: #{e.message}"
|
|
98
|
+
puts " Is the EzLogs server running at #{config.server_url}?\n\n"
|
|
99
|
+
exit 1
|
|
100
|
+
rescue Timeout::Error => e
|
|
101
|
+
puts "❌ Connection timeout: #{e.message}"
|
|
102
|
+
puts " The server is not responding. Check firewall rules.\n\n"
|
|
103
|
+
exit 1
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
puts "❌ Connection failed: #{e.class} - #{e.message}"
|
|
106
|
+
puts " #{e.backtrace.first}\n\n"
|
|
107
|
+
exit 1
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|