rails_spotlight 0.1.6 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -0
  3. data/Guardfile +1 -0
  4. data/README.md +25 -2
  5. data/chrome_ext_private_policy.md +39 -0
  6. data/docker-compose.yml +0 -4
  7. data/fake_spec_res/rails_spotlight_spec.rb +8 -8
  8. data/lib/rails_spotlight/app_notifications.rb +88 -0
  9. data/lib/rails_spotlight/app_request.rb +20 -0
  10. data/lib/rails_spotlight/channels/live_console_channel.rb +91 -0
  11. data/lib/rails_spotlight/channels/request_completed_channel.rb +15 -0
  12. data/lib/rails_spotlight/channels.rb +8 -0
  13. data/lib/rails_spotlight/configuration.rb +91 -0
  14. data/lib/rails_spotlight/event.rb +110 -0
  15. data/lib/rails_spotlight/log_interceptor.rb +47 -0
  16. data/lib/rails_spotlight/middlewares/concerns/skip_request_paths.rb +35 -0
  17. data/lib/rails_spotlight/middlewares/handlers/file_action_handler.rb +13 -6
  18. data/lib/rails_spotlight/middlewares/handlers/meta_action_handler.rb +28 -0
  19. data/lib/rails_spotlight/middlewares/handlers/sql_action_handler.rb +52 -19
  20. data/lib/rails_spotlight/middlewares/handlers/verify_action_handler.rb +4 -1
  21. data/lib/rails_spotlight/middlewares/header_marker.rb +33 -0
  22. data/lib/rails_spotlight/middlewares/main_request_handler.rb +35 -0
  23. data/lib/rails_spotlight/middlewares/request_completed.rb +71 -0
  24. data/lib/rails_spotlight/middlewares/request_handler.rb +2 -0
  25. data/lib/rails_spotlight/middlewares.rb +10 -0
  26. data/lib/rails_spotlight/railtie.rb +30 -10
  27. data/lib/rails_spotlight/storage.rb +47 -0
  28. data/lib/rails_spotlight/utils.rb +28 -0
  29. data/lib/rails_spotlight/version.rb +1 -1
  30. data/lib/rails_spotlight.rb +22 -3
  31. data/lib/tasks/init.rake +36 -0
  32. metadata +82 -9
  33. data/lib/rails_spotlight/support/project.rb +0 -19
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../../support/project'
4
-
5
3
  module RailsSpotlight
6
4
  module Middlewares
7
5
  module Handlers
@@ -9,7 +7,7 @@ module RailsSpotlight
9
7
  def execute
10
8
  raise NotFound, 'File not found' unless path_valid?
11
9
 
12
- File.write(file_path, json_request_body.fetch('content')) if write_mode?
10
+ File.write(file_path, new_content) if write_mode?
13
11
  rescue => e # rubocop:disable Style/RescueStandardError
14
12
  raise UnprocessableEntity, e.message
15
13
  end
@@ -20,11 +18,16 @@ module RailsSpotlight
20
18
  File.read(file_path)
21
19
  end
22
20
 
21
+ def new_content
22
+ json_request_body.fetch('content')
23
+ end
24
+
23
25
  def json_response_body
24
26
  {
25
27
  source: text_response_body,
26
- project: ::RailsSpotlight::Support::Project.instance.name
27
- }
28
+ changed: write_mode?,
29
+ project: ::RailsSpotlight.config.project_name
30
+ }.merge(write_mode? ? { new_content: new_content } : {})
28
31
  end
29
32
 
30
33
  def write_mode?
@@ -40,7 +43,11 @@ module RailsSpotlight
40
43
  end
41
44
 
42
45
  def file_path
43
- @file_path ||= Rails.root.join(json_request_body.fetch('file'))
46
+ @file_path ||= if json_request_body.fetch('file').start_with?(::RailsSpotlight.config.rails_root)
47
+ json_request_body.fetch('file')
48
+ else
49
+ File.join(::RailsSpotlight.config.rails_root, json_request_body.fetch('file'))
50
+ end
44
51
  end
45
52
  end
46
53
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsSpotlight
4
+ module Middlewares
5
+ module Handlers
6
+ class MetaActionHandler < BaseActionHandler
7
+ def execute; end
8
+
9
+ private
10
+
11
+ def json_response_body
12
+ {
13
+ events: events,
14
+ project: ::RailsSpotlight.config.project_name
15
+ }
16
+ end
17
+
18
+ def id
19
+ @id ||= request.params['id']
20
+ end
21
+
22
+ def events
23
+ @events ||= Storage.new(id).read || []
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -1,32 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative '../../support/project'
4
-
5
3
  module RailsSpotlight
6
4
  module Middlewares
7
5
  module Handlers
8
6
  class SqlActionHandler < BaseActionHandler
9
7
  def execute
10
- if ActiveSupport.const_defined?('ExecutionContext')
11
- ActiveSupport::Notifications.subscribed(method(:logger), 'sql.active_record', monotonic: true) do
12
- ActiveSupport::ExecutionContext.set(rails_spotlight: request_id) do
13
- transaction
14
- end
15
- end
16
- else
17
- ActiveSupport::Notifications.subscribed(method(:logger), 'sql.active_record') do
18
- transaction
19
- end
8
+ validate_project!
9
+ return transaction unless ActiveSupport.const_defined?('ExecutionContext')
10
+
11
+ ActiveSupport::ExecutionContext.set(rails_spotlight: request_id) do
12
+ transaction
20
13
  end
21
14
  end
22
15
 
23
16
  private
24
17
 
18
+ def validate_project!
19
+ Rails.logger.warn required_projects
20
+ return if required_projects.blank?
21
+ return if required_projects.include?(::RailsSpotlight.config.project_name)
22
+
23
+ raise UnprocessableEntity, "Check your connetction settings the current query is not allowed to be executed on the #{::RailsSpotlight.config.project_name} project"
24
+ end
25
+
25
26
  def transaction
26
27
  ActiveRecord::Base.transaction do
27
- begin
28
- self.result = ActiveRecord::Base.connection.exec_query(query)
29
- rescue => e
28
+ begin # rubocop:disable Style/RedundantBegin
29
+ ActiveSupport::Notifications.subscribed(method(:logger), 'sql.active_record', monotonic: true) do
30
+ run
31
+ end
32
+ rescue => e # rubocop:disable Style/RescueStandardError
30
33
  self.error = e
31
34
  ensure
32
35
  raise ActiveRecord::Rollback unless force_execution?
@@ -34,16 +37,27 @@ module RailsSpotlight
34
37
  end
35
38
  end
36
39
 
37
- attr_accessor :result
38
- attr_accessor :error
40
+ def run
41
+ return self.result = ActiveRecord::Base.connection.exec_query(query) if connection_options.blank? || !ActiveRecord::Base.respond_to?(:connects_to)
42
+
43
+ connections = ActiveRecord::Base.connects_to(**connection_options)
44
+
45
+ adapter = connections.find { |c| c.role == use['role'] && c.shard.to_s == use['shard'] }
46
+ raise UnprocessableEntity, "Connection not found for role: `#{use["role"]}` and shard: `#{use["shard"]}`" if adapter.blank?
47
+
48
+ self.result = adapter.connection.exec_query(query)
49
+ end
50
+
51
+ attr_accessor :result, :error
39
52
 
40
53
  def json_response_body
41
54
  {
55
+ query: query,
42
56
  result: result,
43
57
  logs: logs,
44
- error: error.inspect,
58
+ error: error.present? ? error.inspect : nil,
45
59
  query_mode: force_execution? ? 'force' : 'default',
46
- project: ::RailsSpotlight::Support::Project.instance.name
60
+ project: ::RailsSpotlight.config.project_name
47
61
  }
48
62
  end
49
63
 
@@ -61,6 +75,25 @@ module RailsSpotlight
61
75
  @query ||= json_request_body.fetch('query')
62
76
  end
63
77
 
78
+ def raw_options
79
+ @raw_options ||= json_request_body.fetch('options', {}) || {}
80
+ end
81
+
82
+ def required_projects
83
+ @required_projects ||= raw_options.fetch('projects', [])
84
+ end
85
+
86
+ def use
87
+ @use ||= { 'shard' => 'default', 'role' => 'reading' }.merge(raw_options.fetch('use', {}))
88
+ end
89
+
90
+ def connection_options
91
+ @connection_options ||= raw_options
92
+ .symbolize_keys
93
+ .slice(:database, :shards)
94
+ .reject { |_, v| v.nil? || (!v.is_a?(TrueClass) && !v.is_a?(FalseClass) && v.empty?) } # TODO: Check for each rails version
95
+ end
96
+
64
97
  def force_execution?
65
98
  @force_execution ||= json_request_body['mode'] == 'force'
66
99
  end
@@ -18,7 +18,10 @@ module RailsSpotlight
18
18
  body: request.body.read,
19
19
  content_type: request.content_type,
20
20
  request_method: request.request_method,
21
- version: request.get_header('HTTP_X_RAILS_SPOTLIGHT')
21
+ version: request.get_header('HTTP_X_RAILS_SPOTLIGHT'),
22
+ current_gem_version: ::RailsSpotlight::VERSION,
23
+ project: ::RailsSpotlight.config.project_name,
24
+ action_cable_path: defined?(ActionCable) ? ActionCable&.server&.config&.mount_path : nil
22
25
  }
23
26
  end
24
27
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack/contrib/response_headers'
4
+ require_relative 'concerns/skip_request_paths'
5
+
6
+ module RailsSpotlight
7
+ module Middlewares
8
+ class HeaderMarker
9
+ include ::RailsSpotlight::Middlewares::SkipRequestPaths
10
+
11
+ def initialize(app, app_config)
12
+ @app = app
13
+ @app_config = app_config
14
+ end
15
+
16
+ def call(env)
17
+ request_path = env['PATH_INFO']
18
+ middleware = Rack::ResponseHeaders.new(app) do |headers|
19
+ headers['X-Rails-Spotlight-Version'] = RailsSpotlight::VERSION unless skip?(request_path)
20
+ end
21
+ middleware.call(env)
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :app, :app_config
27
+
28
+ def default_skip_paths
29
+ %w[/__better_errors]
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RailsSpotlight
6
+ module Middlewares
7
+ class MainRequestHandler
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ attr_reader :app
13
+
14
+ def call(env)
15
+ app_request = AppRequest.new env['action_dispatch.request_id']
16
+ app_request.current!
17
+ app.call(env)
18
+ rescue StandardError => e
19
+ if defined?(ActionDispatch::ExceptionWrapper)
20
+ wrapper = if ActionDispatch::ExceptionWrapper.method_defined? :env
21
+ ActionDispatch::ExceptionWrapper.new(env, e)
22
+ else
23
+ ActionDispatch::ExceptionWrapper.new(env['action_dispatch.backtrace_cleaner'], e)
24
+ end
25
+ app_request.events.push(*Event.events_for_exception(wrapper))
26
+ else
27
+ app_request.events.push(*Event.events_for_exception(e))
28
+ end
29
+ raise
30
+ ensure
31
+ Storage.new(app_request.id).write(app_request.events.to_json) unless app_request.events.empty?
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'concerns/skip_request_paths'
4
+
5
+ module RailsSpotlight
6
+ module Middlewares
7
+ class RequestCompleted
8
+ include ::RailsSpotlight::Middlewares::SkipRequestPaths
9
+
10
+ def initialize(app, app_config)
11
+ @app = app
12
+ @app_config = app_config
13
+ end
14
+
15
+ def call(env)
16
+ if skip?(env['PATH_INFO']) || (env['HTTP_CONNECTION'] == 'Upgrade' && env['HTTP_UPGRADE'] == 'websocket')
17
+ app.call(env)
18
+ else
19
+ status, headers, body = app.call(env)
20
+ publish_event(status, headers, env)
21
+ [status, headers, body]
22
+ end
23
+ rescue => e # rubocop:disable Style/RescueStandardError
24
+ ::RailsSpotlight.config.logger.error "Error in RailsSpotlight::Middlewares::RequestCompletedHandler instrumentation: #{e.message}"
25
+ app.call(env)
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :app, :app_config
31
+
32
+ def rails_spotlight_request_id
33
+ Thread.current[:rails_spotlight_request_id]&.id
34
+ end
35
+
36
+ def publish_event(status, _headers, env)
37
+ return if status < 100
38
+ return unless rails_spotlight_request_id
39
+
40
+ request = ActionDispatch::Request.new(env)
41
+
42
+ host, url = host_and_url(env)
43
+ ActionCable.server.broadcast(
44
+ 'rails_spotlight_request_completed_channel',
45
+ {
46
+ rails_spotlight_version: RailsSpotlight::VERSION,
47
+ id: rails_spotlight_request_id,
48
+ http_method: env['REQUEST_METHOD'],
49
+ host: host,
50
+ url: url,
51
+ format: request.format.symbol,
52
+ controller: request.path_parameters[:controller],
53
+ action: request.path_parameters[:action]
54
+ }
55
+ )
56
+ end
57
+
58
+ def host_and_url(env)
59
+ scheme = env['rack.url_scheme']
60
+ host = env['HTTP_HOST']
61
+ path = env['PATH_INFO']
62
+ query_string = env['QUERY_STRING']
63
+
64
+ host_url = "#{scheme}://#{host}"
65
+ full_url = "#{host_url}#{path}"
66
+ full_url += "?#{query_string}" unless query_string.empty?
67
+ [host_url, full_url]
68
+ end
69
+ end
70
+ end
71
+ end
@@ -5,6 +5,7 @@ require_relative 'handlers/file_action_handler'
5
5
  require_relative 'handlers/sql_action_handler'
6
6
  require_relative 'handlers/verify_action_handler'
7
7
  require_relative 'handlers/not_found_action_handler'
8
+ require_relative 'handlers/meta_action_handler'
8
9
 
9
10
  module RailsSpotlight
10
11
  module Middlewares
@@ -31,6 +32,7 @@ module RailsSpotlight
31
32
  when 'file' then Handlers::FileActionHandler.new(*args).call
32
33
  when 'sql' then Handlers::SqlActionHandler.new(*args).call
33
34
  when 'verify' then Handlers::VerifyActionHandler.new(*args).call
35
+ when 'meta' then Handlers::MetaActionHandler.new(*args).call
34
36
  else
35
37
  Handlers::NotFoundActionHandler.new(*args).call
36
38
  end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsSpotlight
4
+ module Middlewares
5
+ autoload :RequestHandler, 'rails_spotlight/middlewares/request_handler'
6
+ autoload :RequestCompleted, 'rails_spotlight/middlewares/request_completed'
7
+ autoload :HeaderMarker, 'rails_spotlight/middlewares/header_marker'
8
+ autoload :MainRequestHandler, 'rails_spotlight/middlewares/main_request_handler'
9
+ end
10
+ end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rails/railtie'
4
- require 'meta_request'
5
4
 
6
5
  module RailsSpotlight
7
6
  class Railtie < ::Rails::Railtie
@@ -9,20 +8,41 @@ module RailsSpotlight
9
8
  insert_middleware unless Rails.env.production?
10
9
  end
11
10
 
12
- # initializer 'rails_spotlight.log_interceptor' do
13
- # Rails.logger&.extend(LogInterceptor)
14
- # end
15
- #
16
- # initializer 'rails_spotlight.subscribe_to_notifications' do
17
- # AppNotifications.subscribe
18
- # end
11
+ initializer 'rails_spotlight.log_interceptor' do
12
+ Rails.logger&.extend(LogInterceptor) unless Rails.env.production?
13
+ end
14
+
15
+ initializer 'rails_spotlight.subscribe_to_notifications' do
16
+ AppNotifications.subscribe unless Rails.env.production?
17
+ end
18
+
19
+ initializer 'rails_spotlight.action_cable_setup' do
20
+ unless Rails.env.production?
21
+ app.config.after_initialize do
22
+ existing_origins = Array(app.config.action_cable.allowed_request_origins)
23
+ app.config.action_cable.allowed_request_origins = existing_origins | [%r{\Achrome-extension://.*\z}]
24
+
25
+ require 'rails_spotlight/channels/request_completed_channel' if ::RailsSpotlight.config.request_completed_broadcast_enabled?
26
+ require 'rails_spotlight/channels/live_console_channel' if ::RailsSpotlight.config.live_console_enabled?
27
+ Rails.application.routes.draw { mount ActionCable.server => '/cable' } if ::RailsSpotlight.config.auto_mount_action_cable?
28
+ end
29
+ end
30
+ end
19
31
 
20
32
  def insert_middleware
33
+ app.middleware.use ::RailsSpotlight::Middlewares::RequestHandler
34
+
21
35
  if defined? ActionDispatch::DebugExceptions
22
- app.middleware.insert_before ActionDispatch::DebugExceptions, RailsSpotlight::Middlewares::RequestHandler
36
+ app.middleware.insert_before ActionDispatch::DebugExceptions, ::RailsSpotlight::Middlewares::HeaderMarker, app.config
23
37
  else
24
- app.middleware.use RailsSpotlight::Middlewares::RequestHandler
38
+ app.middleware.use ::RailsSpotlight::Middlewares::HeaderMarker, app.config
25
39
  end
40
+
41
+ app.middleware.use ::RailsSpotlight::Middlewares::MainRequestHandler
42
+
43
+ return unless ::RailsSpotlight.config.request_completed_broadcast_enabled?
44
+
45
+ app.middleware.insert_after ::RailsSpotlight::Middlewares::HeaderMarker, RailsSpotlight::Middlewares::RequestCompleted, app.config
26
46
  end
27
47
 
28
48
  def app
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsSpotlight
4
+ class Storage
5
+ attr_reader :key
6
+
7
+ def initialize(key)
8
+ @key = key
9
+ end
10
+
11
+ def write(value)
12
+ FileUtils.mkdir_p dir_path
13
+ # Use File.binwrite instead File.open(json_file, 'wb') { |file| file.write(value) }
14
+ File.binwrite(json_file, value)
15
+ maintain_file_pool(RailsSpotlight.config.storage_pool_size)
16
+ end
17
+
18
+ def read
19
+ # avoid FileNotFound error
20
+ File.exist?(json_file) ? File.read(json_file) : '[]'
21
+ end
22
+
23
+ private
24
+
25
+ def maintain_file_pool(size)
26
+ files = Dir["#{dir_path}/*.json"]
27
+ files = files.sort_by { |f| -file_ctime(f) }
28
+ (files[size..] || []).each do |file|
29
+ FileUtils.rm_f(file)
30
+ end
31
+ end
32
+
33
+ def file_ctime(file)
34
+ File.stat(file).ctime.to_i
35
+ rescue Errno::ENOENT
36
+ 0
37
+ end
38
+
39
+ def json_file
40
+ File.join(dir_path, "#{key}.json")
41
+ end
42
+
43
+ def dir_path
44
+ @dir_path ||= RailsSpotlight.config.storage_path
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsSpotlight
4
+ module Utils
5
+ module_function
6
+
7
+ def dev_callsite(caller)
8
+ app_line = caller.detect { |c| c.start_with? RailsSpotlight.config.rails_root }
9
+ return nil unless app_line
10
+
11
+ _, filename, _, line, _, method = app_line.split(/^(.*?)(:(\d+))(:in `(.*)')?$/)
12
+
13
+ {
14
+ filename: sub_source_path(filename),
15
+ line: line.to_i,
16
+ method: method
17
+ }
18
+ end
19
+
20
+ def sub_source_path(path)
21
+ rails_root = RailsSpotlight.config.rails_root
22
+ source_path = RailsSpotlight.config.source_path
23
+ return path if rails_root == source_path
24
+
25
+ path.sub(rails_root, source_path)
26
+ end
27
+ end
28
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsSpotlight
4
- VERSION = '0.1.6'
4
+ VERSION = '0.2.0'
5
5
  end
@@ -1,9 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'rails_spotlight/version'
4
- require_relative 'rails_spotlight/middlewares/request_handler'
5
-
6
3
  module RailsSpotlight
4
+ autoload :VERSION, 'rails_spotlight/version'
5
+ autoload :Configuration, 'rails_spotlight/configuration'
6
+ autoload :Storage, 'rails_spotlight/storage'
7
+ autoload :Event, 'rails_spotlight/event'
8
+ autoload :AppRequest, 'rails_spotlight/app_request'
9
+ autoload :Middlewares, 'rails_spotlight/middlewares'
10
+ autoload :LogInterceptor, 'rails_spotlight/log_interceptor'
11
+ autoload :AppNotifications, 'rails_spotlight/app_notifications'
12
+ autoload :Utils, 'rails_spotlight/utils'
13
+
14
+ class << self
15
+ def config
16
+ @config ||= Configuration.load_config
17
+ end
18
+ end
19
+
20
+ autoload :Channels, 'rails_spotlight/channels'
7
21
  end
8
22
 
9
23
  require_relative 'rails_spotlight/railtie'
24
+
25
+ if defined?(Rake)
26
+ spec = Gem::Specification.find_by_name 'rails_spotlight'
27
+ load "#{spec.gem_dir}/lib/tasks/init.rake"
28
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rake'
4
+
5
+ namespace :rails_spotlight do
6
+ desc 'Generate rails_spotlight configuration file'
7
+ task generate_config: :environment do
8
+ require 'fileutils'
9
+
10
+ config_path = Rails.root.join('config', 'rails_spotlight.yml')
11
+
12
+ default_config = <<~YAML
13
+ # Default configuration for RailsSpotlight
14
+ PROJECT_NAME: <%=Rails.application.class.respond_to?(:module_parent_name) ? Rails.application.class.module_parent_name : Rails.application.class.parent_name%>
15
+ SOURCE_PATH: <%=Rails.root%>
16
+ STORAGE_PATH: <%=Rails.root.join('tmp', 'data', 'rails_spotlight')%>
17
+ STORAGE_POOL_SIZE: 20
18
+ LOGGER: <%=Logger.new(Rails.root.join('log', 'rails_spotlight.log'))%>
19
+ MIDDLEWARE_SKIPPED_PATHS: []
20
+ NOT_ENCODABLE_EVENT_VALUES:
21
+ # Rest of the configuration is required for ActionCable. It will be disabled automatically in when ActionCable is not available.
22
+ LIVE_CONSOLE_ENABLED: true
23
+ REQUEST_COMPLETED_BROADCAST_ENABLED: false
24
+ AUTO_MOUNT_ACTION_CABLE: true
25
+ ACTION_CABLE_MOUNT_PATH: /cable
26
+ YAML
27
+
28
+ if File.exist?(config_path)
29
+ puts 'Config file already exists: config/rails_spotlight.yml'
30
+ else
31
+ FileUtils.mkdir_p(File.dirname(config_path))
32
+ File.write(config_path, default_config)
33
+ puts 'Created config file: config/rails_spotlight.yml'
34
+ end
35
+ end
36
+ end