bulletin-rb 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/CHANGELOG.md +23 -0
- data/MIT-LICENSE +20 -0
- data/README.md +64 -0
- data/app/controllers/bulletin/application_controller.rb +24 -0
- data/app/controllers/bulletin/issues_controller.rb +39 -0
- data/app/jobs/bulletin/prune_job.rb +13 -0
- data/app/jobs/bulletin/record_job.rb +14 -0
- data/app/models/bulletin/application_record.rb +7 -0
- data/app/models/bulletin/issue.rb +47 -0
- data/app/models/bulletin/occurrence.rb +21 -0
- data/app/views/bulletin/issues/index.html.erb +39 -0
- data/app/views/bulletin/issues/show.html.erb +61 -0
- data/app/views/layouts/bulletin/application.html.erb +57 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260527000001_create_bulletin_tables.rb +36 -0
- data/lib/bulletin/configuration.rb +48 -0
- data/lib/bulletin/engine.rb +27 -0
- data/lib/bulletin/fingerprint.rb +26 -0
- data/lib/bulletin/middleware.rb +58 -0
- data/lib/bulletin/store/active_record.rb +84 -0
- data/lib/bulletin/store/base.rb +20 -0
- data/lib/bulletin/store/null.rb +12 -0
- data/lib/bulletin/store.rb +22 -0
- data/lib/bulletin/version.rb +5 -0
- data/lib/bulletin/warning.rb +47 -0
- data/lib/bulletin-rb.rb +6 -0
- data/lib/bulletin.rb +54 -0
- data/lib/generators/bulletin/install_generator.rb +24 -0
- data/lib/generators/bulletin/templates/bulletin.rb +28 -0
- metadata +103 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8e9c2c120bd6a1529e539a910573d3d6c900263cfba6d18f5670d2e6daecaeb4
|
|
4
|
+
data.tar.gz: cf7559388fe0d95ec84fb8b9544f616a045fde3183f4026bc9828d1309926d44
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 33537b40f838bb67a319ada12e36ade3f41945602f12ae687e191e831d28ece51fad749723295b68ad40e2621b8863cf649fa939330c6c5e3574d7367e75df4d
|
|
7
|
+
data.tar.gz: 6bb1f8ff05ad42bf356dd629fff857a73f41f526fa980bb5cdc66f3b07a8ac09c45fc55da0dcf934e7edf3aa1c58897b2b0bf0fab104c03bbfa5c4baa605eff9
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based on
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Extracted Bulletin into its own repository as a standalone, installable gem,
|
|
11
|
+
published as `bulletin-rb` (the namespace stays `Bulletin`; a `lib/bulletin-rb.rb`
|
|
12
|
+
shim preserves `require "bulletin"`).
|
|
13
|
+
- Dev/test harness: `test/dummy` Rails app, Minitest suite (fingerprint,
|
|
14
|
+
warning extraction, ActiveRecord store, engine mount), and CI.
|
|
15
|
+
|
|
16
|
+
## [0.1.0]
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- Initial MVP: Rack middleware inserted inside `Bullet::Rack` that reads
|
|
20
|
+
Bullet's per-request notifications and persists them as fingerprinted issues.
|
|
21
|
+
- ActiveRecord store with per-issue occurrence cap, regression reopen, and
|
|
22
|
+
retention pruning.
|
|
23
|
+
- Mountable engine with an issues triage UI and an install generator.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) 2026 Charles Harris
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
17
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
18
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
19
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
20
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Bulletin
|
|
2
|
+
|
|
3
|
+
A mountable Rails engine that gives [Bullet](https://github.com/flyerhzm/bullet) a memory.
|
|
4
|
+
|
|
5
|
+
Bullet is a great *sensor* — it detects N+1 queries, unused eager loading, and
|
|
6
|
+
missing counter caches on every request — but it has no *memory*: each warning
|
|
7
|
+
flashes in the footer/log and is gone. Bulletin reads Bullet's per-request
|
|
8
|
+
findings, fingerprints them into durable **issues** (Sentry-style), and exposes
|
|
9
|
+
a mounted UI for triage (Flipper UI / PgHero in spirit).
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
Bullet (sensor) → Bulletin (memory) → mounted UI (attention)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Gemfile (typically the :development group, alongside bullet)
|
|
19
|
+
gem "bulletin-rb" # the Ruby namespace is still `Bulletin`
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bundle install
|
|
24
|
+
bin/rails generate bulletin:install # writes config/initializers/bulletin.rb
|
|
25
|
+
bin/rails db:migrate # creates bulletin_issues / bulletin_occurrences
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# config/routes.rb
|
|
30
|
+
mount Bulletin::Engine => "/bulletin"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Visit `/bulletin`.
|
|
34
|
+
|
|
35
|
+
## How it works
|
|
36
|
+
|
|
37
|
+
- A Rack middleware is inserted **inside** `Bullet::Rack`. On the response
|
|
38
|
+
unwind it reads Bullet's already-deduped `notification_collector.collection`
|
|
39
|
+
(before Bullet clears it), attaches request context from the Rack env, and
|
|
40
|
+
hands a JSON-safe payload to the store.
|
|
41
|
+
- Warnings are **fingerprinted** by `kind + model + association(s) +
|
|
42
|
+
controller#action` — deliberately *not* file:line, so refactors don't spawn
|
|
43
|
+
duplicate issues. The precise call stack is kept per occurrence instead.
|
|
44
|
+
- Each issue carries Bullet's own **suggested fix**. Occurrences are capped per
|
|
45
|
+
issue; a resolved issue that recurs is automatically **reopened**.
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
See `config/initializers/bulletin.rb`. Key knobs:
|
|
50
|
+
|
|
51
|
+
| Option | Default | Notes |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| `store` | `:active_record` | `:null` disables; `:redis`/`:hybrid` planned |
|
|
54
|
+
| `write_strategy` | `:active_job` | `:inline` for zero-setup local dev |
|
|
55
|
+
| `occurrence_cap` | `50` | rows kept per issue |
|
|
56
|
+
| `retention` | `7.days` | `PruneJob` ages out older issues |
|
|
57
|
+
| `enabled` | follows `Bullet.enable?` | dev/staging only by default |
|
|
58
|
+
| `authenticate_with` | dev-only | `->(request) { ... }` like Sidekiq::Web |
|
|
59
|
+
|
|
60
|
+
## Status
|
|
61
|
+
|
|
62
|
+
MVP. The store is abstracted behind a single `Bulletin::Store` boundary so a
|
|
63
|
+
Redis hot-path + hybrid drain (and a live occurrence view) can land later
|
|
64
|
+
without touching the middleware or UI.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletin
|
|
4
|
+
class ApplicationController < ActionController::Base
|
|
5
|
+
protect_from_forgery with: :exception
|
|
6
|
+
layout "bulletin/application"
|
|
7
|
+
|
|
8
|
+
before_action :authenticate_bulletin!
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# Mirrors the Sidekiq::Web / Flipper pattern: delegate auth to the host via
|
|
13
|
+
# a configurable callable, and fail closed outside development by default.
|
|
14
|
+
def authenticate_bulletin!
|
|
15
|
+
auth = Bulletin.config.authenticate_with
|
|
16
|
+
|
|
17
|
+
if auth
|
|
18
|
+
head :forbidden unless auth.call(request)
|
|
19
|
+
elsif !Rails.env.development?
|
|
20
|
+
head :forbidden
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletin
|
|
4
|
+
class IssuesController < ApplicationController
|
|
5
|
+
before_action :set_issue, only: %i[show resolve mute reopen]
|
|
6
|
+
|
|
7
|
+
def index
|
|
8
|
+
@issues = Issue.recent
|
|
9
|
+
@issues = @issues.where(kind: params[:kind]) if params[:kind].present?
|
|
10
|
+
@issues = @issues.where(status: params[:status]) if params[:status].present?
|
|
11
|
+
@kinds = Issue.distinct.pluck(:kind).compact.sort
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def show
|
|
15
|
+
@occurrences = @issue.occurrences.order(created_at: :desc).limit(Bulletin.config.occurrence_cap)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def resolve
|
|
19
|
+
@issue.resolve!
|
|
20
|
+
redirect_back fallback_location: issues_path, notice: "Issue resolved."
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def mute
|
|
24
|
+
@issue.mute!
|
|
25
|
+
redirect_back fallback_location: issues_path, notice: "Issue muted."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def reopen
|
|
29
|
+
@issue.reopen!
|
|
30
|
+
redirect_back fallback_location: issues_path, notice: "Issue reopened."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def set_issue
|
|
36
|
+
@issue = Issue.find(params[:id])
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletin
|
|
4
|
+
# Enqueue periodically (e.g. from cron / a recurring job) to age out issues
|
|
5
|
+
# past the configured retention window.
|
|
6
|
+
class PruneJob < ::ActiveJob::Base
|
|
7
|
+
queue_as { Bulletin.config.job_queue }
|
|
8
|
+
|
|
9
|
+
def perform
|
|
10
|
+
Bulletin.store.prune!
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletin
|
|
4
|
+
# Drains a request's normalized warning payload into the store off the request
|
|
5
|
+
# path. Self-contained (inherits ActiveJob::Base, not the host's ApplicationJob)
|
|
6
|
+
# so the engine works regardless of the host's job setup.
|
|
7
|
+
class RecordJob < ::ActiveJob::Base
|
|
8
|
+
queue_as { Bulletin.config.job_queue }
|
|
9
|
+
|
|
10
|
+
def perform(payload)
|
|
11
|
+
Bulletin.store.record(payload)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletin
|
|
4
|
+
# One row per fingerprint — the triage unit. Holds Bullet's own fix advice
|
|
5
|
+
# plus aggregate stats, so the list view is a single cheap query.
|
|
6
|
+
class Issue < ApplicationRecord
|
|
7
|
+
self.table_name = "bulletin_issues"
|
|
8
|
+
|
|
9
|
+
has_many :occurrences,
|
|
10
|
+
class_name: "Bulletin::Occurrence",
|
|
11
|
+
foreign_key: :bulletin_issue_id,
|
|
12
|
+
inverse_of: :issue,
|
|
13
|
+
dependent: :delete_all
|
|
14
|
+
|
|
15
|
+
enum :status, { active: 0, resolved: 1, muted: 2 }
|
|
16
|
+
|
|
17
|
+
serialize :associations, coder: JSON, type: Array
|
|
18
|
+
|
|
19
|
+
validates :fingerprint, presence: true, uniqueness: true
|
|
20
|
+
|
|
21
|
+
scope :recent, -> { order(last_seen_at: :desc) }
|
|
22
|
+
|
|
23
|
+
def association_label
|
|
24
|
+
Array(associations).join(", ")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def label
|
|
28
|
+
[base_class, association_label].reject(&:blank?).join(" → ")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def endpoint
|
|
32
|
+
[controller, action].compact.join("#")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def resolve!
|
|
36
|
+
update!(status: :resolved, resolved_at: Time.now)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reopen!
|
|
40
|
+
update!(status: :active, resolved_at: nil)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def mute!(until_time = nil)
|
|
44
|
+
update!(status: :muted, muted_until: until_time)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletin
|
|
4
|
+
# A single recorded instance of an issue — the evidence (where/when/stack).
|
|
5
|
+
# Capped per issue by Store::ActiveRecord so a hammered endpoint can't flood
|
|
6
|
+
# the table.
|
|
7
|
+
class Occurrence < ApplicationRecord
|
|
8
|
+
self.table_name = "bulletin_occurrences"
|
|
9
|
+
|
|
10
|
+
belongs_to :issue,
|
|
11
|
+
class_name: "Bulletin::Issue",
|
|
12
|
+
foreign_key: :bulletin_issue_id,
|
|
13
|
+
inverse_of: :occurrences
|
|
14
|
+
|
|
15
|
+
serialize :backtrace, coder: JSON, type: Array
|
|
16
|
+
|
|
17
|
+
def endpoint
|
|
18
|
+
[controller, action].compact.join("#")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<%= form_with url: issues_path, method: :get, class: "filters" do %>
|
|
2
|
+
<%= select_tag :kind,
|
|
3
|
+
options_for_select([["All kinds", ""]] + @kinds.map { |k| [k.humanize, k] }, params[:kind]) %>
|
|
4
|
+
<%= select_tag :status,
|
|
5
|
+
options_for_select([["All statuses", ""], %w[Active active], %w[Resolved resolved], %w[Muted muted]], params[:status]) %>
|
|
6
|
+
<button type="submit">Filter</button>
|
|
7
|
+
<% if params[:kind].present? || params[:status].present? %>
|
|
8
|
+
<%= link_to "Clear", issues_path %>
|
|
9
|
+
<% end %>
|
|
10
|
+
<% end %>
|
|
11
|
+
|
|
12
|
+
<% if @issues.any? %>
|
|
13
|
+
<table>
|
|
14
|
+
<thead>
|
|
15
|
+
<tr>
|
|
16
|
+
<th>Kind</th>
|
|
17
|
+
<th>Issue</th>
|
|
18
|
+
<th>Endpoint</th>
|
|
19
|
+
<th>Count</th>
|
|
20
|
+
<th>Last seen</th>
|
|
21
|
+
<th>Status</th>
|
|
22
|
+
</tr>
|
|
23
|
+
</thead>
|
|
24
|
+
<tbody>
|
|
25
|
+
<% @issues.each do |issue| %>
|
|
26
|
+
<tr>
|
|
27
|
+
<td><span class="badge <%= issue.kind %>"><%= issue.kind.humanize %></span></td>
|
|
28
|
+
<td><%= link_to issue.label.presence || "(unknown)", issue_path(issue) %></td>
|
|
29
|
+
<td><code><%= issue.endpoint %></code></td>
|
|
30
|
+
<td><%= issue.occurrence_count %></td>
|
|
31
|
+
<td class="muted"><%= issue.last_seen_at&.strftime("%b %-d, %H:%M") %></td>
|
|
32
|
+
<td><span class="status <%= issue.status %>"><%= issue.status %></span></td>
|
|
33
|
+
</tr>
|
|
34
|
+
<% end %>
|
|
35
|
+
</tbody>
|
|
36
|
+
</table>
|
|
37
|
+
<% else %>
|
|
38
|
+
<div class="empty">No warnings recorded yet. Exercise the app and they'll show up here.</div>
|
|
39
|
+
<% end %>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<p><%= link_to "← All issues", issues_path %></p>
|
|
2
|
+
|
|
3
|
+
<div class="summary">
|
|
4
|
+
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:16px;">
|
|
5
|
+
<div>
|
|
6
|
+
<span class="badge <%= @issue.kind %>"><%= @issue.kind.humanize %></span>
|
|
7
|
+
<h2 style="margin:8px 0;"><%= @issue.label %></h2>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="actions">
|
|
10
|
+
<% if @issue.active? %>
|
|
11
|
+
<%= button_to "Resolve", resolve_issue_path(@issue), method: :patch %>
|
|
12
|
+
<%= button_to "Mute", mute_issue_path(@issue), method: :patch %>
|
|
13
|
+
<% else %>
|
|
14
|
+
<%= button_to "Reopen", reopen_issue_path(@issue), method: :patch %>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<dl>
|
|
20
|
+
<dt>Status</dt><dd><span class="status <%= @issue.status %>"><%= @issue.status %></span></dd>
|
|
21
|
+
<dt>Endpoint</dt><dd><code><%= @issue.endpoint %></code></dd>
|
|
22
|
+
<dt>Occurrences</dt><dd><%= @issue.occurrence_count %></dd>
|
|
23
|
+
<dt>First seen</dt><dd><%= @issue.first_seen_at&.strftime("%b %-d, %Y %H:%M") %></dd>
|
|
24
|
+
<dt>Last seen</dt><dd><%= @issue.last_seen_at&.strftime("%b %-d, %Y %H:%M") %></dd>
|
|
25
|
+
</dl>
|
|
26
|
+
|
|
27
|
+
<% if @issue.advice.present? %>
|
|
28
|
+
<h3>Suggested fix</h3>
|
|
29
|
+
<pre><%= @issue.advice %></pre>
|
|
30
|
+
<% end %>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<h3>Recent occurrences <span class="muted">(<%= @occurrences.size %>)</span></h3>
|
|
34
|
+
<% if @occurrences.any? %>
|
|
35
|
+
<table>
|
|
36
|
+
<thead>
|
|
37
|
+
<tr><th>When</th><th>Endpoint</th><th>Path</th><th>Stack</th></tr>
|
|
38
|
+
</thead>
|
|
39
|
+
<tbody>
|
|
40
|
+
<% @occurrences.each do |occ| %>
|
|
41
|
+
<tr>
|
|
42
|
+
<td class="muted"><%= occ.created_at&.strftime("%b %-d, %H:%M:%S") %></td>
|
|
43
|
+
<td><code><%= occ.endpoint %></code></td>
|
|
44
|
+
<td class="muted"><%= occ.path %></td>
|
|
45
|
+
<td>
|
|
46
|
+
<% if occ.backtrace.present? %>
|
|
47
|
+
<details>
|
|
48
|
+
<summary class="muted">stack (<%= occ.backtrace.size %>)</summary>
|
|
49
|
+
<pre><%= occ.backtrace.join("\n") %></pre>
|
|
50
|
+
</details>
|
|
51
|
+
<% else %>
|
|
52
|
+
<span class="muted">—</span>
|
|
53
|
+
<% end %>
|
|
54
|
+
</td>
|
|
55
|
+
</tr>
|
|
56
|
+
<% end %>
|
|
57
|
+
</tbody>
|
|
58
|
+
</table>
|
|
59
|
+
<% else %>
|
|
60
|
+
<div class="empty">No occurrences retained.</div>
|
|
61
|
+
<% end %>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Bulletin</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<style>
|
|
8
|
+
:root { --fg:#1f2933; --muted:#6b7280; --line:#e5e7eb; --bg:#f9fafb;
|
|
9
|
+
--red:#9b1c1c; --redbg:#fdf2f2; --green:#0f766e; --amber:#92400e; }
|
|
10
|
+
* { box-sizing: border-box; }
|
|
11
|
+
body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
|
12
|
+
color:var(--fg); background:var(--bg); }
|
|
13
|
+
header { background:#fff; border-bottom:1px solid var(--line); padding:14px 24px; }
|
|
14
|
+
header h1 { margin:0; font-size:18px; }
|
|
15
|
+
header h1 a { color:var(--fg); text-decoration:none; }
|
|
16
|
+
header .tag { color:var(--muted); font-weight:400; font-size:13px; }
|
|
17
|
+
main { max-width:1100px; margin:0 auto; padding:24px; }
|
|
18
|
+
.flash { background:#ecfdf5; color:var(--green); border:1px solid #a7f3d0;
|
|
19
|
+
padding:8px 12px; border-radius:6px; margin-bottom:16px; }
|
|
20
|
+
table { width:100%; border-collapse:collapse; background:#fff;
|
|
21
|
+
border:1px solid var(--line); border-radius:8px; overflow:hidden; }
|
|
22
|
+
th, td { text-align:left; padding:10px 12px; border-bottom:1px solid var(--line); vertical-align:top; }
|
|
23
|
+
th { background:#fafafa; font-size:12px; text-transform:uppercase; letter-spacing:.03em; color:var(--muted); }
|
|
24
|
+
tr:last-child td { border-bottom:none; }
|
|
25
|
+
a { color:#2563eb; text-decoration:none; }
|
|
26
|
+
a:hover { text-decoration:underline; }
|
|
27
|
+
code, pre { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px; }
|
|
28
|
+
pre { background:#0f172a; color:#e2e8f0; padding:12px; border-radius:6px; overflow:auto; }
|
|
29
|
+
.badge { display:inline-block; padding:1px 8px; border-radius:999px; font-size:12px; font-weight:600; }
|
|
30
|
+
.badge.n_plus_one_query { background:var(--redbg); color:var(--red); }
|
|
31
|
+
.badge.unused_eager_loading { background:#fffbeb; color:var(--amber); }
|
|
32
|
+
.badge.counter_cache { background:#eff6ff; color:#1d4ed8; }
|
|
33
|
+
.status.active { color:var(--red); font-weight:600; }
|
|
34
|
+
.status.resolved { color:var(--green); }
|
|
35
|
+
.status.muted { color:var(--muted); }
|
|
36
|
+
.muted { color:var(--muted); }
|
|
37
|
+
.filters { margin-bottom:16px; display:flex; gap:8px; align-items:center; }
|
|
38
|
+
.filters select, .filters button { padding:6px 8px; border:1px solid var(--line); border-radius:6px; background:#fff; }
|
|
39
|
+
.actions form { display:inline; }
|
|
40
|
+
.actions button { padding:4px 10px; border:1px solid var(--line); border-radius:6px;
|
|
41
|
+
background:#fff; cursor:pointer; font-size:13px; }
|
|
42
|
+
.summary { background:#fff; border:1px solid var(--line); border-radius:8px; padding:16px; margin-bottom:20px; }
|
|
43
|
+
.summary dl { display:grid; grid-template-columns:140px 1fr; gap:6px 16px; margin:0; }
|
|
44
|
+
.summary dt { color:var(--muted); }
|
|
45
|
+
.empty { color:var(--muted); padding:40px; text-align:center; }
|
|
46
|
+
</style>
|
|
47
|
+
</head>
|
|
48
|
+
<body>
|
|
49
|
+
<header>
|
|
50
|
+
<h1><%= link_to "Bulletin", root_path %> <span class="tag">— a memory for Bullet</span></h1>
|
|
51
|
+
</header>
|
|
52
|
+
<main>
|
|
53
|
+
<% if notice %><div class="flash"><%= notice %></div><% end %>
|
|
54
|
+
<%= yield %>
|
|
55
|
+
</main>
|
|
56
|
+
</body>
|
|
57
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateBulletinTables < ActiveRecord::Migration[7.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :bulletin_issues do |t|
|
|
6
|
+
t.string :fingerprint, null: false
|
|
7
|
+
t.string :kind
|
|
8
|
+
t.string :base_class
|
|
9
|
+
t.text :associations
|
|
10
|
+
t.string :controller
|
|
11
|
+
t.string :action
|
|
12
|
+
t.text :advice
|
|
13
|
+
t.integer :status, null: false, default: 0
|
|
14
|
+
t.datetime :resolved_at
|
|
15
|
+
t.datetime :muted_until
|
|
16
|
+
t.integer :occurrence_count, null: false, default: 0
|
|
17
|
+
t.datetime :first_seen_at
|
|
18
|
+
t.datetime :last_seen_at
|
|
19
|
+
t.timestamps
|
|
20
|
+
end
|
|
21
|
+
add_index :bulletin_issues, :fingerprint, unique: true
|
|
22
|
+
add_index :bulletin_issues, :last_seen_at
|
|
23
|
+
add_index :bulletin_issues, :status
|
|
24
|
+
|
|
25
|
+
create_table :bulletin_occurrences do |t|
|
|
26
|
+
t.references :bulletin_issue, null: false, foreign_key: true, index: false
|
|
27
|
+
t.string :controller
|
|
28
|
+
t.string :action
|
|
29
|
+
t.string :path
|
|
30
|
+
t.string :request_id
|
|
31
|
+
t.text :backtrace
|
|
32
|
+
t.datetime :created_at, null: false
|
|
33
|
+
end
|
|
34
|
+
add_index :bulletin_occurrences, %i[bulletin_issue_id created_at]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletin
|
|
4
|
+
# Host-facing configuration. Sensible dev-first defaults; the seams that
|
|
5
|
+
# matter for a future production story (store backend, write strategy) are
|
|
6
|
+
# decoupled so they can evolve independently.
|
|
7
|
+
class Configuration
|
|
8
|
+
attr_accessor :write_strategy, :job_queue, :occurrence_cap, :authenticate_with
|
|
9
|
+
attr_reader :store, :retention
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@store = :active_record # :active_record | :null (seam for :redis/:hybrid)
|
|
13
|
+
@write_strategy = :active_job # :active_job | :inline
|
|
14
|
+
@job_queue = :bulletin
|
|
15
|
+
@retention = 7 * 24 * 60 * 60 # seconds; coerced from Durations too
|
|
16
|
+
@occurrence_cap = 50 # max occurrence rows kept per issue
|
|
17
|
+
@enabled = nil # nil => follow Bullet.enable?
|
|
18
|
+
@authenticate_with = nil # ->(request) { ... } ; nil => dev-only access
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def store=(name)
|
|
22
|
+
@store = name.to_sym
|
|
23
|
+
Bulletin.reset_store!
|
|
24
|
+
@store
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def retention=(value)
|
|
28
|
+
# Accept ActiveSupport::Duration (7.days) or a raw seconds Numeric.
|
|
29
|
+
@retention = value.respond_to?(:to_i) ? value.to_i : value
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def retention_cutoff
|
|
33
|
+
Time.now - retention
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Whether Bulletin should record at all. Defaults to Bullet's own switch so
|
|
37
|
+
# it inherits Bullet's dev/staging-only posture unless explicitly overridden.
|
|
38
|
+
def enabled?
|
|
39
|
+
return defined?(Bullet) && Bullet.enable? if @enabled.nil?
|
|
40
|
+
|
|
41
|
+
@enabled.respond_to?(:call) ? !!@enabled.call : !!@enabled
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def enabled=(value)
|
|
45
|
+
@enabled = value
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module Bulletin
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace Bulletin
|
|
8
|
+
|
|
9
|
+
# Run after Bullet has inserted its own middleware so Bullet::Rack exists in
|
|
10
|
+
# the stack; insert_after places us inside it. If Bullet isn't loaded (e.g.
|
|
11
|
+
# production, where it's typically a dev-only dependency), we no-op.
|
|
12
|
+
initializer "bulletin.middleware", after: "bullet.add_middleware" do |app|
|
|
13
|
+
app.middleware.insert_after Bullet::Rack, Bulletin::Middleware if defined?(Bullet::Rack)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Let the host pick up the engine's migrations with a plain `rails db:migrate`,
|
|
17
|
+
# without copying them. (A consumer can still run the install generator.)
|
|
18
|
+
initializer "bulletin.migrations" do |app|
|
|
19
|
+
next if app.root.to_s == root.to_s
|
|
20
|
+
|
|
21
|
+
config.paths["db/migrate"].expanded.each do |expanded_path|
|
|
22
|
+
app.config.paths["db/migrate"] << expanded_path
|
|
23
|
+
::ActiveRecord::Migrator.migrations_paths << expanded_path if defined?(::ActiveRecord::Migrator)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Bulletin
|
|
6
|
+
# The identity of an "issue". Deliberately built from stable facts only:
|
|
7
|
+
# the kind of problem, the model, the association(s), and the
|
|
8
|
+
# controller#action that triggered it. File/line is intentionally excluded
|
|
9
|
+
# so a refactor doesn't spawn duplicate issues — that detail lives on each
|
|
10
|
+
# occurrence instead.
|
|
11
|
+
module Fingerprint
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def for(kind:, base_class:, associations:, controller:, action:)
|
|
15
|
+
raw = [
|
|
16
|
+
kind,
|
|
17
|
+
base_class,
|
|
18
|
+
Array(associations).map(&:to_s).sort.join(","),
|
|
19
|
+
controller,
|
|
20
|
+
action
|
|
21
|
+
].map(&:to_s).join("|")
|
|
22
|
+
|
|
23
|
+
Digest::SHA256.hexdigest(raw)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletin
|
|
4
|
+
# Inserted *after* Bullet::Rack (i.e. inside it), so on the response unwind we
|
|
5
|
+
# run before Bullet's own `ensure` nulls the thread-local collector. We read
|
|
6
|
+
# the already-deduped notifications, attach request context from the env
|
|
7
|
+
# (Bullet never populates notification.path), and hand a JSON-safe payload to
|
|
8
|
+
# the store. Persistence failures are swallowed — Bulletin must never break a
|
|
9
|
+
# host request.
|
|
10
|
+
class Middleware
|
|
11
|
+
def initialize(app)
|
|
12
|
+
@app = app
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(env)
|
|
16
|
+
status, headers, body = @app.call(env)
|
|
17
|
+
|
|
18
|
+
begin
|
|
19
|
+
persist(env) if recordable?
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
Bulletin.logger&.error("[Bulletin] failed to record warnings: #{e.class}: #{e.message}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
[status, headers, body]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def recordable?
|
|
30
|
+
Bulletin.config.enabled? &&
|
|
31
|
+
defined?(Bullet) && Bullet.enable? && Bullet.start?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def persist(env)
|
|
35
|
+
# Force unused-eager-loading detection to finalize; it isn't added to the
|
|
36
|
+
# collection until notification? runs (Bullet does this just after us, but
|
|
37
|
+
# we'd miss it otherwise). Re-running is harmless — the collector is a Set.
|
|
38
|
+
Bullet.notification?
|
|
39
|
+
|
|
40
|
+
collector = Bullet.notification_collector
|
|
41
|
+
collection = collector && collector.collection
|
|
42
|
+
return if collection.nil? || collection.empty?
|
|
43
|
+
|
|
44
|
+
warnings = collection.map { |notification| Bulletin::Warning.from(notification) }
|
|
45
|
+
Bulletin.dispatch(request_context(env).merge("warnings" => warnings))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def request_context(env)
|
|
49
|
+
params = env["action_dispatch.request.path_parameters"] || {}
|
|
50
|
+
{
|
|
51
|
+
"controller" => params[:controller],
|
|
52
|
+
"action" => params[:action],
|
|
53
|
+
"path" => env["PATH_INFO"],
|
|
54
|
+
"request_id" => env["action_dispatch.request_id"]
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletin
|
|
4
|
+
module Store
|
|
5
|
+
# Durable backend: one issue row per fingerprint, with a capped ring of
|
|
6
|
+
# occurrence rows as evidence. Aggregate stats (count, first/last seen)
|
|
7
|
+
# live on the issue so the list view never has to scan occurrences.
|
|
8
|
+
class ActiveRecord < Base
|
|
9
|
+
def record(payload)
|
|
10
|
+
warnings = Array(payload["warnings"])
|
|
11
|
+
return if warnings.empty?
|
|
12
|
+
|
|
13
|
+
warnings.each { |warning| record_one(warning, payload) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def prune!
|
|
17
|
+
Bulletin::Issue.where(arel_last_seen.lt(Bulletin.config.retention_cutoff)).find_each(&:destroy)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def arel_last_seen
|
|
23
|
+
Bulletin::Issue.arel_table[:last_seen_at]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def record_one(warning, context)
|
|
27
|
+
fingerprint = Bulletin::Fingerprint.for(
|
|
28
|
+
kind: warning["kind"],
|
|
29
|
+
base_class: warning["base_class"],
|
|
30
|
+
associations: warning["associations"],
|
|
31
|
+
controller: context["controller"],
|
|
32
|
+
action: context["action"]
|
|
33
|
+
)
|
|
34
|
+
issue = upsert_issue(fingerprint, warning, context)
|
|
35
|
+
append_occurrence(issue, warning, context)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def upsert_issue(fingerprint, warning, context)
|
|
39
|
+
now = Time.now
|
|
40
|
+
issue = Bulletin::Issue.find_or_initialize_by(fingerprint: fingerprint)
|
|
41
|
+
|
|
42
|
+
issue.kind ||= warning["kind"]
|
|
43
|
+
issue.base_class ||= warning["base_class"]
|
|
44
|
+
issue.controller ||= context["controller"]
|
|
45
|
+
issue.action ||= context["action"]
|
|
46
|
+
issue.associations = warning["associations"] if issue.associations.blank?
|
|
47
|
+
issue.advice = warning["advice"]
|
|
48
|
+
issue.first_seen_at ||= now
|
|
49
|
+
issue.last_seen_at = now
|
|
50
|
+
issue.occurrence_count = issue.occurrence_count.to_i + 1
|
|
51
|
+
|
|
52
|
+
# Regression detection: a resolved issue that recurs is reopened.
|
|
53
|
+
if issue.persisted? && issue.resolved?
|
|
54
|
+
issue.status = :active
|
|
55
|
+
issue.resolved_at = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
issue.save!
|
|
59
|
+
issue
|
|
60
|
+
rescue ::ActiveRecord::RecordNotUnique
|
|
61
|
+
retry # another worker created the issue first; reload and bump it
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def append_occurrence(issue, warning, context)
|
|
65
|
+
issue.occurrences.create!(
|
|
66
|
+
controller: context["controller"],
|
|
67
|
+
action: context["action"],
|
|
68
|
+
path: context["path"],
|
|
69
|
+
request_id: context["request_id"],
|
|
70
|
+
backtrace: warning["backtrace"]
|
|
71
|
+
)
|
|
72
|
+
enforce_cap(issue)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def enforce_cap(issue)
|
|
76
|
+
cap = Bulletin.config.occurrence_cap
|
|
77
|
+
return unless cap.to_i.positive?
|
|
78
|
+
|
|
79
|
+
excess_ids = issue.occurrences.order(created_at: :desc).offset(cap).pluck(:id)
|
|
80
|
+
Bulletin::Occurrence.where(id: excess_ids).delete_all if excess_ids.any?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletin
|
|
4
|
+
module Store
|
|
5
|
+
# Interface contract for persistence backends.
|
|
6
|
+
class Base
|
|
7
|
+
# payload is a JSON-safe Hash:
|
|
8
|
+
# { "controller" =>, "action" =>, "path" =>, "request_id" =>,
|
|
9
|
+
# "warnings" => [ { "kind", "base_class", "associations", "advice", "backtrace" }, ... ] }
|
|
10
|
+
def record(_payload)
|
|
11
|
+
raise NotImplementedError, "#{self.class} must implement #record"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Remove issues/occurrences older than the configured retention window.
|
|
15
|
+
def prune!
|
|
16
|
+
raise NotImplementedError, "#{self.class} must implement #prune!"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletin
|
|
4
|
+
# The single write boundary. Every backend (today: ActiveRecord/Null; later:
|
|
5
|
+
# Redis/hybrid) implements the same interface, so the middleware, jobs, and
|
|
6
|
+
# UI never need to know how warnings are stored.
|
|
7
|
+
module Store
|
|
8
|
+
BACKENDS = {
|
|
9
|
+
active_record: "Bulletin::Store::ActiveRecord",
|
|
10
|
+
null: "Bulletin::Store::Null"
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def build(name)
|
|
16
|
+
class_name = BACKENDS.fetch(name.to_sym) do
|
|
17
|
+
raise ArgumentError, "Unknown Bulletin store: #{name.inspect} (expected one of #{BACKENDS.keys})"
|
|
18
|
+
end
|
|
19
|
+
Object.const_get(class_name).new
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulletin
|
|
4
|
+
# Converts a Bullet notification object into a plain, JSON-safe Hash that can
|
|
5
|
+
# survive serialization across an ActiveJob boundary (the thread-local
|
|
6
|
+
# collector is gone by the time a job runs, so we extract eagerly).
|
|
7
|
+
#
|
|
8
|
+
# Notes from Bullet 8.x internals:
|
|
9
|
+
# - `notification.path` is always nil (detectors never pass it), so request
|
|
10
|
+
# context comes from the Rack env instead, not from here.
|
|
11
|
+
# - `title`/`body` are public and together form Bullet's own fix advice.
|
|
12
|
+
# - the call stack lives in @callers (N+1 and unused-eager-loading only;
|
|
13
|
+
# counter-cache notifications have none) and is reachable only via an
|
|
14
|
+
# ivar because the subclasses mark `call_stack_messages` protected.
|
|
15
|
+
module Warning
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def from(notification)
|
|
19
|
+
{
|
|
20
|
+
"kind" => kind_for(notification),
|
|
21
|
+
"base_class" => notification.base_class.to_s,
|
|
22
|
+
"associations" => Array(notification.associations).map(&:to_s),
|
|
23
|
+
"advice" => advice_for(notification),
|
|
24
|
+
"backtrace" => backtrace_for(notification)
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def kind_for(notification)
|
|
29
|
+
# Bullet::Notification::NPlusOneQuery => "n_plus_one_query"
|
|
30
|
+
ActiveSupport::Inflector.underscore(notification.class.name.split("::").last)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def advice_for(notification)
|
|
34
|
+
[safe(notification, :title), safe(notification, :body)].compact.join("\n").strip
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def backtrace_for(notification)
|
|
38
|
+
Array(notification.instance_variable_get(:@callers)).map(&:to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def safe(notification, method)
|
|
42
|
+
notification.public_send(method)
|
|
43
|
+
rescue StandardError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/bulletin-rb.rb
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The gem is published as "bulletin-rb", so Bundler's auto-require looks for
|
|
4
|
+
# "bulletin-rb". The real entrypoint is "bulletin" (namespace `Bulletin`); this
|
|
5
|
+
# shim bridges the two so `gem "bulletin-rb"` works with no `require:` option.
|
|
6
|
+
require "bulletin"
|
data/lib/bulletin.rb
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bulletin/version"
|
|
4
|
+
require "bulletin/configuration"
|
|
5
|
+
require "bulletin/fingerprint"
|
|
6
|
+
require "bulletin/warning"
|
|
7
|
+
require "bulletin/store"
|
|
8
|
+
require "bulletin/store/base"
|
|
9
|
+
require "bulletin/store/null"
|
|
10
|
+
require "bulletin/store/active_record"
|
|
11
|
+
require "bulletin/middleware"
|
|
12
|
+
require "bulletin/engine" if defined?(::Rails::Engine)
|
|
13
|
+
|
|
14
|
+
# Bulletin gives Bullet a memory. Bullet detects N+1 / unused-eager-loading /
|
|
15
|
+
# counter-cache problems per request but forgets them immediately; Bulletin
|
|
16
|
+
# reads Bullet's per-request findings, fingerprints them into durable "issues"
|
|
17
|
+
# (Sentry-style), and exposes a mountable UI for triage.
|
|
18
|
+
module Bulletin
|
|
19
|
+
class << self
|
|
20
|
+
def config
|
|
21
|
+
@config ||= Configuration.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def configure
|
|
25
|
+
yield config
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The active persistence backend (memoized). Reset when the store changes.
|
|
29
|
+
def store
|
|
30
|
+
@store ||= Store.build(config.store)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def reset_store!
|
|
34
|
+
@store = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Routes a normalized payload to the store, inline or via ActiveJob,
|
|
38
|
+
# depending on the configured write strategy.
|
|
39
|
+
def dispatch(payload)
|
|
40
|
+
case config.write_strategy
|
|
41
|
+
when :active_job
|
|
42
|
+
RecordJob.perform_later(payload)
|
|
43
|
+
else # :inline
|
|
44
|
+
store.record(payload)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def logger
|
|
49
|
+
return Rails.logger if defined?(Rails) && Rails.respond_to?(:logger)
|
|
50
|
+
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Bulletin
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
desc "Creates a Bulletin initializer in the host application."
|
|
11
|
+
|
|
12
|
+
def copy_initializer
|
|
13
|
+
template "bulletin.rb", "config/initializers/bulletin.rb"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def show_post_install
|
|
17
|
+
say ""
|
|
18
|
+
say "Bulletin installed.", :green
|
|
19
|
+
say " 1. Run `bin/rails db:migrate` to create its tables."
|
|
20
|
+
say " 2. Mount it in config/routes.rb: mount Bulletin::Engine => \"/bulletin\""
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Bulletin.configure do |config|
|
|
4
|
+
# Persistence backend. :active_record is the only durable backend today;
|
|
5
|
+
# :null disables recording. (:redis / :hybrid are planned.)
|
|
6
|
+
# config.store = :active_record
|
|
7
|
+
|
|
8
|
+
# How warnings are written. :active_job keeps recording off the request path
|
|
9
|
+
# (recommended); :inline writes synchronously (handy in development with no
|
|
10
|
+
# worker running).
|
|
11
|
+
# config.write_strategy = :active_job
|
|
12
|
+
|
|
13
|
+
# ActiveJob queue used when write_strategy is :active_job.
|
|
14
|
+
# config.job_queue = :bulletin
|
|
15
|
+
|
|
16
|
+
# Max occurrence rows kept per issue (older ones are pruned).
|
|
17
|
+
# config.occurrence_cap = 50
|
|
18
|
+
|
|
19
|
+
# How long issues are retained; PruneJob deletes anything older.
|
|
20
|
+
# config.retention = 7.days
|
|
21
|
+
|
|
22
|
+
# Whether to record. Defaults to Bullet.enable? (dev/staging only).
|
|
23
|
+
# config.enabled = Rails.env.local?
|
|
24
|
+
|
|
25
|
+
# Authorize access to the mounted UI (Sidekiq::Web / Flipper style).
|
|
26
|
+
# Returns truthy to allow. Defaults to development-only when nil.
|
|
27
|
+
# config.authenticate_with = ->(request) { request.session[:admin] }
|
|
28
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bulletin-rb
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Charles Harris
|
|
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: bullet
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rails
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.1'
|
|
40
|
+
description: Bulletin persists, fingerprints, and triages the N+1 / unused-eager-loading
|
|
41
|
+
/ counter-cache warnings that Bullet detects, and exposes them through a mountable
|
|
42
|
+
Rails engine — Flipper UI / PgHero style, with a Sentry-style issue model.
|
|
43
|
+
email:
|
|
44
|
+
- charris000@gmail.com
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- CHANGELOG.md
|
|
50
|
+
- MIT-LICENSE
|
|
51
|
+
- README.md
|
|
52
|
+
- app/controllers/bulletin/application_controller.rb
|
|
53
|
+
- app/controllers/bulletin/issues_controller.rb
|
|
54
|
+
- app/jobs/bulletin/prune_job.rb
|
|
55
|
+
- app/jobs/bulletin/record_job.rb
|
|
56
|
+
- app/models/bulletin/application_record.rb
|
|
57
|
+
- app/models/bulletin/issue.rb
|
|
58
|
+
- app/models/bulletin/occurrence.rb
|
|
59
|
+
- app/views/bulletin/issues/index.html.erb
|
|
60
|
+
- app/views/bulletin/issues/show.html.erb
|
|
61
|
+
- app/views/layouts/bulletin/application.html.erb
|
|
62
|
+
- config/routes.rb
|
|
63
|
+
- db/migrate/20260527000001_create_bulletin_tables.rb
|
|
64
|
+
- lib/bulletin-rb.rb
|
|
65
|
+
- lib/bulletin.rb
|
|
66
|
+
- lib/bulletin/configuration.rb
|
|
67
|
+
- lib/bulletin/engine.rb
|
|
68
|
+
- lib/bulletin/fingerprint.rb
|
|
69
|
+
- lib/bulletin/middleware.rb
|
|
70
|
+
- lib/bulletin/store.rb
|
|
71
|
+
- lib/bulletin/store/active_record.rb
|
|
72
|
+
- lib/bulletin/store/base.rb
|
|
73
|
+
- lib/bulletin/store/null.rb
|
|
74
|
+
- lib/bulletin/version.rb
|
|
75
|
+
- lib/bulletin/warning.rb
|
|
76
|
+
- lib/generators/bulletin/install_generator.rb
|
|
77
|
+
- lib/generators/bulletin/templates/bulletin.rb
|
|
78
|
+
homepage: https://github.com/charlesharris/bulletin-rb
|
|
79
|
+
licenses:
|
|
80
|
+
- MIT
|
|
81
|
+
metadata:
|
|
82
|
+
homepage_uri: https://github.com/charlesharris/bulletin-rb
|
|
83
|
+
source_code_uri: https://github.com/charlesharris/bulletin-rb
|
|
84
|
+
changelog_uri: https://github.com/charlesharris/bulletin-rb/blob/main/CHANGELOG.md
|
|
85
|
+
rubygems_mfa_required: 'true'
|
|
86
|
+
rdoc_options: []
|
|
87
|
+
require_paths:
|
|
88
|
+
- lib
|
|
89
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '3.1'
|
|
94
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
95
|
+
requirements:
|
|
96
|
+
- - ">="
|
|
97
|
+
- !ruby/object:Gem::Version
|
|
98
|
+
version: '0'
|
|
99
|
+
requirements: []
|
|
100
|
+
rubygems_version: 3.6.7
|
|
101
|
+
specification_version: 4
|
|
102
|
+
summary: A mountable UI that gives Bullet a memory.
|
|
103
|
+
test_files: []
|