bug_reports_client
A mountable Rails engine that adds a complete bug-and-feature reporting flow to your app, backed by a central bug-reports API that files GitHub issues and calls back when they are closed.
What your users get:
- A polished report form with a bug/feature toggle, driven entirely by a YAML schema you can customise per app - fields, types, required flags, sections and wording.
- Screenshot uploads (Active Storage, optional).
- A "My Reports" page tracking the status of everything they've filed, plus an admin-only "All Reports" view.
- Resolved alerts: when the GitHub issue is closed, a signed webhook flips the local record and the reporter sees a dismissable "your report has been resolved" banner.
Requirements: Rails >= 8.0, Turbo/Stimulus via importmap-rails, Tailwind CSS (for the default views - or copy the views and style them however you like).
Installation
# Gemfile
gem "bug_reports_client", github: "Professional-Squash-Association/bug-reports", glob: "client/*.gemspec"
bundle install
bin/rails g bug_reports_client:install
bin/rails bug_reports_client:install:migrations && bin/rails db:migrate
The install generator creates config/initializers/bug_reports_client.rb,
mounts the engine at /bug_reports, and copies the default form schema to
config/bug_report_form.yml ready to customise. It prints the remaining
checklist:
# app/models/user.rb
include BugReportsClient::Reporter
<%# somewhere in your layout %>
<%= link_to "Report a bug", bug_reports_client.new_bug_report_path %>
<%= bug_report_alerts %>
Environment variables (or set the equivalents in the initializer):
| Variable | Purpose |
|---|---|
BUG_REPORT_API_URL |
Base URL of the bug-reports API, e.g. https://bugs.example.com/api |
BUG_REPORT_API_KEY |
This app's API token (Bearer auth) |
BUG_REPORT_WEBHOOK_SECRET |
This app's webhook secret (HMAC verification of closure callbacks) |
BUG_REPORT_APP_HOST |
Public HTTPS origin of this app (callback URL + screenshot links). Falls back to APP_HOST; prefer this or config.app_host if APP_HOST serves other purposes |
On the API side, the app needs an ApiKey record and an entry in
config/repo_mapping.yml mapping its source name to a GitHub repository.
Configuration
Everything lives in the initializer. Only source is required; the rest have
sensible defaults:
BugReportsClient.configure do |config|
config.source = "myapp" # must match the API key name
# Host integration (defaults shown)
config.parent_controller = "::ApplicationController"
config.current_user_method = :current_user
config.authenticate_method = :authenticate_user!
config.user_class = "User"
config.reporter_email_method = :email # symbol or ->(user) { ... }
config.reporter_name_method = :name
# Behaviour
config.admin_check = ->(user) { user.admin? } # gates /bug_reports/all
config.reporter_external = ->(user) { false } # shown on the GitHub issue
config.ask_severity = true # false hides the picker...
config.default_severity = "medium" # ...and submits this for bugs
config.screenshots_enabled = true # needs publicly reachable storage
config.max_screenshots = 5
# Connection (default from ENV as above)
# config.api_url / config.api_key / config.webhook_secret / config.app_host
# config.callback_url # override the derived callback URL
# config.mount_path = "/bug_reports" # if you mount somewhere else
# config.app_name = "My App" # {{app_name}} in issue templates
end
Customising the form
config/bug_report_form.yml defines the fields per report type:
bug:
- field: what_happened
type: textarea # text | textarea | select | checkbox
required: true
section: the_problem # starts a new card; heading via i18n or humanised
rows: 4
- field: browser
type: select
options: [Chrome, Safari, Firefox, Other] # or {value:, label:} pairs
feature:
- field: problem
type: textarea
required: true
Delete the file to fall back to the engine's default schema (a well-rounded bug/feature form). Define only one report type and the bug/feature toggle disappears.
Answers are stored in a JSON responses column, validated server-side from
the same schema (required flags, and only schema-declared keys are permitted).
Wording
All copy is i18n under the bug_reports_client namespace - override any
subset in your own locale files, no view copying needed:
en:
bug_reports_client:
new:
heading: "Report an issue"
fields:
what_happened:
label: "What happened?"
placeholder: "Tell us what went wrong"
Inline label/placeholder/help keys in the schema win over i18n.
GitHub issue body
By default the issue body is generated from the schema: a ## <label> section
per answered field, a severity line for bugs, and screenshot links. To control
it yourself, create config/bug_report_issue.md (the install generator drops
an .example next to it) using {{field}} placeholders - every schema field
key plus {{title}}, {{report_type}}, {{severity}}, {{reporter_name}},
{{app_name}} and {{screenshots}}.
Views
The default views are plain Tailwind. To restyle:
bin/rails g bug_reports_client:views
copies them into app/views/bug_reports_client/ where they shadow the
engine's per file. The form posts a stable param contract, so you can also
hand-write a completely custom form - anything that submits these names works:
bug_report[title]
bug_report[report_type] bug | feature
bug_report[severity] low | medium | high | critical (bugs)
bug_report[responses][<field_key>] one per schema field
bug_report[screenshots][] file uploads (optional)
Hosts can also override just the Turbo Stream dismissal hook
(app/views/bug_reports_client/shared/_after_dismiss.turbo_stream.erb) to
update their own UI - e.g. a notification badge - when an alert is dismissed.
Styling
The engine views use plain Tailwind utilities; your app compiles the CSS, so add the gem's views to your build:
Tailwind v3 (config/tailwind.config.js):
const { execSync } = require('child_process');
let bugReportsClient = '';
try { bugReportsClient = execSync('bundle show bug_reports_client').toString().trim(); } catch (e) {}
module.exports = {
content: [
'./app/views/**/*.erb',
// ...existing globs...
bugReportsClient && `${bugReportsClient}/app/views/**/*.erb`,
bugReportsClient && `${bugReportsClient}/app/helpers/**/*.rb`,
bugReportsClient && `${bugReportsClient}/app/javascript/**/*.js`,
].filter(Boolean),
}
Tailwind v4 (tailwindcss-rails >= 4.x): the engine ships
app/assets/tailwind/bug_reports_client/engine.css; add to
app/assets/tailwind/application.css:
@import "../builds/tailwind/bug_reports_client";
JavaScript
Three Stimulus controllers ship with the engine and are pinned into your
importmap automatically: bug-reports-client--report-type toggles the
bug/feature field groups, bug-reports-client--screenshot-dropzone powers
the drag-and-drop screenshot picker with thumbnail previews (the hidden file
input stays the submission source, so custom forms can ignore it; its
drag-over highlight is themeable via the highlight-classes Stimulus
value), and
bug-reports-client--file-limit is a standalone file-count guard for
hand-rolled forms. Any standard Stimulus setup that loads controllers from
the importmap (eagerLoadControllersFrom / lazyLoadControllersFrom) picks
them up with no changes.
Automatic error capture (Sentry-style)
Opt in per host and unhandled 500s become GitHub issues, deduplicated:
config.error_reporting_enabled = true
config.error_ignore = [] # extra exception class names to skip
config.error_throttle_period = 300 # seconds between posts per fingerprint
The engine subscribes to the Rails error reporter, so anything that would
render a 500 is captured; exceptions Rails maps to 4xx responses
(RecordNotFound, routing errors, etc) are skipped automatically. Errors
are fingerprinted by exception class + top application frame (line numbers
ignored, so deploys don't spawn duplicate issues), throttled through
Rails.cache, and posted in a background job so failing requests are never
slowed down. The API keeps one open issue per fingerprint, counting repeat
occurrences; when a closed error recurs, a fresh issue is filed (a
regression deserves new attention).
Captured errors are also attributed to the signed-in user who hit them
(stored locally in bug_report_error_events, pruned after 30 days). When
that user opens the report form within 24 hours, it asks "did this happen
alongside an error we caught?" - linking one threads the exception, time
and fingerprint into their report, so the GitHub issue cross-references the
automatically-filed error issue (search the repo for the fingerprint).
Existing installs: run bin/rails bug_reports_client:install:migrations && bin/rails db:migrate to pick up the events table.
Webhook contract
The API POSTs closure callbacks to <APP_HOST><mount_path>/webhook, signed
with your app's webhook secret. Preferred scheme: X-Timestamp plus
X-Signature-Timestamped: sha256=<hex> where the HMAC-SHA256 covers
"<timestamp>.<body>" - the engine rejects timestamps older than 5 minutes
(replay protection). A legacy body-only X-Signature is also accepted when
no timestamp is sent. Bodies over 1 MB are rejected outright. The engine
verifies the signature, marks the local report closed, and returns 200. The
callback URL must be public HTTPS.
Security notes
- Screenshots are validated server-side: content type is sniffed from
the file bytes (not the client-declared type), and size/count limits apply
(
screenshot_content_types,max_screenshot_size,max_screenshots). - Screenshot URLs are embedded in GitHub issues so maintainers can see
them - which means uploads become unauthenticated public URLs on your
storage service. If that's unacceptable, set
screenshots_enabled = falseor serve Active Storage through expiring/signed URLs. - Issue bodies contain verbatim user text. Treat report content on the receiving side (GitHub, the central API) as untrusted: user answers can contain markdown, links and @mentions.
- The issue template path is trusted config.
issue_template_path(andconfig/bug_report_issue.md) is read from disk verbatim - only ever point it at a file you control.
Testing
The engine has its own test suite with a dummy host app:
cd client
bundle install
bundle exec rake test