builder_apm 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +1 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +6 -0
  5. data/CODE_OF_CONDUCT.md +74 -0
  6. data/Gemfile +17 -0
  7. data/Gemfile.lock +196 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +50 -0
  10. data/Rakefile +28 -0
  11. data/app/controllers/builder_apm/dashboard_controller.rb +8 -0
  12. data/app/controllers/builder_apm/error_requests_controller.rb +8 -0
  13. data/app/controllers/builder_apm/n_plus_one_controller.rb +8 -0
  14. data/app/controllers/builder_apm/recent_requests_controller.rb +8 -0
  15. data/app/controllers/builder_apm/request_analysis_controller.rb +8 -0
  16. data/app/controllers/builder_apm/request_data_controller.rb +41 -0
  17. data/app/controllers/builder_apm/request_details_controller.rb +9 -0
  18. data/app/controllers/builder_apm/slow_requests_controller.rb +8 -0
  19. data/app/controllers/builder_apm/wip_controller.rb +8 -0
  20. data/app/views/builder_apm/css/_dark.html.erb +119 -0
  21. data/app/views/builder_apm/css/_main.html.erb +268 -0
  22. data/app/views/builder_apm/dashboard/index.html.erb +10 -0
  23. data/app/views/builder_apm/error_requests/index.html.erb +23 -0
  24. data/app/views/builder_apm/js/_compress.html.erb +93 -0
  25. data/app/views/builder_apm/js/_dashboard.html.erb +199 -0
  26. data/app/views/builder_apm/js/_data_fetcher.html.erb +254 -0
  27. data/app/views/builder_apm/js/_error_requests.html.erb +65 -0
  28. data/app/views/builder_apm/js/_lzma.html.erb +2670 -0
  29. data/app/views/builder_apm/js/_n_plus_one.html.erb +79 -0
  30. data/app/views/builder_apm/js/_recent_requests.html.erb +82 -0
  31. data/app/views/builder_apm/js/_request_analysis.html.erb +77 -0
  32. data/app/views/builder_apm/js/_request_details.html.erb +204 -0
  33. data/app/views/builder_apm/js/_slow_requests.html.erb +74 -0
  34. data/app/views/builder_apm/n_plus_one/index.html.erb +21 -0
  35. data/app/views/builder_apm/recent_requests/index.html.erb +21 -0
  36. data/app/views/builder_apm/request_analysis/index.html.erb +24 -0
  37. data/app/views/builder_apm/request_details/index.html.erb +7 -0
  38. data/app/views/builder_apm/shared/_footer.html.erb +3 -0
  39. data/app/views/builder_apm/shared/_header.html.erb +55 -0
  40. data/app/views/builder_apm/slow_requests/index.html.erb +21 -0
  41. data/app/views/builder_apm/wip/index.html.erb +5 -0
  42. data/bin/console +14 -0
  43. data/bin/setup +8 -0
  44. data/builder_apm.gemspec +23 -0
  45. data/config/routes.rb +12 -0
  46. data/lib/builder_apm/configuration.rb +15 -0
  47. data/lib/builder_apm/controllers/instrumenter.rb +88 -0
  48. data/lib/builder_apm/engine.rb +17 -0
  49. data/lib/builder_apm/methods/instrumenter.rb +79 -0
  50. data/lib/builder_apm/middleware/timing.rb +56 -0
  51. data/lib/builder_apm/models/instrumenter.rb +82 -0
  52. data/lib/builder_apm/railtie.rb +9 -0
  53. data/lib/builder_apm/redis_client.rb +11 -0
  54. data/lib/builder_apm/version.rb +3 -0
  55. data/lib/builder_apm.rb +22 -0
  56. data/lib/generators/builder_apm/install_generator.rb +21 -0
  57. data/lib/generators/builder_apm/templates/builder_apm_config.rb +6 -0
  58. data/lib/generators/builder_apm/templates/create_builder_apm_requests.rb +21 -0
  59. data/lib/generators/builder_apm/templates/create_builder_apm_sql_queries.rb +17 -0
  60. metadata +135 -0
@@ -0,0 +1,21 @@
1
+ <%= render 'builder_apm/shared/header' %>
2
+
3
+ <table>
4
+ <thead>
5
+ <tr>
6
+ <th class="sortable" data-field="start_time">Time</th>
7
+ <th class="sortable" data-field="controller">Controller#Action</th>
8
+ <th class="sortable" data-field="status">Status</th>
9
+ <th class="sortable" data-field="real_duration_time">Duration (ms)</th>
10
+ <th class="sortable" data-field="calc_db_runtime">DB Runtime (ms)</th>
11
+ <th class="sortable" data-field="view_runtime">View Runtime (ms)</th>
12
+ </tr>
13
+ </thead>
14
+ <tbody>
15
+ <!-- Table content will be populated here by JavaScript -->
16
+ </tbody>
17
+ </table>
18
+
19
+ <%= render 'builder_apm/js/slow_requests' %>
20
+
21
+ <%= render 'builder_apm/shared/footer' %>
@@ -0,0 +1,5 @@
1
+ <%= render 'builder_apm/shared/header' %>
2
+ <div class="image-container">
3
+ <img src="https://cdn.discordapp.com/attachments/1098245214516297798/1131953429615485020/dripster82_web_page_under_construction_funncy_image_cartoonish_49e72072-6e1e-46ec-a597-2a53bda5eac0.png" />
4
+ </div>
5
+ <%= render 'builder_apm/shared/footer' %>
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "builder_apm"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,23 @@
1
+ require_relative 'lib/builder_apm/version'
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "builder_apm"
5
+ spec.version = BuilderApm::VERSION
6
+ spec.authors = ["Paul Ketelle"]
7
+ spec.email = ["paul.ketelle@builder.ai"]
8
+
9
+ spec.summary = %q{Write a short summary, because RubyGems requires one.}
10
+ spec.description = %q{Write a longer description or delete this line.}
11
+ # spec.homepage = "http://www.google.com"
12
+ spec.license = "MIT"
13
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0")
14
+ spec.add_dependency 'rails', '>= 4.0', '< 8'
15
+ spec.add_dependency "redis", "~> 4.5"
16
+
17
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
18
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
19
+ end
20
+ spec.bindir = "exe"
21
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
22
+ spec.require_paths = ["lib"]
23
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,12 @@
1
+ BuilderApm::Engine.routes.draw do
2
+ root to: redirect('/builder_apm/dashboard')
3
+ get 'dashboard', to: 'dashboard#index'
4
+ get 'request_data', to: 'request_data#index', defaults: { format: 'json' }
5
+ get 'recent_requests', to: 'recent_requests#index'
6
+ get 'request_details', to: 'request_details#index'
7
+ get 'errors_500', to: 'error_requests#index'
8
+ get 'slow_requests', to: 'slow_requests#index'
9
+ get 'request_analysis', to: 'request_analysis#index'
10
+ get 'n_plus_one', to: 'n_plus_one#index'
11
+
12
+ end
@@ -0,0 +1,15 @@
1
+ module BuilderApm
2
+ class Configuration
3
+ attr_accessor :redis_url
4
+ attr_accessor :enable_controller_profiler
5
+ attr_accessor :enable_active_record_profiler
6
+ attr_accessor :enable_methods_profiler
7
+
8
+ def initialize
9
+ @redis_url = 'redis://localhost:6379/0'
10
+ @enable_controller_profiler = true
11
+ @enable_active_record_profiler = true
12
+ @enable_methods_profiler = true
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,88 @@
1
+ module BuilderApm
2
+ module Controllers
3
+ class Instrumenter
4
+
5
+ def start
6
+ ActiveSupport::Notifications.subscribe 'start_processing.action_controller' do |*args|
7
+ event = event_from_args(*args)
8
+ process_start(event)
9
+ end
10
+
11
+ ActiveSupport::Notifications.subscribe 'process_action.action_controller' do |*args|
12
+ event = event_from_args(*args)
13
+ process_action(event)
14
+ end
15
+ end
16
+
17
+ def process_start(event)
18
+ return if event.payload[:controller].nil? || event.payload[:controller].start_with?("BuilderApm::")
19
+
20
+ uuid = event.payload[:headers].env['action_dispatch.request_id']
21
+ Thread.current[:request_id] = uuid
22
+ end
23
+
24
+ def process_action(event)
25
+ return if event.payload[:controller].start_with?("BuilderApm::")
26
+
27
+ request_id = Thread.current[:request_id]
28
+ Thread.current['request_data'] = extract_data_from_event(event)
29
+ ensure
30
+ clean_up_thread_values
31
+ end
32
+
33
+ private
34
+
35
+ def event_from_args(*args)
36
+ data = args.extract_options!
37
+ ActiveSupport::Notifications::Event.new(*args, data)
38
+ end
39
+
40
+ def extract_data_from_event(event)
41
+ data = {
42
+ request_id: Thread.current[:request_id],
43
+ controller: event.payload[:controller],
44
+ action: event.payload[:action],
45
+ params: event.payload[:params].except(:controller, :action),
46
+ path: event.payload[:path],
47
+ format: event.payload[:format],
48
+ method: event.payload[:method],
49
+ controller_line: file_and_line_number(event.payload[:controller], event.payload[:action]),
50
+ status: event.payload[:status],
51
+ start_time: event.time,
52
+ end_time: event.end,
53
+ duration: event.duration,
54
+ db_runtime: event.payload[:db_runtime],
55
+ view_runtime: event.payload[:view_runtime],
56
+ stack: Thread.current[:stack]
57
+ }
58
+ if event.payload[:exception]
59
+ exception_class, exception_message = event.payload[:exception]
60
+ data[:exception_class] = exception_class
61
+ data[:exception_message] = exception_message
62
+ data[:exception_backtrace] = event.payload[:exception_object].backtrace #.select { |line| line.start_with?(Rails.root.to_s) }
63
+ end
64
+
65
+ data
66
+ end
67
+
68
+ def clean_up_thread_values
69
+ Thread.current[:sql_event_id] = nil
70
+ Thread.current[:request_id] = nil
71
+ Thread.current[:stack] = nil
72
+ end
73
+
74
+ def file_and_line_number(controller, action)
75
+ begin
76
+ controller_class = controller.constantize
77
+ method_info = controller_class.instance_method(action)
78
+ absolute_path = method_info.source_location.first
79
+ line_number = method_info.source_location.last
80
+ relative_path = Pathname.new(absolute_path).relative_path_from(Rails.root)
81
+ "#{relative_path}:#{line_number}"
82
+ rescue NameError
83
+ "???"
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,17 @@
1
+ module BuilderApm
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace BuilderApm
4
+
5
+ initializer 'builder_apm.start' do |app|
6
+ BuilderApm::Controllers::Instrumenter.new.start if BuilderApm.configuration.enable_controller_profiler
7
+ BuilderApm::Models::Instrumenter.start if BuilderApm.configuration.enable_active_record_profiler
8
+ BuilderApm::Methods::Instrumenter.new.start if BuilderApm.configuration.enable_methods_profiler
9
+ end
10
+
11
+ config.after_initialize do
12
+ Rails.application.routes.append do
13
+ mount BuilderApm::Engine => '/builder_apm'
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,79 @@
1
+ module BuilderApm
2
+ module Methods
3
+ class Instrumenter
4
+ def initialize(root_path: Rails.root.to_s)
5
+ @root_path = root_path
6
+ @call_times = {}
7
+ end
8
+
9
+ def start
10
+ @trace = setup_trace
11
+ @trace.enable
12
+ end
13
+
14
+ def stop
15
+ @trace.disable unless @trace.nil?
16
+ end
17
+
18
+ def setup_trace
19
+ me = self
20
+ TracePoint.new(:call, :return) do |tp|
21
+ me.process_trace_point(tp) if me.valid_trace_point?(tp)
22
+ end
23
+ end
24
+
25
+ def valid_trace_point?(tp)
26
+ !Thread.current[:request_id].nil? && tp.path.start_with?(@root_path)
27
+ end
28
+
29
+ def process_trace_point(tp)
30
+ if tp.event == :call
31
+ process_call_event(tp)
32
+ elsif tp.event == :return
33
+ process_return_event(tp)
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def process_call_event(tp)
40
+ method_id = "#{tp.defined_class}##{tp.method_id}"
41
+ @call_times[method_id] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
+ caller_info = caller_locations(4,1).first
43
+ calling_file_path = caller_info.absolute_path
44
+ calling_line_number = caller_info.lineno
45
+
46
+ method_call = {
47
+ method: method_id,
48
+ method_line: "#{tp.path.gsub(@root_path, '')}:#{tp.lineno}",
49
+ triggering_line: "#{calling_file_path.gsub(@root_path, '')}:#{calling_line_number}",
50
+ children: [],
51
+ start_time: Time.now.to_f * 1000,
52
+ sql_events: []
53
+ }
54
+
55
+ (Thread.current[:stack] ||= []).push(method_call)
56
+ end
57
+
58
+ def process_return_event(tp)
59
+ method_id = "#{tp.defined_class}##{tp.method_id}"
60
+
61
+ if @call_times.key?(method_id)
62
+ elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @call_times[method_id]
63
+ elapsed_time_in_ms = (elapsed_time * 1000).round(3)
64
+ @call_times.delete(method_id)
65
+
66
+ method_call = (Thread.current[:stack] ||= []).pop
67
+ method_call[:end_time] = Time.now.to_f * 1000
68
+ method_call[:duration] = elapsed_time_in_ms
69
+
70
+ if Thread.current[:stack]&.any?
71
+ Thread.current[:stack].last[:children].push(method_call)
72
+ else
73
+ Thread.current[:stack].push(method_call)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,56 @@
1
+ module BuilderApm
2
+ module Middleware
3
+ class Timing
4
+ def initialize(app, redis_client: BuilderApm::RedisClient.client)
5
+ @redis_client = redis_client
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ request_id = env["action_dispatch.request_id"]
11
+ Thread.current["request_id"] = request_id
12
+ start_time = Time.now.to_f * 1000
13
+
14
+ begin
15
+ result = @app.call(env)
16
+ rescue => e
17
+ handle_exception(e, start_time, request_id)
18
+ raise e
19
+ end
20
+
21
+ end_time = Time.now.to_f * 1000
22
+ handle_timing(start_time, end_time, request_id)
23
+
24
+ result
25
+ end
26
+
27
+ private
28
+
29
+ def handle_timing(start_time, end_time, request_id)
30
+ duration = end_time - start_time;
31
+ data = Thread.current['request_data']
32
+
33
+ if data
34
+ data[:real_start_time] = start_time
35
+ data[:real_end_time] = end_time
36
+ data[:real_duration_time] = end_time - start_time
37
+ Thread.current['request_data'] = nil
38
+
39
+ @redis_client.zadd("builder_apm:timestamps", end_time, request_id)
40
+ @redis_client.set("builder_apm:Request:#{data[:request_id]}", data.to_json)
41
+ end
42
+ end
43
+
44
+ def handle_exception(e, start_time, request_id)
45
+ end_time = Time.now.to_f * 1000
46
+
47
+ data = Thread.current['request_data'] || {}
48
+ data[:exception_class] = e.class.to_s
49
+ data[:exception_message] = e.message
50
+ data[:exception_backtrace] = e.backtrace
51
+
52
+ handle_timing(start_time, end_time, request_id)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,82 @@
1
+ module BuilderApm
2
+ module Models
3
+ class Instrumenter
4
+ def self.start
5
+ new.subscribe_to_notifications
6
+ end
7
+
8
+ def subscribe_to_notifications
9
+ ActiveSupport::Notifications.subscribe('sql.active_record') { |*args| handle_sql_active_record(*args) }
10
+ ActiveSupport::Notifications.subscribe('instantiation.active_record') { |*args| handle_instantiation_active_record(*args) }
11
+ end
12
+
13
+ private
14
+
15
+ def handle_sql_active_record(*args)
16
+ data = args.extract_options!
17
+ event = ActiveSupport::Notifications::Event.new(*args, data)
18
+ name = event.payload[:name]
19
+ return if name == "SCHEMA" || Thread.current[:request_id].nil?
20
+
21
+ triggering_line = determine_triggering_line(caller)
22
+
23
+ sql_query_data = build_sql_query_data(event, triggering_line)
24
+ store_sql_query_data(sql_query_data)
25
+ end
26
+
27
+ def handle_instantiation_active_record(*args)
28
+ data = args.extract_options!
29
+ event = ActiveSupport::Notifications::Event.new(*args, data)
30
+
31
+ update_last_sql_query_data_with_instantiation_info(event)
32
+ end
33
+
34
+ def determine_triggering_line(call_stack)
35
+ app_stack = call_stack.select { |line| line.include?(Rails.root.to_s) }
36
+ app_stack.first.to_s.gsub(Rails.root.to_s, '')
37
+ end
38
+
39
+ def build_sql_query_data(event, triggering_line)
40
+ {
41
+ request_id: Thread.current[:request_id],
42
+ sql_id: SecureRandom.uuid,
43
+ sql: event.payload[:sql],
44
+ params: event.payload[:binds].map { |a| a.value },
45
+ triggering_line: triggering_line,
46
+ name: event.payload[:name],
47
+ cached: event.payload[:cached] || false,
48
+ start_time: event.time,
49
+ end_time: event.end,
50
+ duration: event.duration,
51
+ record_count: 0,
52
+ class_name: ''
53
+ }
54
+ end
55
+
56
+ def store_sql_query_data(sql_query_data)
57
+ Thread.current[:sql_event_id] = sql_query_data[:sql_id]
58
+
59
+ if Thread.current[:stack]&.any?
60
+ Thread.current[:stack].last[:sql_events].push(sql_query_data)
61
+ else
62
+ (Thread.current[:stack] ||= []).push({sql_events: [sql_query_data], children: []})
63
+ end
64
+ end
65
+
66
+ def update_last_sql_query_data_with_instantiation_info(event)
67
+ stack = Thread.current[:stack]
68
+ request_id = Thread.current[:request_id]
69
+
70
+ return if stack.nil? || stack.empty? || request_id.nil?
71
+
72
+ last_sql = Thread.current[:stack].last[:sql_events].pop
73
+ if last_sql[:sql_id] == Thread.current[:sql_event_id]
74
+ last_sql[:record_count] = event.payload[:record_count]
75
+ last_sql[:class_name] = event.payload[:class_name]
76
+ end
77
+ Thread.current[:stack].last[:sql_events].push(last_sql)
78
+ Thread.current[:sql_event_id] = nil
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,9 @@
1
+ # lib/builder_apm/railtie.rb
2
+ module BuilderApm
3
+ class Railtie < ::Rails::Railtie
4
+ initializer 'builder_apm.insert_middleware' do |app|
5
+ app.config.middleware.use BuilderApm::Middleware::Timing
6
+ end
7
+ end
8
+ end
9
+
@@ -0,0 +1,11 @@
1
+ require 'redis'
2
+
3
+ module BuilderApm
4
+ module RedisClient
5
+ def self.client
6
+ @client ||= begin
7
+ Redis.new(url: BuilderApm.configuration.redis_url)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,3 @@
1
+ module BuilderApm
2
+ VERSION = "0.2.0"
3
+ end
@@ -0,0 +1,22 @@
1
+ require "builder_apm/version"
2
+ require 'builder_apm/controllers/instrumenter'
3
+ require 'builder_apm/models/instrumenter'
4
+ require 'builder_apm/methods/instrumenter'
5
+ require 'builder_apm/middleware/timing'
6
+ require 'builder_apm/configuration'
7
+ require 'builder_apm/engine'
8
+ require 'builder_apm/redis_client'
9
+ require 'builder_apm/railtie'
10
+
11
+ module BuilderApm
12
+ def self.configure
13
+ yield(configuration)
14
+ end
15
+
16
+ def self.configuration
17
+ @configuration ||= Configuration.new
18
+ end
19
+
20
+ class Error < StandardError; end
21
+ # Your code goes here...
22
+ end
@@ -0,0 +1,21 @@
1
+ module BuilderApm
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ include Rails::Generators::Migration
5
+ source_root File.expand_path('templates', __dir__)
6
+
7
+ def self.next_migration_number(dirname)
8
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
9
+ end
10
+
11
+ def copy_migrations
12
+ migration_template "create_builder_apm_requests.rb", "db/migrate/create_builder_apm_requests.rb"
13
+ migration_template "create_builder_apm_sql_queries.rb", "db/migrate/create_builder_apm_sql_queries.rb"
14
+ end
15
+
16
+ def copy_initializer_file
17
+ copy_file 'builder_apm_config.rb', 'config/initializers/builder_apm.rb'
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,6 @@
1
+ BuilderApm.configure do |config|
2
+ # config.redis_url = 'redis://localhost:6379/0'
3
+ # config.enable_controller_profiler = true
4
+ # config.enable_active_record_profiler = true
5
+ # config.enable_methods_profiler = true
6
+ end
@@ -0,0 +1,21 @@
1
+ class CreateBuilderApmRequests < ActiveRecord::Migration[6.0]
2
+ def change
3
+ create_table :builder_apm_requests do |t|
4
+ t.string :request_url
5
+ t.string :request_method
6
+ t.string :controller
7
+ t.string :action
8
+ t.text :params
9
+ t.integer :status
10
+ t.string :format
11
+ t.string :file_and_line_number
12
+ t.float :duration
13
+ t.float :view_runtime
14
+ t.float :db_runtime
15
+ t.datetime :start_time
16
+ t.datetime :end_time
17
+
18
+ t.timestamps
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ class CreateBuilderApmSqlQueries < ActiveRecord::Migration[6.0]
2
+ def change
3
+ create_table :builder_apm_sql_queries do |t|
4
+ t.string :event_uuid
5
+ t.text :sql
6
+ t.text :params
7
+ t.float :duration
8
+ t.integer :result_count
9
+ t.string :class_name
10
+ t.boolean :rails_cached
11
+ t.datetime :start_time
12
+ t.datetime :end_time
13
+
14
+ t.timestamps
15
+ end
16
+ end
17
+ end