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,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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EzLogsAgent
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,42 @@
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/event_builder"
11
+ require_relative "ez_logs_agent/resource_extractor"
12
+ require_relative "ez_logs_agent/buffer"
13
+ require_relative "ez_logs_agent/transport"
14
+ require_relative "ez_logs_agent/retry_sender"
15
+ require_relative "ez_logs_agent/flush_scheduler"
16
+ require_relative "ez_logs_agent/middleware/http_request"
17
+ require_relative "ez_logs_agent/capturers/job_capturer"
18
+ require_relative "ez_logs_agent/capturers/active_job_capturer"
19
+ require_relative "ez_logs_agent/capturers/database_capturer"
20
+
21
+ # Load Railtie only when Rails is present
22
+ require_relative "ez_logs_agent/railtie" if defined?(Rails::Railtie)
23
+
24
+ module EzLogsAgent
25
+ class Error < StandardError; end
26
+
27
+ class << self
28
+ attr_writer :configuration
29
+
30
+ def configuration
31
+ @configuration ||= Configuration.new
32
+ end
33
+
34
+ def configure
35
+ yield(configuration)
36
+ end
37
+
38
+ def reset_configuration!
39
+ @configuration = Configuration.new
40
+ end
41
+ end
42
+ 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
14
+ # Default: "https://app.ezlogs.io" (the hosted EZLogs service)
15
+ # config.server_url = "https://app.ezlogs.io"
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
@@ -0,0 +1,113 @@
1
+ #!/bin/bash
2
+ #
3
+ # publish-to-public.sh — sync the Ruby agent from the private monorepo into
4
+ # the public `dezsirazvan/ez_logs_agent` repo, so RubyGems metadata links,
5
+ # GitHub badges, and external contributors all resolve.
6
+ #
7
+ # Architecture:
8
+ # - This repo (ez_logs) is PRIVATE and holds the source of truth for
9
+ # server code, demo apps, and the agent itself.
10
+ # - A separate PUBLIC repo (ez_logs_agent) holds ONLY the agent so that
11
+ # RubyGems homepage/source/changelog URLs resolve and contributors
12
+ # can open issues + PRs.
13
+ # - This script is the seam: rsync this dir into a temp clone of the
14
+ # public repo, commit, push.
15
+ #
16
+ # Usage:
17
+ # ./script/publish-to-public.sh # sync + push
18
+ # ./script/publish-to-public.sh --dry-run # sync only, skip push
19
+ #
20
+ # Configuration:
21
+ # PUBLIC_REPO — git URL. Default: https://github.com/dezsirazvan/ez_logs_agent.git
22
+ # PUBLIC_BRANCH — Default: master
23
+ #
24
+ # What gets synced:
25
+ # Everything in ez_logs_agent/ EXCEPT: tmp/, vendor/, .bundle/, *.gem,
26
+ # coverage/, .rspec_status, log/, this script's clone dir.
27
+ # (Tests, sig/, CHANGELOG.md, LICENSE.txt, all source — all included.)
28
+
29
+ set -euo pipefail
30
+
31
+ SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
32
+ AGENT_DIR="$( cd "$SCRIPT_DIR/.." && pwd )"
33
+ REPO_ROOT="$( cd "$AGENT_DIR/.." && pwd )"
34
+
35
+ PUBLIC_REPO="${PUBLIC_REPO:-https://github.com/dezsirazvan/ez_logs_agent.git}"
36
+ PUBLIC_BRANCH="${PUBLIC_BRANCH:-master}"
37
+ CLONE_DIR="$REPO_ROOT/.ez-logs-agent-publish"
38
+
39
+ DRY_RUN=false
40
+ for arg in "$@"; do
41
+ case "$arg" in
42
+ --dry-run) DRY_RUN=true ;;
43
+ -h|--help)
44
+ sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'
45
+ exit 0
46
+ ;;
47
+ esac
48
+ done
49
+
50
+ echo "==> Source: $AGENT_DIR"
51
+ echo "==> Target: $PUBLIC_REPO ($PUBLIC_BRANCH)"
52
+ echo "==> Clone: $CLONE_DIR"
53
+ $DRY_RUN && echo "==> Mode: DRY RUN (no push)"
54
+
55
+ # ---------- Clone (or refresh) the public repo --------------------------
56
+ if [ -d "$CLONE_DIR/.git" ]; then
57
+ echo "==> Refreshing existing clone..."
58
+ git -C "$CLONE_DIR" fetch origin
59
+ git -C "$CLONE_DIR" reset --hard "origin/$PUBLIC_BRANCH" 2>/dev/null || true
60
+ else
61
+ echo "==> Cloning public repo..."
62
+ rm -rf "$CLONE_DIR"
63
+ git clone "$PUBLIC_REPO" "$CLONE_DIR"
64
+ fi
65
+
66
+ # Initial-commit case: empty repo with no master branch yet.
67
+ if ! git -C "$CLONE_DIR" rev-parse --verify "$PUBLIC_BRANCH" >/dev/null 2>&1; then
68
+ echo "==> Public repo is empty; will create $PUBLIC_BRANCH on first push."
69
+ git -C "$CLONE_DIR" checkout -b "$PUBLIC_BRANCH"
70
+ fi
71
+
72
+ # ---------- Wipe the clone (except .git) and sync source ----------------
73
+ echo "==> Syncing source into clone..."
74
+ find "$CLONE_DIR" -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
75
+
76
+ rsync -a \
77
+ --exclude='/tmp/' \
78
+ --exclude='/vendor/' \
79
+ --exclude='/.bundle/' \
80
+ --exclude='/coverage/' \
81
+ --exclude='/log/' \
82
+ --exclude='/script/publish-to-public.sh' \
83
+ --exclude='.rspec_status' \
84
+ --exclude='*.gem' \
85
+ --exclude='.DS_Store' \
86
+ "$AGENT_DIR/" "$CLONE_DIR/"
87
+
88
+ # ---------- Commit + push ----------------------------------------------
89
+ cd "$CLONE_DIR"
90
+ git add -A
91
+ if git diff --cached --quiet; then
92
+ echo "==> Working tree matches origin — nothing new to commit."
93
+ else
94
+ COMMIT_MSG="Sync from ez_logs monorepo @ $(git -C "$AGENT_DIR" rev-parse --short HEAD)"
95
+ git commit -m "$COMMIT_MSG"
96
+ fi
97
+
98
+ # Are we ahead of origin? (covers commit-now, push-later case across runs.)
99
+ LOCAL_HEAD="$(git rev-parse HEAD)"
100
+ REMOTE_HEAD="$(git rev-parse "origin/$PUBLIC_BRANCH" 2>/dev/null || echo "")"
101
+ if [ "$LOCAL_HEAD" = "$REMOTE_HEAD" ]; then
102
+ echo "==> Already in sync with origin/$PUBLIC_BRANCH. Nothing to push."
103
+ exit 0
104
+ fi
105
+
106
+ if $DRY_RUN; then
107
+ echo "==> Dry run — skipping push. Diff staged in $CLONE_DIR."
108
+ exit 0
109
+ fi
110
+
111
+ echo "==> Pushing to $PUBLIC_REPO..."
112
+ git push -u origin "$PUBLIC_BRANCH"
113
+ echo "==> Done. https://github.com/dezsirazvan/ez_logs_agent"
@@ -0,0 +1,4 @@
1
+ module EzLogsAgent
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end