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 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulletin
4
+ class ApplicationRecord < ::ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ 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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ Bulletin::Engine.routes.draw do
4
+ resources :issues, only: %i[index show] do
5
+ member do
6
+ patch :resolve
7
+ patch :mute
8
+ patch :reopen
9
+ end
10
+ end
11
+
12
+ root to: "issues#index"
13
+ end
@@ -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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulletin
4
+ module Store
5
+ # No-op backend. Useful in tests and as a kill switch.
6
+ class Null < Base
7
+ def record(_payload); end
8
+
9
+ def prune!; end
10
+ end
11
+ end
12
+ 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulletin
4
+ VERSION = "0.1.0"
5
+ 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
@@ -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: []