railswatch_gem 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,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module RailswatchGem
6
+ module Instrumentation
7
+ class RequestsInstrumenter
8
+ def initialize(client, config)
9
+ @client = client
10
+ @config = config
11
+ end
12
+
13
+ def start
14
+ # Subscribe to the standard Rails controller event.
15
+ # This event includes details like status, path, duration, and DB runtime.
16
+ ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
17
+ event = ActiveSupport::Notifications::Event.new(*args)
18
+ process_event(event)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def process_event(event)
25
+ payload = event.payload
26
+
27
+ # Retrieve request ID for correlation
28
+ request_id = payload[:request_id] || Thread.current[:railswatch_request_id]
29
+
30
+ # Filter parameters to prevent PII leakage
31
+ raw_params = payload[:params] || {}
32
+ safe_params = filter_parameters(raw_params)
33
+
34
+ # Extract extra context (Headers, Session, User)
35
+ headers_obj = payload[:headers]
36
+
37
+ safe_headers = extract_headers(headers_obj)
38
+ safe_session = extract_session(headers_obj)
39
+ user_info = extract_user(headers_obj)
40
+
41
+ data = {
42
+ event_type: "request",
43
+ # Fix: Ensure timestamp is a Time object before calling utc
44
+ timestamp: Time.at(event.end).utc.iso8601,
45
+ request_id: request_id,
46
+
47
+ # HTTP Context
48
+ method: payload[:method],
49
+ path: payload[:path],
50
+ status: payload[:status] || 500,
51
+ format: payload[:format].to_s,
52
+
53
+ # Inputs & State
54
+ params: safe_params,
55
+ headers: safe_headers,
56
+ session: safe_session,
57
+ user: user_info,
58
+
59
+ # Rails Context
60
+ controller: payload[:controller],
61
+ action: payload[:action],
62
+
63
+ # Performance Metrics
64
+ duration_ms: event.duration.round(2),
65
+ view_runtime_ms: payload[:view_runtime]&.round(2),
66
+ db_runtime_ms: payload[:db_runtime]&.round(2),
67
+ allocations: event.allocations
68
+ }
69
+
70
+ # Capture Exception Details if the request failed
71
+ if payload[:exception]
72
+ data[:error_class] = payload[:exception].first.to_s
73
+ data[:error_message] = payload[:exception].last.to_s
74
+ end
75
+
76
+ @client.record(data)
77
+ rescue => e
78
+ warn "RailswatchGem: Failed to process request event: #{e.message}"
79
+ end
80
+
81
+ # --- Extraction Helpers ---
82
+
83
+ def extract_headers(headers)
84
+ return {} unless headers.respond_to?(:env)
85
+
86
+ interesting_headers = %w[
87
+ CONTENT_TYPE CONTENT_LENGTH
88
+ HTTP_AUTHORIZATION HTTP_USER_AGENT HTTP_REFERER HTTP_ACCEPT
89
+ HTTP_X_REQUEST_ID HTTP_X_FORWARDED_FOR
90
+ ]
91
+
92
+ headers.env.select { |k, _| interesting_headers.include?(k) }.transform_keys do |k|
93
+ k.sub(/^HTTP_/, "").split("_").map(&:capitalize).join("-")
94
+ end
95
+ end
96
+
97
+ def extract_session(headers)
98
+ return {} unless headers.respond_to?(:env)
99
+
100
+ raw_session = headers.env["rack.session"]
101
+ return {} unless raw_session
102
+
103
+ session_hash = raw_session.to_hash
104
+ filter_parameters(session_hash)
105
+ rescue
106
+ { error: "Could not serialize session" }
107
+ end
108
+
109
+ def extract_user(headers)
110
+ return nil unless headers.respond_to?(:env)
111
+
112
+ warden = headers.env["warden"]
113
+ if warden && warden.user
114
+ user = warden.user
115
+ {
116
+ id: user.respond_to?(:id) ? user.id : nil,
117
+ email: user.respond_to?(:email) ? user.email : nil,
118
+ class: user.class.name
119
+ }
120
+ else
121
+ nil
122
+ end
123
+ rescue
124
+ nil
125
+ end
126
+
127
+ def filter_parameters(params)
128
+ if defined?(::ActiveSupport::ParameterFilter) && defined?(::Rails.application)
129
+ filter = ::ActiveSupport::ParameterFilter.new(::Rails.application.config.filter_parameters)
130
+ return filter.filter(params)
131
+ end
132
+
133
+ if defined?(::ActionDispatch::Http::ParameterFilter) && defined?(::Rails.application)
134
+ filter = ::ActionDispatch::Http::ParameterFilter.new(::Rails.application.config.filter_parameters)
135
+ return filter.filter(params)
136
+ end
137
+
138
+ sensitive = %w[password password_confirmation token secret credit_card]
139
+ params.select { |k, _v| !sensitive.include?(k.to_s) }
140
+ rescue
141
+ { error: "Could not filter parameters" }
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module RailswatchGem
6
+ module Instrumentation
7
+ class ScheduledTasksInstrumenter
8
+ def initialize(client, config)
9
+ @client = client
10
+ @config = config
11
+ end
12
+
13
+ def start
14
+ # Only instrument Rake if it is loaded.
15
+ # This prevents errors if the gem is used in a context where Rake isn't present.
16
+ if defined?(::Rake::Task)
17
+ patch_rake_tasks
18
+
19
+ ActiveSupport::Notifications.subscribe("task.rake") do |*args|
20
+ event = ActiveSupport::Notifications::Event.new(*args)
21
+ process_event(event)
22
+ end
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def patch_rake_tasks
29
+ # Ensure we only patch once
30
+ return if ::Rake::Task.include?(RakeTaskPatch)
31
+
32
+ ::Rake::Task.prepend(RakeTaskPatch)
33
+ end
34
+
35
+ def process_event(event)
36
+ payload = event.payload
37
+ task_name = payload[:name]
38
+
39
+ # Optional: Ignore internal Railswatch tasks to prevent noise
40
+ return if task_name.to_s.start_with?("railswatch:")
41
+
42
+ data = {
43
+ event_type: "scheduled_task",
44
+ # FIX: Handle Float timestamps safely
45
+ timestamp: Time.at(event.end).utc.iso8601,
46
+
47
+ # Task Identity
48
+ name: task_name,
49
+ args: payload[:args].to_a, # Rake args come as a wrapper, convert to array
50
+
51
+ # Performance
52
+ duration_ms: event.duration.round(2)
53
+ }
54
+
55
+ # Error handling
56
+ if payload[:exception]
57
+ data[:error_class] = payload[:exception].first.to_s
58
+ data[:error_message] = payload[:exception].last.to_s
59
+ data[:status] = "failed"
60
+ else
61
+ data[:status] = "success"
62
+ end
63
+
64
+ @client.record(data)
65
+ rescue => e
66
+ warn "RailswatchGem: Failed to process scheduled task event: #{e.message}"
67
+ end
68
+
69
+ # ----------------------------------------------------------------
70
+ # Internal Patch for Rake::Task
71
+ # ----------------------------------------------------------------
72
+ module RakeTaskPatch
73
+ def execute(args = nil)
74
+ # We explicitly capture 'name' here because it's available on the task instance
75
+ ActiveSupport::Notifications.instrument("task.rake", { name: name, args: args }) do
76
+ super
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module RailswatchGem
6
+ module Middleware
7
+ class RequestContext
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ # 1. Try ActionDispatch's ID (standard Rails)
14
+ # 2. Try HTTP header (load balancers)
15
+ # 3. Fallback to random UUID
16
+ req_id = env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"] || SecureRandom.uuid
17
+
18
+ # Store in thread local so LogsInstrumenter can read it deeper in the stack
19
+ Thread.current[:railswatch_request_id] = req_id
20
+
21
+ @app.call(env)
22
+ ensure
23
+ # Clean up to prevent leakage between requests in threaded web servers (Puma/Unicorn)
24
+ Thread.current[:railswatch_request_id] = nil
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+ require_relative "middleware/request_context"
5
+
6
+ module RailswatchGem
7
+ class Railtie < ::Rails::Railtie
8
+ # Insert our middleware at the very top (index 0) to ensure we catch everything,
9
+ # including potential errors in other middlewares.
10
+ initializer "railswatch.middleware" do |app|
11
+ app.middleware.insert_before 0, RailswatchGem::Middleware::RequestContext
12
+ end
13
+
14
+ # Automatically boot the instrumentation when Rails finishes initializing.
15
+ config.after_initialize do
16
+ # Only boot if the user hasn't disabled auto_boot in their config
17
+ if RailswatchGem.configuration.auto_boot
18
+ RailswatchGem.boot!
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailswatchGem
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "railswatch_gem/version"
4
+ require_relative "railswatch_gem/configuration"
5
+ require_relative "railswatch_gem/client"
6
+ require_relative "railswatch_gem/instrumentation/registry"
7
+ require_relative "railswatch_gem/instrumentation/requests_instrumenter"
8
+ require_relative "railswatch_gem/instrumentation/queries_instrumenter"
9
+ require_relative "railswatch_gem/instrumentation/jobs_instrumenter"
10
+ require_relative "railswatch_gem/instrumentation/cache_instrumenter"
11
+ require_relative "railswatch_gem/instrumentation/mail_instrumenter"
12
+ require_relative "railswatch_gem/instrumentation/outgoing_requests_instrumenter"
13
+ require_relative "railswatch_gem/instrumentation/scheduled_tasks_instrumenter"
14
+ require_relative "railswatch_gem/instrumentation/commands_instrumenter"
15
+ require_relative "railswatch_gem/instrumentation/notifications_instrumenter"
16
+ require_relative "railswatch_gem/instrumentation/logs_instrumenter"
17
+ require_relative "railswatch_gem/instrumentation/errors_instrumenter"
18
+ require_relative "railswatch_gem/instrumentation/models_instrumenter"
19
+ require_relative "railswatch_gem/middleware/request_context"
20
+ require_relative "railswatch_gem/helpers"
21
+
22
+ # Load the Railtie if we are inside a Rails application.
23
+ # We wrap this in a rescue block because sometimes 'Rails' is defined
24
+ # (e.g. in test mocks or specific environments) but the 'rails/railtie' file
25
+ # is not actually available in the load path.
26
+ if defined?(Rails)
27
+ begin
28
+ require_relative "railswatch_gem/railtie"
29
+ rescue LoadError
30
+ # Rails constant was defined, but we couldn't load rails/railtie.
31
+ # Proceeding without Railtie integration.
32
+ end
33
+ end
34
+
35
+ module RailswatchGem
36
+ class << self
37
+ def configure
38
+ yield(configuration) if block_given?
39
+
40
+ # Automatically boot if configured, or allow manual booting
41
+ # Ideally, you might want to call boot! explicitly in a Railtie
42
+ boot! if configuration.auto_boot && !defined?(Rails) # If Rails, Railtie handles it
43
+ end
44
+
45
+ def configuration
46
+ @configuration ||= Configuration.new
47
+ end
48
+
49
+ def client
50
+ @client ||= Client.new(
51
+ ingest_url: configuration.ingest_url,
52
+ env_token: configuration.env_token,
53
+ batch_size: configuration.batch_size,
54
+ flush_interval: configuration.flush_interval
55
+ )
56
+ end
57
+
58
+ def instrumentation_registry
59
+ @instrumentation_registry ||= Instrumentation::Registry.new
60
+ end
61
+
62
+ # Safe to call multiple times
63
+ def boot!
64
+ return if @booted
65
+
66
+ instrumentation_registry.boot!(client, configuration)
67
+ @booted = true
68
+ end
69
+
70
+ def record(event)
71
+ # Ensure client is initialized
72
+ client.record(event)
73
+ end
74
+ end
75
+ end
data/manual_test.rb ADDED
@@ -0,0 +1,103 @@
1
+ require "socket"
2
+ require "json"
3
+ require "thread"
4
+ require "logger"
5
+
6
+ # Since we are running this script standalone (outside of Rails), we need to
7
+ # manually load ActiveSupport parts that the gem expects to be present.
8
+ # Ideally, your gem files should require what they need, but in a Rails gem
9
+ # it is common to assume these are available.
10
+ require "active_support"
11
+ require "active_support/concern"
12
+ require "active_support/core_ext"
13
+ require "active_support/notifications"
14
+
15
+ # --- MOCK RAILS ENVIRONMENT ---
16
+ # The LogsInstrumenter and ErrorsInstrumenter expect Rails to be present
17
+ # and configured. We mock the minimal requirements here.
18
+ unless defined?(Rails)
19
+ module Rails; end
20
+ end
21
+
22
+ # Add logger if missing (ActiveSupport defines the Module, but not the method)
23
+ unless Rails.respond_to?(:logger)
24
+ def Rails.logger
25
+ @logger ||= Logger.new($stdout)
26
+ end
27
+ end
28
+
29
+ # Add error reporter mock for ErrorsInstrumenter
30
+ unless Rails.respond_to?(:error)
31
+ def Rails.error
32
+ @error_mock ||= Class.new do
33
+ def subscribe(subscriber); end
34
+ end.new
35
+ end
36
+ end
37
+
38
+ # Add application config mock for RequestsInstrumenter (parameter filtering)
39
+ unless Rails.respond_to?(:application)
40
+ def Rails.application
41
+ Struct.new(:config).new(
42
+ Struct.new(:filter_parameters).new([])
43
+ )
44
+ end
45
+ end
46
+ # -----------------------------
47
+
48
+ # 1. Load your gem files (adjust paths if necessary)
49
+ require_relative "lib/railswatch_gem"
50
+
51
+ # 2. Spin up a tiny TCP server to act as the "Ingestion API"
52
+ # This lets us see exactly what the gem sends over the network.
53
+ server = TCPServer.new(0) # 0 assigns a random available port
54
+ port = server.addr[1]
55
+ puts "--- 📡 Mock Ingestion Server started on port #{port} ---"
56
+
57
+ server_thread = Thread.new do
58
+ loop do
59
+ client = server.accept
60
+
61
+ # Read the HTTP headers and body
62
+ request_lines = []
63
+ while (line = client.gets) && line !~ /^\s*$/
64
+ request_lines << line
65
+ end
66
+
67
+ # Parse Content-Length to read the body
68
+ content_length = request_lines.find { |l| l =~ /^Content-Length:/ }&.split(":")&.last&.to_i || 0
69
+ body = client.read(content_length)
70
+
71
+ # Acknowledge receipt to the gem (202 Accepted)
72
+ client.print "HTTP/1.1 202 Accepted\r\n"
73
+ client.print "Content-Type: application/json\r\n"
74
+ client.print "\r\n"
75
+ client.print '{"status":"ok"}'
76
+ client.close
77
+
78
+ puts "\n--- 📦 Payload Received ---"
79
+ puts JSON.pretty_generate(JSON.parse(body))
80
+ puts "---------------------------\n"
81
+ end
82
+ end
83
+
84
+ # 3. Configure the Gem to point to our mock server
85
+ RailswatchGem.configure do |config|
86
+ config.ingest_url = "http://localhost:#{port}/ingest"
87
+ config.env_token = "test-token-123"
88
+ config.batch_size = 1 # Flush immediately for testing
89
+ config.flush_interval = 0.1 # Check queue rapidly
90
+ config.auto_boot = true # Ensure systems are go
91
+ end
92
+
93
+ # Manually boot since we aren't in Rails
94
+ RailswatchGem.boot!
95
+
96
+ # 4. Trigger an event using your new helper
97
+ puts "firing event..."
98
+ RailswatchGem.rw("Hello, World!", { user_id: 42, role: "admin" })
99
+
100
+ # 5. Wait a moment for the thread to flush
101
+ sleep 2
102
+
103
+ puts "Done."
@@ -0,0 +1,4 @@
1
+ module RailswatchGem
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: railswatch_gem
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tyler Hammett
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2025-12-08 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Railswatch Gem
14
+ email:
15
+ - thammett@railswatch.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE.txt
22
+ - README.md
23
+ - Rakefile
24
+ - lib/railswatch_gem.rb
25
+ - lib/railswatch_gem/client.rb
26
+ - lib/railswatch_gem/configuration.rb
27
+ - lib/railswatch_gem/helpers.rb
28
+ - lib/railswatch_gem/instrumentation/cache_instrumenter.rb
29
+ - lib/railswatch_gem/instrumentation/commands_instrumenter.rb
30
+ - lib/railswatch_gem/instrumentation/errors_instrumenter.rb
31
+ - lib/railswatch_gem/instrumentation/jobs_instrumenter.rb
32
+ - lib/railswatch_gem/instrumentation/logs_instrumenter.rb
33
+ - lib/railswatch_gem/instrumentation/mail_instrumenter.rb
34
+ - lib/railswatch_gem/instrumentation/models_instrumenter.rb
35
+ - lib/railswatch_gem/instrumentation/notifications_instrumenter.rb
36
+ - lib/railswatch_gem/instrumentation/outgoing_requests_instrumenter.rb
37
+ - lib/railswatch_gem/instrumentation/queries_instrumenter.rb
38
+ - lib/railswatch_gem/instrumentation/registry.rb
39
+ - lib/railswatch_gem/instrumentation/requests_instrumenter.rb
40
+ - lib/railswatch_gem/instrumentation/scheduled_tasks_instrumenter.rb
41
+ - lib/railswatch_gem/middleware/request_context.rb
42
+ - lib/railswatch_gem/railtie.rb
43
+ - lib/railswatch_gem/version.rb
44
+ - manual_test.rb
45
+ - sig/railswatch_gem.rbs
46
+ homepage: https://github.com/railswatch/railswatch_gem
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ homepage_uri: https://github.com/railswatch/railswatch_gem
51
+ source_code_uri: https://github.com/railswatch/railswatch_gem
52
+ changelog_uri: https://github.com/railswatch/railswatch_gem/blob/main/CHANGELOG.md
53
+ post_install_message:
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 3.2.0
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 3.4.19
69
+ signing_key:
70
+ specification_version: 4
71
+ summary: Railswatch Gem
72
+ test_files: []