activeinsights 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []