activeinsights 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e4eaa4679af5bdd26c11fee43867a9fb1dd49604962280c7adf7542a55e996ff
4
+ data.tar.gz: 8cf132a8cb971a91c0f3bac654de0cfc43e6ee6a0d3e858a5ef09bffcf3fdd01
5
+ SHA512:
6
+ metadata.gz: 70cad20e56ce23648fae3e31e35846fdaa936e90326e81767ab80a65363b3e5a63ca4d39db2496584ba9318c7e6f836e368f2551baf8dec5bbb28194e668c3fc
7
+ data.tar.gz: 940ee1ff0796fea342a072aadd51e1f81224e6c1f26cbd4f392fa5552630cb78b07c82bad331d729f3b2dd5e02e3f77f206bf674f0684dc2a2b576012b8a6b10
data/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # ActiveInsights
2
+
3
+ One of the fundemental tools needed to take your Rails app to production is a
4
+ way to track response times. Unfortunately, theres no free, easy,
5
+ open source way to track them for small or medium apps. Skylight, Honeybadger,
6
+ Sentry, and AppSignal are great, but they are are closed source and
7
+ there should be an easy open source alternative where you control the data.
8
+
9
+ ActiveInsights hooks into the ActiveSupport [instrumention](https://guides.rubyonrails.org/active_support_instrumentation.html#) baked directly into Rails. ActiveInsights tracks RPM, RPM per controller, and p50/p95/p99 response times and charts all those by the minute.
10
+
11
+ ![screenshot 1](https://github.com/npezza93/activeinsights/blob/main/.github/screenshot1.png)
12
+ ![screenshot 2](https://github.com/npezza93/activeinsights/blob/main/.github/screenshot2.png)
13
+ ![screenshot 3](https://github.com/npezza93/activeinsights/blob/main/.github/screenshot3.png)
14
+ ![screenshot 4](https://github.com/npezza93/activeinsights/blob/main/.github/screenshot4.png)
15
+
16
+ ## Installation
17
+ Add this line to your application's Gemfile:
18
+
19
+ ```ruby
20
+ gem "activeinsights"
21
+ ```
22
+
23
+ And then execute:
24
+ ```bash
25
+ $ bundle
26
+ ```
27
+
28
+ Or install it yourself as:
29
+ ```bash
30
+ $ gem install activeinsights
31
+ ```
32
+
33
+ And then install migrations:
34
+ ```bash
35
+ bin/rails active_insights:install
36
+ bin/rails rails db:migrate
37
+ ```
38
+
39
+ This also mounts a route in your routes file to view the insights at `/insights`.
40
+
41
+
42
+ ##### Config
43
+
44
+ You can supply a hash of connection options to `connects_to` set the connection
45
+ options for the `Request` model.
46
+
47
+ ```ruby
48
+ ActiveInsights.connects_to = { database: { writing: :requests, reading: :requests } }
49
+ ```
50
+
51
+ ## Development
52
+
53
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
54
+ `rails test` to run the unit tests.
55
+
56
+ To install this gem onto your local machine, run `bundle exec rake install`. To
57
+ release a new version, execute `bin/publish (major|minor|patch)` which will
58
+ update the version number in `version.rb`, create a git tag for the version,
59
+ push git commits and tags, and push the `.gem` file to GitHub.
60
+
61
+ ## Contributing
62
+
63
+ Bug reports and pull requests are welcome on
64
+ [GitHub](https://github.com/npezza93/activeinsights). This project is intended to
65
+ be a safe, welcoming space for collaboration, and contributors are expected to
66
+ adhere to the [Contributor Covenant](http://contributor-covenant.org) code of
67
+ conduct.
68
+
69
+ ## License
70
+
71
+ The gem is available as open source under the terms of the
72
+ [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "bundler/setup"
5
+ rescue LoadError
6
+ puts "You must `gem install bundler` and `bundle install` to run rake tasks"
7
+ end
8
+
9
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
10
+ load "rails/tasks/engine.rake"
11
+
12
+ load "rails/tasks/statistics.rake"
13
+
14
+ require "bundler/gem_tasks"
15
+
16
+ require "rake/testtask"
17
+
18
+ Rake::TestTask.new(:test) do |t|
19
+ t.libs << "test"
20
+ t.pattern = "test/**/*_test.rb"
21
+ t.verbose = false
22
+ end
23
+
24
+ task default: :test
@@ -0,0 +1,5 @@
1
+ import "chartkick"
2
+ import "Chart.bundle"
3
+ import zoomPlugin from 'chartjs-plugin-zoom'
4
+
5
+ Chart.register(zoomPlugin)
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInsights
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery with: :exception
6
+
7
+ around_action :setup_time_zone
8
+ before_action :set_date
9
+
10
+ private
11
+
12
+ def set_date
13
+ @date =
14
+ if params[:date].present?
15
+ Date.parse(params[:date])
16
+ else
17
+ Date.current
18
+ end.all_day
19
+ end
20
+
21
+ def setup_time_zone(&block) # rubocop:disable Style/ArgumentsForwarding
22
+ Time.use_zone("Eastern Time (US & Canada)", &block) # rubocop:disable Style/ArgumentsForwarding
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInsights
4
+ class ControllerPValuesController < ApplicationController
5
+ def index
6
+ @p50 = minutes.map{ |minute| [minute.pretty_started_at, minute.p50] }
7
+ @p95 = minutes.map{ |minute| [minute.pretty_started_at, minute.p95] }
8
+ @p99 = minutes.map{ |minute| [minute.pretty_started_at, minute.p99] }
9
+ end
10
+
11
+ def redirection
12
+ redirect_to controller_p_values_path(params[:date],
13
+ params[:formatted_controller])
14
+ end
15
+
16
+ private
17
+
18
+ def minutes
19
+ @minutes ||=
20
+ ActiveInsights::Request.where(started_at: @date).
21
+ where(formatted_controller: params[:formatted_controller]).
22
+ group_by_minute.with_durations.select_started_at
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInsights
4
+ class PValuesController < ApplicationController
5
+ def index
6
+ @p50 = minutes.map{ |minute| [minute.pretty_started_at, minute.p50] }
7
+ @p95 = minutes.map{ |minute| [minute.pretty_started_at, minute.p95] }
8
+ @p99 = minutes.map{ |minute| [minute.pretty_started_at, minute.p99] }
9
+ end
10
+
11
+ def redirection
12
+ redirect_to p_values_path(params[:date])
13
+ end
14
+
15
+ private
16
+
17
+ def minutes
18
+ @minutes ||=
19
+ ActiveInsights::Request.where(started_at: @date).
20
+ group_by_minute.with_durations.select_started_at
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInsights
4
+ class RequestsController < ApplicationController
5
+ def index
6
+ @requests =
7
+ ActiveInsights::Request.where(started_at: @date).
8
+ with_durations.select(:formatted_controller).
9
+ group(:formatted_controller).
10
+ sort_by { |model| model.durations.count(",") + 1 }.reverse
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInsights
4
+ class RpmController < ApplicationController
5
+ def index
6
+ @minutes =
7
+ ActiveInsights::Request.where(started_at: @date).
8
+ group_by_minute.select("COUNT(id) AS rpm").select_started_at.
9
+ map { |minute| [minute.started_at.strftime("%-l:%M%P"), minute.rpm] }
10
+ end
11
+
12
+ def redirection
13
+ redirect_to rpm_path(params[:date])
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInsights
4
+ module ApplicationHelper
5
+ def active_insights_importmap_tags(entry_point = "application")
6
+ importmap = ActiveInsights::Engine.importmap
7
+
8
+ safe_join [
9
+ javascript_inline_importmap_tag(importmap.to_json(resolver: self)),
10
+ javascript_importmap_module_preload_tags(importmap),
11
+ javascript_import_module_tag(entry_point)
12
+ ], "\n"
13
+ end
14
+
15
+ def display_date(date)
16
+ if Date.current.year == date.year
17
+ date.strftime("%B %-d")
18
+ else
19
+ date.strftime("%B %-d, %Y")
20
+ end
21
+ end
22
+
23
+ def p50(data)
24
+ percentile_value(data, 0.5)
25
+ end
26
+
27
+ def p95(data)
28
+ percentile_value(data, 0.95)
29
+ end
30
+
31
+ def p99(data)
32
+ percentile_value(data, 0.99)
33
+ end
34
+
35
+ def per_minute(amount, duration)
36
+ (amount / duration.in_minutes).round(0)
37
+ end
38
+
39
+ private
40
+
41
+ def percentile_value(data, percentile)
42
+ value = data[(percentile * data.size).ceil - 1]
43
+
44
+ value&.round(1) || "N/A"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInsights
4
+ class Record < ApplicationRecord
5
+ self.abstract_class = true
6
+
7
+ connects_to(**ActiveInsights.connects_to) if ActiveInsights.connects_to
8
+ end
9
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInsights
4
+ class Request < ::ActiveInsights::Record
5
+ scope :with_durations, lambda {
6
+ case connection.adapter_name
7
+ when "SQLite", "Mysql2", "Mysql2Spatial", "Mysql2Rgeo", "Trilogy"
8
+ select("GROUP_CONCAT(duration) AS durations")
9
+ when "PostgreSQL"
10
+ select("STRING_AGG(CAST(duration AS varchar), ',') AS durations")
11
+ end
12
+ }
13
+ scope :group_by_minute, lambda {
14
+ case connection.adapter_name
15
+ when "SQLite"
16
+ group("strftime('%Y-%m-%d %H:%M:00 UTC', " \
17
+ "'active_insights_requests'.'started_at')")
18
+ when "Mysql2", "Mysql2Spatial", "Mysql2Rgeo", "Trilogy"
19
+ group("CONVERT_TZ(DATE_FORMAT(`active_insights_requests`.`started_at`" \
20
+ ", '%Y-%m-%d %H:%i:00'), 'Etc/UTC', '+00:00')")
21
+ when "PostgreSQL"
22
+ group("DATE_TRUNC('minute', \"active_insights_requests\"." \
23
+ "\"started_at\"::timestamptz AT TIME ZONE 'Etc/UTC') " \
24
+ "AT TIME ZONE 'Etc/UTC'")
25
+ end
26
+ }
27
+ scope :select_started_at, lambda {
28
+ case connection.adapter_name
29
+ when "SQLite", "Mysql2", "Mysql2Spatial", "Mysql2Rgeo", "Trilogy"
30
+ select(:started_at)
31
+ when "PostgreSQL"
32
+ select("DATE_TRUNC('minute', \"active_insights_requests\"." \
33
+ "\"started_at\"::timestamptz AT TIME ZONE 'Etc/UTC') " \
34
+ "AT TIME ZONE 'Etc/UTC' as started_at")
35
+ end
36
+ }
37
+
38
+ def agony
39
+ parsed_durations.sum
40
+ end
41
+
42
+ def parsed_durations
43
+ return unless respond_to?(:durations)
44
+
45
+ @parsed_durations ||= durations.split(",").map(&:to_f).sort
46
+ end
47
+
48
+ def pretty_started_at
49
+ started_at.strftime("%-l:%M%P")
50
+ end
51
+
52
+ def p50
53
+ percentile_value(0.5)
54
+ end
55
+
56
+ def p95
57
+ percentile_value(0.95)
58
+ end
59
+
60
+ def p99
61
+ percentile_value(0.99)
62
+ end
63
+
64
+ private
65
+
66
+ def percentile_value(percentile)
67
+ parsed_durations[(percentile * parsed_durations.size).ceil - 1].round(1)
68
+ end
69
+
70
+ end
71
+ end
@@ -0,0 +1,26 @@
1
+ <header>
2
+ <h1><%= params[:formatted_controller] %> metrics for <%= display_date(@date.first) %> (in ms)</h1>
3
+ <%= form_with url: active_insights.controller_p_values_redirection_path, method: :get do |f| %>
4
+ <%= f.date_field :date, max: Date.current, onchange: "this.form.submit()", value: @date.first.to_date %>
5
+ <% f.submit "submit", class: "hidden" %>
6
+ <% end %>
7
+ </header>
8
+
9
+ <div class="p-16px">
10
+ <%= line_chart [{ name: "p50", data: @p50 }, { name: "p95", data: @p95 }, { name: "p99", data: @p99 }], height: "80%", colors: ["rgb(255, 249, 216)", "green", "#C00"], library: {
11
+ plugins: { zoom: { zoom: { wheel: { enabled: false }, drag: { enabled: true, backgroundColor: 'rgba(225,225,225,0.5)' }, mode: 'x' } }, decimation: { enabled: true, algorithm: 'lttb' } },
12
+ elements: { point: { radius: 0 } },
13
+ scales: {
14
+ x: { autoSkip: false, display: false },
15
+ y: { grid: { display: false }, ticks: { color: "white" }, min: (@p50.map(&:second).min * 0.98).ceil, max: (@p99.map(&:second).max) }
16
+ } } %>
17
+ </div>
18
+
19
+ <script>
20
+ document.addEventListener('keydown',(event) => {
21
+ const chart = Chartkick.charts[Object.keys(Chartkick.charts)[0]].getChartObject()
22
+ if (event.key === 'Escape' || event.key === 'Esc') {
23
+ chart.resetZoom()
24
+ }
25
+ });
26
+ </script>
@@ -0,0 +1,26 @@
1
+ <header>
2
+ <h1>Response Metrics for <%= display_date(@date.first) %> (in ms)</h1>
3
+ <%= form_with url: active_insights.p_values_redirection_path, method: :get do |f| %>
4
+ <%= f.date_field :date, max: Date.current, onchange: "this.form.submit()", value: @date.first.to_date %>
5
+ <% f.submit "submit", class: "hidden" %>
6
+ <% end %>
7
+ </header>
8
+
9
+ <div class="p-16px">
10
+ <%= line_chart [{ name: "p50", data: @p50 }, { name: "p95", data: @p95 }, { name: "p99", data: @p99 }], height: "80%", colors: ["rgb(255, 249, 216)", "green", "#C00"], library: {
11
+ plugins: { zoom: { zoom: { wheel: { enabled: false }, drag: { enabled: true, backgroundColor: 'rgba(225,225,225,0.5)' }, mode: 'x' } }, decimation: { enabled: true, algorithm: 'lttb' } },
12
+ elements: { point: { radius: 0 } },
13
+ scales: {
14
+ x: { autoSkip: false, display: false },
15
+ y: { grid: { display: false }, ticks: { color: "white" }, min: (@p50.map(&:second).min * 0.98).ceil, max: (@p99.map(&:second).max) }
16
+ } } %>
17
+ </div>
18
+
19
+ <script>
20
+ document.addEventListener('keydown',(event) => {
21
+ const chart = Chartkick.charts[Object.keys(Chartkick.charts)[0]].getChartObject()
22
+ if (event.key === 'Escape' || event.key === 'Esc') {
23
+ chart.resetZoom()
24
+ }
25
+ });
26
+ </script>
@@ -0,0 +1,54 @@
1
+ <header>
2
+ <h1>Metrics for <%= display_date(@date.first) %></h1>
3
+ <%= form_with url: active_insights.requests_path, method: :get do |f| %>
4
+ <%= f.date_field :date, max: Date.current, onchange: "this.form.submit()", value: @date.first.to_date %>
5
+ <% f.submit "submit", class: "hidden" %>
6
+ <% end %>
7
+ </header>
8
+
9
+ <div class="pl-30px pt-30px flex flex-row justify-around font-size-30">
10
+ <% @requests.flat_map(&:parsed_durations).tap do |durations| %>
11
+ <%= link_to rpm_path(@date.first.to_date), class: "flex flex-col justify-center items-center no-underline" do %>
12
+ <div><%= per_minute(durations.size, 24.hours) %></div>
13
+ <b>RPM</b>
14
+ <% end %>
15
+
16
+ <%= link_to p_values_path(@date.first.to_date), class: "flex flex-col justify-center items-center no-underline" do %>
17
+ <div><%= p50(durations) %> ms</div>
18
+ <b>p50</b>
19
+ <% end %>
20
+
21
+ <%= link_to p_values_path(@date.first.to_date), class: "flex flex-col justify-center items-center no-underline" do %>
22
+ <div><%= p95(durations) %> ms</div>
23
+ <b>p95</b>
24
+ <% end %>
25
+
26
+ <%= link_to p_values_path(@date.first.to_date), class: "flex flex-col justify-center items-center no-underline" do %>
27
+ <div><%= p99(durations) %> ms</div>
28
+ <b>p99</b>
29
+ <% end %>
30
+ <% end %>
31
+ </div>
32
+
33
+ <table>
34
+ <thead>
35
+ <tr>
36
+ <th>Controller</th>
37
+ <th>RPM</th>
38
+ <th>p50</th>
39
+ <th>p95</th>
40
+ <th>p99</th>
41
+ </tr>
42
+ </thead>
43
+ <tbody>
44
+ <% @requests.sort_by(&:agony).reverse.each do |model| %>
45
+ <tr>
46
+ <td><%= link_to model.formatted_controller, controller_p_values_path(@date.first.to_date, model.formatted_controller) %></td>
47
+ <td><%= per_minute(model.parsed_durations.size, 24.hours) %></td>
48
+ <td><%= model.p50 %> ms</td>
49
+ <td><%= model.p95 %> ms</td>
50
+ <td><%= model.p99 %> ms</td>
51
+ </tr>
52
+ <% end %>
53
+ </tbody>
54
+ </table>
@@ -0,0 +1,26 @@
1
+ <header>
2
+ <h1>RPM Metrics for <%= display_date(@date.first) %></h1>
3
+ <%= form_with url: active_insights.rpm_redirection_path, method: :get do |f| %>
4
+ <%= f.date_field :date, max: Date.current, onchange: "this.form.submit()", value: @date.first.to_date %>
5
+ <% f.submit "submit", class: "hidden" %>
6
+ <% end %>
7
+ </header>
8
+
9
+ <div class="p-16px">
10
+ <%= column_chart @minutes, height: "80%", colors: ["#C00"], library: {
11
+ borderSkipped: true, barPercentage: 1, categoryPercentage: 1,
12
+ plugins: { zoom: { zoom: { wheel: { enabled: false }, drag: { enabled: true, backgroundColor: 'rgba(225,225,225,0.5)' }, mode: 'x' } } },
13
+ scales: {
14
+ x: { barPercentage: 1.0, autoSkip: false, display: false },
15
+ y: { grid: { display: false }, ticks: { color: "white" }, min: (@minutes.map(&:second).min * 0.98).ceil, max: (@minutes.map(&:second).max) }
16
+ } } %>
17
+ </div>
18
+
19
+ <script>
20
+ document.addEventListener('keydown',(event) => {
21
+ const chart = Chartkick.charts[Object.keys(Chartkick.charts)[0]].getChartObject()
22
+ if (event.key === 'Escape' || event.key === 'Esc') {
23
+ chart.resetZoom()
24
+ }
25
+ });
26
+ </script>
@@ -0,0 +1,345 @@
1
+ <html lang="en">
2
+ <head>
3
+ <meta charset="utf-8" />
4
+ <meta name="viewport" content="width=device-width, initial-scale=1">
5
+ <meta name="turbo-visit-control" content="reload">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+ <%= active_insights_importmap_tags %>
9
+
10
+ <title>Active Insights</title>
11
+ <style>
12
+ body {
13
+ background-color: #FAFAFA;
14
+ color: #333;
15
+ color-scheme: light dark;
16
+ supported-color-schemes: light dark;
17
+ margin: 0px;
18
+ }
19
+
20
+ body, p, ol, ul, td, input {
21
+ font-family: helvetica, verdana, arial, sans-serif;
22
+ font-size: 15px;
23
+ line-height: 18px;
24
+ }
25
+
26
+ form {
27
+ margin-bottom: 0px;
28
+ }
29
+
30
+ pre {
31
+ font-size: 11px;
32
+ white-space: pre-wrap;
33
+ }
34
+
35
+ pre.box {
36
+ border: 1px solid #EEE;
37
+ padding: 10px;
38
+ margin: 0px;
39
+ width: 958px;
40
+ }
41
+
42
+ header {
43
+ color: #F0F0F0;
44
+ background: #C00;
45
+ padding: 0.5em 1.5em;
46
+ display: flex;
47
+ flex-direction: row;
48
+ justify-content: space-between;
49
+ align-items: center;
50
+ }
51
+
52
+ h1 {
53
+ overflow-wrap: break-word;
54
+ margin: 0.2em 0;
55
+ line-height: 1.1em;
56
+ font-size: 2em;
57
+ }
58
+
59
+ h2 {
60
+ color: #C00;
61
+ line-height: 25px;
62
+ }
63
+
64
+ code.traces {
65
+ font-size: 11px;
66
+ }
67
+
68
+ .response-heading, .request-heading {
69
+ margin-top: 30px;
70
+ }
71
+
72
+ .exception-message {
73
+ padding: 8px 0;
74
+ }
75
+
76
+ .exception-message .message {
77
+ margin-bottom: 8px;
78
+ line-height: 25px;
79
+ font-size: 1.5em;
80
+ font-weight: bold;
81
+ color: #C00;
82
+ }
83
+
84
+ .details {
85
+ border: 1px solid #D0D0D0;
86
+ border-radius: 4px;
87
+ margin: 1em 0px;
88
+ display: block;
89
+ max-width: 978px;
90
+ }
91
+
92
+ .summary {
93
+ padding: 8px 15px;
94
+ border-bottom: 1px solid #D0D0D0;
95
+ display: block;
96
+ }
97
+
98
+ a.summary {
99
+ color: #F0F0F0;
100
+ text-decoration: none;
101
+ background: #C52F24;
102
+ border-bottom: none;
103
+ }
104
+
105
+ .details pre {
106
+ margin: 5px;
107
+ border: none;
108
+ }
109
+
110
+ #container {
111
+ box-sizing: border-box;
112
+ width: 100%;
113
+ padding: 0 1.5em;
114
+ }
115
+
116
+ .source * {
117
+ margin: 0px;
118
+ padding: 0px;
119
+ }
120
+
121
+ .source {
122
+ border: 1px solid #D9D9D9;
123
+ background: #ECECEC;
124
+ max-width: 978px;
125
+ }
126
+
127
+ .source pre {
128
+ padding: 10px 0px;
129
+ border: none;
130
+ }
131
+
132
+ .source .data {
133
+ font-size: 80%;
134
+ overflow: auto;
135
+ background-color: #FFF;
136
+ }
137
+
138
+ .info {
139
+ padding: 0.5em;
140
+ }
141
+
142
+ .source .data .line_numbers {
143
+ background-color: #ECECEC;
144
+ color: #555;
145
+ padding: 1em .5em;
146
+ border-right: 1px solid #DDD;
147
+ text-align: right;
148
+ }
149
+
150
+ .line {
151
+ padding-left: 10px;
152
+ white-space: pre;
153
+ }
154
+
155
+ .line:hover {
156
+ background-color: #F6F6F6;
157
+ }
158
+
159
+ .line.active {
160
+ background-color: #FCC;
161
+ }
162
+
163
+ .error_highlight {
164
+ display: inline-block;
165
+ background-color: #FF9;
166
+ text-decoration: #F00 wavy underline;
167
+ }
168
+
169
+ .error_highlight_tip {
170
+ color: #666;
171
+ padding: 2px 2px;
172
+ font-size: 10px;
173
+ }
174
+
175
+ .button_to {
176
+ display: inline-block;
177
+ margin-top: 0.75em;
178
+ margin-bottom: 0.75em;
179
+ }
180
+
181
+ .hidden {
182
+ display: none;
183
+ }
184
+
185
+ .correction {
186
+ list-style-type: none;
187
+ }
188
+
189
+ input[type="submit"] {
190
+ color: white;
191
+ background-color: #C00;
192
+ border: none;
193
+ border-radius: 12px;
194
+ box-shadow: 0 3px #F99;
195
+ font-size: 13px;
196
+ font-weight: bold;
197
+ margin: 0;
198
+ padding: 10px 18px;
199
+ cursor: pointer;
200
+ -webkit-appearance: none;
201
+ }
202
+ input[type="submit"]:focus,
203
+ input[type="submit"]:hover {
204
+ opacity: 0.8;
205
+ }
206
+ input[type="submit"]:active {
207
+ box-shadow: 0 2px #F99;
208
+ transform: translateY(1px)
209
+ }
210
+
211
+ a { color: #980905; }
212
+ a:visited { color: #666; }
213
+ a.trace-frames {
214
+ color: #666;
215
+ overflow-wrap: break-word;
216
+ }
217
+ a:hover, a.trace-frames.selected { color: #C00; }
218
+ a.summary:hover { color: #FFF; }
219
+
220
+ @media (prefers-color-scheme: dark) {
221
+ body {
222
+ background-color: #222;
223
+ color: #ECECEC;
224
+ }
225
+
226
+ .details, .summary {
227
+ border-color: #666;
228
+ }
229
+
230
+ .source {
231
+ border-color: #555;
232
+ background-color: #333;
233
+ }
234
+
235
+ .source .data {
236
+ background: #444;
237
+ }
238
+
239
+ .source .data .line_numbers {
240
+ background: #333;
241
+ border-color: #222;
242
+ }
243
+
244
+ .line:hover {
245
+ background: #666;
246
+ }
247
+
248
+ .line.active {
249
+ background-color: #900;
250
+ }
251
+
252
+ .error_highlight {
253
+ color: #333;
254
+ }
255
+
256
+ input[type="submit"] {
257
+ box-shadow: 0 3px #800;
258
+ }
259
+ input[type="submit"]:active {
260
+ box-shadow: 0 2px #800;
261
+ }
262
+
263
+ a { color: #C00; }
264
+ }
265
+
266
+ table {
267
+ margin: 0;
268
+ border-collapse: collapse;
269
+ word-wrap:break-word;
270
+ table-layout: auto;
271
+ width: 100%;
272
+ margin-top: 50px;
273
+ }
274
+
275
+ table thead tr {
276
+ border-bottom: 2px solid #ddd;
277
+ }
278
+
279
+ table th {
280
+ padding-left: 30px;
281
+ text-align: left;
282
+ }
283
+
284
+ table tbody tr {
285
+ border-bottom: 1px solid #ddd;
286
+ }
287
+
288
+ table tbody tr:nth-child(odd) {
289
+ background: #f2f2f2;
290
+ }
291
+
292
+ table td {
293
+ padding: 4px 30px;
294
+ }
295
+
296
+ @media (prefers-color-scheme: dark) {
297
+ table tbody tr:nth-child(odd) {
298
+ background: #282828;
299
+ }
300
+ }
301
+
302
+ .pl-30px {
303
+ padding-left: 30px;
304
+ }
305
+
306
+ .pt-30px {
307
+ padding-top: 30px;
308
+ }
309
+
310
+ .flex {
311
+ display: flex;
312
+ }
313
+
314
+ .flex-col {
315
+ flex-direction: column;
316
+ }
317
+ .flex-row {
318
+ flex-direction: row;
319
+ }
320
+ .justify-around {
321
+ justify-content: space-around;
322
+ }
323
+
324
+ .justify-center {
325
+ justify-content: center;
326
+ }
327
+ .items-center {
328
+ align-items: center;
329
+ }
330
+ .font-size-30 {
331
+ font-size: 30px;
332
+ line-height: 30px;
333
+ }
334
+ .no-underline {
335
+ text-decoration: none;
336
+ }
337
+ .p-16px {
338
+ padding: 16px;
339
+ }
340
+
341
+ <%= yield :style %>
342
+ </style>
343
+ </head>
344
+ <body><%= yield %></body>
345
+ </html>
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_insights"
4
+
5
+ ActiveInsights::Engine.importmap.draw do
6
+ pin "application", to: "active_insights/application.js"
7
+
8
+ pin "chartkick", to: "chartkick.js"
9
+ pin "Chart.bundle", to: "Chart.bundle.js"
10
+
11
+ pin "chartjs-plugin-zoom", to: "https://ga.jspm.io/npm:chartjs-plugin-zoom@2.0.1/dist/chartjs-plugin-zoom.esm.js"
12
+ pin "hammerjs", to: "https://ga.jspm.io/npm:hammerjs@2.0.8/hammer.js"
13
+ pin "@kurkle/color", to: "https://ga.jspm.io/npm:@kurkle/color@0.3.2/dist/color.esm.js"
14
+ pin "chart.js/helpers", to: "https://ga.jspm.io/npm:chart.js@4.4.1/helpers/helpers.js"
15
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveInsights::Engine.routes.draw do
4
+ resources :requests, only: %i(index)
5
+ get "/requests/:date", to: "requests#index"
6
+ get "/requests/rpm/redirection", to: "rpm#redirection", as: :rpm_redirection
7
+ get "/requests/:date/rpm", to: "rpm#index", as: :rpm
8
+
9
+ get "/requests/p_values/redirection", to: "p_values#redirection",
10
+ as: :p_values_redirection
11
+ get "/requests/:date/p_values", to: "p_values#index", as: :p_values
12
+ get "/requests/:date/:formatted_controller/p_values",
13
+ to: "controller_p_values#index", as: :controller_p_values
14
+ get "/requests/:formatted_controller/p_values/redirection",
15
+ to: "controller_p_values#redirection",
16
+ as: :controller_p_values_redirection
17
+
18
+ root "requests#index"
19
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveInsightsRequest < ActiveRecord::Migration[7.1]
4
+ def change # rubocop:disable Metrics/AbcSize
5
+ create_table :active_insights_requests, if_not_exists: true do |t|
6
+ t.string :controller
7
+ t.string :action
8
+ t.string :format
9
+ t.string :http_method
10
+ t.text :path
11
+ t.integer :status
12
+ t.float :view_runtime
13
+ t.float :db_runtime
14
+ t.datetime :started_at
15
+ t.datetime :finished_at
16
+ t.string :uuid
17
+ t.float :duration
18
+ case connection.adapter_name
19
+ when "Mysql2", "Mysql2Spatial", "Mysql2Rgeo", "Trilogy"
20
+ t.virtual :formatted_controller, type: :string, as: "CONCAT(controller, '#', action)", stored: true
21
+ else
22
+ t.virtual :formatted_controller, type: :string, as: "controller || '#'|| action", stored: true
23
+ end
24
+
25
+ t.index :started_at
26
+ t.index %i(started_at duration)
27
+ t.index %i(started_at formatted_controller)
28
+
29
+ t.timestamps
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInsights
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace ActiveInsights
6
+
7
+ def self.importmap
8
+ @importmap ||=
9
+ Importmap::Map.new.tap do |mapping|
10
+ mapping.draw(Engine.root.join("config/importmap.rb"))
11
+ end
12
+ end
13
+
14
+ initializer "active_insights.importmap", before: "importmap" do |app|
15
+ app.config.importmap.paths <<
16
+ Engine.root.join("config/initializers/importmap.rb")
17
+ end
18
+
19
+ initializer "active_insights.subscriber" do |_app|
20
+ ActiveSupport::Notifications.
21
+ subscribe("process_action.action_controller") do |_name,
22
+ started, finished, unique_id, payload|
23
+ Thread.new do
24
+ ActiveRecord::Base.connection_pool.with_connection do
25
+ ActiveInsights::Request.create!(
26
+ started_at: started, finished_at: finished, uuid: unique_id,
27
+ duration: (finished - started) * 1000.0,
28
+ controller: payload[:controller],
29
+ action: payload[:action], format: payload[:format],
30
+ http_method: payload[:method], status: payload[:status],
31
+ view_runtime: payload[:view_runtime],
32
+ db_runtime: payload[:db_runtime]
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubystats"
4
+
5
+ module ActiveInsights
6
+ class Seeder
7
+ def initialize(date, rpm, p50, p95, p99)
8
+ @date = date
9
+ @rpm = rpm
10
+ @p50 = p50
11
+ @p95 = p95
12
+ @p99 = p99
13
+ end
14
+
15
+ def seed
16
+ ActiveInsights::Request.insert_all(seed_attributes)
17
+ end
18
+
19
+ def find_percentile(sorted_data, percentile)
20
+ sorted_data[(percentile * sorted_data.length).ceil - 1]
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :date, :rpm, :p50, :p95, :p99
26
+
27
+ def seed_attributes
28
+ 24.times.flat_map do |hour|
29
+ 60.times.flat_map do |min|
30
+ response_times.map { |duration| attributes(hour, min, duration) }
31
+ end
32
+ end
33
+ end
34
+
35
+ def response_times
36
+ Array.new(rpm) do
37
+ p50 + (beta_distribution.rng * (p95 - p50))
38
+ end.select { |time| time <= p99 }
39
+ end
40
+
41
+ def beta_distribution
42
+ @beta_distribution ||= Rubystats::BetaDistribution.new(2, 5)
43
+ end
44
+
45
+ def sample_controller
46
+ ["AppsController#show", "OrganizationsController#show",
47
+ "AgentController#create", "AppComponentsController#index",
48
+ "BadgesController#show", "ContactController#create",
49
+ "AppsController#update"].sample.split("#")
50
+ end
51
+
52
+ def attributes(hour, min, duration)
53
+ sample_controller.then do |controller, action|
54
+ started_at = date.dup.to_time.change(hour:, min:)
55
+
56
+ default_attributes.merge(controller:, action:, duration:, started_at:,
57
+ finished_at: started_at + (duration / 1000.0))
58
+ end
59
+ end
60
+
61
+ def default_attributes
62
+ { created_at: Time.current, updated_at: Time.current, format: :html,
63
+ http_method: :get, uuid: SecureRandom.uuid }
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveInsights
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "importmap-rails"
4
+ require "chartkick"
5
+
6
+ require "active_insights/version"
7
+ require "active_insights/engine"
8
+
9
+ module ActiveInsights
10
+ mattr_accessor :connects_to
11
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_insights"
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveInsights::InstallGenerator < Rails::Generators::Base
4
+ def add_route
5
+ route "mount ActiveInsights::Engine => \"/insights\""
6
+ end
7
+
8
+ def create_migrations
9
+ rails_command "active_insights:install:migrations", inline: true
10
+ end
11
+ end
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activeinsights
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nick Pezza
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-01-17 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: chartkick
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: importmap-rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '7.1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '7.1'
55
+ description:
56
+ email:
57
+ - pezza@hey.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - README.md
63
+ - Rakefile
64
+ - app/assets/javascripts/active_insights/application.js
65
+ - app/controllers/active_insights/application_controller.rb
66
+ - app/controllers/active_insights/controller_p_values_controller.rb
67
+ - app/controllers/active_insights/p_values_controller.rb
68
+ - app/controllers/active_insights/requests_controller.rb
69
+ - app/controllers/active_insights/rpm_controller.rb
70
+ - app/helpers/active_insights/application_helper.rb
71
+ - app/models/active_insights/record.rb
72
+ - app/models/active_insights/request.rb
73
+ - app/views/active_insights/controller_p_values/index.html.erb
74
+ - app/views/active_insights/p_values/index.html.erb
75
+ - app/views/active_insights/requests/index.html.erb
76
+ - app/views/active_insights/rpm/index.html.erb
77
+ - app/views/layouts/active_insights/application.html.erb
78
+ - config/initializers/importmap.rb
79
+ - config/routes.rb
80
+ - db/migrate/20240111225806_active_insights_request.rb
81
+ - lib/active_insights.rb
82
+ - lib/active_insights/engine.rb
83
+ - lib/active_insights/seeder.rb
84
+ - lib/active_insights/version.rb
85
+ - lib/activeinsights.rb
86
+ - lib/generators/active_insights/install/install_generator.rb
87
+ homepage: https://github.com/npezza93/activeinsights
88
+ licenses:
89
+ - MIT
90
+ metadata:
91
+ rubygems_mfa_required: 'true'
92
+ post_install_message:
93
+ rdoc_options: []
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: 3.1.0
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ requirements: []
107
+ rubygems_version: 3.5.3
108
+ signing_key:
109
+ specification_version: 4
110
+ summary: Rails performance tracking
111
+ test_files: []