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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +130 -0
- data/Rakefile +8 -0
- data/lib/railswatch_gem/client.rb +145 -0
- data/lib/railswatch_gem/configuration.rb +53 -0
- data/lib/railswatch_gem/helpers.rb +71 -0
- data/lib/railswatch_gem/instrumentation/cache_instrumenter.rb +64 -0
- data/lib/railswatch_gem/instrumentation/commands_instrumenter.rb +93 -0
- data/lib/railswatch_gem/instrumentation/errors_instrumenter.rb +66 -0
- data/lib/railswatch_gem/instrumentation/jobs_instrumenter.rb +74 -0
- data/lib/railswatch_gem/instrumentation/logs_instrumenter.rb +104 -0
- data/lib/railswatch_gem/instrumentation/mail_instrumenter.rb +67 -0
- data/lib/railswatch_gem/instrumentation/models_instrumenter.rb +91 -0
- data/lib/railswatch_gem/instrumentation/notifications_instrumenter.rb +71 -0
- data/lib/railswatch_gem/instrumentation/outgoing_requests_instrumenter.rb +96 -0
- data/lib/railswatch_gem/instrumentation/queries_instrumenter.rb +58 -0
- data/lib/railswatch_gem/instrumentation/registry.rb +49 -0
- data/lib/railswatch_gem/instrumentation/requests_instrumenter.rb +145 -0
- data/lib/railswatch_gem/instrumentation/scheduled_tasks_instrumenter.rb +82 -0
- data/lib/railswatch_gem/middleware/request_context.rb +28 -0
- data/lib/railswatch_gem/railtie.rb +22 -0
- data/lib/railswatch_gem/version.rb +5 -0
- data/lib/railswatch_gem.rb +75 -0
- data/manual_test.rb +103 -0
- data/sig/railswatch_gem.rbs +4 -0
- metadata +72 -0
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
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,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
|