builder_apm 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 (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