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.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +14 -0
  3. data/README.md +76 -0
  4. data/Rakefile +10 -0
  5. data/app/controllers/dead_bro_hub/application_controller.rb +58 -0
  6. data/app/controllers/dead_bro_hub/deploys_controller.rb +7 -0
  7. data/app/controllers/dead_bro_hub/endpoints_controller.rb +8 -0
  8. data/app/controllers/dead_bro_hub/errors_controller.rb +15 -0
  9. data/app/controllers/dead_bro_hub/findings_controller.rb +8 -0
  10. data/app/controllers/dead_bro_hub/overview_controller.rb +7 -0
  11. data/app/controllers/dead_bro_hub/queries_controller.rb +11 -0
  12. data/app/controllers/dead_bro_hub/status_controller.rb +9 -0
  13. data/app/helpers/dead_bro_hub/application_helper.rb +97 -0
  14. data/app/views/dead_bro_hub/deploys/index.html.erb +21 -0
  15. data/app/views/dead_bro_hub/endpoints/index.html.erb +27 -0
  16. data/app/views/dead_bro_hub/errors/index.html.erb +22 -0
  17. data/app/views/dead_bro_hub/errors/not_found.html.erb +3 -0
  18. data/app/views/dead_bro_hub/errors/show.html.erb +43 -0
  19. data/app/views/dead_bro_hub/findings/index.html.erb +24 -0
  20. data/app/views/dead_bro_hub/overview/show.html.erb +59 -0
  21. data/app/views/dead_bro_hub/queries/index.html.erb +37 -0
  22. data/app/views/dead_bro_hub/shared/_page_styles.html.erb +15 -0
  23. data/app/views/dead_bro_hub/shared/forbidden.html.erb +3 -0
  24. data/app/views/dead_bro_hub/status/show.html.erb +47 -0
  25. data/config/routes.rb +11 -0
  26. data/lib/dead_bro_hub/api.rb +81 -0
  27. data/lib/dead_bro_hub/configuration.rb +52 -0
  28. data/lib/dead_bro_hub/engine.rb +22 -0
  29. data/lib/dead_bro_hub/local_status.rb +49 -0
  30. data/lib/dead_bro_hub/version.rb +3 -0
  31. data/lib/dead_bro_hub.rb +29 -0
  32. data/lib/generators/dead_bro_hub/install/install_generator.rb +30 -0
  33. data/lib/generators/dead_bro_hub/install/templates/dead_bro_hub.rb +18 -0
  34. 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,10 @@
1
+ require "bundler/setup"
2
+ require "bundler/gem_tasks"
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test"
7
+ t.pattern = "test/**/*_test.rb"
8
+ end
9
+
10
+ task default: :test
@@ -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,7 @@
1
+ module DeadBroHub
2
+ class DeploysController < ApplicationController
3
+ def index
4
+ @data = dead_bro_hub_fetch { dead_bro_hub_api.deploys }
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,8 @@
1
+ module DeadBroHub
2
+ class EndpointsController < ApplicationController
3
+ def index
4
+ @filters = dead_bro_hub_params(:window, :sort)
5
+ @data = dead_bro_hub_fetch { dead_bro_hub_api.endpoints(@filters) }
6
+ end
7
+ end
8
+ 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,8 @@
1
+ module DeadBroHub
2
+ class FindingsController < ApplicationController
3
+ def index
4
+ @filters = dead_bro_hub_params(:status)
5
+ @data = dead_bro_hub_fetch { dead_bro_hub_api.findings(@filters) }
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ module DeadBroHub
2
+ class OverviewController < ApplicationController
3
+ def show
4
+ @overview = dead_bro_hub_fetch { dead_bro_hub_api.overview }
5
+ end
6
+ end
7
+ 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,3 @@
1
+ <% bro_hub_crumb "Errors", "Not found" %>
2
+ <% content_for :bro_hub_crumb_actions do %><%= bro_hub_crumb_link "← All errors", errors_path %><% end %>
3
+ <%= bro_hub_hero eyebrow: "Exceptions", title: "Not found", tone: :red, lead: "DeadBro has no such error for this app." %>
@@ -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,3 @@
1
+ <% bro_hub_crumb "Not allowed" %>
2
+ <%= bro_hub_hero eyebrow: "Access", title: "Nothing here", subtitle: "for you", tone: :red,
3
+ lead: "DeadBro's data isn't open to your account. Ask whoever runs this app to give you access." %>
@@ -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
@@ -0,0 +1,3 @@
1
+ module DeadBroHub
2
+ VERSION = "0.1.0"
3
+ end
@@ -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: []