dead_bro_hub 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 +7 -0
- data/MIT-LICENSE +14 -0
- data/README.md +76 -0
- data/Rakefile +10 -0
- data/app/controllers/dead_bro_hub/application_controller.rb +58 -0
- data/app/controllers/dead_bro_hub/deploys_controller.rb +7 -0
- data/app/controllers/dead_bro_hub/endpoints_controller.rb +8 -0
- data/app/controllers/dead_bro_hub/errors_controller.rb +15 -0
- data/app/controllers/dead_bro_hub/findings_controller.rb +8 -0
- data/app/controllers/dead_bro_hub/overview_controller.rb +7 -0
- data/app/controllers/dead_bro_hub/queries_controller.rb +11 -0
- data/app/controllers/dead_bro_hub/status_controller.rb +9 -0
- data/app/helpers/dead_bro_hub/application_helper.rb +97 -0
- data/app/views/dead_bro_hub/deploys/index.html.erb +21 -0
- data/app/views/dead_bro_hub/endpoints/index.html.erb +27 -0
- data/app/views/dead_bro_hub/errors/index.html.erb +22 -0
- data/app/views/dead_bro_hub/errors/not_found.html.erb +3 -0
- data/app/views/dead_bro_hub/errors/show.html.erb +43 -0
- data/app/views/dead_bro_hub/findings/index.html.erb +24 -0
- data/app/views/dead_bro_hub/overview/show.html.erb +59 -0
- data/app/views/dead_bro_hub/queries/index.html.erb +37 -0
- data/app/views/dead_bro_hub/shared/_page_styles.html.erb +15 -0
- data/app/views/dead_bro_hub/shared/forbidden.html.erb +3 -0
- data/app/views/dead_bro_hub/status/show.html.erb +47 -0
- data/config/routes.rb +11 -0
- data/lib/dead_bro_hub/api.rb +81 -0
- data/lib/dead_bro_hub/configuration.rb +52 -0
- data/lib/dead_bro_hub/engine.rb +22 -0
- data/lib/dead_bro_hub/local_status.rb +49 -0
- data/lib/dead_bro_hub/version.rb +3 -0
- data/lib/dead_bro_hub.rb +29 -0
- data/lib/generators/dead_bro_hub/install/install_generator.rb +30 -0
- data/lib/generators/dead_bro_hub/install/templates/dead_bro_hub.rb +18 -0
- metadata +121 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8a1d1f1c9243fa53f0ca84fc3a2f2cbf71d440b67ac3b8817c025c5194bc7b1d
|
|
4
|
+
data.tar.gz: 16a6af3fec491613e9a2e68a933ecc064c4433abc5f98dd7ebf5f7204b92953c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1bfb1606b64382bd09fe76b46e6338b601fcf81e2468a458be9045c8c9855594126a9e0cae4bc5a9ffebf2481aaa9fa0bcf07b45c705a4c0ff1aeb1fcf062948
|
|
7
|
+
data.tar.gz: 59c0d406169a94ca953bbdf9972a47501ce12311e0e872c14efe7d2c4a0535e31b9731ebb85ebb9a720fef937c70d3e215be89e5d5070bd52f514282fdca75df
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Copyright Ruby Dev SRL
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
4
|
+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
|
5
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
6
|
+
persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
7
|
+
|
|
8
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
|
9
|
+
Software.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
12
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
13
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
14
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# DeadBroHub
|
|
2
|
+
|
|
3
|
+
DeadBro's view of your app, inside the [BroHub](https://github.com/rubydevro/bro_hub) dashboard, next to EnvBro and SafeBro.
|
|
4
|
+
|
|
5
|
+
It reads from DeadBro with the **API key the `dead_bro` gem already uses**, so there is nothing new to configure except who may look.
|
|
6
|
+
|
|
7
|
+
| Page | Shows |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| Overview | Requests, error rate, throughput and p95 per hour (24h), slowest endpoints, latest deploy |
|
|
10
|
+
| Endpoints | Controller actions and jobs ranked by mean time, error rate, slow, N+1, CPU (1h / 24h / 7d / 30d) |
|
|
11
|
+
| Errors | Distinct errors by last seen; detail with affected endpoints and recent occurrences |
|
|
12
|
+
| SQL queries | Normalized statements ranked by total time, runs, p95, max; search by table or keyword |
|
|
13
|
+
| Findings | What DeadBro keeps tracking, ranked by cost |
|
|
14
|
+
| Deploys | Revisions and the errors since each |
|
|
15
|
+
| Status | The gem in *this* process (on, sending, circuit breaker, dropped payloads) next to the settings DeadBro hands it |
|
|
16
|
+
|
|
17
|
+
Rails 7.1+, Ruby 3.2+ (`dead_bro` itself still runs on 2.7+; this gem is the part that needs a newer Ruby). No database, no JavaScript.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
gem "dead_bro_hub"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
bin/rails generate dead_bro_hub:install
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This sets up BroHub if it isn't yet (mount point, the other Bro gems) and adds `config/initializers/dead_bro_hub.rb`.
|
|
30
|
+
Then say who may look. **Nobody can until you do**: the pages show SQL, exception messages and request paths.
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
DeadBroHub.configure do |config|
|
|
34
|
+
config.can_view = ->(user) { user&.admin? }
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Visit `/bro_hub/dead_bro_hub`.
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
DeadBroHub.configure do |config|
|
|
44
|
+
config.can_view = ->(user) { ... } # required, default denies everyone
|
|
45
|
+
config.current_user = ->(controller) { controller.current_user } # default: BroHub.config.current_user
|
|
46
|
+
config.parent_controller = "::ApplicationController"
|
|
47
|
+
config.cache_ttl = 30 # seconds in Rails.cache, 0 = off
|
|
48
|
+
config.api_key = ENV["..."] # default: DeadBro.configuration.api_key
|
|
49
|
+
config.base_url = "https://www.deadbro.com" # default: what the dead_bro client sends to
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## How it works
|
|
54
|
+
|
|
55
|
+
Each page makes one request to its own endpoint, `GET /api/v1/hub/<page>` on DeadBro, with
|
|
56
|
+
`Authorization: Bearer <app api key>`. The key resolves to exactly one application, so a leaked key reads that app's
|
|
57
|
+
data and nothing else of the account. Successful responses are cached briefly in `Rails.cache` (the key is hashed into
|
|
58
|
+
the cache key, never stored). If DeadBro is unreachable, slow or answers 5xx, the page renders a notice rather than failing.
|
|
59
|
+
|
|
60
|
+
| Page | Endpoint |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| Overview | `overview` |
|
|
63
|
+
| Endpoints | `endpoints?window=&sort=` |
|
|
64
|
+
| Errors | `errors?offset=&limit=`, `errors/:id` |
|
|
65
|
+
| SQL queries | `queries?window=&sort=&search=&offset=&limit=` |
|
|
66
|
+
| Findings | `findings?status=` |
|
|
67
|
+
| Deploys | `deploys` |
|
|
68
|
+
| Status | `settings` (plus the gem's in-memory state, read locally) |
|
|
69
|
+
|
|
70
|
+
SQL is returned normalized only; the sample statement with real values never leaves DeadBro.
|
|
71
|
+
|
|
72
|
+
## Tests
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
bundle exec rake test
|
|
76
|
+
```
|
data/Rakefile
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module DeadBroHub
|
|
2
|
+
class ApplicationController < DeadBroHub.config.parent_controller.constantize
|
|
3
|
+
include BroHub::Layout
|
|
4
|
+
bro_hub_section :dead_bro_hub
|
|
5
|
+
|
|
6
|
+
protect_from_forgery with: :exception
|
|
7
|
+
|
|
8
|
+
before_action :dead_bro_hub_authorize
|
|
9
|
+
before_action :dead_bro_hub_no_store
|
|
10
|
+
|
|
11
|
+
helper_method :dead_bro_hub_user
|
|
12
|
+
|
|
13
|
+
rescue_from DeadBroHub::Forbidden, with: :dead_bro_hub_forbidden
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
def dead_bro_hub_user
|
|
17
|
+
return @dead_bro_hub_user if defined?(@dead_bro_hub_user)
|
|
18
|
+
@dead_bro_hub_user = DeadBroHub.config.current_user.call(self)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# BroHub shows this name in the top bar, so it must be the same person DeadBroHub authorizes.
|
|
22
|
+
def bro_hub_user
|
|
23
|
+
dead_bro_hub_user
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def dead_bro_hub_authorize
|
|
27
|
+
raise DeadBroHub::Forbidden unless DeadBroHub.config.allows?(dead_bro_hub_user)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Pages show SQL, exception messages and paths: keep them out of browser and proxy caches.
|
|
31
|
+
def dead_bro_hub_no_store
|
|
32
|
+
response.headers["Cache-Control"] = "no-store"
|
|
33
|
+
response.headers["Pragma"] = "no-cache"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def dead_bro_hub_forbidden
|
|
37
|
+
render "dead_bro_hub/shared/forbidden", status: :forbidden
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def dead_bro_hub_api
|
|
41
|
+
DeadBroHub.api
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Runs the block (a call to DeadBro) and returns its data. When DeadBro can't answer, @error is set
|
|
45
|
+
# and the page renders a notice instead of failing: the hub must not 500 because DeadBro is down.
|
|
46
|
+
def dead_bro_hub_fetch
|
|
47
|
+
yield
|
|
48
|
+
rescue DeadBroHub::Api::Error => error
|
|
49
|
+
@error = error
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Only these reach DeadBro; everything else in the query string is dropped.
|
|
54
|
+
def dead_bro_hub_params(*keys)
|
|
55
|
+
params.permit(*keys).to_h.symbolize_keys
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module DeadBroHub
|
|
2
|
+
class ErrorsController < ApplicationController
|
|
3
|
+
PER_PAGE = 25
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
@offset = [ params[:offset].to_i, 0 ].max
|
|
7
|
+
@data = dead_bro_hub_fetch { dead_bro_hub_api.errors(offset: @offset, limit: PER_PAGE) }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def show
|
|
11
|
+
@error_issue = dead_bro_hub_fetch { dead_bro_hub_api.error(params[:id]) }
|
|
12
|
+
render :not_found, status: :not_found if @error&.kind == :not_found
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module DeadBroHub
|
|
2
|
+
class QueriesController < ApplicationController
|
|
3
|
+
PER_PAGE = 25
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
@filters = dead_bro_hub_params(:window, :sort, :search)
|
|
7
|
+
@offset = [ params[:offset].to_i, 0 ].max
|
|
8
|
+
@data = dead_bro_hub_fetch { dead_bro_hub_api.queries(@filters.merge(offset: @offset, limit: PER_PAGE)) }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
module DeadBroHub
|
|
2
|
+
class StatusController < ApplicationController
|
|
3
|
+
# Two halves: what the gem has in memory here, and what DeadBro tells it to do.
|
|
4
|
+
def show
|
|
5
|
+
@local = LocalStatus.snapshot
|
|
6
|
+
@remote = dead_bro_hub_fetch { dead_bro_hub_api.settings }
|
|
7
|
+
end
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
module DeadBroHub
|
|
2
|
+
module ApplicationHelper
|
|
3
|
+
# DeadBroHub's additions to BroHub's styles, once per page (and nonce-friendly).
|
|
4
|
+
def dead_bro_hub_styles
|
|
5
|
+
content_for(:head, render("dead_bro_hub/shared/page_styles")) unless content_for?(:head)
|
|
6
|
+
nil
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def dbh_int(value)
|
|
10
|
+
value.nil? ? "-" : number_with_delimiter(value)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def dbh_ms(value)
|
|
14
|
+
return "-" if value.nil?
|
|
15
|
+
|
|
16
|
+
value = value.to_f
|
|
17
|
+
value >= 1000 ? "#{(value / 1000).round(2)} s" : "#{value.round(value < 10 ? 2 : 1)} ms"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def dbh_pct(value)
|
|
21
|
+
value.nil? ? "-" : "#{value}%"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# ISO8601 string from the API, or a Time/epoch from the gem's own state -> "5 minutes ago", exact time on hover.
|
|
25
|
+
def dbh_time(value)
|
|
26
|
+
time = case value
|
|
27
|
+
when nil, "" then nil
|
|
28
|
+
when Time, ActiveSupport::TimeWithZone then value
|
|
29
|
+
when Numeric then Time.at(value)
|
|
30
|
+
else Time.zone.parse(value.to_s)
|
|
31
|
+
end
|
|
32
|
+
return content_tag(:span, "-", class: "bh-cell-muted") unless time
|
|
33
|
+
|
|
34
|
+
content_tag(:span, "#{time_ago_in_words(time)} ago", title: time.utc.iso8601)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def dbh_error_rate_tone(rate)
|
|
38
|
+
rate = rate.to_f
|
|
39
|
+
rate >= 5 ? :red : (rate >= 1 ? :amber : :green)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def dbh_tag(text, style = nil)
|
|
43
|
+
content_tag(:span, text, class: [ "bh-tag", style ].compact_blank)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# What went wrong reaching DeadBro, in words that say what to do about it.
|
|
47
|
+
def dbh_error_notice(error)
|
|
48
|
+
advice = case error.kind
|
|
49
|
+
when :not_configured then "Configure the dead_bro gem with this app's API key."
|
|
50
|
+
when :unauthorized then "Check the API key in the dead_bro configuration: DeadBro does not recognise it."
|
|
51
|
+
when :unavailable then "DeadBro could not read its data just now. Try again in a minute."
|
|
52
|
+
when :network then "This server could not reach DeadBro. Check the network and DeadBroHub's base_url."
|
|
53
|
+
else "Try again in a minute."
|
|
54
|
+
end
|
|
55
|
+
content_tag(:div, class: "bh-alert bh-alert-error") { safe_join([ content_tag(:strong, error.message), " ", advice ]) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Tiny bar chart: values -> inline SVG, one bar per value, scaled to the largest.
|
|
59
|
+
def dbh_bars(values, tone: "var(--bh-violet)", width: 420, height: 56)
|
|
60
|
+
values = Array(values).map(&:to_f)
|
|
61
|
+
return content_tag(:div, "No data in this window", class: "bh-cell-muted") if values.empty? || values.max <= 0
|
|
62
|
+
|
|
63
|
+
gap = 2
|
|
64
|
+
bar = [ (width - gap * (values.size - 1)).fdiv(values.size), 1 ].max
|
|
65
|
+
rects = values.each_with_index.map do |value, i|
|
|
66
|
+
h = [ value / values.max * height, 1 ].max
|
|
67
|
+
tag.rect(x: (i * (bar + gap)).round(2), y: (height - h).round(2), width: bar.round(2), height: h.round(2), fill: tone)
|
|
68
|
+
end
|
|
69
|
+
tag.svg(safe_join(rects), viewBox: "0 0 #{width} #{height}", width: "100%", height: height, preserveAspectRatio: "none",
|
|
70
|
+
role: "img", class: "dbh-bars")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A row of chips linking to the same page with one param changed.
|
|
74
|
+
def dbh_chips(param, options, current, label_for: nil)
|
|
75
|
+
links = options.map do |option|
|
|
76
|
+
link_to((label_for ? label_for.call(option) : option), url_for(request.query_parameters.symbolize_keys.merge(param => option, offset: nil)),
|
|
77
|
+
class: [ "bh-chip", ("is-on" if option.to_s == current.to_s) ])
|
|
78
|
+
end
|
|
79
|
+
safe_join(links, " ")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def dbh_pager(rows_shown, per_page, offset)
|
|
83
|
+
links = []
|
|
84
|
+
links << link_to("← Newer", url_for(request.query_parameters.symbolize_keys.merge(offset: [ offset - per_page, 0 ].max)), class: "bh-link-quiet") if offset.positive?
|
|
85
|
+
links << link_to("Older →", url_for(request.query_parameters.symbolize_keys.merge(offset: offset + per_page)), class: "bh-link-quiet") if rows_shown >= per_page
|
|
86
|
+
return if links.empty?
|
|
87
|
+
|
|
88
|
+
content_tag(:div, class: "bh-pager") { safe_join([ content_tag(:span, "#{offset + 1}-#{offset + rows_shown}", class: "bh-meta"), *links ]) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def dbh_yes_no(value, yes: "on", no: "off")
|
|
92
|
+
return dbh_tag("unknown") if value.nil?
|
|
93
|
+
|
|
94
|
+
value ? dbh_tag(yes, "is-green") : dbh_tag(no, "is-amber")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<% dead_bro_hub_styles %>
|
|
2
|
+
<% bro_hub_crumb "Deploys" %>
|
|
3
|
+
<%= bro_hub_hero eyebrow: "Releases", title: "Deploys", tone: :violet, lead: "Detected from the revision your app reports. Errors are counted per deploy." %>
|
|
4
|
+
<% if @error %>
|
|
5
|
+
<%= dbh_error_notice(@error) %>
|
|
6
|
+
<% elsif @data["deploys"].empty? %>
|
|
7
|
+
<div class="bh-table-wrap"><div class="bh-empty">No deploys recorded yet.</div></div>
|
|
8
|
+
<% else %>
|
|
9
|
+
<div class="bh-table-wrap"><div class="bh-table dbh-cols-deploys">
|
|
10
|
+
<div class="bh-row is-head"><span>Revision</span><span>Deployed</span><span>State</span><span>Errors</span><span>Per hour</span></div>
|
|
11
|
+
<% @data["deploys"].each do |d| %>
|
|
12
|
+
<div class="bh-row">
|
|
13
|
+
<span class="bh-cell-strong"><code><%= truncate(d["revision"].to_s, length: 24) %></code></span>
|
|
14
|
+
<span class="bh-cell-muted"><%= dbh_time(d["deployed_at"]) %></span>
|
|
15
|
+
<span><%= d["finished"] ? dbh_tag("replaced") : dbh_tag("live", "is-green") %></span>
|
|
16
|
+
<span class="<%= "bh-tone-red" if d["error_count"].to_i.positive? %>"><%= dbh_int(d["error_count"]) %></span>
|
|
17
|
+
<span class="bh-cell-muted"><%= d["errors_per_hour"] %></span>
|
|
18
|
+
</div>
|
|
19
|
+
<% end %>
|
|
20
|
+
</div></div>
|
|
21
|
+
<% end %>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<% dead_bro_hub_styles %>
|
|
2
|
+
<% bro_hub_crumb "Endpoints" %>
|
|
3
|
+
<%= bro_hub_hero eyebrow: "Performance", title: "Endpoints", tone: :violet,
|
|
4
|
+
lead: "Controller actions and jobs, ranked. Window and ranking are yours to change." %>
|
|
5
|
+
<div class="bh-filters">
|
|
6
|
+
<span><%= dbh_chips(:window, %w[1h 24h 7d 30d], @data&.fetch("window", nil) || "24h") %></span>
|
|
7
|
+
<span><%= dbh_chips(:sort, %w[mean_duration error_rate slow_count n_plus_one cpu_heavy], @data&.fetch("sort", nil) || "mean_duration", label_for: ->(s) { s.humanize.downcase }) %></span>
|
|
8
|
+
</div>
|
|
9
|
+
<% if @error %>
|
|
10
|
+
<%= dbh_error_notice(@error) %>
|
|
11
|
+
<% elsif @data["endpoints"].empty? %>
|
|
12
|
+
<div class="bh-table-wrap"><div class="bh-empty">No requests in this window.</div></div>
|
|
13
|
+
<% else %>
|
|
14
|
+
<div class="bh-table-wrap"><div class="bh-table dbh-cols-endpoints">
|
|
15
|
+
<div class="bh-row is-head"><span>Endpoint</span><span>Requests</span><span>Mean</span><span>Errors</span><span>Slow</span><span>N+1</span></div>
|
|
16
|
+
<% @data["endpoints"].each do |e| %>
|
|
17
|
+
<div class="bh-row">
|
|
18
|
+
<span class="bh-cell-strong"><%= e["name"] %></span>
|
|
19
|
+
<span><%= dbh_int(e["requests"]) %></span>
|
|
20
|
+
<span><%= dbh_ms(e["mean_duration_ms"]) %></span>
|
|
21
|
+
<span class="<%= "bh-tone-#{dbh_error_rate_tone(e["error_rate_pct"])}" %>"><%= dbh_pct(e["error_rate_pct"]) %></span>
|
|
22
|
+
<span class="bh-cell-muted"><%= dbh_int(e["slow_count"]) %></span>
|
|
23
|
+
<span class="bh-cell-muted"><%= dbh_int(e["n_plus_one_count"]) %></span>
|
|
24
|
+
</div>
|
|
25
|
+
<% end %>
|
|
26
|
+
</div></div>
|
|
27
|
+
<% end %>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<% dead_bro_hub_styles %>
|
|
2
|
+
<% bro_hub_crumb "Errors" %>
|
|
3
|
+
<%= bro_hub_hero eyebrow: "Exceptions", title: (@data ? dbh_int(@data["total"]) : "-"), subtitle: "distinct errors", tone: :red,
|
|
4
|
+
lead: "Grouped by fingerprint, most recently seen first." %>
|
|
5
|
+
<% if @error %>
|
|
6
|
+
<%= dbh_error_notice(@error) %>
|
|
7
|
+
<% elsif @data["errors"].empty? %>
|
|
8
|
+
<div class="bh-table-wrap"><div class="bh-empty">No errors recorded. Nice.</div></div>
|
|
9
|
+
<% else %>
|
|
10
|
+
<div class="bh-table-wrap"><div class="bh-table dbh-cols-errors">
|
|
11
|
+
<div class="bh-row is-head"><span>Error</span><span>Count</span><span>First seen</span><span>Last seen</span></div>
|
|
12
|
+
<% @data["errors"].each do |e| %>
|
|
13
|
+
<%= link_to error_path(e["id"]), class: "bh-row" do %>
|
|
14
|
+
<span><span class="bh-cell-strong"><%= e["exception_class"] || "Error" %></span><br><span class="bh-cell-muted"><%= truncate(e["message"].to_s, length: 140) %></span></span>
|
|
15
|
+
<span><%= dbh_int(e["occurrences"]) %></span>
|
|
16
|
+
<span class="bh-cell-muted"><%= dbh_time(e["first_seen_at"]) %></span>
|
|
17
|
+
<span class="bh-cell-muted"><%= dbh_time(e["last_seen_at"]) %></span>
|
|
18
|
+
<% end %>
|
|
19
|
+
<% end %>
|
|
20
|
+
</div></div>
|
|
21
|
+
<%= dbh_pager(@data["errors"].size, DeadBroHub::ErrorsController::PER_PAGE, @offset) %>
|
|
22
|
+
<% end %>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<% dead_bro_hub_styles %>
|
|
2
|
+
<% bro_hub_crumb "Errors", "##{params[:id]}" %>
|
|
3
|
+
<% content_for :bro_hub_crumb_actions do %><%= bro_hub_crumb_link "← All errors", errors_path %><% end %>
|
|
4
|
+
<% if @error %>
|
|
5
|
+
<%= bro_hub_hero eyebrow: "Exceptions", title: "Error ##{params[:id]}", tone: :red %>
|
|
6
|
+
<%= dbh_error_notice(@error) %>
|
|
7
|
+
<% else %>
|
|
8
|
+
<% e = @error_issue %>
|
|
9
|
+
<%= bro_hub_hero eyebrow: "Exception", title: e["exception_class"] || "Error", subtitle: "× #{dbh_int(e["occurrences"])}", tone: :red, lead: e["message"] %>
|
|
10
|
+
<section class="bh-block">
|
|
11
|
+
<dl class="bh-dl">
|
|
12
|
+
<dt>Top frame</dt><dd><code><%= e["top_frame"].presence || "-" %></code></dd>
|
|
13
|
+
<dt>First seen</dt><dd><%= dbh_time(e["first_seen_at"]) %></dd>
|
|
14
|
+
<dt>Last seen</dt><dd><%= dbh_time(e["last_seen_at"]) %></dd>
|
|
15
|
+
</dl>
|
|
16
|
+
</section>
|
|
17
|
+
<section class="bh-block">
|
|
18
|
+
<div class="bh-block-head"><h2 class="bh-h2">Affected endpoints</h2></div>
|
|
19
|
+
<% if e["affected_endpoints"].blank? %>
|
|
20
|
+
<div class="bh-empty">No endpoint data for this error.</div>
|
|
21
|
+
<% else %>
|
|
22
|
+
<div class="bh-table-wrap"><div class="bh-table dbh-cols-top">
|
|
23
|
+
<div class="bh-row is-head"><span>Endpoint</span><span>Hits</span><span></span></div>
|
|
24
|
+
<% e["affected_endpoints"].each do |a| %>
|
|
25
|
+
<div class="bh-row"><span class="bh-cell-strong"><%= a["endpoint"] %></span><span><%= dbh_int(a["hits"]) %></span><span></span></div>
|
|
26
|
+
<% end %>
|
|
27
|
+
</div></div>
|
|
28
|
+
<% end %>
|
|
29
|
+
</section>
|
|
30
|
+
<section class="bh-block">
|
|
31
|
+
<div class="bh-block-head"><h2 class="bh-h2">Recent occurrences</h2></div>
|
|
32
|
+
<% if e["recent_occurrences"].blank? %>
|
|
33
|
+
<div class="bh-empty">None available.</div>
|
|
34
|
+
<% else %>
|
|
35
|
+
<div class="bh-table-wrap"><div class="bh-table dbh-cols-errors">
|
|
36
|
+
<div class="bh-row is-head"><span>Request</span><span>Duration</span><span>When</span><span></span></div>
|
|
37
|
+
<% e["recent_occurrences"].each do |r| %>
|
|
38
|
+
<div class="bh-row"><span><span class="bh-cell-strong"><%= r["endpoint"] %></span><br><span class="bh-cell-muted"><%= r["path"] %></span></span><span><%= dbh_ms(r["duration_ms"]) %></span><span class="bh-cell-muted"><%= dbh_time(r["at"]) %></span><span></span></div>
|
|
39
|
+
<% end %>
|
|
40
|
+
</div></div>
|
|
41
|
+
<% end %>
|
|
42
|
+
</section>
|
|
43
|
+
<% end %>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<% dead_bro_hub_styles %>
|
|
2
|
+
<% bro_hub_crumb "Findings" %>
|
|
3
|
+
<% findings = @data && @data["findings"] %>
|
|
4
|
+
<%= bro_hub_hero eyebrow: "What to fix", title: (findings ? findings.size.to_s : "-"), subtitle: (params[:status] == "all" ? "findings" : "open findings"), tone: :amber,
|
|
5
|
+
lead: "Problems DeadBro keeps tracking, ranked by how much time they cost." %>
|
|
6
|
+
<div class="bh-filters"><span><%= dbh_chips(:status, %w[active all], params[:status] == "all" ? "all" : "active") %></span></div>
|
|
7
|
+
<% if @error %>
|
|
8
|
+
<%= dbh_error_notice(@error) %>
|
|
9
|
+
<% elsif findings.empty? %>
|
|
10
|
+
<div class="bh-table-wrap"><div class="bh-empty">Nothing to fix right now.</div></div>
|
|
11
|
+
<% else %>
|
|
12
|
+
<div class="bh-table-wrap"><div class="bh-table dbh-cols-findings">
|
|
13
|
+
<div class="bh-row is-head"><span>Finding</span><span>Kind</span><span>Status</span><span>Impact</span><span>Last seen</span></div>
|
|
14
|
+
<% findings.each do |f| %>
|
|
15
|
+
<div class="bh-row">
|
|
16
|
+
<span><span class="bh-cell-strong"><%= f["title"] %></span><% if f["recommendation"].present? %><br><span class="bh-cell-muted"><%= truncate(f["recommendation"], length: 180) %></span><% end %></span>
|
|
17
|
+
<span><%= dbh_tag(f["kind"].to_s.humanize.downcase) %></span>
|
|
18
|
+
<span><%= dbh_tag(f["status"].to_s.humanize.downcase, { "open" => "is-amber", "regressed" => "is-red", "resolved" => "is-green" }[f["status"]]) %></span>
|
|
19
|
+
<span><% if f["kind"] == "memory_heavy_endpoint" %><%= (f.dig("impact", "mb_per_day") || 0).to_f.round(1) %> MB/day<% else %><%= dbh_ms(f["impact_ms_per_day"]) %>/day<% end %></span>
|
|
20
|
+
<span class="bh-cell-muted"><%= dbh_time(f["last_seen_at"]) %></span>
|
|
21
|
+
</div>
|
|
22
|
+
<% end %>
|
|
23
|
+
</div></div>
|
|
24
|
+
<% end %>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<% dead_bro_hub_styles %>
|
|
2
|
+
<% bro_hub_crumb "Overview" %>
|
|
3
|
+
<% o = @overview %>
|
|
4
|
+
<% stats = o && o["stats_24h"] %>
|
|
5
|
+
<% if @error %>
|
|
6
|
+
<%= bro_hub_hero eyebrow: "DeadBro", title: "Overview", tone: :violet, lead: "The last 24 hours of this app, from DeadBro." %>
|
|
7
|
+
<%= dbh_error_notice(@error) %>
|
|
8
|
+
<% else %>
|
|
9
|
+
<%= bro_hub_hero eyebrow: "#{o.dig("application", "name")} · #{o.dig("application", "environment")}",
|
|
10
|
+
title: stats ? dbh_int(stats["total_requests"]) : "-", subtitle: "requests in 24h", tone: :violet,
|
|
11
|
+
lead: (o["last_request_at"] ? safe_join([ "Last request seen ", dbh_time(o["last_request_at"]), "." ]) : "DeadBro has not received a request from this app yet."),
|
|
12
|
+
stats: stats ? [
|
|
13
|
+
[ dbh_pct(stats["error_rate_pct"]), "error rate", dbh_error_rate_tone(stats["error_rate_pct"]) ],
|
|
14
|
+
[ dbh_int(stats["error_count"]), "errors" ],
|
|
15
|
+
[ dbh_pct(stats["slow_rate_pct"]), "slow requests", (:amber if stats["slow_rate_pct"].to_f >= 10) ],
|
|
16
|
+
[ dbh_int(o["active_findings"]), "open findings", (:amber if o["active_findings"].to_i.positive?) ]
|
|
17
|
+
] : [] %>
|
|
18
|
+
|
|
19
|
+
<% unless stats %>
|
|
20
|
+
<div class="bh-alert bh-alert-error">Request statistics are temporarily unavailable from DeadBro.</div>
|
|
21
|
+
<% end %>
|
|
22
|
+
|
|
23
|
+
<section class="bh-block">
|
|
24
|
+
<div class="bh-block-head"><h2 class="bh-h2">Throughput, per hour</h2></div>
|
|
25
|
+
<%= dbh_bars((o["hourly"] || []).map { |b| b["calls"] }) %>
|
|
26
|
+
<p class="dbh-sub">p95 response time per hour</p>
|
|
27
|
+
<%= dbh_bars((o["hourly"] || []).map { |b| b["p95_ms"] }, tone: "var(--bh-amber)") %>
|
|
28
|
+
</section>
|
|
29
|
+
|
|
30
|
+
<section class="bh-block">
|
|
31
|
+
<div class="bh-block-head">
|
|
32
|
+
<h2 class="bh-h2">Slowest endpoints (7 days)</h2>
|
|
33
|
+
<%= link_to "All endpoints →", endpoints_path, class: "bh-link-quiet" %>
|
|
34
|
+
</div>
|
|
35
|
+
<% if o["slowest_endpoints"].blank? %>
|
|
36
|
+
<div class="bh-empty">No endpoints with enough samples yet.</div>
|
|
37
|
+
<% else %>
|
|
38
|
+
<div class="bh-table-wrap"><div class="bh-table dbh-cols-top">
|
|
39
|
+
<div class="bh-row is-head"><span>Endpoint</span><span>Mean</span><span>Samples</span></div>
|
|
40
|
+
<% o["slowest_endpoints"].each do |e| %>
|
|
41
|
+
<div class="bh-row"><span class="bh-cell-strong"><%= e["name"] %></span><span><%= dbh_ms(e["mean_duration_ms"]) %></span><span class="bh-cell-muted"><%= dbh_int(e["samples"]) %></span></div>
|
|
42
|
+
<% end %>
|
|
43
|
+
</div></div>
|
|
44
|
+
<% end %>
|
|
45
|
+
</section>
|
|
46
|
+
|
|
47
|
+
<section class="bh-block">
|
|
48
|
+
<div class="bh-block-head"><h2 class="bh-h2">Latest deploy</h2><%= link_to "All deploys →", deploys_path, class: "bh-link-quiet" %></div>
|
|
49
|
+
<% if (d = o["latest_deploy"]) %>
|
|
50
|
+
<dl class="bh-dl">
|
|
51
|
+
<dt>Revision</dt><dd><code><%= d["revision"] %></code></dd>
|
|
52
|
+
<dt>Deployed</dt><dd><%= dbh_time(d["deployed_at"]) %></dd>
|
|
53
|
+
<dt>Errors since</dt><dd><%= dbh_int(d["error_count"]) %></dd>
|
|
54
|
+
</dl>
|
|
55
|
+
<% else %>
|
|
56
|
+
<div class="bh-empty">No deploys recorded.</div>
|
|
57
|
+
<% end %>
|
|
58
|
+
</section>
|
|
59
|
+
<% end %>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<% dead_bro_hub_styles %>
|
|
2
|
+
<% bro_hub_crumb "SQL queries" %>
|
|
3
|
+
<%= bro_hub_hero eyebrow: "Database", title: "SQL queries", tone: :violet,
|
|
4
|
+
lead: "Normalized statements (values replaced by ?), ranked across all requests." %>
|
|
5
|
+
<div class="bh-filters">
|
|
6
|
+
<%= form_with url: queries_path, method: :get, class: "dbh-search" do %>
|
|
7
|
+
<%= hidden_field_tag :window, params[:window] if params[:window].present? %>
|
|
8
|
+
<%= hidden_field_tag :sort, params[:sort] if params[:sort].present? %>
|
|
9
|
+
<%= search_field_tag :search, params[:search], placeholder: "table or keyword", class: "bh-input" %>
|
|
10
|
+
<%= submit_tag "Search", name: nil, class: "bh-chip" %>
|
|
11
|
+
<% end %>
|
|
12
|
+
<span><%= dbh_chips(:window, %w[24h 7d 30d], @data&.fetch("window", nil) || "7d") %></span>
|
|
13
|
+
<span><%= dbh_chips(:sort, %w[total_time executions p95 max], @data&.fetch("sort", nil) || "total_time", label_for: ->(s) { s.humanize.downcase }) %></span>
|
|
14
|
+
</div>
|
|
15
|
+
<% if @error %>
|
|
16
|
+
<%= dbh_error_notice(@error) %>
|
|
17
|
+
<% elsif @data["queries"].empty? %>
|
|
18
|
+
<div class="bh-table-wrap"><div class="bh-empty">No queries match.</div></div>
|
|
19
|
+
<% else %>
|
|
20
|
+
<div class="bh-table-wrap"><div class="bh-table dbh-cols-queries">
|
|
21
|
+
<div class="bh-row is-head"><span>Statement</span><span>Runs</span><span>Total</span><span>Avg</span><span>Max</span><span>p95 request</span></div>
|
|
22
|
+
<% @data["queries"].each do |q| %>
|
|
23
|
+
<div class="bh-row">
|
|
24
|
+
<span><span class="dbh-sql"><%= truncate(q["sql"].to_s, length: 400) %></span><br>
|
|
25
|
+
<%= dbh_tag(q["query_type"]) if q["query_type"].present? %>
|
|
26
|
+
<% Array(q["tables"]).first(4).each do |table| %><%= dbh_tag(table) %><% end %>
|
|
27
|
+
<%= dbh_tag("#{q["n_plus_one_requests"]} N+1", "is-amber") if q["n_plus_one_requests"].to_i.positive? %></span>
|
|
28
|
+
<span><%= dbh_int(q["executions"]) %></span>
|
|
29
|
+
<span><%= dbh_ms(q["total_duration_ms"]) %></span>
|
|
30
|
+
<span><%= dbh_ms(q["avg_duration_ms"]) %></span>
|
|
31
|
+
<span class="bh-cell-muted"><%= dbh_ms(q["max_duration_ms"]) %></span>
|
|
32
|
+
<span class="bh-cell-muted"><%= dbh_ms(q["p95_request_ms"]) %></span>
|
|
33
|
+
</div>
|
|
34
|
+
<% end %>
|
|
35
|
+
</div></div>
|
|
36
|
+
<%= dbh_pager(@data["queries"].size, DeadBroHub::QueriesController::PER_PAGE, @offset) %>
|
|
37
|
+
<% end %>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<%# What DeadBroHub's pages need on top of BroHub's shared styles. %>
|
|
2
|
+
<style<%= %( nonce="#{content_security_policy_nonce}").html_safe if content_security_policy_nonce %>>
|
|
3
|
+
.bh-main code { font: 500 .92em var(--bh-mono); color: var(--bh-text); overflow-wrap: anywhere; }
|
|
4
|
+
.dbh-cols-endpoints { --cols: minmax(220px, 1.6fr) 90px 100px 90px 80px 90px; --min: 720px; }
|
|
5
|
+
.dbh-cols-errors { --cols: minmax(220px, 1.6fr) 90px 150px 150px; --min: 680px; }
|
|
6
|
+
.dbh-cols-queries { --cols: minmax(280px, 2.4fr) 90px 100px 100px 100px 100px; --min: 880px; }
|
|
7
|
+
.dbh-cols-findings { --cols: minmax(240px, 2fr) 130px 110px 110px 150px; --min: 760px; }
|
|
8
|
+
.dbh-cols-deploys { --cols: minmax(180px, 1.4fr) 150px 120px 100px 110px; --min: 680px; }
|
|
9
|
+
.dbh-cols-top { --cols: minmax(220px, 1fr) 100px 100px; --min: 420px; }
|
|
10
|
+
.dbh-sql { font: 500 12px/1.5 var(--bh-mono); white-space: pre-wrap; overflow-wrap: anywhere; color: var(--bh-text); }
|
|
11
|
+
.dbh-bars { display: block; margin-top: 8px; }
|
|
12
|
+
.dbh-sub { margin: 4px 0 0; font: 400 12px var(--bh-mono); color: var(--bh-muted); }
|
|
13
|
+
.bh-chip.is-on { border-color: var(--bh-violet); background: var(--bh-violet); }
|
|
14
|
+
.dbh-search { display: flex; gap: 8px; }
|
|
15
|
+
</style>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<% dead_bro_hub_styles %>
|
|
2
|
+
<% bro_hub_crumb "Status" %>
|
|
3
|
+
<% l = @local %>
|
|
4
|
+
<% problem = l[:enabled] == false || !l[:api_key_set] || l[:sending_paused_until] || l.dig(:circuit_breaker, :state).to_s == "open" || @error&.kind == :unauthorized %>
|
|
5
|
+
<%= bro_hub_hero eyebrow: "The gem, in this process", title: (problem ? "Needs attention" : "Healthy"), tone: (problem ? :amber : :green),
|
|
6
|
+
lead: "Whether dead_bro is on, sending and in sync with DeadBro, and the settings DeadBro hands it." %>
|
|
7
|
+
|
|
8
|
+
<% if @error %><%= dbh_error_notice(@error) %><% end %>
|
|
9
|
+
|
|
10
|
+
<div class="bh-split">
|
|
11
|
+
<div class="bh-split-main">
|
|
12
|
+
<section class="bh-block">
|
|
13
|
+
<div class="bh-block-head"><h2 class="bh-h2">In this app</h2></div>
|
|
14
|
+
<dl class="bh-dl">
|
|
15
|
+
<dt>dead_bro version</dt><dd><%= l[:gem_version] %></dd>
|
|
16
|
+
<dt>Tracking</dt><dd><%= dbh_yes_no(l[:enabled], yes: "enabled", no: "disabled") %></dd>
|
|
17
|
+
<dt>API key</dt><dd><%= l[:api_key_set] ? dbh_tag("set", "is-green") : dbh_tag("missing", "is-red") %></dd>
|
|
18
|
+
<dt>Sending to</dt><dd><code><%= l[:endpoint] %></code></dd>
|
|
19
|
+
<dt>Rails env</dt><dd><%= l[:environment] || "-" %></dd>
|
|
20
|
+
<dt>Deploy revision</dt><dd><code><%= l[:deploy_revision] || "-" %></code></dd>
|
|
21
|
+
<dt>Paused until</dt><dd><%= l[:sending_paused_until] ? "#{l[:sending_paused_until]} (account limit reached)" : "not paused" %></dd>
|
|
22
|
+
<dt>Settings received</dt><dd><%= dbh_time(l[:settings_received_at]) %></dd>
|
|
23
|
+
<dt>Last heartbeat</dt><dd><%= dbh_time(l[:last_heartbeat_at]) %></dd>
|
|
24
|
+
<dt>Circuit breaker</dt>
|
|
25
|
+
<dd><% if (cb = l[:circuit_breaker]) %><%= dbh_tag(cb[:state].to_s, cb[:state].to_s == "closed" ? "is-green" : "is-red") %> <span class="bh-cell-muted"><%= cb[:failures] %> failures · last success <%= dbh_time(cb[:last_success_at]) %></span><% else %>-<% end %></dd>
|
|
26
|
+
<dt>Dropped payloads</dt><dd><%= dbh_int(l[:dropped_payloads]) %> <span class="bh-cell-muted">(queue full)</span></dd>
|
|
27
|
+
<dt>Unserializable</dt><dd><%= dbh_int(l[:unserializable_payloads]) %></dd>
|
|
28
|
+
</dl>
|
|
29
|
+
</section>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="bh-split-aside">
|
|
32
|
+
<section class="bh-block">
|
|
33
|
+
<div class="bh-block-head"><h2 class="bh-h2">From DeadBro</h2></div>
|
|
34
|
+
<% if @remote %>
|
|
35
|
+
<dl class="bh-dl">
|
|
36
|
+
<dt>Last gem version seen</dt><dd><%= @remote["gem_version"] || "-" %></dd>
|
|
37
|
+
<dt>Settings changed</dt><dd><%= dbh_time(@remote["settings_updated_at"]) %></dd>
|
|
38
|
+
<% @remote["settings"].each do |key, value| %>
|
|
39
|
+
<dt><%= key %></dt><dd><%= value.is_a?(Hash) || value.is_a?(Array) ? (value.blank? ? "-" : value.to_json) : value.to_s %></dd>
|
|
40
|
+
<% end %>
|
|
41
|
+
</dl>
|
|
42
|
+
<% else %>
|
|
43
|
+
<div class="bh-empty">Not available.</div>
|
|
44
|
+
<% end %>
|
|
45
|
+
</section>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
DeadBroHub::Engine.routes.draw do
|
|
2
|
+
root "overview#show"
|
|
3
|
+
|
|
4
|
+
get "endpoints" => "endpoints#index", as: :endpoints
|
|
5
|
+
get "errors" => "errors#index", as: :errors
|
|
6
|
+
get "errors/:id" => "errors#show", as: :error, constraints: { id: /\d+/ }
|
|
7
|
+
get "queries" => "queries#index", as: :queries
|
|
8
|
+
get "findings" => "findings#index", as: :findings
|
|
9
|
+
get "deploys" => "deploys#index", as: :deploys
|
|
10
|
+
get "status" => "status#show", as: :status
|
|
11
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module DeadBroHub
|
|
5
|
+
# Reads the hub pages' data from DeadBro (/api/v1/hub/*), authenticated with the app's API key.
|
|
6
|
+
# One method per page, so a page fetches only what it shows.
|
|
7
|
+
class Api
|
|
8
|
+
# kind: :not_configured, :unauthorized, :not_found, :unavailable, :network or :bad_response
|
|
9
|
+
class Error < StandardError
|
|
10
|
+
attr_reader :kind
|
|
11
|
+
|
|
12
|
+
def initialize(kind, message)
|
|
13
|
+
@kind = kind
|
|
14
|
+
super(message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
PREFIX = "/api/v1/hub".freeze
|
|
19
|
+
|
|
20
|
+
def initialize(config = DeadBroHub.config)
|
|
21
|
+
@config = config
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def overview = get("overview")
|
|
25
|
+
def endpoints(params = {}) = get("endpoints", params)
|
|
26
|
+
def errors(params = {}) = get("errors", params)
|
|
27
|
+
def error(id) = get("errors/#{Integer(id)}")
|
|
28
|
+
def queries(params = {}) = get("queries", params)
|
|
29
|
+
def findings(params = {}) = get("findings", params)
|
|
30
|
+
def deploys(params = {}) = get("deploys", params)
|
|
31
|
+
def settings = get("settings")
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
attr_reader :config
|
|
35
|
+
|
|
36
|
+
def get(path, params = {})
|
|
37
|
+
raise Error.new(:not_configured, "No DeadBro API key: configure the dead_bro gem first") unless config.api_key
|
|
38
|
+
|
|
39
|
+
params = params.to_h.compact_blank
|
|
40
|
+
return request(path, params) unless config.cache_ttl.to_i.positive?
|
|
41
|
+
|
|
42
|
+
# Only successful responses are cached: a failure raises out of the block.
|
|
43
|
+
Rails.cache.fetch(cache_key(path, params), expires_in: config.cache_ttl) { request(path, params) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The key is hashed in: it must not end up in a cache store or its logs.
|
|
47
|
+
def cache_key(path, params)
|
|
48
|
+
[ "dead_bro_hub", Digest::SHA256.hexdigest(config.api_key)[0, 16], path, params.sort.to_h.to_query ].join("/")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def request(path, params)
|
|
52
|
+
uri = URI("#{config.base_url}#{PREFIX}/#{path}")
|
|
53
|
+
uri.query = params.to_query if params.any?
|
|
54
|
+
|
|
55
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
56
|
+
http.use_ssl = uri.scheme == "https"
|
|
57
|
+
http.open_timeout = config.open_timeout
|
|
58
|
+
http.read_timeout = config.read_timeout
|
|
59
|
+
|
|
60
|
+
req = Net::HTTP::Get.new(uri)
|
|
61
|
+
req["Authorization"] = "Bearer #{config.api_key}"
|
|
62
|
+
req["Accept"] = "application/json"
|
|
63
|
+
|
|
64
|
+
parse(http.request(req))
|
|
65
|
+
rescue Timeout::Error, SocketError, SystemCallError, EOFError, OpenSSL::SSL::SSLError => error
|
|
66
|
+
raise Error.new(:network, "Could not reach DeadBro (#{error.class})")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse(response)
|
|
70
|
+
case response.code.to_i
|
|
71
|
+
when 200 then JSON.parse(response.body)
|
|
72
|
+
when 401 then raise Error.new(:unauthorized, "DeadBro rejected this app's API key")
|
|
73
|
+
when 404 then raise Error.new(:not_found, "Not found")
|
|
74
|
+
when 502, 503, 504 then raise Error.new(:unavailable, "DeadBro is temporarily unavailable")
|
|
75
|
+
else raise Error.new(:bad_response, "Unexpected response from DeadBro (HTTP #{response.code})")
|
|
76
|
+
end
|
|
77
|
+
rescue JSON::ParserError
|
|
78
|
+
raise Error.new(:bad_response, "DeadBro sent a response that is not JSON")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module DeadBroHub
|
|
2
|
+
class Configuration
|
|
3
|
+
# Controller the engine controllers inherit from (gives access to the host's authentication).
|
|
4
|
+
attr_accessor :parent_controller
|
|
5
|
+
|
|
6
|
+
# ->(controller) { ... } returning the signed in user, or nil. Defaults to BroHub.config.current_user.
|
|
7
|
+
attr_accessor :current_user
|
|
8
|
+
|
|
9
|
+
# ->(user) { true/false }. Who may see DeadBro's data here. Nobody until you say so: the pages show
|
|
10
|
+
# SQL statements, exception messages and request paths.
|
|
11
|
+
attr_accessor :can_view
|
|
12
|
+
|
|
13
|
+
# Both default to what the dead_bro gem already uses, so there is normally nothing to set.
|
|
14
|
+
attr_writer :api_key, :base_url
|
|
15
|
+
|
|
16
|
+
# Seconds a DeadBro response is kept in Rails.cache, so browsing the hub doesn't hit DeadBro on
|
|
17
|
+
# every click. 0 turns caching off.
|
|
18
|
+
attr_accessor :cache_ttl
|
|
19
|
+
|
|
20
|
+
attr_accessor :open_timeout, :read_timeout
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@parent_controller = "::ApplicationController"
|
|
24
|
+
@current_user = ->(controller) { BroHub.config.current_user.call(controller) }
|
|
25
|
+
@can_view = ->(_user) { false }
|
|
26
|
+
@cache_ttl = 30
|
|
27
|
+
@open_timeout = 2
|
|
28
|
+
@read_timeout = 5
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def allows?(user)
|
|
32
|
+
!user.nil? && !!can_view.call(user)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def api_key
|
|
36
|
+
(@api_key.presence || DeadBro.configuration.api_key).to_s.presence
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Same endpoint selection as the dead_bro client uses to send metrics.
|
|
40
|
+
def base_url
|
|
41
|
+
return @base_url.to_s.chomp("/") if @base_url.present?
|
|
42
|
+
|
|
43
|
+
if DeadBro.configuration.ruby_dev
|
|
44
|
+
"http://localhost:3100"
|
|
45
|
+
elsif ENV["USE_STAGING_ENDPOINT"].present?
|
|
46
|
+
"https://deadbro.aberatii.com"
|
|
47
|
+
else
|
|
48
|
+
"https://www.deadbro.com"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module DeadBroHub
|
|
2
|
+
# Items in the BroHub sidebar. Paths are relative to the engine.
|
|
3
|
+
BRO_HUB_ITEMS = [
|
|
4
|
+
{ label: "Overview", path: "/" },
|
|
5
|
+
{ label: "Endpoints", path: "/endpoints" },
|
|
6
|
+
{ label: "Errors", path: "/errors" },
|
|
7
|
+
{ label: "SQL queries", path: "/queries" },
|
|
8
|
+
{ label: "Findings", path: "/findings" },
|
|
9
|
+
{ label: "Deploys", path: "/deploys" },
|
|
10
|
+
{ label: "Status", path: "/status" }
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
class Engine < ::Rails::Engine
|
|
14
|
+
isolate_namespace DeadBroHub
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Registered when the gem loads, so it is in place before routes are drawn and BroHub can mount the engine.
|
|
19
|
+
# Showing a section is navigation only: every DeadBroHub controller still authorizes each request.
|
|
20
|
+
BroHub.register :dead_bro_hub, name: "DeadBro", label: "DeadBro · performance", accent: :violet,
|
|
21
|
+
engine: "DeadBroHub::Engine", order: 30, items: DeadBroHub::BRO_HUB_ITEMS,
|
|
22
|
+
visible_to: ->(user) { DeadBroHub.config.allows?(user) }
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module DeadBroHub
|
|
2
|
+
# What the dead_bro gem is doing inside this process, read from its in-memory state. This is the half of
|
|
3
|
+
# the status page DeadBro itself can't know: whether the gem is on, sending and healthy *here*.
|
|
4
|
+
#
|
|
5
|
+
# Every read is guarded: the page must keep rendering against a dead_bro version that lacks one of these.
|
|
6
|
+
module LocalStatus
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def snapshot
|
|
10
|
+
config = DeadBro.configuration
|
|
11
|
+
|
|
12
|
+
{
|
|
13
|
+
gem_version: DeadBro::VERSION,
|
|
14
|
+
enabled: read { config.enabled },
|
|
15
|
+
api_key_set: read { config.api_key.present? },
|
|
16
|
+
environment: read { DeadBro.env },
|
|
17
|
+
endpoint: DeadBroHub.config.base_url,
|
|
18
|
+
sending_paused_until: read { config.skip_until if config.skip_tracking? },
|
|
19
|
+
settings_received_at: read { config.settings_received_at },
|
|
20
|
+
last_heartbeat_at: read { config.last_heartbeat_at },
|
|
21
|
+
sample_rate: read { config.sample_rate },
|
|
22
|
+
monitor_enabled: read { config.monitor_enabled },
|
|
23
|
+
explain_active: read { config.explain_active? },
|
|
24
|
+
circuit_breaker: circuit_breaker,
|
|
25
|
+
dropped_payloads: read { DeadBro::Dispatcher.instance.dropped_count },
|
|
26
|
+
unserializable_payloads: read { DeadBro.client.serialization_failures },
|
|
27
|
+
deploy_revision: read { config.resolve_deploy_id }
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The client keeps its breaker private; this is the one place that reaches in for it.
|
|
32
|
+
def circuit_breaker
|
|
33
|
+
client = DeadBro.client
|
|
34
|
+
breaker = client.instance_variable_get(:@circuit_breaker)
|
|
35
|
+
return nil unless breaker
|
|
36
|
+
|
|
37
|
+
{ state: breaker.state, failures: breaker.failure_count, last_failure_at: breaker.last_failure_time,
|
|
38
|
+
last_success_at: breaker.last_success_time }
|
|
39
|
+
rescue StandardError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def read
|
|
44
|
+
yield
|
|
45
|
+
rescue StandardError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/dead_bro_hub.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
require "bro_hub"
|
|
2
|
+
require "dead_bro"
|
|
3
|
+
require "dead_bro_hub/version"
|
|
4
|
+
require "dead_bro_hub/configuration"
|
|
5
|
+
require "dead_bro_hub/api"
|
|
6
|
+
require "dead_bro_hub/local_status"
|
|
7
|
+
require "dead_bro_hub/engine"
|
|
8
|
+
|
|
9
|
+
module DeadBroHub
|
|
10
|
+
class Forbidden < StandardError; end
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def config
|
|
14
|
+
@config ||= Configuration.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def configure
|
|
18
|
+
yield config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reset_config!
|
|
22
|
+
@config = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def api
|
|
26
|
+
Api.new(config)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "generators/bro_hub/installable"
|
|
3
|
+
|
|
4
|
+
module DeadBroHub
|
|
5
|
+
module Generators
|
|
6
|
+
# bin/rails generate dead_bro_hub:install
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
# BroHub first (one mount for all the Bro gems), then DeadBroHub itself.
|
|
9
|
+
include BroHub::Generators::Installable
|
|
10
|
+
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
|
+
|
|
13
|
+
desc "Sets up the BroHub dashboard and adds the DeadBroHub initializer."
|
|
14
|
+
|
|
15
|
+
def copy_initializer
|
|
16
|
+
copy_config_once "dead_bro_hub.rb", "config/initializers/dead_bro_hub.rb"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def next_steps
|
|
20
|
+
say <<~TEXT
|
|
21
|
+
|
|
22
|
+
DeadBroHub is installed. Next:
|
|
23
|
+
1. Set who may see DeadBro's data in config/initializers/dead_bro_hub.rb (nobody until you do)
|
|
24
|
+
2. Make sure the dead_bro gem is configured with this app's API key: DeadBroHub reuses it
|
|
25
|
+
3. Visit #{bro_hub_path}/dead_bro_hub
|
|
26
|
+
TEXT
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# DeadBroHub shows what DeadBro knows about this app inside the BroHub dashboard. It reads with the API key the
|
|
2
|
+
# dead_bro gem already uses, so there is nothing to configure except who may look.
|
|
3
|
+
DeadBroHub.configure do |config|
|
|
4
|
+
# Who may see DeadBro's data. Everything is denied until you change this. The pages show SQL statements,
|
|
5
|
+
# exception messages and request paths, so keep it to people who can already see that.
|
|
6
|
+
config.can_view = ->(user) { false } # e.g. user&.admin?
|
|
7
|
+
|
|
8
|
+
# How DeadBroHub finds the signed in user (default: BroHub.config.current_user).
|
|
9
|
+
# config.parent_controller = "::ApplicationController"
|
|
10
|
+
# config.current_user = ->(controller) { controller.send(:current_user) }
|
|
11
|
+
|
|
12
|
+
# Seconds a response from DeadBro is cached in Rails.cache (0 = off).
|
|
13
|
+
# config.cache_ttl = 30
|
|
14
|
+
|
|
15
|
+
# Only if you need to override what the dead_bro gem uses:
|
|
16
|
+
# config.api_key = ENV["DEAD_BRO_API_KEY"]
|
|
17
|
+
# config.base_url = "https://www.deadbro.com"
|
|
18
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: dead_bro_hub
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Ruby Dev SRL
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: bro_hub
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 0.1.1
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 0.1.1
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: dead_bro
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 0.2.34
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: 0.2.34
|
|
54
|
+
description: A BroHub section that shows what DeadBro knows about this app (overview,
|
|
55
|
+
endpoints, errors, slow SQL, findings, deploys) next to the other Bro gems. It reads
|
|
56
|
+
from DeadBro with the API key the dead_bro gem already uses, and shows whether the
|
|
57
|
+
gem itself is healthy.
|
|
58
|
+
email:
|
|
59
|
+
- office@rubydev.ro
|
|
60
|
+
executables: []
|
|
61
|
+
extensions: []
|
|
62
|
+
extra_rdoc_files: []
|
|
63
|
+
files:
|
|
64
|
+
- MIT-LICENSE
|
|
65
|
+
- README.md
|
|
66
|
+
- Rakefile
|
|
67
|
+
- app/controllers/dead_bro_hub/application_controller.rb
|
|
68
|
+
- app/controllers/dead_bro_hub/deploys_controller.rb
|
|
69
|
+
- app/controllers/dead_bro_hub/endpoints_controller.rb
|
|
70
|
+
- app/controllers/dead_bro_hub/errors_controller.rb
|
|
71
|
+
- app/controllers/dead_bro_hub/findings_controller.rb
|
|
72
|
+
- app/controllers/dead_bro_hub/overview_controller.rb
|
|
73
|
+
- app/controllers/dead_bro_hub/queries_controller.rb
|
|
74
|
+
- app/controllers/dead_bro_hub/status_controller.rb
|
|
75
|
+
- app/helpers/dead_bro_hub/application_helper.rb
|
|
76
|
+
- app/views/dead_bro_hub/deploys/index.html.erb
|
|
77
|
+
- app/views/dead_bro_hub/endpoints/index.html.erb
|
|
78
|
+
- app/views/dead_bro_hub/errors/index.html.erb
|
|
79
|
+
- app/views/dead_bro_hub/errors/not_found.html.erb
|
|
80
|
+
- app/views/dead_bro_hub/errors/show.html.erb
|
|
81
|
+
- app/views/dead_bro_hub/findings/index.html.erb
|
|
82
|
+
- app/views/dead_bro_hub/overview/show.html.erb
|
|
83
|
+
- app/views/dead_bro_hub/queries/index.html.erb
|
|
84
|
+
- app/views/dead_bro_hub/shared/_page_styles.html.erb
|
|
85
|
+
- app/views/dead_bro_hub/shared/forbidden.html.erb
|
|
86
|
+
- app/views/dead_bro_hub/status/show.html.erb
|
|
87
|
+
- config/routes.rb
|
|
88
|
+
- lib/dead_bro_hub.rb
|
|
89
|
+
- lib/dead_bro_hub/api.rb
|
|
90
|
+
- lib/dead_bro_hub/configuration.rb
|
|
91
|
+
- lib/dead_bro_hub/engine.rb
|
|
92
|
+
- lib/dead_bro_hub/local_status.rb
|
|
93
|
+
- lib/dead_bro_hub/version.rb
|
|
94
|
+
- lib/generators/dead_bro_hub/install/install_generator.rb
|
|
95
|
+
- lib/generators/dead_bro_hub/install/templates/dead_bro_hub.rb
|
|
96
|
+
homepage: https://www.deadbro.com
|
|
97
|
+
licenses:
|
|
98
|
+
- MIT
|
|
99
|
+
metadata:
|
|
100
|
+
allowed_push_host: https://rubygems.org
|
|
101
|
+
rubygems_mfa_required: 'true'
|
|
102
|
+
homepage_uri: https://www.deadbro.com
|
|
103
|
+
source_code_uri: https://github.com/rubydevro/dead_bro_hub
|
|
104
|
+
rdoc_options: []
|
|
105
|
+
require_paths:
|
|
106
|
+
- lib
|
|
107
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
108
|
+
requirements:
|
|
109
|
+
- - ">="
|
|
110
|
+
- !ruby/object:Gem::Version
|
|
111
|
+
version: '3.2'
|
|
112
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - ">="
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '0'
|
|
117
|
+
requirements: []
|
|
118
|
+
rubygems_version: 4.0.10
|
|
119
|
+
specification_version: 4
|
|
120
|
+
summary: DeadBro's performance data, inside your BroHub dashboard.
|
|
121
|
+
test_files: []
|