rails_spotlight 0.1.6 → 0.2.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.
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