rails_pulse 0.2.3 → 0.2.5.pre.pre.2
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.
- checksums.yaml +4 -4
- data/README.md +270 -13
- data/Rakefile +142 -8
- data/app/assets/stylesheets/rails_pulse/components/table.css +16 -1
- data/app/assets/stylesheets/rails_pulse/components/tags.css +7 -2
- data/app/assets/stylesheets/rails_pulse/components/utilities.css +3 -0
- data/app/controllers/concerns/chart_table_concern.rb +3 -3
- data/app/controllers/rails_pulse/application_controller.rb +20 -3
- data/app/controllers/rails_pulse/assets_controller.rb +18 -2
- data/app/controllers/rails_pulse/job_runs_controller.rb +37 -0
- data/app/controllers/rails_pulse/jobs_controller.rb +80 -0
- data/app/controllers/rails_pulse/operations_controller.rb +43 -31
- data/app/controllers/rails_pulse/queries_controller.rb +1 -1
- data/app/controllers/rails_pulse/requests_controller.rb +3 -8
- data/app/controllers/rails_pulse/routes_controller.rb +1 -1
- data/app/controllers/rails_pulse/tags_controller.rb +31 -5
- data/app/helpers/rails_pulse/application_helper.rb +79 -3
- data/app/helpers/rails_pulse/breadcrumbs_helper.rb +15 -1
- data/app/helpers/rails_pulse/chart_helper.rb +32 -2
- data/app/helpers/rails_pulse/status_helper.rb +16 -0
- data/app/helpers/rails_pulse/tags_helper.rb +39 -1
- data/app/javascript/rails_pulse/application.js +3 -54
- data/app/javascript/rails_pulse/controllers/chart_controller.js +333 -0
- data/app/javascript/rails_pulse/controllers/index_controller.js +9 -14
- data/app/javascript/rails_pulse/controllers/pagination_controller.js +27 -33
- data/app/jobs/rails_pulse/backfill_summaries_job.rb +0 -2
- data/app/jobs/rails_pulse/cleanup_job.rb +0 -2
- data/app/jobs/rails_pulse/summary_job.rb +0 -2
- data/app/models/concerns/rails_pulse/taggable.rb +25 -2
- data/app/models/rails_pulse/charts/operations_chart.rb +33 -0
- data/app/models/rails_pulse/dashboard/charts/p95_response_time.rb +1 -2
- data/app/models/rails_pulse/dashboard/tables/slow_routes.rb +1 -1
- data/app/models/rails_pulse/job.rb +85 -0
- data/app/models/rails_pulse/job_run.rb +76 -0
- data/app/models/rails_pulse/jobs/cards/average_duration.rb +85 -0
- data/app/models/rails_pulse/jobs/cards/base.rb +70 -0
- data/app/models/rails_pulse/jobs/cards/failure_rate.rb +85 -0
- data/app/models/rails_pulse/jobs/cards/total_jobs.rb +74 -0
- data/app/models/rails_pulse/jobs/cards/total_runs.rb +48 -0
- data/app/models/rails_pulse/operation.rb +16 -3
- data/app/models/rails_pulse/queries/cards/average_query_times.rb +3 -3
- data/app/models/rails_pulse/queries/cards/execution_rate.rb +1 -1
- data/app/models/rails_pulse/queries/cards/percentile_query_times.rb +1 -1
- data/app/models/rails_pulse/queries/charts/average_query_times.rb +1 -1
- data/app/models/rails_pulse/queries/tables/index.rb +2 -1
- data/app/models/rails_pulse/query.rb +10 -1
- data/app/models/rails_pulse/requests/charts/average_response_times.rb +1 -1
- data/app/models/rails_pulse/routes/cards/average_response_times.rb +3 -2
- data/app/models/rails_pulse/routes/cards/error_rate_per_route.rb +1 -1
- data/app/models/rails_pulse/routes/cards/percentile_response_times.rb +1 -1
- data/app/models/rails_pulse/routes/cards/request_count_totals.rb +1 -1
- data/app/models/rails_pulse/routes/charts/average_response_times.rb +1 -1
- data/app/models/rails_pulse/routes/tables/index.rb +2 -1
- data/app/models/rails_pulse/summary.rb +10 -3
- data/app/services/rails_pulse/summary_service.rb +46 -0
- data/app/views/layouts/rails_pulse/_menu_items.html.erb +7 -0
- data/app/views/layouts/rails_pulse/application.html.erb +23 -0
- data/app/views/rails_pulse/components/_active_filters.html.erb +7 -6
- data/app/views/rails_pulse/components/_metric_card.html.erb +2 -2
- data/app/views/rails_pulse/components/_page_header.html.erb +8 -7
- data/app/views/rails_pulse/components/_sparkline_stats.html.erb +1 -1
- data/app/views/rails_pulse/components/_table.html.erb +7 -4
- data/app/views/rails_pulse/components/_table_pagination.html.erb +8 -6
- data/app/views/rails_pulse/csp_test/show.html.erb +1 -1
- data/app/views/rails_pulse/dashboard/charts/_bar_chart.html.erb +1 -1
- data/app/views/rails_pulse/dashboard/index.html.erb +5 -4
- data/app/views/rails_pulse/job_runs/_operations.html.erb +78 -0
- data/app/views/rails_pulse/job_runs/index.html.erb +3 -0
- data/app/views/rails_pulse/job_runs/show.html.erb +51 -0
- data/app/views/rails_pulse/jobs/_job_runs_table.html.erb +35 -0
- data/app/views/rails_pulse/jobs/_table.html.erb +43 -0
- data/app/views/rails_pulse/jobs/index.html.erb +34 -0
- data/app/views/rails_pulse/jobs/show.html.erb +49 -0
- data/app/views/rails_pulse/operations/_operation_analysis_application.html.erb +29 -27
- data/app/views/rails_pulse/operations/_operation_analysis_view.html.erb +11 -9
- data/app/views/rails_pulse/operations/show.html.erb +10 -8
- data/app/views/rails_pulse/queries/_table.html.erb +3 -3
- data/app/views/rails_pulse/queries/index.html.erb +2 -1
- data/app/views/rails_pulse/queries/show.html.erb +2 -1
- data/app/views/rails_pulse/requests/_table.html.erb +6 -6
- data/app/views/rails_pulse/routes/_table.html.erb +3 -3
- data/app/views/rails_pulse/routes/index.html.erb +2 -1
- data/app/views/rails_pulse/routes/show.html.erb +3 -2
- data/app/views/rails_pulse/tags/_tag_manager.html.erb +7 -14
- data/config/brakeman.ignore +213 -0
- data/config/brakeman.yml +68 -0
- data/config/importmap.rb +1 -1
- data/config/initializers/rails_pulse.rb +52 -0
- data/config/routes.rb +6 -0
- data/db/rails_pulse_migrate/20250113000000_add_jobs_to_rails_pulse.rb +95 -0
- data/db/rails_pulse_migrate/20250122000000_add_query_fingerprinting.rb +150 -0
- data/db/rails_pulse_migrate/20250202000000_add_index_to_request_uuid.rb +14 -0
- data/db/rails_pulse_schema.rb +186 -103
- data/lib/generators/rails_pulse/templates/db/rails_pulse_schema.rb +186 -103
- data/lib/generators/rails_pulse/templates/migrations/install_rails_pulse_tables.rb +30 -1
- data/lib/generators/rails_pulse/templates/rails_pulse.rb +31 -0
- data/lib/rails_pulse/active_job_extensions.rb +13 -0
- data/lib/rails_pulse/adapters/delayed_job_plugin.rb +25 -0
- data/lib/rails_pulse/adapters/sidekiq_middleware.rb +41 -0
- data/lib/rails_pulse/cleanup_service.rb +65 -0
- data/lib/rails_pulse/configuration.rb +80 -7
- data/lib/rails_pulse/engine.rb +29 -28
- data/lib/rails_pulse/extensions/active_record.rb +82 -0
- data/lib/rails_pulse/job_run_collector.rb +172 -0
- data/lib/rails_pulse/middleware/request_collector.rb +20 -43
- data/lib/rails_pulse/subscribers/operation_subscriber.rb +11 -5
- data/lib/rails_pulse/tracker.rb +82 -0
- data/lib/rails_pulse/version.rb +1 -1
- data/lib/rails_pulse.rb +2 -0
- data/lib/rails_pulse_server.ru +107 -0
- data/lib/tasks/rails_pulse_benchmark.rake +382 -0
- data/public/rails-pulse-assets/csp-test.js +10 -10
- data/public/rails-pulse-assets/rails-pulse-icons.js +3 -2
- data/public/rails-pulse-assets/rails-pulse-icons.js.map +1 -1
- data/public/rails-pulse-assets/rails-pulse.css +1 -1
- data/public/rails-pulse-assets/rails-pulse.css.map +1 -1
- data/public/rails-pulse-assets/rails-pulse.js +48 -48
- data/public/rails-pulse-assets/rails-pulse.js.map +4 -4
- metadata +38 -30
- data/app/models/rails_pulse/requests/charts/operations_chart.rb +0 -35
- data/config/initializers/rails_charts_csp_patch.rb +0 -75
- data/db/migrate/20250930105043_install_rails_pulse_tables.rb +0 -23
|
@@ -2,13 +2,24 @@ module RailsPulse
|
|
|
2
2
|
module Taggable
|
|
3
3
|
extend ActiveSupport::Concern
|
|
4
4
|
|
|
5
|
+
# Tag validation constants
|
|
6
|
+
TAG_NAME_REGEX = /\A[a-z0-9_-]+\z/i
|
|
7
|
+
MAX_TAG_LENGTH = 50
|
|
8
|
+
|
|
5
9
|
included do
|
|
6
10
|
# Callbacks
|
|
7
11
|
before_save :ensure_tags_is_array
|
|
8
12
|
|
|
9
13
|
# Scopes with table name qualification to avoid ambiguity
|
|
10
|
-
|
|
11
|
-
scope :
|
|
14
|
+
# Note: LIKE patterns are sanitized to prevent SQL injection via wildcards
|
|
15
|
+
scope :with_tag, ->(tag) {
|
|
16
|
+
sanitized_tag = sanitize_sql_like(tag.to_s, "\\")
|
|
17
|
+
where("#{table_name}.tags LIKE ?", "%#{sanitized_tag}%")
|
|
18
|
+
}
|
|
19
|
+
scope :without_tag, ->(tag) {
|
|
20
|
+
sanitized_tag = sanitize_sql_like(tag.to_s, "\\")
|
|
21
|
+
where.not("#{table_name}.tags LIKE ?", "%#{sanitized_tag}%")
|
|
22
|
+
}
|
|
12
23
|
scope :with_tags, -> { where("#{table_name}.tags IS NOT NULL AND #{table_name}.tags != '[]'") }
|
|
13
24
|
end
|
|
14
25
|
|
|
@@ -26,11 +37,16 @@ module RailsPulse
|
|
|
26
37
|
end
|
|
27
38
|
|
|
28
39
|
def add_tag(tag)
|
|
40
|
+
# Validate tag format and length
|
|
41
|
+
return false unless valid_tag_name?(tag)
|
|
42
|
+
|
|
29
43
|
current_tags = tag_list
|
|
30
44
|
unless current_tags.include?(tag.to_s)
|
|
31
45
|
current_tags << tag.to_s
|
|
32
46
|
self.tag_list = current_tags
|
|
33
47
|
save
|
|
48
|
+
else
|
|
49
|
+
true # Tag already exists, return success
|
|
34
50
|
end
|
|
35
51
|
end
|
|
36
52
|
|
|
@@ -45,6 +61,13 @@ module RailsPulse
|
|
|
45
61
|
|
|
46
62
|
private
|
|
47
63
|
|
|
64
|
+
def valid_tag_name?(tag)
|
|
65
|
+
return false if tag.blank?
|
|
66
|
+
return false if tag.to_s.length > MAX_TAG_LENGTH
|
|
67
|
+
return false unless tag.to_s.match?(TAG_NAME_REGEX)
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
|
|
48
71
|
def parsed_tags
|
|
49
72
|
return [] if tags.nil? || tags.empty?
|
|
50
73
|
JSON.parse(tags)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Charts
|
|
3
|
+
class OperationsChart
|
|
4
|
+
OperationBar = Struct.new(:operation, :duration, :left_pct, :width_pct)
|
|
5
|
+
|
|
6
|
+
attr_reader :bars, :min_start, :max_end, :total_duration
|
|
7
|
+
|
|
8
|
+
HORIZONTAL_OFFSET_PX = 20
|
|
9
|
+
|
|
10
|
+
def initialize(operations)
|
|
11
|
+
@operations = operations
|
|
12
|
+
@min_start = @operations.map(&:start_time).min || 0
|
|
13
|
+
@max_end = @operations.map { |op| op.start_time + op.duration }.max || 1
|
|
14
|
+
@total_duration = (@max_end - @min_start).nonzero? || 1
|
|
15
|
+
@bars = build_bars
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def build_bars
|
|
21
|
+
@operations.map do |operation|
|
|
22
|
+
left_pct = ((operation.start_time - @min_start).to_f / @total_duration) * (100 - px_to_pct) + px_to_pct / 2
|
|
23
|
+
width_pct = (operation.duration.to_f / @total_duration) * (100 - px_to_pct)
|
|
24
|
+
OperationBar.new(operation, operation.duration.round(0), left_pct, width_pct)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def px_to_pct
|
|
29
|
+
(HORIZONTAL_OFFSET_PX.to_f / 1000) * 100
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -23,9 +23,8 @@ module RailsPulse
|
|
|
23
23
|
)
|
|
24
24
|
|
|
25
25
|
actual_data = summaries
|
|
26
|
-
.
|
|
26
|
+
.group_by_date(:period_start)
|
|
27
27
|
.average(:p95_duration)
|
|
28
|
-
.transform_keys { |date| date.to_date }
|
|
29
28
|
.transform_values { |avg| avg&.round(0) || 0 }
|
|
30
29
|
|
|
31
30
|
# Fill in all dates with zero values for missing days
|
|
@@ -49,7 +49,7 @@ module RailsPulse
|
|
|
49
49
|
# Return new structure with columns and data
|
|
50
50
|
{
|
|
51
51
|
columns: [
|
|
52
|
-
{ field: :route_path, label: "Route", link_to: :route_link, class: "w-
|
|
52
|
+
{ field: :route_path, label: "Route", link_to: :route_link, class: "w-48", cell_class: "truncate-cell" },
|
|
53
53
|
{ field: :average_time, label: "Average Time", class: "w-32" },
|
|
54
54
|
{ field: :request_count, label: "Requests", class: "w-24" },
|
|
55
55
|
{ field: :last_request, label: "Last Request", class: "w-32" }
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
class Job < RailsPulse::ApplicationRecord
|
|
3
|
+
include Taggable
|
|
4
|
+
|
|
5
|
+
self.table_name = "rails_pulse_jobs"
|
|
6
|
+
|
|
7
|
+
has_many :runs,
|
|
8
|
+
class_name: "RailsPulse::JobRun",
|
|
9
|
+
foreign_key: :job_id,
|
|
10
|
+
inverse_of: :job,
|
|
11
|
+
dependent: :destroy
|
|
12
|
+
|
|
13
|
+
validates :name, presence: true, uniqueness: true
|
|
14
|
+
|
|
15
|
+
def self.ransackable_attributes(auth_object = nil)
|
|
16
|
+
%w[id name queue_name runs_count failures_count retries_count avg_duration]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.ransackable_associations(auth_object = nil)
|
|
20
|
+
%w[runs]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
scope :by_queue, ->(queue) { where(queue_name: queue) }
|
|
24
|
+
scope :with_failures, -> { where("failures_count > 0") }
|
|
25
|
+
scope :ordered_by_runs, -> { order(runs_count: :desc) }
|
|
26
|
+
|
|
27
|
+
def apply_run!(run)
|
|
28
|
+
return unless run.duration
|
|
29
|
+
|
|
30
|
+
duration = run.duration.to_f
|
|
31
|
+
|
|
32
|
+
with_lock do
|
|
33
|
+
reload
|
|
34
|
+
total_runs = runs_count.to_i
|
|
35
|
+
previous_total = [ total_runs - 1, 0 ].max
|
|
36
|
+
previous_average = avg_duration.to_f
|
|
37
|
+
|
|
38
|
+
new_average = if previous_total.zero?
|
|
39
|
+
duration
|
|
40
|
+
else
|
|
41
|
+
((previous_average * previous_total) + duration) / (previous_total + 1)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
updates = { avg_duration: new_average }
|
|
45
|
+
if run.failure_like_status?
|
|
46
|
+
updates[:failures_count] = failures_count + 1
|
|
47
|
+
end
|
|
48
|
+
if run.status == "retried"
|
|
49
|
+
updates[:retries_count] = retries_count + 1
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
update!(updates)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def failure_rate
|
|
57
|
+
return 0.0 if runs_count.zero?
|
|
58
|
+
|
|
59
|
+
((failures_count.to_f / runs_count) * 100).round(2)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def performance_status
|
|
63
|
+
thresholds = RailsPulse.configuration.job_thresholds
|
|
64
|
+
duration = avg_duration.to_f
|
|
65
|
+
|
|
66
|
+
if duration < thresholds[:slow]
|
|
67
|
+
:fast
|
|
68
|
+
elsif duration < thresholds[:very_slow]
|
|
69
|
+
:slow
|
|
70
|
+
elsif duration < thresholds[:critical]
|
|
71
|
+
:very_slow
|
|
72
|
+
else
|
|
73
|
+
:critical
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_param
|
|
78
|
+
id.to_s
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_breadcrumb
|
|
82
|
+
name
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
class JobRun < RailsPulse::ApplicationRecord
|
|
3
|
+
include Taggable
|
|
4
|
+
|
|
5
|
+
self.table_name = "rails_pulse_job_runs"
|
|
6
|
+
|
|
7
|
+
STATUSES = %w[enqueued running success failed discarded retried].freeze
|
|
8
|
+
FINAL_STATUSES = %w[success failed discarded retried].freeze
|
|
9
|
+
|
|
10
|
+
belongs_to :job,
|
|
11
|
+
class_name: "RailsPulse::Job",
|
|
12
|
+
counter_cache: :runs_count,
|
|
13
|
+
inverse_of: :runs
|
|
14
|
+
has_many :operations,
|
|
15
|
+
class_name: "RailsPulse::Operation",
|
|
16
|
+
foreign_key: :job_run_id,
|
|
17
|
+
inverse_of: :job_run,
|
|
18
|
+
dependent: :destroy
|
|
19
|
+
|
|
20
|
+
validates :run_id, presence: true, uniqueness: true
|
|
21
|
+
validates :status, inclusion: { in: STATUSES }
|
|
22
|
+
validates :occurred_at, presence: true
|
|
23
|
+
|
|
24
|
+
def self.ransackable_attributes(auth_object = nil)
|
|
25
|
+
%w[id job_id run_id status occurred_at duration attempts adapter]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.ransackable_associations(auth_object = nil)
|
|
29
|
+
%w[job operations]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
scope :successful, -> { where(status: "success") }
|
|
33
|
+
scope :failed, -> { where(status: %w[failed discarded]) }
|
|
34
|
+
scope :recent, -> { order(occurred_at: :desc) }
|
|
35
|
+
scope :by_adapter, ->(adapter) { where(adapter: adapter) }
|
|
36
|
+
|
|
37
|
+
after_commit :apply_to_job_caches, on: %i[create update], if: :finalized?
|
|
38
|
+
|
|
39
|
+
def all_tags
|
|
40
|
+
(job.tag_list + tag_list).uniq
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def performance_status
|
|
44
|
+
thresholds = RailsPulse.configuration.job_thresholds
|
|
45
|
+
duration = self.duration.to_f
|
|
46
|
+
|
|
47
|
+
if duration < thresholds[:slow]
|
|
48
|
+
:fast
|
|
49
|
+
elsif duration < thresholds[:very_slow]
|
|
50
|
+
:slow
|
|
51
|
+
elsif duration < thresholds[:critical]
|
|
52
|
+
:very_slow
|
|
53
|
+
else
|
|
54
|
+
:critical
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def failure_like_status?
|
|
59
|
+
FINAL_STATUSES.include?(status) && status != "success"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def finalized?
|
|
63
|
+
change = previous_changes["status"]
|
|
64
|
+
return false unless change
|
|
65
|
+
|
|
66
|
+
previous_state, new_state = change
|
|
67
|
+
FINAL_STATUSES.include?(new_state) && !FINAL_STATUSES.include?(previous_state)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def apply_to_job_caches
|
|
73
|
+
job.apply_run!(self)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Jobs
|
|
3
|
+
module Cards
|
|
4
|
+
class AverageDuration < Base
|
|
5
|
+
def initialize(job: nil)
|
|
6
|
+
@job = job
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def to_metric_card
|
|
10
|
+
base_query = RailsPulse::Summary
|
|
11
|
+
.where(
|
|
12
|
+
summarizable_type: "RailsPulse::Job",
|
|
13
|
+
period_type: "day",
|
|
14
|
+
period_start: range_start..now
|
|
15
|
+
)
|
|
16
|
+
base_query = base_query.where(summarizable_id: @job.id) if @job
|
|
17
|
+
|
|
18
|
+
metrics = base_query.select(
|
|
19
|
+
"SUM(avg_duration * count) AS total_weighted_duration",
|
|
20
|
+
"SUM(count) AS total_runs",
|
|
21
|
+
"SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN avg_duration * count ELSE 0 END) AS current_weighted_duration",
|
|
22
|
+
"SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END) AS current_runs",
|
|
23
|
+
"SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN avg_duration * count ELSE 0 END) AS previous_weighted_duration",
|
|
24
|
+
"SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN count ELSE 0 END) AS previous_runs"
|
|
25
|
+
).take
|
|
26
|
+
|
|
27
|
+
total_runs = metrics&.total_runs.to_i
|
|
28
|
+
total_weighted_duration = metrics&.total_weighted_duration.to_f
|
|
29
|
+
current_runs = metrics&.current_runs.to_i
|
|
30
|
+
current_weighted_duration = metrics&.current_weighted_duration.to_f
|
|
31
|
+
previous_runs = metrics&.previous_runs.to_i
|
|
32
|
+
previous_weighted_duration = metrics&.previous_weighted_duration.to_f
|
|
33
|
+
|
|
34
|
+
average_duration = average_for(total_weighted_duration, total_runs)
|
|
35
|
+
current_average = average_for(current_weighted_duration, current_runs)
|
|
36
|
+
previous_average = average_for(previous_weighted_duration, previous_runs)
|
|
37
|
+
|
|
38
|
+
trend_icon, trend_amount = trend_for(current_average, previous_average)
|
|
39
|
+
|
|
40
|
+
grouped_weighted = base_query
|
|
41
|
+
.group_by_date(:period_start)
|
|
42
|
+
.sum(Arel.sql("avg_duration * count"))
|
|
43
|
+
|
|
44
|
+
grouped_counts = base_query
|
|
45
|
+
.group_by_date(:period_start)
|
|
46
|
+
.sum(:count)
|
|
47
|
+
|
|
48
|
+
sparkline_data = sparkline_from_averages(grouped_weighted, grouped_counts)
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
id: "jobs_average_duration",
|
|
52
|
+
context: "jobs",
|
|
53
|
+
title: "Average Duration",
|
|
54
|
+
summary: format_duration(average_duration),
|
|
55
|
+
chart_data: sparkline_data,
|
|
56
|
+
trend_icon: trend_icon,
|
|
57
|
+
trend_amount: trend_amount,
|
|
58
|
+
trend_text: "Compared to previous week"
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def average_for(weighted_duration, total_runs)
|
|
65
|
+
return 0.0 if total_runs.zero?
|
|
66
|
+
|
|
67
|
+
(weighted_duration.to_f / total_runs).round(1)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sparkline_from_averages(weighted_by_day, counts_by_day)
|
|
71
|
+
start_date = range_start.to_date
|
|
72
|
+
end_date = now.to_date
|
|
73
|
+
|
|
74
|
+
(start_date..end_date).each_with_object({}) do |day, hash|
|
|
75
|
+
weighted = weighted_by_day[day].to_f
|
|
76
|
+
count = counts_by_day[day].to_f
|
|
77
|
+
avg = count.zero? ? 0.0 : (weighted / count).round(1)
|
|
78
|
+
label = day.strftime("%b %-d")
|
|
79
|
+
hash[label] = { value: avg }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require "active_support/number_helper"
|
|
2
|
+
|
|
3
|
+
module RailsPulse
|
|
4
|
+
module Jobs
|
|
5
|
+
module Cards
|
|
6
|
+
class Base
|
|
7
|
+
RANGE_DAYS = 14
|
|
8
|
+
WINDOW_DAYS = 7
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def now
|
|
13
|
+
@now ||= Time.current
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def previous_window_start
|
|
17
|
+
(now - (WINDOW_DAYS * 2).days).beginning_of_day
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def current_window_start
|
|
21
|
+
(now - WINDOW_DAYS.days).beginning_of_day
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def range_start
|
|
25
|
+
previous_window_start
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def quote(time)
|
|
29
|
+
RailsPulse::Summary.connection.quote(time)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def sparkline_from(grouped_values)
|
|
33
|
+
start_date = range_start.to_date
|
|
34
|
+
end_date = now.to_date
|
|
35
|
+
|
|
36
|
+
(start_date..end_date).each_with_object({}) do |day, hash|
|
|
37
|
+
label = day.strftime("%b %-d")
|
|
38
|
+
hash[label] = { value: grouped_values[day] || 0 }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def trend_for(current_value, previous_value, precision: 1)
|
|
43
|
+
percentage = previous_value.zero? ? 0.0 : ((current_value - previous_value) / previous_value.to_f * 100).round(precision)
|
|
44
|
+
|
|
45
|
+
icon = if percentage.abs < 0.1
|
|
46
|
+
"move-right"
|
|
47
|
+
elsif percentage.positive?
|
|
48
|
+
"trending-up"
|
|
49
|
+
else
|
|
50
|
+
"trending-down"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
[ icon, format_percentage(percentage.abs, precision) ]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def format_percentage(value, precision)
|
|
57
|
+
"#{value.round(precision)}%"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def format_number(value)
|
|
61
|
+
ActiveSupport::NumberHelper.number_to_delimited(value)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def format_duration(value)
|
|
65
|
+
"#{value.round(0)} ms"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Jobs
|
|
3
|
+
module Cards
|
|
4
|
+
class FailureRate < Base
|
|
5
|
+
def initialize(job: nil)
|
|
6
|
+
@job = job
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def to_metric_card
|
|
10
|
+
base_query = RailsPulse::Summary
|
|
11
|
+
.where(
|
|
12
|
+
summarizable_type: "RailsPulse::Job",
|
|
13
|
+
period_type: "day",
|
|
14
|
+
period_start: range_start..now
|
|
15
|
+
)
|
|
16
|
+
base_query = base_query.where(summarizable_id: @job.id) if @job
|
|
17
|
+
|
|
18
|
+
metrics = base_query.select(
|
|
19
|
+
"SUM(count) AS total_count",
|
|
20
|
+
"SUM(error_count) AS total_errors",
|
|
21
|
+
"SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END) AS current_count",
|
|
22
|
+
"SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN error_count ELSE 0 END) AS current_errors",
|
|
23
|
+
"SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN count ELSE 0 END) AS previous_count",
|
|
24
|
+
"SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN error_count ELSE 0 END) AS previous_errors"
|
|
25
|
+
).take
|
|
26
|
+
|
|
27
|
+
total_runs = metrics&.total_count.to_i
|
|
28
|
+
total_errors = metrics&.total_errors.to_i
|
|
29
|
+
current_runs = metrics&.current_count.to_i
|
|
30
|
+
current_errors = metrics&.current_errors.to_i
|
|
31
|
+
previous_runs = metrics&.previous_count.to_i
|
|
32
|
+
previous_errors = metrics&.previous_errors.to_i
|
|
33
|
+
|
|
34
|
+
failure_rate = rate_for(total_errors, total_runs)
|
|
35
|
+
current_rate = rate_for(current_errors, current_runs)
|
|
36
|
+
previous_rate = rate_for(previous_errors, previous_runs)
|
|
37
|
+
|
|
38
|
+
trend_icon, trend_amount = trend_for(current_rate, previous_rate)
|
|
39
|
+
|
|
40
|
+
grouped_errors = base_query
|
|
41
|
+
.group_by_date(:period_start)
|
|
42
|
+
.sum(:error_count)
|
|
43
|
+
|
|
44
|
+
grouped_counts = base_query
|
|
45
|
+
.group_by_date(:period_start)
|
|
46
|
+
.sum(:count)
|
|
47
|
+
|
|
48
|
+
sparkline_data = sparkline_from_failure_rates(grouped_errors, grouped_counts)
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
id: "jobs_failure_rate",
|
|
52
|
+
context: "jobs",
|
|
53
|
+
title: "Failure Rate",
|
|
54
|
+
summary: "#{format_percentage(failure_rate, 1)}",
|
|
55
|
+
chart_data: sparkline_data,
|
|
56
|
+
trend_icon: trend_icon,
|
|
57
|
+
trend_amount: trend_amount,
|
|
58
|
+
trend_text: "Compared to previous week"
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def rate_for(errors, total)
|
|
65
|
+
return 0.0 if total.zero?
|
|
66
|
+
|
|
67
|
+
(errors.to_f / total * 100).round(1)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sparkline_from_failure_rates(errors_by_day, counts_by_day)
|
|
71
|
+
start_date = range_start.to_date
|
|
72
|
+
end_date = now.to_date
|
|
73
|
+
|
|
74
|
+
(start_date..end_date).each_with_object({}) do |day, hash|
|
|
75
|
+
errors = errors_by_day[day].to_f
|
|
76
|
+
total = counts_by_day[day].to_f
|
|
77
|
+
rate = total.zero? ? 0.0 : (errors / total * 100).round(1)
|
|
78
|
+
label = day.strftime("%b %-d")
|
|
79
|
+
hash[label] = { value: rate }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Jobs
|
|
3
|
+
module Cards
|
|
4
|
+
class TotalJobs < Base
|
|
5
|
+
def initialize(job: nil)
|
|
6
|
+
@job = job
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def to_metric_card
|
|
10
|
+
# When scoped to a job, show runs count instead of job count
|
|
11
|
+
if @job
|
|
12
|
+
base_query = RailsPulse::Summary
|
|
13
|
+
.where(
|
|
14
|
+
summarizable_type: "RailsPulse::Job",
|
|
15
|
+
summarizable_id: @job.id,
|
|
16
|
+
period_type: "day",
|
|
17
|
+
period_start: range_start..now
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
metrics = base_query.select(
|
|
21
|
+
"SUM(count) AS total_count",
|
|
22
|
+
"SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END) AS current_count",
|
|
23
|
+
"SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN count ELSE 0 END) AS previous_count"
|
|
24
|
+
).take
|
|
25
|
+
|
|
26
|
+
total_runs = metrics&.total_count.to_i
|
|
27
|
+
current_runs = metrics&.current_count.to_i
|
|
28
|
+
previous_runs = metrics&.previous_count.to_i
|
|
29
|
+
|
|
30
|
+
trend_icon, trend_amount = trend_for(current_runs, previous_runs)
|
|
31
|
+
|
|
32
|
+
grouped_runs = base_query
|
|
33
|
+
.group_by_date(:period_start)
|
|
34
|
+
.sum(:count)
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
id: "jobs_total_jobs",
|
|
38
|
+
context: "jobs",
|
|
39
|
+
title: "Total Runs",
|
|
40
|
+
summary: "#{format_number(total_runs)} runs",
|
|
41
|
+
chart_data: sparkline_from(grouped_runs),
|
|
42
|
+
trend_icon: trend_icon,
|
|
43
|
+
trend_amount: trend_amount,
|
|
44
|
+
trend_text: "Compared to previous week"
|
|
45
|
+
}
|
|
46
|
+
else
|
|
47
|
+
total_jobs = RailsPulse::Job.count
|
|
48
|
+
|
|
49
|
+
current_new_jobs = RailsPulse::Job.where(created_at: current_window_start..now).count
|
|
50
|
+
previous_new_jobs = RailsPulse::Job.where(created_at: range_start...current_window_start).count
|
|
51
|
+
|
|
52
|
+
trend_icon, trend_amount = trend_for(current_new_jobs, previous_new_jobs)
|
|
53
|
+
|
|
54
|
+
grouped_new_jobs = RailsPulse::Job
|
|
55
|
+
.where(created_at: range_start..now)
|
|
56
|
+
.group_by_date(:created_at)
|
|
57
|
+
.count
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
id: "jobs_total_jobs",
|
|
61
|
+
context: "jobs",
|
|
62
|
+
title: "Total Jobs",
|
|
63
|
+
summary: "#{format_number(total_jobs)} jobs",
|
|
64
|
+
chart_data: sparkline_from(grouped_new_jobs),
|
|
65
|
+
trend_icon: trend_icon,
|
|
66
|
+
trend_amount: trend_amount,
|
|
67
|
+
trend_text: "New jobs vs previous week"
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module RailsPulse
|
|
2
|
+
module Jobs
|
|
3
|
+
module Cards
|
|
4
|
+
class TotalRuns < Base
|
|
5
|
+
def initialize(job: nil)
|
|
6
|
+
@job = job
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def to_metric_card
|
|
10
|
+
base_query = RailsPulse::Summary
|
|
11
|
+
.where(
|
|
12
|
+
summarizable_type: "RailsPulse::Job",
|
|
13
|
+
period_type: "day",
|
|
14
|
+
period_start: range_start..now
|
|
15
|
+
)
|
|
16
|
+
base_query = base_query.where(summarizable_id: @job.id) if @job
|
|
17
|
+
|
|
18
|
+
metrics = base_query.select(
|
|
19
|
+
"SUM(count) AS total_count",
|
|
20
|
+
"SUM(CASE WHEN period_start >= #{quote(current_window_start)} THEN count ELSE 0 END) AS current_count",
|
|
21
|
+
"SUM(CASE WHEN period_start >= #{quote(range_start)} AND period_start < #{quote(current_window_start)} THEN count ELSE 0 END) AS previous_count"
|
|
22
|
+
).take
|
|
23
|
+
|
|
24
|
+
total_runs = metrics&.total_count.to_i
|
|
25
|
+
current_runs = metrics&.current_count.to_i
|
|
26
|
+
previous_runs = metrics&.previous_count.to_i
|
|
27
|
+
|
|
28
|
+
trend_icon, trend_amount = trend_for(current_runs, previous_runs)
|
|
29
|
+
|
|
30
|
+
grouped_runs = base_query
|
|
31
|
+
.group_by_date(:period_start)
|
|
32
|
+
.sum(:count)
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
id: "jobs_total_runs",
|
|
36
|
+
context: "jobs",
|
|
37
|
+
title: "Job Runs",
|
|
38
|
+
summary: "#{format_number(total_runs)} runs",
|
|
39
|
+
chart_data: sparkline_from(grouped_runs),
|
|
40
|
+
trend_icon: trend_icon,
|
|
41
|
+
trend_amount: trend_amount,
|
|
42
|
+
trend_text: "Compared to previous week"
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
1
3
|
module RailsPulse
|
|
2
4
|
class Operation < RailsPulse::ApplicationRecord
|
|
3
5
|
self.table_name = "rails_pulse_operations"
|
|
@@ -18,15 +20,16 @@ module RailsPulse
|
|
|
18
20
|
].freeze
|
|
19
21
|
|
|
20
22
|
# Associations
|
|
21
|
-
belongs_to :request, class_name: "RailsPulse::Request"
|
|
23
|
+
belongs_to :request, class_name: "RailsPulse::Request", optional: true
|
|
24
|
+
belongs_to :job_run, class_name: "RailsPulse::JobRun", optional: true
|
|
22
25
|
belongs_to :query, class_name: "RailsPulse::Query", optional: true
|
|
23
26
|
|
|
24
27
|
# Validations
|
|
25
|
-
validates :request_id, presence: true
|
|
26
28
|
validates :operation_type, presence: true, inclusion: { in: OPERATION_TYPES }
|
|
27
29
|
validates :label, presence: true
|
|
28
30
|
validates :occurred_at, presence: true
|
|
29
31
|
validates :duration, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
32
|
+
validate :has_request_or_job_run
|
|
30
33
|
|
|
31
34
|
# Scopes (optional, for convenience)
|
|
32
35
|
scope :by_type, ->(type) { where(operation_type: type) }
|
|
@@ -72,11 +75,21 @@ module RailsPulse
|
|
|
72
75
|
|
|
73
76
|
private
|
|
74
77
|
|
|
78
|
+
def has_request_or_job_run
|
|
79
|
+
return if request_id.present? || job_run_id.present?
|
|
80
|
+
|
|
81
|
+
errors.add(:base, "Operation must belong to a request or a job run")
|
|
82
|
+
end
|
|
83
|
+
|
|
75
84
|
def associate_query
|
|
76
85
|
return unless operation_type == "sql" && label.present?
|
|
77
86
|
|
|
78
87
|
normalized = normalize_query_label(label)
|
|
79
|
-
|
|
88
|
+
hashed = Digest::MD5.hexdigest(normalized)
|
|
89
|
+
|
|
90
|
+
self.query = RailsPulse::Query.find_or_create_by(hashed_sql: hashed) do |q|
|
|
91
|
+
q.normalized_sql = normalized
|
|
92
|
+
end
|
|
80
93
|
end
|
|
81
94
|
|
|
82
95
|
# Normalize SQL query using the dedicated service
|