railswatch 1.0.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 (138) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +485 -0
  4. data/Rakefile +37 -0
  5. data/app/assets/config/railswatch_manifest.js +0 -0
  6. data/app/assets/images/activity.svg +13 -0
  7. data/app/assets/images/bot.svg +1 -0
  8. data/app/assets/images/close.svg +13 -0
  9. data/app/assets/images/details.svg +3 -0
  10. data/app/assets/images/download.svg +3 -0
  11. data/app/assets/images/export.svg +13 -0
  12. data/app/assets/images/external.svg +1 -0
  13. data/app/assets/images/git.svg +1 -0
  14. data/app/assets/images/github.svg +1 -0
  15. data/app/assets/images/home.svg +16 -0
  16. data/app/assets/images/import.svg +13 -0
  17. data/app/assets/images/menu.svg +16 -0
  18. data/app/assets/images/moon.svg +3 -0
  19. data/app/assets/images/stat.svg +1 -0
  20. data/app/assets/images/sun.svg +4 -0
  21. data/app/assets/images/user.svg +1 -0
  22. data/app/controllers/railswatch/base_controller.rb +35 -0
  23. data/app/controllers/railswatch/concerns/csv_exportable.rb +31 -0
  24. data/app/controllers/railswatch/railswatch_controller.rb +183 -0
  25. data/app/engine_assets/javascripts/apex_ext.js +30 -0
  26. data/app/engine_assets/javascripts/application.js +9 -0
  27. data/app/engine_assets/javascripts/autoupdate.js +79 -0
  28. data/app/engine_assets/javascripts/charts.js +279 -0
  29. data/app/engine_assets/javascripts/navbar.js +11 -0
  30. data/app/engine_assets/javascripts/panel.js +43 -0
  31. data/app/engine_assets/javascripts/table.js +12 -0
  32. data/app/engine_assets/javascripts/theme.js +43 -0
  33. data/app/engine_assets/stylesheets/panel.css +111 -0
  34. data/app/engine_assets/stylesheets/responsive.css +102 -0
  35. data/app/engine_assets/stylesheets/style.css +960 -0
  36. data/app/helpers/railswatch/railswatch_helper.rb +338 -0
  37. data/app/views/railswatch/_panel.html.erb +15 -0
  38. data/app/views/railswatch/layouts/railswatch.html.erb +81 -0
  39. data/app/views/railswatch/railswatch/_card.html.erb +7 -0
  40. data/app/views/railswatch/railswatch/_chart.html.erb +13 -0
  41. data/app/views/railswatch/railswatch/_crashes_table_content.html.erb +62 -0
  42. data/app/views/railswatch/railswatch/_custom_events_table_content.html.erb +27 -0
  43. data/app/views/railswatch/railswatch/_delayed_job_table_content.html.erb +52 -0
  44. data/app/views/railswatch/railswatch/_export.html.erb +4 -0
  45. data/app/views/railswatch/railswatch/_grape_requests_table_content.html.erb +31 -0
  46. data/app/views/railswatch/railswatch/_overview.html.erb +124 -0
  47. data/app/views/railswatch/railswatch/_rake_tasks_table_content.html.erb +25 -0
  48. data/app/views/railswatch/railswatch/_recent_requests_table_content.html.erb +28 -0
  49. data/app/views/railswatch/railswatch/_recent_row.html.erb +41 -0
  50. data/app/views/railswatch/railswatch/_requests_table_content.html.erb +51 -0
  51. data/app/views/railswatch/railswatch/_sidekiq_jobs_table_content.html.erb +50 -0
  52. data/app/views/railswatch/railswatch/_summary.html.erb +50 -0
  53. data/app/views/railswatch/railswatch/_table.html.erb +30 -0
  54. data/app/views/railswatch/railswatch/_trace.html.erb +78 -0
  55. data/app/views/railswatch/railswatch/crashes.html.erb +2 -0
  56. data/app/views/railswatch/railswatch/custom.html.erb +6 -0
  57. data/app/views/railswatch/railswatch/delayed_job.html.erb +6 -0
  58. data/app/views/railswatch/railswatch/grape.html.erb +6 -0
  59. data/app/views/railswatch/railswatch/index.html.erb +9 -0
  60. data/app/views/railswatch/railswatch/rake.html.erb +6 -0
  61. data/app/views/railswatch/railswatch/recent.html.erb +2 -0
  62. data/app/views/railswatch/railswatch/requests.html.erb +2 -0
  63. data/app/views/railswatch/railswatch/resources.html.erb +28 -0
  64. data/app/views/railswatch/railswatch/sidekiq.html.erb +6 -0
  65. data/app/views/railswatch/railswatch/slow.html.erb +2 -0
  66. data/app/views/railswatch/railswatch/summary.js.erb +3 -0
  67. data/app/views/railswatch/railswatch/trace.js.erb +9 -0
  68. data/app/views/railswatch/shared/_header.html.erb +39 -0
  69. data/app/views/railswatch/shared/_page_header.html.erb +23 -0
  70. data/config/routes.rb +27 -0
  71. data/lib/generators/railswatch/install/USAGE +19 -0
  72. data/lib/generators/railswatch/install/install_generator.rb +46 -0
  73. data/lib/generators/railswatch/install/templates/create_railswatch_tables.rb +140 -0
  74. data/lib/generators/railswatch/install/templates/initializer.rb +87 -0
  75. data/lib/railswatch/data_source.rb +106 -0
  76. data/lib/railswatch/engine.rb +103 -0
  77. data/lib/railswatch/events/record.rb +63 -0
  78. data/lib/railswatch/extensions/trace.rb +14 -0
  79. data/lib/railswatch/extensions/trace_db.rb +21 -0
  80. data/lib/railswatch/gems/custom_ext.rb +38 -0
  81. data/lib/railswatch/gems/delayed_job_ext.rb +70 -0
  82. data/lib/railswatch/gems/grape_ext.rb +64 -0
  83. data/lib/railswatch/gems/rake_ext.rb +69 -0
  84. data/lib/railswatch/gems/sidekiq_ext.rb +55 -0
  85. data/lib/railswatch/instrument/metrics_collector.rb +70 -0
  86. data/lib/railswatch/interface.rb +9 -0
  87. data/lib/railswatch/models/application_record.rb +31 -0
  88. data/lib/railswatch/models/base_record.rb +59 -0
  89. data/lib/railswatch/models/collection.rb +37 -0
  90. data/lib/railswatch/models/custom_record.rb +32 -0
  91. data/lib/railswatch/models/delayed_job_record.rb +39 -0
  92. data/lib/railswatch/models/event_record.rb +11 -0
  93. data/lib/railswatch/models/grape_record.rb +61 -0
  94. data/lib/railswatch/models/rake_record.rb +33 -0
  95. data/lib/railswatch/models/request_record.rb +105 -0
  96. data/lib/railswatch/models/resource_record.rb +33 -0
  97. data/lib/railswatch/models/sidekiq_record.rb +41 -0
  98. data/lib/railswatch/models/trace_record.rb +21 -0
  99. data/lib/railswatch/pruner.rb +47 -0
  100. data/lib/railswatch/rails/middleware.rb +117 -0
  101. data/lib/railswatch/rails/query_builder.rb +20 -0
  102. data/lib/railswatch/reports/annotations_report.rb +13 -0
  103. data/lib/railswatch/reports/base_report.rb +48 -0
  104. data/lib/railswatch/reports/breakdown_report.rb +11 -0
  105. data/lib/railswatch/reports/crash_report.rb +11 -0
  106. data/lib/railswatch/reports/overview_report.rb +88 -0
  107. data/lib/railswatch/reports/percentile_report.rb +16 -0
  108. data/lib/railswatch/reports/recent_requests_report.rb +21 -0
  109. data/lib/railswatch/reports/requests_report.rb +75 -0
  110. data/lib/railswatch/reports/resources_report.rb +42 -0
  111. data/lib/railswatch/reports/response_time_report.rb +27 -0
  112. data/lib/railswatch/reports/slow_requests_report.rb +21 -0
  113. data/lib/railswatch/reports/throughput_report.rb +16 -0
  114. data/lib/railswatch/reports/trace_report.rb +17 -0
  115. data/lib/railswatch/system_monitor/resources_monitor.rb +88 -0
  116. data/lib/railswatch/thread/current_request.rb +37 -0
  117. data/lib/railswatch/utils.rb +58 -0
  118. data/lib/railswatch/version.rb +7 -0
  119. data/lib/railswatch/widgets/base.rb +17 -0
  120. data/lib/railswatch/widgets/card.rb +19 -0
  121. data/lib/railswatch/widgets/chart.rb +33 -0
  122. data/lib/railswatch/widgets/crashes_table.rb +27 -0
  123. data/lib/railswatch/widgets/custom_events_table.rb +48 -0
  124. data/lib/railswatch/widgets/delayed_job_table.rb +31 -0
  125. data/lib/railswatch/widgets/grape_requests_table.rb +31 -0
  126. data/lib/railswatch/widgets/percentile_card.rb +23 -0
  127. data/lib/railswatch/widgets/rake_tasks_table.rb +31 -0
  128. data/lib/railswatch/widgets/recent_requests_table.rb +35 -0
  129. data/lib/railswatch/widgets/requests_table.rb +27 -0
  130. data/lib/railswatch/widgets/resource_chart.rb +116 -0
  131. data/lib/railswatch/widgets/response_time_chart.rb +29 -0
  132. data/lib/railswatch/widgets/sidekiq_jobs_table.rb +31 -0
  133. data/lib/railswatch/widgets/slow_requests_table.rb +33 -0
  134. data/lib/railswatch/widgets/table.rb +43 -0
  135. data/lib/railswatch/widgets/throughput_chart.rb +29 -0
  136. data/lib/railswatch.rb +184 -0
  137. data/lib/tasks/railswatch.rake +9 -0
  138. metadata +445 -0
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railswatch
4
+ module Models
5
+ class RequestRecord < BaseRecord
6
+ self.table_name = 'railswatch_request_records'
7
+
8
+ serialize :custom_data, coder: JSON
9
+ serialize :backtrace, coder: JSON
10
+ serialize :request_context, coder: JSON
11
+
12
+ attr_accessor :exception_object
13
+
14
+ validates :request_id, presence: true
15
+ validates :occurred_at, presence: true
16
+
17
+ def method
18
+ http_method
19
+ end
20
+
21
+ def method=(value)
22
+ self.http_method = value
23
+ end
24
+
25
+ def view_runtime
26
+ view_runtime_ms
27
+ end
28
+
29
+ def view_runtime=(value)
30
+ self.view_runtime_ms = value
31
+ end
32
+
33
+ def db_runtime
34
+ db_runtime_ms
35
+ end
36
+
37
+ def db_runtime=(value)
38
+ self.db_runtime_ms = value
39
+ end
40
+
41
+ def duration
42
+ duration_ms
43
+ end
44
+
45
+ def duration=(value)
46
+ self.duration_ms = value
47
+ end
48
+
49
+ def controller_action
50
+ "#{controller}##{action}"
51
+ end
52
+
53
+ def controller_action_format
54
+ "#{controller}##{action}|#{format}"
55
+ end
56
+
57
+ def payload_hash
58
+ {
59
+ 'view_runtime' => view_runtime_ms,
60
+ 'db_runtime' => db_runtime_ms,
61
+ 'duration' => duration_ms,
62
+ 'http_referer' => http_referer,
63
+ 'custom_data' => custom_data,
64
+ 'exception' => exception,
65
+ 'backtrace' => backtrace,
66
+ 'request_context' => request_context
67
+ }
68
+ end
69
+
70
+ def record_hash
71
+ base_record_hash.merge(custom_record_hash)
72
+ end
73
+
74
+ def base_record_hash # rubocop:disable Metrics/MethodLength
75
+ {
76
+ controller: controller,
77
+ action: action,
78
+ format: self.format,
79
+ status: status,
80
+ method: http_method,
81
+ path: path,
82
+ request_id: request_id,
83
+ datetime: occurred_at,
84
+ datetimei: occurred_at.to_i,
85
+ duration: duration_ms,
86
+ db_runtime: db_runtime_ms,
87
+ view_runtime: view_runtime_ms,
88
+ exception: exception,
89
+ backtrace: backtrace,
90
+ http_referer: http_referer,
91
+ request_context: request_context
92
+ }
93
+ end
94
+
95
+ def custom_record_hash
96
+ custom_data.is_a?(Hash) ? custom_data.deep_symbolize_keys : {}
97
+ end
98
+
99
+ before_save do
100
+ self.exception = Array.wrap(exception).compact.join(' ') if exception.is_a?(Array)
101
+ self.backtrace = exception_object.backtrace.take(20) if exception_object
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railswatch
4
+ module Models
5
+ class ResourceRecord < BaseRecord
6
+ self.table_name = 'railswatch_resource_records'
7
+
8
+ serialize :payload, coder: JSON
9
+
10
+ def json
11
+ payload
12
+ end
13
+
14
+ def json=(value)
15
+ self.payload = value
16
+ end
17
+
18
+ def payload_hash
19
+ payload || {}
20
+ end
21
+
22
+ def record_hash
23
+ payload_hash.symbolize_keys.merge(
24
+ server: server,
25
+ role: role,
26
+ context: context,
27
+ datetime: occurred_at,
28
+ datetimei: occurred_at.to_i
29
+ )
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railswatch
4
+ module Models
5
+ class SidekiqRecord < BaseRecord
6
+ self.table_name = 'railswatch_sidekiq_records'
7
+
8
+ serialize :job_args, coder: JSON
9
+
10
+ def duration
11
+ duration_ms
12
+ end
13
+
14
+ def duration=(value)
15
+ self.duration_ms = value
16
+ end
17
+
18
+ def payload_hash
19
+ {
20
+ 'message' => message,
21
+ 'duration' => duration_ms
22
+ }
23
+ end
24
+
25
+ def record_hash # rubocop:disable Metrics/MethodLength
26
+ {
27
+ worker: worker,
28
+ queue: queue,
29
+ jid: jid,
30
+ status: status,
31
+ datetimei: occurred_at.to_i,
32
+ datetime: Railswatch::Utils.from_datetimei(start_timei.to_i),
33
+ duration: duration_ms,
34
+ message: message,
35
+ job_args: job_args,
36
+ error_backtrace: error_backtrace
37
+ }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railswatch
4
+ module Models
5
+ class TraceRecord < BaseRecord
6
+ self.table_name = 'railswatch_trace_records'
7
+
8
+ serialize :entries, coder: JSON
9
+
10
+ validates :request_id, presence: true
11
+
12
+ def value
13
+ entries || []
14
+ end
15
+
16
+ def value=(val)
17
+ self.entries = val
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railswatch
4
+ class Pruner
5
+ DEFAULT_BATCH_SIZE = 1_000
6
+
7
+ MODEL_TYPES = {
8
+ requests: 'RequestRecord',
9
+ sidekiq: 'SidekiqRecord',
10
+ delayed_job: 'DelayedJobRecord',
11
+ grape: 'GrapeRecord',
12
+ rake: 'RakeRecord',
13
+ custom: 'CustomRecord',
14
+ resources: 'ResourceRecord',
15
+ traces: 'TraceRecord',
16
+ events: 'EventRecord'
17
+ }.freeze
18
+
19
+ def self.call(batch_size: DEFAULT_BATCH_SIZE)
20
+ MODEL_TYPES.each_with_object({}) do |(type, class_name), results|
21
+ deleted = deleted_records(type, class_name, batch_size)
22
+ results[type] = deleted unless deleted.nil?
23
+ end
24
+ end
25
+
26
+ def self.deleted_records(type, class_name, batch_size)
27
+ retention = Railswatch.retention[type]
28
+ return if retention.nil?
29
+
30
+ model = Railswatch::Models.const_get(class_name)
31
+ threshold = Railswatch::Utils.kind_of_now - retention
32
+
33
+ delete_in_batches(model, threshold, batch_size)
34
+ end
35
+
36
+ def self.delete_in_batches(model, threshold, batch_size)
37
+ deleted = 0
38
+
39
+ loop do
40
+ ids = model.where('occurred_at < ?', threshold).limit(batch_size).pluck(:id)
41
+ break deleted if ids.empty?
42
+
43
+ deleted += model.where(id: ids).delete_all
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railswatch
4
+ module Rails
5
+ class MiddlewareTraceStorerAndCleanup
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ def call(env)
11
+ dup.call!(env)
12
+ end
13
+
14
+ def call!(env)
15
+ Railswatch.skip = true if monitoring_path?(env)
16
+
17
+ @status, @headers, @response = @app.call(env)
18
+ save_trace_record unless Railswatch.skip
19
+
20
+ CurrentRequest.cleanup
21
+
22
+ [@status, @headers, @response]
23
+ end
24
+
25
+ private
26
+
27
+ def monitoring_path?(env)
28
+ /#{Railswatch.mount_at}/.match?(env['PATH_INFO'])
29
+ end
30
+
31
+ def save_trace_record
32
+ Railswatch::Models::TraceRecord.new(
33
+ request_id: CurrentRequest.current.request_id,
34
+ value: CurrentRequest.current.tracings,
35
+ occurred_at: Railswatch::Utils.time
36
+ ).save
37
+ end
38
+ end
39
+
40
+ class Middleware
41
+ def initialize(app)
42
+ @app = app
43
+ end
44
+
45
+ def call(env)
46
+ dup.call!(env)
47
+ end
48
+
49
+ def call!(env)
50
+ @status, @headers, @response = @app.call(env)
51
+ save_request_record(env)
52
+
53
+ [@status, @headers, @response]
54
+ end
55
+
56
+ private
57
+
58
+ def save_request_record(env)
59
+ data = CurrentRequest.current.data
60
+ return if Railswatch.skip || CurrentRequest.current.ignore.include?(:monitoring) || data.blank?
61
+
62
+ record = Railswatch::Models::RequestRecord.new(**data, request_id: CurrentRequest.current.request_id)
63
+ normalize_status!(record)
64
+ add_http_referer!(record, env)
65
+ add_custom_data!(record, env)
66
+ add_request_context!(record, env)
67
+ record.save
68
+ end
69
+
70
+ def normalize_status!(record)
71
+ record.status ||= @status
72
+ record.status = record.status.to_s if record.status.present?
73
+ end
74
+
75
+ def add_http_referer!(record, env)
76
+ record.http_referer = env['HTTP_REFERER'] if record.status == '404'
77
+ end
78
+
79
+ def add_custom_data!(record, env)
80
+ return unless Railswatch.custom_data_proc
81
+
82
+ record.custom_data = Railswatch.custom_data_proc.call(env)
83
+ end
84
+
85
+ def add_request_context!(record, env)
86
+ record.request_context = {
87
+ ip: request_ip(env),
88
+ user_agent: env['HTTP_USER_AGENT'],
89
+ params: filtered_request_params(env).presence,
90
+ user: current_user_info(env)
91
+ }.compact
92
+ rescue StandardError
93
+ nil
94
+ end
95
+
96
+ def request_ip(env)
97
+ forwarded = env['HTTP_X_FORWARDED_FOR']
98
+ forwarded ? forwarded.split(',').first&.strip : env['REMOTE_ADDR']
99
+ end
100
+
101
+ def filtered_request_params(env)
102
+ params = ActionDispatch::Request.new(env).params.to_h
103
+ Railswatch::Utils.filter_params(params)
104
+ rescue StandardError
105
+ {}
106
+ end
107
+
108
+ def current_user_info(env)
109
+ return nil unless Railswatch.current_user_proc
110
+
111
+ Railswatch.current_user_proc.call(env)&.presence
112
+ rescue StandardError
113
+ nil
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railswatch
4
+ module Rails
5
+ class QueryBuilder
6
+ def self.compose_from(params)
7
+ result = {}
8
+
9
+ result[:controller] = params[:controller_eq]
10
+ result[:action] = params[:action_eq]
11
+ result[:format] = params[:format_eq]
12
+ result[:status] = params[:status_eq]
13
+
14
+ result.delete_if { |_k, v| v.nil? }
15
+
16
+ { query: result }
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railswatch
4
+ module Reports
5
+ class AnnotationsReport
6
+ def data
7
+ {
8
+ xaxis: Railswatch::Events::Record.all.map(&:to_annotation)
9
+ }
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railswatch
4
+ module Reports
5
+ class BaseReport
6
+ attr_reader :db, :group, :sort, :title
7
+
8
+ def initialize(db, group: nil, sort: nil, title: nil)
9
+ @db = db
10
+ @group = group
11
+ @sort = sort
12
+ @title = title
13
+ set_defaults
14
+ end
15
+
16
+ def set_defaults; end
17
+
18
+ def self.time_in_app_time_zone(time)
19
+ app_time_zone = ::Rails.application.config.time_zone
20
+ app_time_zone.present? ? time.in_time_zone(app_time_zone) : time
21
+ end
22
+
23
+ def nil_data(duration = Railswatch.duration)
24
+ @nil_data_cache ||= {}
25
+ @nil_data_cache[duration] ||= build_nil_data(duration)
26
+ end
27
+
28
+ def nullify_data(input, duration = Railswatch.duration)
29
+ nil_data(duration).merge(input).sort
30
+ end
31
+
32
+ private
33
+
34
+ def build_nil_data(duration)
35
+ result = {}
36
+ stop = Railswatch::Utils.kind_of_now.change(sec: 0, usec: 0)
37
+ current = stop - duration
38
+
39
+ while current <= stop
40
+ result[current.to_i * 1000] = nil
41
+ current += 1.minute
42
+ end
43
+
44
+ result
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railswatch
4
+ module Reports
5
+ class BreakdownReport < BaseReport
6
+ def data
7
+ db.order(occurred_at: :desc).map(&:record_hash)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railswatch
4
+ module Reports
5
+ class CrashReport < BaseReport
6
+ def data
7
+ db.order(occurred_at: :desc).map(&:record_hash)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railswatch
4
+ module Reports
5
+ class OverviewReport < BaseReport
6
+ def data
7
+ rows = request_rows
8
+ grouped = grouped_rows(rows)
9
+ build_overview_data(rows, grouped)
10
+ end
11
+
12
+ private
13
+
14
+ def build_overview_data(rows, grouped)
15
+ {
16
+ total_requests: rows.size,
17
+ average_duration: average_duration(rows),
18
+ error_rate: error_rate(rows),
19
+ slow_requests: slow_request_count(rows),
20
+ unique_endpoints: grouped.keys.size,
21
+ busiest_endpoint: endpoint_volume(grouped),
22
+ slowest_endpoint: slowest_endpoint(grouped),
23
+ latest_request_at: latest_request_at(rows)
24
+ }
25
+ end
26
+
27
+ def request_rows
28
+ db.pluck(:controller, :action, :status, :duration_ms, :occurred_at)
29
+ end
30
+
31
+ def grouped_rows(rows)
32
+ rows.group_by { |controller, action, *_rest| "#{controller}##{action}" }
33
+ end
34
+
35
+ def average_duration(rows)
36
+ return nil if rows.empty?
37
+
38
+ rows.sum { |row| duration_value(row) } / rows.size
39
+ end
40
+
41
+ def error_rate(rows)
42
+ return 0 if rows.empty?
43
+
44
+ (error_count(rows).to_f / rows.size) * 100
45
+ end
46
+
47
+ def error_count(rows)
48
+ rows.count { |row| row[2].to_s.start_with?('5') }
49
+ end
50
+
51
+ def slow_request_count(rows)
52
+ rows.count { |row| duration_value(row) >= slow_threshold }
53
+ end
54
+
55
+ def slow_threshold
56
+ Railswatch.slow_requests_threshold.to_f
57
+ end
58
+
59
+ def endpoint_volume(grouped)
60
+ name, samples = grouped.max_by { |_group_name, group_rows| group_rows.size }
61
+ { name: name, count: samples&.size.to_i }
62
+ end
63
+
64
+ def slowest_endpoint(grouped)
65
+ name, samples = grouped.max_by do |_group_name, group_rows|
66
+ endpoint_p95(group_rows).to_f
67
+ end
68
+
69
+ {
70
+ name: name,
71
+ p95_duration: samples.present? ? endpoint_p95(samples) : nil
72
+ }
73
+ end
74
+
75
+ def endpoint_p95(rows)
76
+ Railswatch::Utils.percentile(rows.filter_map { |row| row[3] }, 95)
77
+ end
78
+
79
+ def latest_request_at(rows)
80
+ rows.max_by { |row| row[4].to_i }&.last
81
+ end
82
+
83
+ def duration_value(row)
84
+ row[3].to_f
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railswatch
4
+ module Reports
5
+ class PercentileReport < BaseReport
6
+ def data
7
+ durations = db.pluck(:duration_ms).compact
8
+ {
9
+ p50: Railswatch::Utils.percentile(durations, 50),
10
+ p95: Railswatch::Utils.percentile(durations, 95),
11
+ p99: Railswatch::Utils.percentile(durations, 99)
12
+ }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railswatch
4
+ module Reports
5
+ class RecentRequestsReport < BaseReport
6
+ def data
7
+ time_ago = Railswatch.recent_requests_time_window.ago
8
+ db.where('occurred_at > ?', time_ago)
9
+ .order(occurred_at: :desc)
10
+ .limit(limit)
11
+ .map(&:record_hash)
12
+ end
13
+
14
+ private
15
+
16
+ def limit
17
+ Railswatch.recent_requests_limit ? Railswatch.recent_requests_limit.to_i : 100_000
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railswatch
4
+ module Reports
5
+ class RequestsReport < BaseReport
6
+ def set_defaults
7
+ @set_defaults ||= :count
8
+ end
9
+
10
+ def data
11
+ rows = grouped_rows.map { |group_name, grouped| build_row(group_name, grouped) }
12
+ rows.sort_by { |row| -row[sort].to_f }
13
+ end
14
+
15
+ private
16
+
17
+ def grouped_rows
18
+ db.pluck(:controller, :action, :format, :duration_ms, :view_runtime_ms, :db_runtime_ms)
19
+ .each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |row, grouped|
20
+ key = group_key(*row.first(3))
21
+ grouped[key] << metrics_hash(row)
22
+ end
23
+ end
24
+
25
+ def build_row(group_name, rows) # rubocop:disable Metrics/MethodLength
26
+ durations = metric_values(rows, :duration_ms)
27
+ view_runtimes = metric_values(rows, :view_runtime_ms)
28
+ db_runtimes = metric_values(rows, :db_runtime_ms)
29
+
30
+ {
31
+ group: group_name,
32
+ count: rows.size,
33
+ duration_average: average(durations),
34
+ view_runtime_average: average(view_runtimes),
35
+ db_runtime_average: average(db_runtimes),
36
+ duration_slowest: durations.max,
37
+ view_runtime_slowest: view_runtimes.max,
38
+ db_runtime_slowest: db_runtimes.max,
39
+ p50_duration: Railswatch::Utils.percentile(durations, 50),
40
+ p95_duration: Railswatch::Utils.percentile(durations, 95),
41
+ p99_duration: Railswatch::Utils.percentile(durations, 99)
42
+ }
43
+ end
44
+
45
+ def group_key(controller, action, format)
46
+ case group
47
+ when :controller_action
48
+ "#{controller}##{action}"
49
+ when :controller
50
+ controller
51
+ else
52
+ "#{controller}##{action}|#{format}"
53
+ end
54
+ end
55
+
56
+ def metrics_hash(row)
57
+ {
58
+ duration_ms: row[3],
59
+ view_runtime_ms: row[4],
60
+ db_runtime_ms: row[5]
61
+ }
62
+ end
63
+
64
+ def metric_values(rows, key)
65
+ rows.filter_map { |row| row[key] }
66
+ end
67
+
68
+ def average(values)
69
+ return nil if values.empty?
70
+
71
+ values.sum.to_f / values.size
72
+ end
73
+ end
74
+ end
75
+ end