rails_error_dashboard 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +123 -49
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +3 -0
  4. data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +8 -1
  5. data/app/models/rails_error_dashboard/error_log.rb +1 -0
  6. data/app/views/layouts/rails_error_dashboard.html.erb +30 -0
  7. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +5 -0
  8. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +1 -0
  9. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +8 -0
  10. data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +9 -0
  11. data/app/views/rails_error_dashboard/errors/analytics.html.erb +51 -1
  12. data/app/views/rails_error_dashboard/errors/database_health_summary.html.erb +1 -1
  13. data/app/views/rails_error_dashboard/errors/index.html.erb +8 -1
  14. data/app/views/rails_error_dashboard/errors/overview.html.erb +2 -2
  15. data/app/views/rails_error_dashboard/errors/settings.html.erb +2 -0
  16. data/config/locales/de.yml +19 -2
  17. data/config/locales/en.yml +17 -0
  18. data/config/locales/es.yml +18 -2
  19. data/config/locales/fr.yml +19 -2
  20. data/config/locales/it.yml +27 -3
  21. data/config/locales/ja.yml +17 -3
  22. data/config/locales/pl.yml +19 -2
  23. data/config/locales/pt-BR.yml +18 -2
  24. data/config/locales/ru.yml +18 -2
  25. data/config/locales/uk.yml +18 -2
  26. data/config/locales/zh-CN.yml +15 -3
  27. data/db/migrate/20260826000001_add_environment_to_error_logs.rb +26 -0
  28. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +11 -0
  29. data/lib/rails_error_dashboard/commands/backfill_environments.rb +58 -0
  30. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +76 -29
  31. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +40 -9
  32. data/lib/rails_error_dashboard/commands/log_error.rb +17 -0
  33. data/lib/rails_error_dashboard/configuration.rb +52 -0
  34. data/lib/rails_error_dashboard/queries/analytics_stats.rb +9 -0
  35. data/lib/rails_error_dashboard/queries/errors_list.rb +8 -0
  36. data/lib/rails_error_dashboard/queries/filter_options.rb +9 -0
  37. data/lib/rails_error_dashboard/services/discord_payload_builder.rb +8 -0
  38. data/lib/rails_error_dashboard/services/error_broadcaster.rb +6 -2
  39. data/lib/rails_error_dashboard/services/issue_body_formatter.rb +1 -0
  40. data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +1 -0
  41. data/lib/rails_error_dashboard/services/notification_throttler.rb +23 -0
  42. data/lib/rails_error_dashboard/services/pagerduty_payload_builder.rb +1 -0
  43. data/lib/rails_error_dashboard/services/slack_payload_builder.rb +9 -0
  44. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +2 -0
  45. data/lib/rails_error_dashboard/services/webhook_payload_builder.rb +1 -0
  46. data/lib/rails_error_dashboard/version.rb +1 -1
  47. data/lib/rails_error_dashboard.rb +1 -0
  48. data/lib/tasks/rails_error_dashboard_tasks.rake +6 -0
  49. metadata +24 -27
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7065559d38e3152511024cd4a44698c3b215cc6cd60e4917d52cf4d893180b6c
4
- data.tar.gz: 0d9237d4e89f4cb08392aecfe109dbb38bc1aaf673e834d827441bebf84a6e3f
3
+ metadata.gz: 4aa6cdee526bf9210f3762861a373dbefbe99b9ed94ecdb5f6464fb781bdddb4
4
+ data.tar.gz: 3a7977ff26332c869b5bf6e4e5316d3470e978ff736184dcc4119f0982822c4c
5
5
  SHA512:
6
- metadata.gz: 996b72e8406365ca4ee064464faa785b7a43dfa5f643ed9f2ca3be39d4d98b3dd13ed6426a1dbef3eb53fad3d6433d89b70b3f8a3d8e40434aad1e882cff0eb1
7
- data.tar.gz: 4814ed8949397ce5ca0cd27cf4e9f52f5ba2e5bf243eff411eb0e7c80539fef978c0dc87505c02bb0e2a259725a8fec97a5897c62073eae2a9142c0f0b2c2ea8
6
+ metadata.gz: ea46f47e2e96da83cf0c83d94596e63d63ab0dafeea5a3f696b2a1021b9eb57026485c9fec4d0827e8f8fcd4852fd587a69d28d3890ec14a5ec295d549826264
7
+ data.tar.gz: 4ea0ff21bbcb9177fd43297a5145e17a6316ec52f2f8a5370b30e36879a32810cd289b22e7c1d66abb2b9f9a66593bc2e013ab3ccfc19af1c0ffc46c42863537
data/README.md CHANGED
@@ -7,27 +7,100 @@
7
7
  [![Sponsor](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-ea4aaa?logo=githubsponsors)](https://github.com/sponsors/AnjanJ)
8
8
  [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-support-yellow?logo=buymeacoffee)](https://buymeacoffee.com/anjanj)
9
9
 
10
- **Self-hosted Rails error monitoring free, forever.**
10
+ **Rails-native error tracking for failure investigation — see the Ruby state and Rails runtime health behind every exception. Self-hosted, inside your app, in your own database. The gem is MIT and free forever.**
11
11
 
12
12
  ```ruby
13
- gem 'rails_error_dashboard'
13
+ gem "rails_error_dashboard"
14
14
  ```
15
15
 
16
- **5-minute setup** · **Works out-of-the-box** · **PostgreSQL, MySQL/Trilogy, SQLite — shared or separate database** · **No vendor lock-in**
16
+ ```bash
17
+ bundle install
18
+ rails generate rails_error_dashboard:install
19
+ rails db:migrate
20
+ ```
17
21
 
18
- [Full Documentation](https://anjanj.github.io/rails_error_dashboard/) · [Live Demo](https://rails-error-dashboard.anjan.dev) · [RubyGems](https://rubygems.org/gems/rails_error_dashboard)
22
+ Open `/red` and raise a test exception. No monitoring account or ingestion service is required.
19
23
 
20
- ---
24
+ [Try the live demo](https://rails-error-dashboard.anjan.dev) (`gandalf` / `youshallnotpass`) · [Read the documentation](https://anjanj.github.io/rails_error_dashboard/) · [View on RubyGems](https://rubygems.org/gems/rails_error_dashboard)
25
+
26
+ > **Beta:** RED is functional and extensively tested, but configuration and APIs may change before 1.0. Supports Rails 7.0–8.1 and Ruby 3.2–4.0 (CI runs Ruby 3.2–3.4 against every supported Rails version; Ruby 4.0 is verified by the maintainer).
27
+
28
+ ## See the Ruby state and Rails runtime health behind every exception
29
+
30
+ Rails Error Dashboard (RED) is an open-source, self-hosted Rails engine for investigating production failures. It helps you answer not only **what failed**, but **what was happening inside Ruby and Rails when it failed**.
31
+
32
+ - Inspect local variables and the raising object's instance variables before the stack unwinds.
33
+ - See error-time Active Record, Puma, job queue, GC, memory and process health.
34
+ - Follow the SQL, cache, controller, job, mailer and other Rails events leading to the exception.
35
+ - Stay safe during error floods with progressive, count-preserving storm protection.
36
+ - Keep exception data on infrastructure you control.
37
+
38
+ ![Local and instance variables captured at the raise, scrubbed with filter_parameters](docs/images/local-variables.png)
39
+
40
+ ## The questions RED helps you answer
41
+
42
+ A stack trace tells you where execution stopped. RED helps you investigate the state behind it:
43
+
44
+ - What did `params`, local variables and objects such as `@order` contain?
45
+ - Was the Active Record pool exhausted?
46
+ - Was Puma out of thread capacity or building a backlog?
47
+ - Were jobs failing or queues growing?
48
+ - Was the process under GC, memory, descriptor or system pressure?
49
+ - Which SQL queries, cache operations or Rails events preceded the failure?
50
+ - Did a deploy introduce the error?
51
+ - Can the failing request become a cURL reproduction or RSpec regression-test scaffold?
52
+
53
+ ## What makes RED different
54
+
55
+ ### Failure-time Ruby state
56
+
57
+ Optionally capture local variables and — something no other error tracker does — the raising receiver's instance variables at `TracePoint(:raise)`, with bounded serialization and your Rails `filter_parameters` applied to sensitive values. Binding objects are never retained.
58
+
59
+ ### Failure-time Rails health
60
+
61
+ Attach connection-pool, Puma, background-job, GC, memory, file-descriptor, TCP, RubyVM and YJIT state to the error record, refreshed on every captured occurrence — not merely to a separate periodic metrics chart. Every APM has these as time-series; none attaches them to the error. Opt-in; the procfs-backed fields are Linux-only.
62
+
63
+ ### Monitoring that degrades safely
64
+
65
+ During an error flood, RED progressively reduces captured context and database work, keeps a fresh exemplar every minute, records the storm in a Storm History ledger and reconciles exact in-process occurrence counts onto the error records. On by default.
21
66
 
22
- ### Try the Live Demo
67
+ ### Rails-specific investigation
23
68
 
24
- **[rails-error-dashboard.anjan.dev](https://rails-error-dashboard.anjan.dev)** Username: `gandalf` · Password: `youshallnotpass`
69
+ Connect exceptions with SQL, caching, Active Job, Action Cable, Active Storage, Rack::Attack, deprecations and other Rails subsystems from one dashboard.
25
70
 
26
- > **Beta Software** Functional and tested (2,700+ tests passing), but the API may change before v1.0. Supports Rails 7.0-8.1 and Ruby 3.2-4.0.
71
+ ### Things no other tracker does
72
+
73
+ Verified against Sentry, Honeybadger, AppSignal, Rollbar, Bugsnag, Airbrake, Raygun, New Relic, Datadog, Scout, Skylight and every self-hosted Rails tracker in August 2026 ([the ledger](.shipkit/research/red-unique-features-verified.md)):
74
+
75
+ - **Copy as RSpec** — a runnable request spec generated from the captured request (Sentry offers curl only).
76
+ - **Swallowed-exception aggregate** — raise-vs-rescue ratio per location, no APM span needed (Datadog's paid APM detects rescued exceptions but keeps no aggregate).
77
+ - **Rack::Attack ledger** — throttle, blocklist and track events persisted with per-rule stats and an AI-crawler classifier; rack-attack ships no UI of its own.
78
+ - **Codeberg issue tracking**, alongside GitHub, GitLab and Linear with two-way sync.
79
+ - **The tracker instruments itself** — its capture pipeline exported as OpenTelemetry spans, so you can audit its overhead in your own APM.
80
+
81
+ ## How RED compares
82
+
83
+ | Basic embedded tracker | General SaaS monitoring | RED |
84
+ |---|---|---|
85
+ | Stack trace and context | Cross-language telemetry and managed ingestion | Deep failure-time Ruby/Rails state inside the application boundary |
86
+ | Lightweight and local | Strong distributed and frontend observability | Rails-specific operational investigation and storm-safe local capture |
87
+
88
+ That makes RED a self-hosted Sentry alternative for teams that want Rails-specific depth and need error data to stay inside the application boundary — not a replacement for cross-language telemetry. RED has no mobile SDKs, no merge/split, no MCP server and no hosted operations.
89
+
90
+ ## Choose how you run it
91
+
92
+ - Store data in the application's existing PostgreSQL, MySQL/Trilogy or SQLite database.
93
+ - Isolate monitoring writes in a separate error database.
94
+ - Use synchronous writes, or async logging through Sidekiq or Solid Queue (GoodJob is detected for job-health stats but is not an async adapter).
95
+ - Track several Rails applications through a shared database.
96
+
97
+ No RED licence or event-ingestion fee, and no plan limits — your database is the only cap, and storm protection deliberately sheds context during floods.
98
+
99
+ ---
27
100
 
28
101
  ### Screenshots
29
102
 
30
- **Dashboard Overview** — Real-time error stats, severity breakdown, and trend charts.
103
+ **Dashboard Overview** — Live error stats, severity breakdown, and trend charts.
31
104
 
32
105
  ![Dashboard Overview](docs/images/dashboard-overview.png)
33
106
 
@@ -49,25 +122,12 @@ gem 'rails_error_dashboard'
49
122
 
50
123
  ---
51
124
 
52
- ## Who This Is For
125
+ ## Safety, performance and compatibility
53
126
 
54
- - **Solo bootstrappers** who need professional error tracking without recurring costs
55
- - **Indie SaaS founders** building profitable apps on tight budgets
56
- - **Small dev teams** (2-5 people) who hate SaaS bloat
57
- - **Privacy-conscious apps** that need to keep error data on their own servers
58
- - **Side projects** that might become real businesses
59
-
60
- ## What It Replaces
61
-
62
- | Before | After |
63
- |--------|-------|
64
- | $29-99/month for error monitoring | $0/month — runs on your existing Rails server |
65
- | Sensitive error data sent to third parties | All data stays on your infrastructure |
66
- | SaaS pricing tiers and usage limits | Unlimited errors, unlimited projects |
67
- | Vendor lock-in with proprietary APIs | 100% open source, fully portable |
68
- | Complex SDK setup and external services | 5-minute Rails Engine installation |
69
- | Pay extra for local variable capture (Sentry) | Local + instance variables included free |
70
- | No tool detects silently rescued exceptions | Swallowed exception detection built in |
127
+ - **Host-app safety** nothing in the capture path raises into your app; every subscriber and callback is rescue-wrapped, `Thread.current` is cleaned up in `ensure`, and the original exception is always re-raised. Variables, health and breadcrumbs are opt-in and off by default; storm protection is on by default and fails open.
128
+ - **Performance** the storm-protection hot path is a digest plus an atomic increment with no I/O; the figures quoted below are a maintainer's single-machine measurements and no benchmark script ships with the gem yet.
129
+ - **Security** HTTP Basic Auth or your own `authenticate_with` lambda (Devise, Warden, session); your Rails `filter_parameters` are applied to params, variables and breadcrumbs; prompts are never recorded by LLM observability. Vulnerability reports: [SECURITY.md](SECURITY.md).
130
+ - **Compatibility** — Rails 7.0–8.1, Ruby 3.2–4.0, PostgreSQL, MySQL/Trilogy or SQLite; `turbo-rails` plus ActionCable are needed for live updates (no polling fallback); the gem's own CSS/JS is inline but Bootstrap JS, Chart.js, highlight.js and Google Fonts load from CDNs, so it is not air-gap clean.
71
131
 
72
132
  ---
73
133
 
@@ -75,14 +135,14 @@ gem 'rails_error_dashboard'
75
135
 
76
136
  ### Core (Always Enabled)
77
137
 
78
- Error capture from controllers, jobs, and middleware. Custom-designed dashboard with dark/light mode, search, filtering, and real-time updates. Analytics with trend charts, severity breakdown, and spike detection. Workflow management with assignment, priority, snooze, mute/unmute (notification suppression), comments, and batch operations. Security via HTTP Basic Auth or custom lambda (Devise, Warden, session-based). Exception cause chains, enriched HTTP context, custom fingerprinting, CurrentAttributes integration, auto-reopen on recurrence, and sensitive data filtering — all built in.
138
+ Error capture from controllers, jobs, and middleware. Custom-designed dashboard with dark/light mode, search, filtering, and real-time updates (the latter with `turbo-rails` + ActionCable in the host). Analytics with trend charts, severity breakdown, and spike detection. Workflow management with assignment, priority, snooze, mute/unmute (notification suppression), comments, and batch operations. Security via HTTP Basic Auth or custom lambda (Devise, Warden, session-based). Exception cause chains, enriched HTTP context, custom fingerprinting, CurrentAttributes integration, auto-reopen on recurrence, and sensitive data filtering — all built in.
79
139
 
80
140
  ### Optional Features
81
141
 
82
142
  <details>
83
143
  <summary><strong>Storm Protection — Circuit Breaker + Adaptive Sampling</strong></summary>
84
144
 
85
- When the error rate spikes (a bad deploy throwing thousands of errors a minute), the nightmare scenario for any in-process tracker is amplifying the outage with its own database writes. Storm protection makes the gem **provably degrade itself first** — ON by default.
145
+ When the error rate spikes (a bad deploy throwing thousands of errors a minute), the nightmare scenario for any in-process tracker is amplifying the outage with its own database writes. Storm protection is designed to **shed the gem's own expensive work first** — ON by default. The behaviour is measured (see Overhead below), though a bundled, reproducible benchmark is still to come.
86
146
 
87
147
  - **Per-fingerprint caps:** past N occurrences/minute per error, context is shed, then rows are sampled deterministically (a fresh exemplar is always kept each minute)
88
148
  - **Global circuit breaker:** sustained floods flip the gem to count-only mode — zero per-event I/O, exact in-memory counts reconciled onto error records every 30s. Async mode is gated too (a SolidQueue enqueue is itself a DB write)
@@ -98,7 +158,7 @@ config.storm_open_threshold_per_second = 50 # per process
98
158
 
99
159
  All thresholds are per process and individually configurable. Disable with one flag.
100
160
 
101
- **Measured overhead** (Apple Silicon, Ruby 4.0): 2.4µs/error with protection active and calm, 2.95µs in count-only mode, 0.2µs when disabled against a 5µs budget. The check is a digest plus an atomic increment; there is no I/O on the hot path.
161
+ **Overhead:** the check is a digest plus an atomic increment; there is no I/O on the hot path. The maintainer's single-machine measurement (Apple Silicon, Ruby 4.0) was 2.4µs/error with protection active and calm, 2.95µs in count-only mode and 0.2µs when disabled, against a 5µs budget a reproducible benchmark script is not yet part of the gem.
102
162
  </details>
103
163
 
104
164
  <details>
@@ -108,7 +168,7 @@ See exactly what happened before the crash — SQL queries, controller actions,
108
168
 
109
169
  - Automatic capture — zero config beyond the enable flag
110
170
  - N+1 query detection with aggregate patterns page
111
- - Deprecation warnings with aggregate view
171
+ - Deprecation warnings with aggregate view (needs the host's deprecation behaviour to include `:notify`; only requests that later raised are seen)
112
172
  - Custom breadcrumbs via `RailsErrorDashboard.add_breadcrumb("checkout started", { cart_id: 123 })`
113
173
  - Safe by design — fixed-size ring buffer, thread-local, every subscriber wrapped in rescue
114
174
 
@@ -146,7 +206,7 @@ Cross-error N+1 detection grouped by SQL fingerprint, and aggregate deprecation
146
206
 
147
207
  ![N+1 Query Patterns](docs/images/n-plus-one-queries.png)
148
208
 
149
- Requires breadcrumbs to be enabled.
209
+ Requires breadcrumbs to be enabled. Deprecations are seen only when the host's `ActiveSupport::Deprecation` behaviour includes `:notify` (the production default does not) and only inside requests that later raised.
150
210
 
151
211
  [Complete documentation →](docs/FEATURES.md#n1-query-detection)
152
212
  </details>
@@ -154,7 +214,7 @@ Requires breadcrumbs to be enabled.
154
214
  <details>
155
215
  <summary><strong>Operational Health Panels — Jobs, Database, Cache, ActionCable</strong></summary>
156
216
 
157
- **Job Health** — Auto-detects Sidekiq, SolidQueue, or GoodJob. Per-error table with adapter badge, failed count (color-coded), sorted worst-first.
217
+ **Job Health** — Aggregates the queue stats captured on each error (Sidekiq, SolidQueue or GoodJob auto-detected; needs `enable_system_health`). Not a live queue view — a per-error table with adapter badge, failed count (color-coded), sorted worst-first.
158
218
 
159
219
  ![Job Health](docs/images/job-health.png)
160
220
 
@@ -184,12 +244,12 @@ config.enable_activestorage_tracking = true # requires enable_breadcrumbs = tru
184
244
  <details>
185
245
  <summary><strong>LLM Observability — Calls, Tokens, Cost, Tool Use</strong></summary>
186
246
 
187
- Capture every LLM call your app makes model, latency, token counts, estimated USD cost, and tool-use requests — as breadcrumbs on the error that follows. When a request crashes, you see the chat completion that preceded it: which model was called, how long it took, what it cost, and which tools it asked to invoke.
247
+ Capture your app's LLM calls through a Faraday middleware, OpenTelemetry GenAI spans or a manual notification; nothing is auto-instrumented — as breadcrumbs on the error that follows, with model, latency, token counts, estimated USD cost and tool-use requests. When a request crashes, you see the chat completion that preceded it: which model was called, how long it took, what it cost, and which tools it asked to invoke.
188
248
 
189
249
  - Three capture paths — pick whichever matches your stack
190
250
  - Cost estimated from a built-in pricing table (Claude 4.x, GPT-4o/o1, Gemini 2.5) — override per-model via `config.llm_pricing_overrides`
191
251
  - Tool-call requests summarized inline; tool *execution* spans captured separately via the OTel path
192
- - Content capture (prompts/completions) **OFF by default** — only token counts and metadata are recorded
252
+ - Prompts and completions are **never recorded** — only token counts and metadata (the `llm_observability_content_capture` flag is reserved and currently a no-op)
193
253
  - Same host-app safety guarantees as the rest of the gem — never raises, never blocks the request, every callback rescue-wrapped
194
254
 
195
255
  ```ruby
@@ -343,7 +403,7 @@ config.enable_git_blame = true
343
403
  <details>
344
404
  <summary><strong>Code Path Coverage (Diagnostic Mode)</strong></summary>
345
405
 
346
- Enable coverage via a dashboard button to see which production code paths were executed. Source code viewer overlays green checkmarks on executed lines and gray dots on unexecuted lines. Uses Ruby's `Coverage.setup(oneshot_lines: true)` — near-zero overhead, each line fires once. Zero overhead when off.
406
+ Enable coverage via a dashboard button to see which production code paths were executed. Source code viewer overlays green checkmarks on executed lines and gray dots on unexecuted lines. Uses Ruby's `Coverage.setup(oneshot_lines: true)` — near-zero overhead, each line fires once. Zero overhead when off. Diagnostic mode only: coverage is process-global (a multi-threaded Puma blends requests), held in memory and not persisted. No error tracker integrates this; Coverband does it standalone with persistence.
347
407
 
348
408
  ```ruby
349
409
  config.enable_coverage_tracking = true # shows Enable/Disable buttons on error detail page
@@ -372,7 +432,7 @@ config.llm_model = "gpt-5"
372
432
  <details>
373
433
  <summary><strong>Notifications — Slack, Discord, PagerDuty, Email, Webhooks</strong></summary>
374
434
 
375
- Multi-channel alerting with severity filters, per-error cooldown, and milestone threshold alerts to prevent alert fatigue.
435
+ Multi-channel alerting with severity filters, per-error cooldown, milestone threshold alerts, and a per-environment allowlist (`config.notification_environments = %w[production]`) so a staging deploy never pages anyone.
376
436
 
377
437
  ```ruby
378
438
  config.enable_slack_notifications = true
@@ -382,6 +442,19 @@ config.slack_webhook_url = ENV['SLACK_WEBHOOK_URL']
382
442
  [Notification setup guide →](docs/guides/NOTIFICATIONS.md)
383
443
  </details>
384
444
 
445
+ <details>
446
+ <summary><strong>Environment Awareness — Filter, Badge, Notify per Environment</strong></summary>
447
+
448
+ Every error records the environment it came from — `production`, `staging`, `uat`, `preprod`, any name your deploys use. The errors index filters by it, rows and the detail page carry a badge, the analytics page breaks errors down by environment, and every notification names it. The same error in staging and production is two rows with independent status, so resolving one never hides the other.
449
+
450
+ ```ruby
451
+ config.environment = ENV.fetch("ERROR_DASHBOARD_ENVIRONMENT", Rails.env) # free-form, defaults to Rails.env
452
+ config.notification_environments = %w[production] # nil = notify everywhere
453
+ ```
454
+
455
+ Errors captured before v0.11.0 show no badge until they recur (the next occurrence claims the row) or you run `rails rails_error_dashboard:backfill_environments`.
456
+ </details>
457
+
385
458
  <details>
386
459
  <summary><strong>Advanced Analytics</strong></summary>
387
460
 
@@ -392,9 +465,9 @@ Seven analysis engines built in:
392
465
  1. **Baseline Anomaly Alerts** — Statistical spike detection (mean + std dev) with intelligent cooldown
393
466
  2. **Fuzzy Error Matching** — Jaccard similarity + Levenshtein distance to find related errors
394
467
  3. **Co-occurring Errors** — Detect errors that happen together within configurable time windows
395
- 4. **Error Cascade Detection** — Identify chains (A causes B causes C) with probability and delays
468
+ 4. **Error Cascade Detection** — Identify potential cascades (A is followed by B is followed by C) with probability and delays — temporal association, not proven causation
396
469
  5. **Error Correlation Analysis** — Correlate errors with app versions, git commits, and users
397
- 6. **Platform Comparison** — iOS vs Android vs Web health metrics side-by-side
470
+ 6. **Platform Comparison** — iOS vs Android vs API health metrics side-by-side
398
471
  7. **Occurrence Pattern Detection** — Cyclical patterns (business hours, weekends) and burst detection
399
472
 
400
473
  [Complete documentation →](docs/FEATURES.md#advanced-analytics-features)
@@ -424,7 +497,7 @@ config.enable_instance_variables = true
424
497
  <details>
425
498
  <summary><strong>Swallowed Exception Detection</strong></summary>
426
499
 
427
- Detect exceptions that are raised but silently rescued — the hardest bugs to find. No other error tracker does this.
500
+ Detect exceptions that are raised but silently rescued — the hardest bugs to find. Only Datadog's paid APM detects rescued exceptions (Ruby 3.3+, and only inside a traced request); RED does it free, without an APM span, and aggregates the raise-vs-rescue ratio per location — no other tracker does that.
428
501
 
429
502
  - Uses TracePoint(`:raise`) + TracePoint(`:rescue`) to track exception lifecycle
430
503
  - Identifies code paths where exceptions are caught but never logged or re-raised
@@ -465,7 +538,8 @@ config.enable_diagnostic_dump = true
465
538
  Track Rack Attack security events (throttles, blocklists, tracks) as breadcrumbs attached to errors, with a dedicated summary page.
466
539
 
467
540
  - Captures throttle, blocklist, and track events automatically
468
- - Dashboard page at `/errors/rack_attack_summary` with event breakdown
541
+ - Dashboard page at `/errors/rack_attack_summary` with event breakdown and per-rule stats — rack-attack ships no UI of its own
542
+ - Classifies AI-agent user agents (GPTBot, ClaudeBot, …) on `track` events
469
543
  - Requires breadcrumbs to be enabled
470
544
 
471
545
  ```ruby
@@ -483,7 +557,7 @@ Capture unhandled exceptions that crash the Ruby process via an `at_exit` hook
483
557
  - Disk-based fallback: writes crash data to disk because the database may be unavailable during shutdown
484
558
  - Imported automatically on next boot
485
559
  - Captures exception details, backtrace, uptime, GC stats, thread count, and cause chain
486
- - A self-hosted only feature impossible for SaaS tools
560
+ - Honeybadger, Bugsnag and AppSignal have `at_exit` reporters too; RED's writes to disk and imports at next boot because the database may already be gone during shutdown
487
561
 
488
562
  ```ruby
489
563
  config.enable_crash_capture = true
@@ -604,7 +678,7 @@ end
604
678
 
605
679
  ## Languages
606
680
 
607
- The dashboard, its emails and its notification payloads are translated. Eleven locales ship:
681
+ RED ships in English with machine-translated previews for ten additional languages, covering the dashboard, its emails and its notification payloads. Native-speaking Rails developers are invited to review and improve them; once a locale has been reviewed it will be marked individually as community-reviewed. Eleven locales ship:
608
682
 
609
683
  | Locale | Language | Status |
610
684
  |---|---|---|
@@ -648,13 +722,13 @@ SQLite, PostgreSQL, and MySQL/Trilogy — in either shared or separate-database
648
722
  Yes. It runs entirely inside your own Rails process — no external services, no SDK calling out, no per-event pricing. Error data never leaves your infrastructure.
649
723
 
650
724
  **Does it capture local variables like Sentry?**
651
- Yes — local **and** instance variables at the moment the exception is raised, via `TracePoint(:raise)`, with sensitive-data filtering and configurable limits. This is opt-in and a capability Sentry charges extra for.
725
+ Yes — local **and** instance variables at the moment the exception is raised, via `TracePoint(:raise)`, with sensitive-data filtering and configurable limits. It is opt-in. (Sentry's SDK can also capture locals as an opt-in option; RED adds instance variables and applies your Rails `filter_parameters` automatically.)
652
726
 
653
727
  **Will a flood of errors take down my app?**
654
- No. Storm protection (a circuit breaker with adaptive sampling, **ON by default**) makes the gem degrade itself first during error floods — occurrence counts stay exact while it sheds the expensive work. Measured hot-path overhead is ~2.4µs/error.
728
+ No. Storm protection (a circuit breaker with adaptive sampling, **ON by default**) makes the gem degrade itself first during error floods — occurrence counts stay exact while it sheds the expensive work, and a Storm History page shows exactly what was shed. There is no I/O on the hot path the check is a digest and an atomic increment.
655
729
 
656
730
  **Does it work with my background jobs?**
657
- Yes — it auto-detects and supports Sidekiq, SolidQueue, and GoodJob, and can log errors asynchronously through any of them.
731
+ Yes — errors raised in jobs are captured, and it can log errors asynchronously through Sidekiq or SolidQueue (or the in-process `:async` adapter). Sidekiq, SolidQueue and GoodJob are all auto-detected for the job-queue stats stored on each error.
658
732
 
659
733
  **Does it work with my authentication?**
660
734
  Yes — HTTP Basic Auth out of the box, or a custom `authenticate_with` lambda that integrates with Devise, Warden, or session-based auth.
@@ -681,7 +755,7 @@ Rails 7.0–8.1 and Ruby 3.2–4.0.
681
755
  - **[Batch Operations](docs/guides/BATCH_OPERATIONS.md)** — Bulk resolve/delete
682
756
  - **[Real-Time Updates](docs/guides/REAL_TIME_UPDATES.md)** — Live dashboard
683
757
  - **[Error Trends](docs/guides/ERROR_TREND_VISUALIZATIONS.md)** — Charts and analytics
684
- - **[Translations](docs/guides/TRANSLATIONS.md)** — Seven shipped locales, correcting a string, adding a language
758
+ - **[Translations](docs/guides/TRANSLATIONS.md)** — Eleven shipped locales, correcting a string, adding a language
685
759
 
686
760
  ### Advanced
687
761
  - **[Multi-App Support](docs/MULTI_APP_PERFORMANCE.md)** — Track multiple applications
@@ -690,7 +764,7 @@ Rails 7.0–8.1 and Ruby 3.2–4.0.
690
764
  - **[Customization](docs/CUSTOMIZATION.md)** — Customize everything
691
765
  - **[Database Options](docs/guides/DATABASE_OPTIONS.md)** — Separate database setup
692
766
  - **[Database Optimization](docs/guides/DATABASE_OPTIMIZATION.md)** — Performance tuning
693
- - **[Mobile App Integration](docs/guides/MOBILE_APP_INTEGRATION.md)** — React Native, Flutter, etc.
767
+ - **[Mobile App Integration](docs/guides/MOBILE_APP_INTEGRATION.md)** — log mobile-originated errors through your own API endpoint, tagged by platform
694
768
  - **[FAQ](docs/FAQ.md)** — Common questions answered
695
769
 
696
770
  [View all documentation →](docs/README.md)
@@ -709,7 +783,7 @@ Built with **CQRS (Command/Query Responsibility Segregation)**:
709
783
 
710
784
  ## Testing
711
785
 
712
- 2,600+ tests covering unit, integration, and browser-based system tests.
786
+ An RSpec suite of unit, request and browser-based system specs runs in CI on every supported Rails version (see the Tests badge above); the current count lives in the CI log rather than here, where it would go stale.
713
787
 
714
788
  ```bash
715
789
  bundle exec rspec # Full suite
@@ -11,6 +11,7 @@ module RailsErrorDashboard
11
11
  error_type
12
12
  unresolved
13
13
  platform
14
+ environment
14
15
  application_id
15
16
  search
16
17
  severity
@@ -94,6 +95,7 @@ module RailsErrorDashboard
94
95
  filter_options = Queries::FilterOptions.call(application_id: @current_application_id)
95
96
  @error_types = filter_options[:error_types]
96
97
  @platforms = filter_options[:platforms]
98
+ @environments = filter_options[:environments]
97
99
  @assignees = filter_options[:assignees]
98
100
  end
99
101
 
@@ -242,6 +244,7 @@ module RailsErrorDashboard
242
244
  @errors_over_time = analytics[:errors_over_time]
243
245
  @errors_by_type = analytics[:errors_by_type]
244
246
  @errors_by_platform = analytics[:errors_by_platform]
247
+ @errors_by_environment = analytics[:errors_by_environment]
245
248
  @errors_by_hour = analytics[:errors_by_hour]
246
249
  @top_users = analytics[:top_users]
247
250
  @resolution_rate = analytics[:resolution_rate]
@@ -20,12 +20,19 @@ module RailsErrorDashboard
20
20
  # The subject is assembled from a key rather than interpolated inline, so a
21
21
  # locale can reorder or drop the emoji. error_type and message are verbatim
22
22
  # error content and are never translated.
23
+ #
24
+ # An error that carries an environment uses subject_with_environment; a
25
+ # legacy row keeps the pre-0.11 subject so existing mail filters still match.
23
26
  def subject_for(error_log, locale)
27
+ environment = error_log.respond_to?(:environment) ? error_log.environment : nil
28
+ key = environment.present? ? "red.mailers.error_alert.subject_with_environment" : "red.mailers.error_alert.subject"
29
+
24
30
  I18nStore.translate(
25
- "red.mailers.error_alert.subject",
31
+ key,
26
32
  locale: locale,
27
33
  application: error_log.application&.name ||
28
34
  I18nStore.translate("red.mailers.shared.unknown_application", locale: locale),
35
+ environment: environment,
29
36
  error_type: error_log.error_type,
30
37
  message: truncate_subject(error_log.message)
31
38
  )
@@ -61,6 +61,7 @@ module RailsErrorDashboard
61
61
  scope :by_error_type, ->(type) { where(error_type: type) }
62
62
  scope :by_type, ->(type) { where(error_type: type) }
63
63
  scope :by_platform, ->(platform) { where(platform: platform) }
64
+ scope :by_environment, ->(environment) { where(environment: environment) }
64
65
  scope :last_24_hours, -> { where("occurred_at >= ?", 24.hours.ago) }
65
66
  scope :last_week, -> { where("occurred_at >= ?", 1.week.ago) }
66
67
 
@@ -660,7 +660,16 @@ dd { color: var(--text-primary); }
660
660
  height: 48px; flex-shrink: 0;
661
661
  background: var(--surface-primary);
662
662
  border-bottom: 1px solid var(--border-primary);
663
+ /* A flex item's default min-width is auto, meaning "never shrink below my
664
+ content". Both navbar groups therefore kept their full intrinsic width on a
665
+ narrow screen and pushed the right-hand controls past the viewport — the
666
+ dashboard scrolled sideways at 375px in EVERY locale, English included.
667
+ min-width:0 lets them shrink; overflow:hidden keeps whatever still cannot
668
+ fit from extending the page rather than being clipped inside the bar. */
669
+ min-width: 0;
670
+ overflow: hidden;
663
671
  }
672
+ .red-navbar > * { min-width: 0; }
664
673
  .red-search-input {
665
674
  width: 240px; padding: 5px 10px 5px 30px; font-size: 13px;
666
675
  border: 1px solid var(--border-primary); border-radius: var(--radius-sm);
@@ -720,6 +729,21 @@ dd { color: var(--text-primary); }
720
729
  .red-sidebar.collapsed { display: none; }
721
730
  }
722
731
 
732
+ /* Stat grids
733
+ These were inline `grid-template-columns: repeat(N, 1fr)`, which cannot carry
734
+ a media query, so three or four cards stayed side by side at 375px and pushed
735
+ the page to 488px — it scrolled sideways on a phone in EVERY locale, English
736
+ worst of all. auto-fit + minmax lets the row reflow to two columns and then
737
+ one, and minmax's 0 floor stops a long word forcing a track wider than the
738
+ screen. */
739
+ .red-stat-grid {
740
+ display: grid;
741
+ grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
742
+ gap: var(--space-4);
743
+ margin-bottom: var(--space-6);
744
+ }
745
+ .red-stat-grid > * { min-width: 0; }
746
+
723
747
  /* Error detail responsive grid */
724
748
  .red-detail-grid { display: grid; grid-template-columns: 1fr 260px; gap: var(--space-4); }
725
749
  @media (max-width: 1023.98px) { .red-detail-grid { grid-template-columns: 1fr; } }
@@ -729,7 +753,13 @@ dd { color: var(--text-primary); }
729
753
  padding: 3px 10px; font-size: 11px; font-weight: 600;
730
754
  border-radius: var(--radius-full);
731
755
  text-transform: uppercase; letter-spacing: 0.05em;
756
+ white-space: nowrap;
732
757
  }
758
+ /* Below the sidebar breakpoint the navbar has to fit a menu button, search,
759
+ language picker, shortcuts, theme toggle and the env badge. The badge is
760
+ decoration — the environment is also on the overview page — so it is the
761
+ first thing dropped rather than letting it push the page sideways. */
762
+ @media (max-width: 575.98px) { .red-env-badge { display: none; } }
733
763
 
734
764
  /* Kbd shortcut */
735
765
  kbd {
@@ -148,6 +148,11 @@
148
148
  </div>
149
149
  <% end %>
150
150
 
151
+ <% if @error_log.respond_to?(:environment) && @error_log.environment.present? %>
152
+ <div class="label"><%= red_t("red.mailers.error_alert.labels.environment") %>:</div>
153
+ <div class="value"><strong><%= @error_log.environment %></strong></div>
154
+ <% end %>
155
+
151
156
  <div class="label"><%= red_t("red.mailers.error_alert.labels.occurred_at") %>:</div>
152
157
  <div class="value"><%= red_mail_time(@error_log.occurred_at) %></div>
153
158
 
@@ -10,6 +10,7 @@
10
10
  <%= red_mail_t("red.mailers.error_alert.labels.application") %>: <%= @error_log.application&.name || red_mail_t("red.mailers.shared.unknown_application") %>
11
11
  <%= red_mail_t("red.mailers.error_alert.labels.error_type") %>: <%= @error_log.error_type %>
12
12
  <% if @error_log.platform.present? %><%= red_mail_t("red.mailers.error_alert.labels.platform") %>: <%= @error_log.platform %><% end %>
13
+ <% if @error_log.respond_to?(:environment) && @error_log.environment.present? %><%= red_mail_t("red.mailers.error_alert.labels.environment") %>: <%= @error_log.environment %><% end %>
13
14
  <%= red_mail_t("red.mailers.error_alert.labels.occurred_at") %>: <%= red_mail_time(@error_log.occurred_at) %>
14
15
  <% if @error_log.user_id.present? %><%= red_mail_t("red.mailers.error_alert.labels.user_id") %>: <%= @error_log.user_id %><% end %>
15
16
  <% if @error_log.ip_address.present? %><%= red_mail_t("red.mailers.error_alert.labels.ip_address") %>: <%= @error_log.ip_address %><% end %>
@@ -99,4 +99,12 @@
99
99
  <% end %>
100
100
  </td>
101
101
  <% end %>
102
+ <% if local_assigns[:show_environment] %>
103
+ <%# Environment names are free-form data (production, staging, uat...) and are never translated. %>
104
+ <td style="padding: var(--space-3) var(--space-4); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="<%= error.environment %>">
105
+ <% if error.environment.present? %>
106
+ <span class="badge badge-api" data-environment-badge="<%= error.environment %>"><i class="bi bi-hdd-stack"></i> <%= error.environment %></span>
107
+ <% end %>
108
+ </td>
109
+ <% end %>
102
110
  </tr>
@@ -70,6 +70,15 @@
70
70
  <% end %>
71
71
  </div>
72
72
 
73
+ <% if error.respond_to?(:environment) && error.environment.present? %>
74
+ <div class="mb-3">
75
+ <small class="metadata-label d-block mb-1"><%= red_t("red.errors.sidebar.environment") %></small>
76
+ <%= link_to errors_path(environment: error.environment, unresolved: '0'), class: "text-decoration-none", title: red_t("red.errors.sidebar.view_all_environment", environment: error.environment) do %>
77
+ <span class="badge badge-api" data-environment-badge="<%= error.environment %>"><i class="bi bi-hdd-stack"></i> <%= error.environment %></span>
78
+ <% end %>
79
+ </div>
80
+ <% end %>
81
+
73
82
  <div class="mb-3">
74
83
  <small class="metadata-label d-block mb-1"><%= red_t("red.errors.sidebar.user") %></small>
75
84
  <% if error.respond_to?(:user) && error.user %>
@@ -45,7 +45,7 @@
45
45
  </div>
46
46
 
47
47
  <!-- Summary Stats -->
48
- <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); margin-bottom: var(--space-6);">
48
+ <div class="red-stat-grid">
49
49
  <div class="card stat-card" style="border-left: 3px solid var(--accent); padding: var(--space-5) var(--space-6);">
50
50
  <div class="stat-label" style="margin-bottom: 4px;"><%= red_t("red.analytics.overview.stats.total_errors") %></div>
51
51
  <span class="stat-value"><%= @error_stats[:total] %></span>
@@ -155,6 +155,56 @@
155
155
  </div>
156
156
  <% end %>
157
157
 
158
+ <%# Errors by environment: same shape as the platform chart, shown only when
159
+ there is something to compare. Environment names are data, not text to
160
+ translate; NULL rows (captured before the column existed) are :unknown. %>
161
+ <% environment_counts = (@errors_by_environment || {}).transform_keys { |env| env == :unknown ? red_t("red.analytics.overview.environment_chart.unknown") : env } %>
162
+ <% if environment_counts.size > 1 %>
163
+ <div class="row g-4 mb-4">
164
+ <div class="col-md-12">
165
+ <div class="card">
166
+ <div class="card-header">
167
+ <h5 class="mb-0"><i class="bi bi-hdd-stack"></i> <%= red_t("red.analytics.overview.environment_chart.title") %></h5>
168
+ </div>
169
+ <div class="card-body">
170
+ <div id="errors-by-environment-chart"></div>
171
+ <script<%= " nonce=\"#{red_csp_nonce}\"".html_safe if red_csp_nonce %>>
172
+ document.addEventListener('DOMContentLoaded', function() {
173
+ const colors = window.getChartColors();
174
+ const environmentColors = ["#EF4444", "#F59E0B", "#2563EB", "#10B981", "#8B5CF6", "#6B7280"];
175
+
176
+ new Chartkick.PieChart("errors-by-environment-chart", <%= raw environment_counts.to_json %>, {
177
+ colors: environmentColors,
178
+ height: "300px",
179
+ legend: "bottom",
180
+ donut: true,
181
+ library: {
182
+ plugins: {
183
+ legend: {
184
+ labels: {
185
+ color: colors.textColor,
186
+ font: { size: 12, weight: 'bold' }
187
+ }
188
+ }
189
+ }
190
+ }
191
+ });
192
+ });
193
+ </script>
194
+ </div>
195
+ <div class="card-footer border-top-0">
196
+ <div class="d-flex gap-2 flex-wrap">
197
+ <small class="text-muted me-2"><%= red_t("red.analytics.overview.environment_chart.quick_links") %></small>
198
+ <% (@errors_by_environment || {}).keys.reject { |env| env == :unknown }.each do |environment| %>
199
+ <%= link_to environment, errors_path(environment: environment, unresolved: '0'), class: "btn btn-sm btn-outline-secondary" %>
200
+ <% end %>
201
+ </div>
202
+ </div>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ <% end %>
207
+
158
208
  <!-- Charts Row 2 -->
159
209
  <div class="row g-4 mb-4">
160
210
  <div class="col-md-12">
@@ -91,7 +91,7 @@
91
91
  <% if @live_health[:postgresql] %>
92
92
  <%# Summary cards for PostgreSQL %>
93
93
  <% total_db_size = (@host_tables + @gem_tables).sum { |t| t[:total_bytes] } %>
94
- <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-4); margin-bottom: var(--space-4);">
94
+ <div class="red-stat-grid" style="margin-bottom: var(--space-4);">
95
95
  <div class="card stat-card" style="padding: var(--space-4) var(--space-5); text-align: center;">
96
96
  <div class="stat-label" style="margin-bottom: 4px;"><%= red_t("red.health.database.summary.total_db_size") %></div>
97
97
  <div class="stat-value"><%= number_to_human_size(total_db_size) %></div>
@@ -157,6 +157,9 @@
157
157
  <% if @platforms.size > 1 %>
158
158
  <%= select_tag :platform, options_for_select([[red_t("red.errors.index.filters.all_platforms"), '']] + @platforms.map { |p| [p, p] }, params[:platform]), class: "form-select", style: "width: auto; min-width: 120px;" %>
159
159
  <% end %>
160
+ <% if @environments.size > 1 %>
161
+ <%= select_tag :environment, options_for_select([[red_t("red.errors.index.filters.all_environments"), '']] + @environments.map { |e| [e, e] }, params[:environment]), class: "form-select", style: "width: auto; min-width: 120px;" %>
162
+ <% end %>
160
163
  <%= select_tag :error_type, options_for_select([[red_t("red.errors.index.filters.all_types"), '']] + @error_types.map { |t| [t, t] }, params[:error_type]), class: "form-select", style: "width: auto; min-width: 120px;" %>
161
164
  <%
162
165
  # Built from the same keys the active chips read, so a label can never
@@ -217,6 +220,7 @@
217
220
  active_filters << { label: red_t("red.errors.index.filters.chips.application", value: app_name), param: :application_id } if app_name
218
221
  end
219
222
  active_filters << { label: red_t("red.errors.index.filters.chips.platform", value: params[:platform]), param: :platform } if params[:platform].present?
223
+ active_filters << { label: red_t("red.errors.index.filters.chips.environment", value: params[:environment]), param: :environment } if params[:environment].present?
220
224
  active_filters << { label: red_t("red.errors.index.filters.chips.error_type", value: params[:error_type]), param: :error_type } if params[:error_type].present?
221
225
  active_filters << { label: red_t("red.errors.index.filters.chips.severity", value: severity_label.call(params[:severity])), param: :severity } if params[:severity].present?
222
226
  active_filters << { label: red_t("red.errors.index.filters.chips.timeframe", value: timeframe_label.call(params[:timeframe])), param: :timeframe } if params[:timeframe].present?
@@ -287,11 +291,14 @@
287
291
  <% if @platforms.size > 1 %>
288
292
  <th style="padding: var(--space-3) var(--space-4); width: 100px;"><%= red_t("red.errors.index.columns.platform") %></th>
289
293
  <% end %>
294
+ <% if @environments.size > 1 %>
295
+ <th style="padding: var(--space-3) var(--space-4); width: 110px;"><%= red_t("red.errors.index.columns.environment") %></th>
296
+ <% end %>
290
297
  </tr>
291
298
  </thead>
292
299
  <tbody id="error_list">
293
300
  <% @errors.each do |error| %>
294
- <%= render "error_row", error: error, show_platform: @platforms.size > 1, show_application: (@applications.size > 1 && params[:application_id].blank?) %>
301
+ <%= render "error_row", error: error, show_platform: @platforms.size > 1, show_environment: @environments.size > 1, show_application: (@applications.size > 1 && params[:application_id].blank?) %>
295
302
  <% end %>
296
303
  </tbody>
297
304
  </table>