ideasbugs
In-app product feedback for Rails. A "Send feedback" widget plus a triage dashboard — bug reports, feature requests, screenshots — all in your own database. Self-hosted alternative to Canny.
|
|
Install
# Gemfile
gem "ideasbugs"
bundle install
bin/rails generate ideasbugs:install
bin/rails db:migrate
<%# app/views/layouts/application.html.erb, before </body> %>
<%= ideasbugs_tag %>
That's it. A floating Feedback button appears bottom-right; submissions
land at /feedback.
[!IMPORTANT] The dashboard defaults to development only. Set
authorize_adminbefore you deploy — see Configure.
Ruby >= 3.2 · Rails >= 7.1 · Active Storage only if you want screenshots ·
CSRF token comes from csrf_meta_tags, already in a standard Rails layout.
What you get
| | |
| ------------ | ------------------------------------------------------------------------ |
| Widget | Type (bug / feature / other), optional section, message, up to 3 screenshots |
| Dashboard| Status tabs (open → in review → resolved), type filter, search, inline screenshots |
| Storage | ideasbugs_feedbacks — ordinary Active Record rows you can query and export |
| Deps | None. Plain JS — no Tailwind, no Stimulus, no importmap, no build step |
| Auth | Lambdas over the raw request — Devise, Rails 8 auth, Basic auth, anything |
| i18n | 26 languages, RTL mirrored, English fallback |
| Theme | Follows system light/dark. No config |
| Turbo | Works with Turbo Drive out of the box |
| CSP | Carries the request nonce. Works under strict script-src |
Why not Canny
ideasbugs |
Hosted feedback SaaS | |
|---|---|---|
| Cost | Free, MIT | Monthly, often per-seat |
| Where feedback lives | Your database | The vendor's |
| Who users log in as | Already signed in to your app | A second account |
| Attribution | Your current_user |
Email they type |
| Querying / exporting | Plain Active Record | Their API |
| Public voting board | On the roadmap | ✅ |
Configure
Everything is optional — a fresh install works with zero config. In
config/initializers/ideasbugs.rb:
| Option | Default | What it does |
|---|---|---|
authorize_admin |
development only | Who can read the dashboard. Override before deploying |
enabled |
everyone | Who can send feedback. false hides the widget and rejects posts |
current_user |
nil |
Attribute a submission to a user. Receives the request |
author_label |
the user's email |
Short label stored and shown in the dashboard |
tenant |
nil |
One board per tenant — see Multi-tenancy |
kinds |
%w[bug feature other] |
Feedback types. Labels via ideasbugs.kinds.<kind> |
sections |
[] |
App areas shown as a select. Empty hides it |
screenshots |
true |
Allow uploads (needs Active Storage) |
max_screenshots |
3 |
Enforced server-side |
max_screenshot_size |
5.megabytes |
Enforced server-side |
rate_limit |
{ to: 10, within: 1.minute } |
Per-IP throttle (Rails 7.2+). nil disables |
show_button |
true |
The floating button. false to use your own trigger |
button_label |
nil |
Fixed button text. nil uses the localized default |
mount_path |
"/feedback" |
Keep in sync with mount in routes.rb |
on_submit |
no-op | Runs inline after each save — Slack, email, tickets |
Gates receive the raw request, so they work with any auth:
# Devise / Warden
config.current_user = ->(request) { request.env["warden"]&.user }
config. = ->(request) { request.env["warden"]&.user&.admin? }
# Rails 8 built-in auth (bin/rails generate authentication)
config.current_user = lambda do |request|
token = request.["session_token"]
Session.find_signed(token)&.user if token
end
# Anything else based on the request
config. = ->(request) { Flipper.enabled?(:feedback_admin) }
Other ways to protect the dashboard
Wrap the mount in a routing constraint — the lambda still applies on top:
authenticate :user, ->(user) { user.admin? } do
mount Ideasbugs::Engine => "/feedback"
end
Notifications on new feedback
on_submit runs inline after each save — keep it fast or hand off to a job:
config.on_submit = ->(feedback) do
SlackNotifier.post("New #{feedback.kind}: #{feedback..truncate(100)}")
end
Trigger it from your own UI
Prefer a nav item over the floating button? Add data-ideasbugs-open to any
element, and optionally set config.show_button = false.
Gate the trigger with the same check the widget uses — ideasbugs_tag renders
nothing when enabled rejects the request, so an unguarded trigger is a dead
button for those users. ideasbugs.title ships in every bundled language:
<% if Ideasbugs.enabled?(request) %>
<button data-ideasbugs-open><%= t("ideasbugs.title") %></button>
<% end %>
Loading the widget on one page only
ideasbugs_tag in the layout puts the widget on every page. If your only
trigger lives on one page, render the tag there instead:
<%# layouts/application.html.erb %>
<head>
<%= yield :head %>
</head>
<%# users/show.html.erb — the page with the trigger %>
<% content_for :head, ideasbugs_tag %>
Turbo-safe either way: the script installs its document-level listeners once
per session and re-renders on turbo:load.
Trade-off: a data-ideasbugs-open element on a page that never rendered
the tag silently does nothing. If triggers appear on several pages, keep the
tag in the layout.
Screenshots
Uploads use Active Storage — run bin/rails active_storage:install if you
haven't. Count, size and content type are enforced server-side. If Active
Storage isn't loaded, the upload control doesn't render and uploads are
rejected.
The data
Submissions are ordinary records:
Ideasbugs::Feedback.where(status: "open").newest_first.each do |feedback|
puts "[#{feedback.kind}] #{feedback.} — #{feedback.}"
end
Each row stores kind, section, message, status (open / in_review /
resolved), page_url, user_agent, an optional tenant, and optional
author_id / author_label. Screenshots are Active Storage attachments
(feedback.screenshots).
Multi-tenancy
Give each customer its own board — its own submissions, its own dashboard — with one resolver:
config.tenant = ->(request) { Current.organization&.to_gid&.to_s }
Return an opaque key: a GlobalID, an id, a subdomain, a slug. The gem never
takes a foreign key into your models and never needs to know what a Customer
is. It stamps the key on each submission and scopes the dashboard (and
screenshot access) to it. nil — the default — is a single global board, so
single-tenant apps need none of this.
Authorization composes: authorize_admin says who is an admin, tenant says
which tenant they're in. A customer's admin sees only their own board, and a
cross-tenant id 404s.
Optional model sugar, and upgrading an existing install
A veneer over the string key, not a new coupling:
class Customer < ApplicationRecord
has_feedback # keyed by to_gid.to_s (match config.tenant)
end
customer.feedback.open # a normal Active Record relation
Apps that skip the concern still get full multi-tenancy from the resolver
alone. Keying by something other than GlobalID? Pass a matching resolver to
both: has_feedback(key: ->(c){ c.subdomain }) and the same in
config.tenant.
Upgrading: the tenant column is additive and nullable. Run
bin/rails generate ideasbugs:tenant && bin/rails db:migrate (a fresh install
already includes it). Existing rows keep a nil tenant — the global board — so
nothing changes until you set config.tenant.
Localization
Every string resolves through Rails I18n under ideasbugs.* and follows the
current I18n.locale. 26 languages ship; missing keys fall back to English;
RTL locales render mirrored.
Bundled languages, and overriding the copy
Arabic, Bengali, Bulgarian, Chinese (Simplified), Croatian, Dutch, English, French, German, Greek, Hindi, Indonesian, Italian, Japanese, Korean, Luxembourgish, Polish, Portuguese, Romanian, Russian, Spanish, Thai, Turkish, Ukrainian, Urdu, Vietnamese.
Define the keys in your own locale files — yours win over the gem's:
# config/locales/nl.yml
nl:
ideasbugs:
button: "Feedback"
title: "Feedback versturen"
kinds:
bug: "Fout melden"
feature: "Functie aanvragen"
other: "Overig"
# …see config/locales/ideasbugs.en.yml for the full key list
Custom kinds get labels the same way (ideasbugs.kinds.<kind>), falling back
to kind.humanize.
Security
- Both gates run server-side, on every request. The dashboard denies
everything outside development until you configure
authorize_admin. - Screenshots stream through the dashboard's own gate — never public Active
Storage blob URLs. A screenshot can contain anything the user's screen
showed, so it's reachable only by someone who passes
authorize_admin, whatever your app's blob settings are. - Rate-limited per IP (10/min by default, Rails 7.2+), so one bot can't flood your database with uploads.
- Uploads validated server-side — count, size, and images-only content type — regardless of what the client claims.
- CSP nonce carried from
ActionDispatch. Runtime config ships as<script type="application/json">(data, not code), so it needs no nonce and survives Turbo visits. - No foreign key into your user table. Attribution is loose fields, so the gem never couples to your user model.
Development
bin/setup
bundle exec rake test # unit + integration
bundle exec rake test:system # browser tests (headless Chrome)
bundle exec rubocop
Tests run against a dummy Rails app in test/dummy; the widget is covered by
Capybara system tests in a real browser. CI runs Rails 7.1 / 7.2 / 8.0 / 8.1
against Ruby 3.2–3.4 (per-version Gemfiles in gemfiles/).
Also by the same author
- testimonials — testimonials, reviews and NPS for Rails. Its NPS detractor hook feeds straight into this gem.
- SupeRails — Rails screencasts.
License
MIT. Formerly published as feedback_engine — see the
CHANGELOG for rename notes.