rails_action_tracker 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,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+ require 'logger'
5
+ require 'fileutils'
6
+
7
+ module RailsActionTracker
8
+ class Tracker
9
+ THREAD_KEY = :rails_action_tracker_logs
10
+
11
+ class << self
12
+ attr_accessor :config, :custom_logger
13
+
14
+ def configure(options = {})
15
+ @config = {
16
+ print_to_rails_log: true,
17
+ write_to_file: false,
18
+ log_file_path: nil,
19
+ services: [],
20
+ ignored_tables: %w[pg_attribute pg_index pg_class pg_namespace pg_type ar_internal_metadata
21
+ schema_migrations],
22
+ ignored_controllers: [],
23
+ ignored_actions: {}
24
+ }.merge(options)
25
+
26
+ setup_custom_logger if @config[:write_to_file] && @config[:log_file_path]
27
+ end
28
+
29
+ def start_tracking
30
+ Thread.current[THREAD_KEY] = {
31
+ read: Set.new,
32
+ write: Set.new,
33
+ captured_logs: [],
34
+ controller: nil,
35
+ action: nil
36
+ }
37
+ subscribe_to_sql_notifications
38
+ subscribe_to_logger
39
+ end
40
+
41
+ def stop_tracking
42
+ unsubscribe_from_sql_notifications
43
+ unsubscribe_from_logger
44
+ logs = Thread.current[THREAD_KEY] || { read: Set.new, write: Set.new, captured_logs: [], controller: nil,
45
+ action: nil }
46
+ Thread.current[THREAD_KEY] = nil
47
+ logs
48
+ end
49
+
50
+ def print_summary
51
+ logs = Thread.current[THREAD_KEY]
52
+ return unless logs
53
+
54
+ # Check if this controller/action should be ignored
55
+ return if should_ignore_controller_action?(logs[:controller], logs[:action])
56
+
57
+ services_accessed = detect_services(logs[:captured_logs])
58
+ read_models = logs[:read].to_a.uniq.sort
59
+ write_models = logs[:write].to_a.uniq.sort
60
+
61
+ controller_action = "#{logs[:controller]}##{logs[:action]}" if logs[:controller] && logs[:action]
62
+
63
+ # Generate outputs with and without colors
64
+ colored_output = format_summary(read_models, write_models, services_accessed, controller_action, true)
65
+ plain_output = format_summary(read_models, write_models, services_accessed, controller_action, false)
66
+
67
+ log_output(colored_output, plain_output)
68
+ end
69
+
70
+ private
71
+
72
+ def should_ignore_controller_action?(controller, action)
73
+ return false unless config && controller && action
74
+
75
+ # Check if entire controller is ignored
76
+ ignored_controllers = config[:ignored_controllers] || []
77
+ return true if ignored_controllers.include?(controller)
78
+
79
+ # Check controller-specific action ignores
80
+ ignored_actions = config[:ignored_actions] || {}
81
+
82
+ # Handle flexible controller/action filtering
83
+ ignored_actions.each do |pattern_controller, actions|
84
+ # Match controller name (exact match or empty string for all controllers)
85
+ next unless pattern_controller.empty? || pattern_controller == controller
86
+
87
+ # If actions is empty array or nil, ignore entire controller
88
+ return true if actions.nil? || (actions.is_a?(Array) && actions.empty?)
89
+
90
+ # Check specific actions
91
+ return true if actions.is_a?(Array) && actions.include?(action)
92
+ end
93
+
94
+ false
95
+ end
96
+
97
+ def setup_custom_logger
98
+ return unless config[:log_file_path]
99
+
100
+ log_dir = File.dirname(config[:log_file_path])
101
+ FileUtils.mkdir_p(log_dir) unless Dir.exist?(log_dir)
102
+
103
+ @custom_logger = Logger.new(config[:log_file_path])
104
+ @custom_logger.formatter = proc do |severity, datetime, _progname, msg|
105
+ "[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity}: #{msg}\n"
106
+ end
107
+ end
108
+
109
+ def log_query(sql)
110
+ logs = Thread.current[THREAD_KEY]
111
+ return unless logs
112
+
113
+ return unless (match = sql.match(/(FROM|INTO|UPDATE|INSERT INTO)\s+["']?(\w+)["']?/i))
114
+
115
+ table = match[2]
116
+
117
+ # Skip ignored tables (case insensitive)
118
+ ignored_tables = config&.dig(:ignored_tables) || %w[pg_attribute pg_index pg_class pg_namespace
119
+ pg_type ar_internal_metadata schema_migrations]
120
+ return if ignored_tables.map(&:downcase).include?(table.downcase)
121
+
122
+ if sql =~ /\A\s*SELECT/i
123
+ logs[:read] << table
124
+ else
125
+ logs[:write] << table
126
+ end
127
+ end
128
+
129
+ def log_message(message)
130
+ logs = Thread.current[THREAD_KEY]
131
+ return unless logs
132
+
133
+ logs[:captured_logs] << message
134
+ end
135
+
136
+ def detect_services(captured_logs)
137
+ services_accessed = []
138
+ default_service_patterns = [
139
+ { name: 'Pusher', pattern: /pusher/i },
140
+ { name: 'Honeybadger', pattern: /honeybadger/i },
141
+ { name: 'Redis', pattern: /redis/i },
142
+ { name: 'Sidekiq', pattern: /sidekiq/i },
143
+ { name: 'ActionMailer', pattern: /mail|email/i },
144
+ { name: 'HTTP', pattern: /http|api/i }
145
+ ]
146
+
147
+ service_patterns = config&.dig(:services) || default_service_patterns
148
+
149
+ captured_logs.each do |line|
150
+ service_patterns.each do |service_config|
151
+ if service_config.is_a?(Hash)
152
+ services_accessed << service_config[:name] if line.match?(service_config[:pattern])
153
+ elsif service_config.is_a?(String)
154
+ services_accessed << service_config if line.downcase.include?(service_config.downcase)
155
+ end
156
+ end
157
+ end
158
+
159
+ services_accessed.uniq
160
+ end
161
+
162
+ # rubocop:disable Style/OptionalBooleanParameter
163
+ def format_summary(read_models, write_models, services, controller_action = nil, colorize = true)
164
+ colors = setup_colors(colorize)
165
+ max_rows = [read_models.size, write_models.size, services.size].max
166
+
167
+ return format_empty_summary(controller_action, colors) if max_rows.zero?
168
+
169
+ padded_arrays = pad_arrays_to_max_length(read_models, write_models, services, max_rows)
170
+ column_widths = calculate_column_widths(padded_arrays[:read], padded_arrays[:write], padded_arrays[:services])
171
+
172
+ build_table(padded_arrays, column_widths, controller_action, colors, max_rows)
173
+ end
174
+ # rubocop:enable Style/OptionalBooleanParameter
175
+
176
+ def setup_colors(colorize)
177
+ return { green: '', red: '', blue: '', yellow: '', reset: '' } unless colorize
178
+ return default_colors unless rails_colorized?
179
+
180
+ {
181
+ green: fetch_rails_color('GREEN', "\e[32m"),
182
+ red: fetch_rails_color('RED', "\e[31m"),
183
+ blue: fetch_rails_color('BLUE', "\e[34m"),
184
+ yellow: fetch_rails_color('YELLOW', "\e[33m"),
185
+ reset: fetch_rails_color('CLEAR', "\e[0m")
186
+ }
187
+ end
188
+
189
+ def default_colors
190
+ { green: '', red: '', blue: '', yellow: '', reset: '' }
191
+ end
192
+
193
+ def rails_colorized?
194
+ defined?(Rails) && Rails.logger.respond_to?(:colorize_logging) && Rails.logger.colorize_logging
195
+ end
196
+
197
+ def fetch_rails_color(color_name, fallback)
198
+ if defined?(ActiveSupport::LogSubscriber.const_get(color_name))
199
+ ActiveSupport::LogSubscriber.const_get(color_name)
200
+ else
201
+ fallback
202
+ end
203
+ end
204
+
205
+ def format_empty_summary(controller_action, colors)
206
+ header = controller_action ? "#{colors[:yellow]}#{controller_action}#{colors[:reset]}: " : ''
207
+ "#{header}No models or services accessed during this request.\n"
208
+ end
209
+
210
+ def pad_arrays_to_max_length(read_models, write_models, services, max_rows)
211
+ {
212
+ read: read_models + [''] * (max_rows - read_models.size),
213
+ write: write_models + [''] * (max_rows - write_models.size),
214
+ services: services + [''] * (max_rows - services.size)
215
+ }
216
+ end
217
+
218
+ def calculate_column_widths(read_models, write_models, services)
219
+ {
220
+ read: [read_models.map(&:length).max || 0, 'Models Read'.length].max,
221
+ write: [write_models.map(&:length).max || 0, 'Models Written'.length].max,
222
+ services: [services.map(&:length).max || 0, 'Services Accessed'.length].max
223
+ }
224
+ end
225
+
226
+ def build_table(arrays, widths, controller_action, colors, max_rows)
227
+ separator = build_separator(widths)
228
+ header = controller_action ? "#{colors[:yellow]}#{controller_action}#{colors[:reset]} - " : ''
229
+
230
+ table = "#{header}Models and Services accessed during request:\n"
231
+ table += "#{separator}\n"
232
+ table += build_header_row(widths, colors)
233
+ table += "#{separator}\n"
234
+ table += build_data_rows(arrays, widths, max_rows)
235
+ table + "#{separator}\n"
236
+ end
237
+
238
+ def build_separator(widths)
239
+ "+#{'-' * (widths[:read] + 2)}+#{'-' * (widths[:write] + 2)}+#{'-' * (widths[:services] + 2)}+"
240
+ end
241
+
242
+ def build_header_row(widths, colors)
243
+ read_header = "#{colors[:green]}#{'Models Read'.ljust(widths[:read])}#{colors[:reset]}"
244
+ write_header = "#{colors[:red]}#{'Models Written'.ljust(widths[:write])}#{colors[:reset]}"
245
+ services_header = "#{colors[:blue]}#{'Services Accessed'.ljust(widths[:services])}#{colors[:reset]}"
246
+
247
+ "| #{read_header} | #{write_header} | #{services_header} |\n"
248
+ end
249
+
250
+ def build_data_rows(arrays, widths, max_rows)
251
+ table = ''
252
+ max_rows.times do |i|
253
+ read_cell = arrays[:read][i].ljust(widths[:read])
254
+ write_cell = arrays[:write][i].ljust(widths[:write])
255
+ services_cell = arrays[:services][i].ljust(widths[:services])
256
+ table += "| #{read_cell} | #{write_cell} | #{services_cell} |\n"
257
+ end
258
+ table
259
+ end
260
+
261
+ def log_output(colored_output, plain_output)
262
+ if config.nil?
263
+ Rails.logger.info "\n#{colored_output}" if defined?(Rails)
264
+ else
265
+ Rails.logger.info "\n#{colored_output}" if config[:print_to_rails_log] && defined?(Rails)
266
+
267
+ custom_logger.info "\n#{plain_output}" if config[:write_to_file] && custom_logger
268
+ end
269
+ end
270
+
271
+ def subscribe_to_sql_notifications
272
+ @subscribe_to_sql_notifications ||= ActiveSupport::Notifications
273
+ .subscribe('sql.active_record') do |_name, _start, _finish, _id, payload|
274
+ sql = payload[:sql]
275
+ log_query(sql) unless sql.include?('SCHEMA')
276
+ end
277
+ end
278
+
279
+ def unsubscribe_from_sql_notifications
280
+ ActiveSupport::Notifications.unsubscribe(@sql_subscriber) if @sql_subscriber
281
+ @sql_subscriber = nil
282
+ end
283
+
284
+ def subscribe_to_logger
285
+ return unless defined?(Rails)
286
+
287
+ @subscribe_to_logger ||= ActiveSupport::Notifications.subscribe(/.*/) do |name, _start, _finish, _id, payload|
288
+ next if name.include?('sql.active_record')
289
+
290
+ # Capture controller and action information
291
+ if name == 'process_action.action_controller'
292
+ logs = Thread.current[THREAD_KEY]
293
+ if logs
294
+ logs[:controller] = payload[:controller]
295
+ logs[:action] = payload[:action]
296
+ end
297
+ end
298
+
299
+ message = case name
300
+ when 'process_action.action_controller'
301
+ "Controller: #{payload[:controller]}##{payload[:action]}"
302
+ when 'render_template.action_view'
303
+ "Template: #{payload[:identifier]}"
304
+ else
305
+ payload.to_s
306
+ end
307
+
308
+ log_message(message) if message && !message.empty?
309
+ end
310
+ end
311
+
312
+ def unsubscribe_from_logger
313
+ ActiveSupport::Notifications.unsubscribe(@logger_subscriber) if @logger_subscriber
314
+ @logger_subscriber = nil
315
+ end
316
+ end
317
+ end
318
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsActionTracker
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rails_action_tracker/version'
4
+ require_relative 'rails_action_tracker/tracker'
5
+ require_relative 'rails_action_tracker/middleware'
6
+
7
+ module RailsActionTracker
8
+ class Error < StandardError; end
9
+ end
10
+
11
+ require_relative 'rails_action_tracker/railtie' if defined?(Rails) && defined?(Rails::Railtie)
data/script/test-all ADDED
@@ -0,0 +1,65 @@
1
+ #!/bin/bash
2
+
3
+ set -e
4
+
5
+ echo "🚀 Testing Rails Action Tracker across all Rails versions..."
6
+ echo
7
+
8
+ # Colors for output
9
+ GREEN='\033[0;32m'
10
+ RED='\033[0;31m'
11
+ YELLOW='\033[1;33m'
12
+ NC='\033[0m' # No Color
13
+
14
+ # Track results
15
+ PASSED=()
16
+ FAILED=()
17
+
18
+ # Rails versions to test
19
+ RAILS_VERSIONS=(
20
+ "rails-5.0"
21
+ "rails-5.1"
22
+ "rails-5.2"
23
+ "rails-6.0"
24
+ "rails-6.1"
25
+ "rails-7.0"
26
+ "rails-7.1"
27
+ "rails-8.0"
28
+ )
29
+
30
+ echo -e "${YELLOW}Installing Appraisal gemfiles...${NC}"
31
+ bundle exec appraisal install
32
+ echo
33
+
34
+ for rails_version in "${RAILS_VERSIONS[@]}"; do
35
+ echo -e "${YELLOW}Testing $rails_version...${NC}"
36
+
37
+ if bundle exec appraisal $rails_version rake test; then
38
+ echo -e "${GREEN}✅ $rails_version PASSED${NC}"
39
+ PASSED+=($rails_version)
40
+ else
41
+ echo -e "${RED}❌ $rails_version FAILED${NC}"
42
+ FAILED+=($rails_version)
43
+ fi
44
+ echo
45
+ done
46
+
47
+ echo "========================================="
48
+ echo "📊 SUMMARY:"
49
+ echo "========================================="
50
+ echo -e "${GREEN}✅ PASSED (${#PASSED[@]}):${NC}"
51
+ for version in "${PASSED[@]}"; do
52
+ echo " - $version"
53
+ done
54
+
55
+ if [ ${#FAILED[@]} -ne 0 ]; then
56
+ echo -e "${RED}❌ FAILED (${#FAILED[@]}):${NC}"
57
+ for version in "${FAILED[@]}"; do
58
+ echo " - $version"
59
+ done
60
+ echo
61
+ exit 1
62
+ else
63
+ echo -e "${GREEN}🎉 All Rails versions passed!${NC}"
64
+ echo
65
+ fi
@@ -0,0 +1,4 @@
1
+ module RailsActionTracker
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails_action_tracker
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Deepak Mahakale
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actionpack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: railties
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ description: A Rails gem that provides detailed tracking of model read/write operations
55
+ and service usage during controller action execution, with configurable logging
56
+ options.
57
+ email:
58
+ - deepakmahakale@gmail.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - ".rubocop.yml"
64
+ - Appraisals
65
+ - CHANGELOG.md
66
+ - CONTRIBUTING.md
67
+ - DEVELOPMENT.md
68
+ - LICENSE.txt
69
+ - README.md
70
+ - Rakefile
71
+ - lib/generators/rails_action_tracker/install_generator.rb
72
+ - lib/generators/rails_action_tracker/templates/initializer.rb
73
+ - lib/rails_action_tracker.rb
74
+ - lib/rails_action_tracker/middleware.rb
75
+ - lib/rails_action_tracker/railtie.rb
76
+ - lib/rails_action_tracker/tracker.rb
77
+ - lib/rails_action_tracker/version.rb
78
+ - script/test-all
79
+ - sig/rails_action_tracker.rbs
80
+ homepage: https://github.com/deepakmahakale/rails_action_tracker
81
+ licenses:
82
+ - MIT
83
+ metadata:
84
+ homepage_uri: https://github.com/deepakmahakale/rails_action_tracker
85
+ source_code_uri: https://github.com/deepakmahakale/rails_action_tracker
86
+ changelog_uri: https://github.com/deepakmahakale/rails_action_tracker/blob/master/CHANGELOG.md
87
+ rdoc_options: []
88
+ require_paths:
89
+ - lib
90
+ required_ruby_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: 2.7.0
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ requirements: []
101
+ rubygems_version: 3.6.7
102
+ specification_version: 4
103
+ summary: Track ActiveRecord model operations and service usage during Rails action
104
+ calls
105
+ test_files: []