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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cc820b02199b2d925bc18397d3cb039a3e9e332cd8e7ea668863144fc3a23452
4
+ data.tar.gz: a24e3915898b0365269ba6ef2291740b4625e34e4bd3f427e9c594e0dec089e2
5
+ SHA512:
6
+ metadata.gz: bb6b2f5080c931110063a5bb83ab768263cb4da8cdcd73d8a4fdf49a967e5ec32be74f6410dd4bb520c18c3d58b88eda0e720b8dbbca3ae0de83baf52e1f7211
7
+ data.tar.gz: 745f55a53664c95d0ae86a415d58a02eaa47c0200db63e2cc101273db5b13c4ae31d40ea2d2ed4568c684429f494ca683866077c3d77825e140072af57b5aa06
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-12-03
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 TODO: Write your name
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # RailsWatch Gem
2
+
3
+ RailsWatch is a lightweight, high-performance observability probe for Ruby on Rails applications. It captures requests, SQL queries, background jobs, logs, and exceptions in real-time and forwards them to your RailsWatch dashboard.
4
+
5
+ Designed to be the Rails equivalent of Laravel Nightwatch/Telescope, it provides deep insight into your application's lifecycle without the overhead of heavy APM tools.
6
+
7
+ ## 🚀 Features
8
+
9
+ - **Zero-Config**: Automatically hooks into Rails via ActiveSupport::Notifications.
10
+
11
+ - **Full Lifecycle Tracking**:
12
+ - HTTP Requests (Headers, Session, Params)
13
+ - Database Queries (SQL, Duration)
14
+ - Background Jobs (Sidekiq, ActiveJob)
15
+ - Mail Deliveries
16
+ - Cache Hits/Misses (Redis, Memcached)
17
+ - Outgoing HTTP Requests (Net::HTTP)
18
+ - Rake Tasks & CLI Commands
19
+
20
+ - **Active Debugging**: Includes a global `rw()` helper to dump variables to your dashboard instantly.
21
+
22
+ - **Performance First**: Uses a background thread and buffered queue to ensure your application performance is never impacted by monitoring.
23
+
24
+ - **Safe**: Handles thread-safety and swallows internal errors to prevent bringing down your app.
25
+
26
+ ## 📦 Installation
27
+
28
+ Add this line to your application's Gemfile:
29
+
30
+ ```ruby
31
+ gem 'railswatch_gem'
32
+ ```
33
+
34
+ And then execute:
35
+
36
+ ```bash
37
+ bundle install
38
+ ```
39
+
40
+ ## ⚙️ Configuration
41
+
42
+ Create an initializer file at `config/initializers/railswatch.rb`. You will need your Project Token from your RailsWatch dashboard settings.
43
+
44
+ ```ruby
45
+ # config/initializers/railswatch.rb
46
+
47
+ RailswatchGem.configure do |config|
48
+ # The URL of your Railswatch SaaS instance (or self-hosted)
49
+ config.ingest_url = "https://api.railswatch.com/api/v1/ingest"
50
+
51
+ # Your unique Project Token
52
+ config.env_token = ENV.fetch("RAILSWATCH_TOKEN", "your-project-token")
53
+
54
+ # Optional: Fine-tune performance
55
+ # config.batch_size = 100 # Max events to send in one HTTP request
56
+ # config.flush_interval = 2.0 # Send data every 2 seconds
57
+ end
58
+ ```
59
+
60
+ That's it! Restart your application, and data will start flowing to your dashboard.
61
+
62
+ ## 🛠 Usage
63
+
64
+ ### The rw() Helper
65
+
66
+ Stop using `puts` or `binding.pry` in production. Use the global `rw()` helper (aliased as `railswatch_dump`) to send any variable directly to your "Dumps" dashboard tab.
67
+
68
+ ```ruby
69
+ class UsersController < ApplicationController
70
+ def create
71
+ @user = User.new(user_params)
72
+
73
+ # Inspect the user object in your dashboard without stopping the request
74
+ rw(@user)
75
+
76
+ if @user.save
77
+ # ...
78
+ end
79
+ end
80
+ end
81
+ ```
82
+
83
+ It returns the original object, so you can wrap expressions transparently:
84
+
85
+ ```ruby
86
+ # The result is assigned to 'result' AND sent to the dashboard
87
+ result = rw(ComplexCalculation.perform(x, y))
88
+ ```
89
+
90
+ ### Manual Logging
91
+
92
+ RailsWatch automatically captures standard Rails.logger output. However, you can also manually record specific events if needed:
93
+
94
+ ```ruby
95
+ RailswatchGem.record({
96
+ event_type: "custom_alert",
97
+ message: "Something interesting happened",
98
+ metadata: { user_id: 123 }
99
+ })
100
+ ```
101
+
102
+ ## 🔒 Security
103
+
104
+ - **Parameter Filtering**: RailsWatch respects your `Rails.application.config.filter_parameters`. Passwords and secrets defined there are scrubbed before leaving your server.
105
+
106
+ - **Async Processing**: Data is buffered in memory and sent asynchronously. If the ingestion API is down, your app continues running smoothly.
107
+
108
+ ## 🧩 Supported Instrumenters
109
+
110
+ RailsWatch automatically instruments the following libraries if they are loaded:
111
+
112
+ | Instrumenter | Description |
113
+ |-------------|-------------|
114
+ | Requests | Controller actions, status codes, paths, IP addresses. |
115
+ | Queries | ActiveRecord SQL queries (filters out SCHEMA/EXPLAIN). |
116
+ | Jobs | ActiveJob & Sidekiq execution, latency, and retries. |
117
+ | Mail | ActionMailer deliveries, recipients, and subjects. |
118
+ | Cache | ActiveSupport::Cache reads, writes, and hit rates. |
119
+ | Outbound | Net::HTTP requests to external APIs. |
120
+ | Models | ActiveRecord Create/Update/Destroy events with changesets. |
121
+ | Exceptions | Unhandled exceptions and Rails.error.report calls. |
122
+ | Commands | Rake tasks and Thor CLI commands. |
123
+
124
+ ## 🤝 Contributing
125
+
126
+ Bug reports and pull requests are welcome on GitHub at https://github.com/yourusername/railswatch_gem. This project is intended to be a safe, welcoming space for collaboration.
127
+
128
+ ## 📄 License
129
+
130
+ The gem is available as open source under the terms of the MIT License.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+ require "thread"
7
+
8
+ module RailswatchGem
9
+ class Client
10
+ # Maximum number of events to hold in memory before dropping to prevent OOM.
11
+ MAX_QUEUE_SIZE = 10_000
12
+
13
+ def initialize(ingest_url:, env_token:, batch_size:, flush_interval:, logger: nil)
14
+ @ingest_uri = URI(ingest_url)
15
+ @env_token = env_token
16
+ @batch_size = batch_size
17
+ @flush_interval = flush_interval
18
+ @logger = logger || default_logger
19
+
20
+ # Use SizedQueue for backpressure. If the consumer falls behind,
21
+ # we don't want to consume infinite memory.
22
+ @queue = SizedQueue.new(MAX_QUEUE_SIZE)
23
+
24
+ @running = true
25
+ start_worker_thread
26
+
27
+ # Ensure we flush on process exit
28
+ at_exit { stop_and_flush }
29
+ end
30
+
31
+ def record(event)
32
+ return unless @running
33
+
34
+ normalized = normalize_event(event)
35
+
36
+ # Non-blocking push. If queue is full, we drop the event to protect the app.
37
+ begin
38
+ @queue.push(normalized, true)
39
+ rescue ThreadError
40
+ # Queue is full. Log warning periodically or increment a metric.
41
+ # Avoid logging every failure to prevent disk fill-up.
42
+ log_error("RailswatchGem buffer full. Dropping event.")
43
+ end
44
+ end
45
+
46
+ def stop_and_flush
47
+ @running = false
48
+ flush
49
+ end
50
+
51
+ private
52
+
53
+ def normalize_event(event)
54
+ event = event.dup
55
+ event[:timestamp] ||= Time.now.utc.iso8601
56
+ event[:event_type] ||= "custom"
57
+ event
58
+ end
59
+
60
+ def start_worker_thread
61
+ @worker_thread = Thread.new do
62
+ while @running
63
+ # Wait for events or timeout (interval)
64
+ # We use a simple sleep loop here for simplicity,
65
+ # but a ConditionVariable could be used for smarter waking.
66
+ current_batch = []
67
+
68
+ # Drain queue up to batch size
69
+ while current_batch.size < @batch_size && !@queue.empty?
70
+ begin
71
+ current_batch << @queue.pop(true)
72
+ rescue ThreadError
73
+ break
74
+ end
75
+ end
76
+
77
+ if current_batch.any?
78
+ send_batch(current_batch)
79
+ else
80
+ # If no events, sleep to avoid hot loop
81
+ sleep @flush_interval
82
+ end
83
+ end
84
+
85
+ # Final flush on exit
86
+ flush_remaining
87
+ end
88
+
89
+ @worker_thread.name = "railswatch_gem_worker" if @worker_thread.respond_to?(:name=)
90
+ end
91
+
92
+ # Explicit flush (used at exit)
93
+ def flush
94
+ flush_remaining
95
+ end
96
+
97
+ def flush_remaining
98
+ all_events = []
99
+ loop do
100
+ begin
101
+ all_events << @queue.pop(true)
102
+ rescue ThreadError
103
+ break
104
+ end
105
+ end
106
+
107
+ # Send in chunks of batch_size
108
+ all_events.each_slice(@batch_size) do |batch|
109
+ send_batch(batch)
110
+ end
111
+ end
112
+
113
+ def send_batch(events)
114
+ # Using Net::HTTP.start with a block handles opening/closing cleanly.
115
+ # For higher performance, keep the connection open in an instance variable,
116
+ # but handle timeouts and reconnection logic.
117
+ Net::HTTP.start(@ingest_uri.host, @ingest_uri.port, use_ssl: @ingest_uri.scheme == "https") do |http|
118
+ request = Net::HTTP::Post.new(@ingest_uri.request_uri)
119
+ request["Content-Type"] = "application/json"
120
+ request["Environment-Token"] = @env_token
121
+ request.body = { events: events }.to_json
122
+
123
+ response = http.request(request)
124
+
125
+ unless response.is_a?(Net::HTTPSuccess) || response.code.to_i == 202
126
+ log_error("RailswatchGem::Client#send_batch non-success: #{response.code} #{response.body}")
127
+ end
128
+ end
129
+ rescue => e
130
+ log_error("RailswatchGem::Client#send_batch failed: #{e.class} - #{e.message}")
131
+ end
132
+
133
+ def default_logger
134
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
135
+ Rails.logger
136
+ else
137
+ Logger.new($stdout)
138
+ end
139
+ end
140
+
141
+ def log_error(message)
142
+ @logger&.error("[Railswatch] #{message}")
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailswatchGem
4
+ class Configuration
5
+ attr_accessor :env_token, :batch_size, :flush_interval, :ingest_url, :auto_boot
6
+ attr_reader :enabled_events, :custom_event_blocks
7
+
8
+ DEFAULT_EVENTS = %i[
9
+ requests
10
+ queries
11
+ outgoing_requests
12
+ jobs
13
+ scheduled_tasks
14
+ commands
15
+ cache
16
+ mail
17
+ notifications
18
+ logs
19
+ errors
20
+ models
21
+ ].freeze
22
+
23
+ DEFAULT_INGEST_URL = "https://api.railswatch.com/ingest".freeze
24
+
25
+ def initialize
26
+ @env_token = nil
27
+ @batch_size = 200
28
+ @flush_interval = 2.0
29
+ @ingest_url = DEFAULT_INGEST_URL
30
+ @auto_boot = true # Defaults to true for ease of use
31
+
32
+ # all default event families enabled
33
+ @enabled_events = DEFAULT_EVENTS.dup
34
+
35
+ # user-supplied instrumentation hooks
36
+ @custom_event_blocks = []
37
+ end
38
+
39
+ def enable_event(name)
40
+ name = name.to_sym
41
+ @enabled_events << name unless @enabled_events.include?(name)
42
+ end
43
+
44
+ def disable_event(name)
45
+ @enabled_events.delete(name.to_sym)
46
+ end
47
+
48
+ # for custom subscriptions using the client
49
+ def on_custom_event(&block)
50
+ @custom_event_blocks << block if block
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailswatchGem
4
+ module Helpers
5
+ # Usage: rw(user, params) or railswatch_dump(variable)
6
+ def railswatch_dump(*args)
7
+ # 1. Capture the location where this was called (file:line)
8
+ call_site = caller(1, 1).first
9
+
10
+ # 2. Correlate with request if inside one
11
+ request_id = Thread.current[:railswatch_request_id]
12
+
13
+ # 3. Format the arguments for display
14
+ formatted_args = args.map do |arg|
15
+ format_dump_arg(arg)
16
+ end
17
+
18
+ payload = {
19
+ event_type: "dump",
20
+ timestamp: Time.now.utc.iso8601,
21
+ values: formatted_args,
22
+ location: call_site,
23
+ request_id: request_id
24
+ }
25
+
26
+ # Send to client
27
+ RailswatchGem.record(payload)
28
+
29
+ # 4. Return original args to allow debugging without breaking chains:
30
+ # return rw(result)
31
+ if args.size == 1
32
+ args.first
33
+ else
34
+ args
35
+ end
36
+ rescue => e
37
+ warn "RailswatchGem: Dump failed: #{e.message}"
38
+ # Always return args so we don't break the app flow on error
39
+ args.size == 1 ? args.first : args
40
+ end
41
+
42
+ # Short alias for ease of use
43
+ alias_method :rw, :railswatch_dump
44
+
45
+ private
46
+
47
+ def format_dump_arg(arg)
48
+ case arg
49
+ when Hash
50
+ # Attempt to keep hashes as structures for the JSON viewer in the dashboard
51
+ begin
52
+ arg.transform_keys(&:to_s)
53
+ rescue
54
+ arg.inspect
55
+ end
56
+ when Array
57
+ arg.map { |item| format_dump_arg(item) }
58
+ when String, Numeric, TrueClass, FalseClass, NilClass
59
+ arg
60
+ else
61
+ # For Active Record models or complex objects, .inspect gives the string representation
62
+ # which is usually what developers want to see.
63
+ arg.inspect
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ # Automatically mix into the top-level Object so it's available everywhere
70
+ # (Models, Controllers, Views, Console, Irb)
71
+ Object.include(RailswatchGem::Helpers) if defined?(Object)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module RailswatchGem
6
+ module Instrumentation
7
+ class CacheInstrumenter
8
+ def initialize(client, config)
9
+ @client = client
10
+ @config = config
11
+ end
12
+
13
+ def start
14
+ # Subscribe to all cache events (read, write, delete, fetch_hit, generate, etc.)
15
+ ActiveSupport::Notifications.subscribe(/cache_.*\.active_support/) do |*args|
16
+ event = ActiveSupport::Notifications::Event.new(*args)
17
+ process_event(event)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def process_event(event)
24
+ payload = event.payload
25
+ name = event.name # e.g., "cache_read.active_support"
26
+
27
+ # Extract action from name: cache_read.active_support -> read
28
+ # Common actions: read, write, delete, fetch_hit, generate
29
+ action = name.split(".").first.sub("cache_", "")
30
+
31
+ data = {
32
+ event_type: "cache",
33
+ # FIX: Handle Float timestamps safely
34
+ timestamp: Time.at(event.end).utc.iso8601,
35
+ action: action,
36
+ key: payload[:key],
37
+
38
+ # Performance
39
+ duration_ms: event.duration.round(2)
40
+ }
41
+
42
+ # 'hit' is often present on read/fetch operations (true/false)
43
+ if payload.key?(:hit)
44
+ data[:hit] = payload[:hit]
45
+ end
46
+
47
+ # 'super_operation' exists in Rails 6.1+ for nested fetch calls
48
+ if payload[:super_operation]
49
+ data[:super_operation] = payload[:super_operation].to_s
50
+ end
51
+
52
+ # Error handling
53
+ if payload[:exception]
54
+ data[:error_class] = payload[:exception].first.to_s
55
+ data[:error_message] = payload[:exception].last.to_s
56
+ end
57
+
58
+ @client.record(data)
59
+ rescue => e
60
+ warn "RailswatchGem: Failed to process cache event: #{e.message}"
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module RailswatchGem
6
+ module Instrumentation
7
+ class CommandsInstrumenter
8
+ def initialize(client, config)
9
+ @client = client
10
+ @config = config
11
+ end
12
+
13
+ def start
14
+ # Hook into Thor to capture CLI commands (e.g., rails runner, rails generate, custom CLI tools)
15
+ if defined?(::Thor::Command)
16
+ patch_thor_commands
17
+
18
+ ActiveSupport::Notifications.subscribe("command.thor") do |*args|
19
+ event = ActiveSupport::Notifications::Event.new(*args)
20
+ process_event(event)
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def patch_thor_commands
28
+ # Ensure we only patch once
29
+ return if ::Thor::Command.include?(ThorCommandPatch)
30
+
31
+ ::Thor::Command.prepend(ThorCommandPatch)
32
+ end
33
+
34
+ def process_event(event)
35
+ payload = event.payload
36
+ command_name = payload[:name]
37
+
38
+ data = {
39
+ event_type: "command",
40
+ # FIX: Handle Float timestamps safely using Time.at()
41
+ timestamp: Time.at(event.end).utc.iso8601,
42
+
43
+ # Command Identity
44
+ name: command_name,
45
+ args: payload[:args],
46
+ options: payload[:options],
47
+
48
+ # Context (Optional: class name of the tool running, e.g., 'Rails::Generators::ModelGenerator')
49
+ tool: payload[:tool_class],
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 command event: #{e.message}"
67
+ end
68
+
69
+ # ----------------------------------------------------------------
70
+ # Internal Patch for Thor::Command
71
+ # ----------------------------------------------------------------
72
+ module ThorCommandPatch
73
+ def run(instance, args = [])
74
+ # Thor::Command#run(instance, args)
75
+ # 'name' is available on the command object
76
+
77
+ # Capture options if available on the instance (parsed flags)
78
+ opts = instance.respond_to?(:options) ? instance.options : nil
79
+ tool_class = instance.class.name
80
+
81
+ ActiveSupport::Notifications.instrument("command.thor", {
82
+ name: name,
83
+ args: args,
84
+ options: opts,
85
+ tool_class: tool_class
86
+ }) do
87
+ super
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailswatchGem
4
+ module Instrumentation
5
+ class ErrorsInstrumenter
6
+ def initialize(client, config)
7
+ @client = client
8
+ @config = config
9
+ end
10
+
11
+ def start
12
+ # This instrumenter relies on the Rails 7.0+ Error Reporter interface.
13
+ # It allows capturing both unhandled exceptions and manually reported errors
14
+ # (e.g., via Rails.error.handle { ... }).
15
+ if defined?(::Rails) && ::Rails.respond_to?(:error)
16
+ ::Rails.error.subscribe(ErrorSubscriber.new(@client))
17
+ end
18
+ end
19
+
20
+ class ErrorSubscriber
21
+ def initialize(client)
22
+ @client = client
23
+ end
24
+
25
+ # The signature required by Rails.error.subscribe
26
+ def report(error, handled:, severity:, context:, source: nil)
27
+ # 1. Infinite Loop Protection:
28
+ # Don't report errors originating from our own gem to prevent recursion.
29
+ return if error.class.name.start_with?("RailswatchGem::")
30
+
31
+ # 2. Context Correlation:
32
+ # Try to link this error to the current HTTP request if possible.
33
+ # We check the passed context first, then fall back to our thread-local storage.
34
+ request_id = context[:request_id] || Thread.current[:railswatch_request_id]
35
+
36
+ data = {
37
+ event_type: "error",
38
+ timestamp: Time.now.utc.iso8601,
39
+
40
+ # Exception Details
41
+ class: error.class.name,
42
+ message: error.message,
43
+ # Limit backtrace to save bandwidth; the dashboard usually only needs the top frames.
44
+ backtrace: (error.backtrace || []).first(25),
45
+
46
+ # Rails Error Reporter Context
47
+ handled: handled,
48
+ severity: severity,
49
+ source: source, # e.g., "application.job"
50
+
51
+ # Correlation
52
+ request_id: request_id,
53
+
54
+ # Extra context passed to Rails.error.report(e, context: { user_id: 1 })
55
+ context: context
56
+ }
57
+
58
+ @client.record(data)
59
+ rescue => e
60
+ # Absolute safety net: If reporting the error fails, print to stderr and move on.
61
+ warn "RailswatchGem: Failed to report error event: #{e.message}"
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end