findbug 0.3.5 → 0.5.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 +4 -4
- data/README.md +49 -34
- data/app/controllers/findbug/alerts_controller.rb +224 -0
- data/app/models/findbug/alert_channel.rb +167 -0
- data/app/models/findbug/error_event.rb +34 -6
- data/app/models/findbug/performance_event.rb +38 -16
- data/app/views/findbug/alerts/edit.html.erb +133 -0
- data/app/views/findbug/alerts/index.html.erb +127 -0
- data/app/views/findbug/alerts/new.html.erb +154 -0
- data/app/views/layouts/findbug/application.html.erb +56 -1
- data/docs/docs.html +976 -0
- data/docs/index.html +816 -22
- data/lib/findbug/adapter_helper.rb +74 -0
- data/lib/findbug/alerts/dispatcher.rb +18 -32
- data/lib/findbug/configuration.rb +17 -56
- data/lib/findbug/engine.rb +9 -0
- data/lib/findbug/railtie.rb +8 -0
- data/lib/findbug/version.rb +1 -1
- data/lib/findbug.rb +1 -0
- data/lib/generators/findbug/install_generator.rb +6 -0
- data/lib/generators/findbug/templates/POST_INSTALL +5 -8
- data/lib/generators/findbug/templates/create_findbug_alert_channels.rb +24 -0
- data/lib/generators/findbug/templates/create_findbug_error_events.rb +3 -3
- data/lib/generators/findbug/templates/create_findbug_performance_events.rb +3 -3
- data/lib/generators/findbug/templates/initializer.rb +3 -26
- data/lib/generators/findbug/upgrade_generator.rb +79 -0
- metadata +15 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc983a190a0ac08221e7bbaf108e52d55ecc20a28712f5bdf380acf61a27e43d
|
|
4
|
+
data.tar.gz: 77bc4565c0686c4d60646b4e7d9dc65f4faae482c83d8df6b98a4c02f540c162
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c6d0d26042c62b4ea65bd5f574e6c43d588fdfa83914b416a44638dae85c97d3fa12d74d4ffa6bb08b8575d96daa11ed87aeaefc314ee319353cb4acf961a0d6
|
|
7
|
+
data.tar.gz: 3d8ef1a2aece7c33efb7b629aa5b19013fe96da2b6ec3ec741eba8447b2fa80b36a5c3b6f1be926c4f19cda0ff44388eebc0a6eede1ce42d4e5fe199b448a5b2
|
data/README.md
CHANGED
|
@@ -8,16 +8,20 @@
|
|
|
8
8
|
|
|
9
9
|
FindBug provides Sentry-like functionality with all data stored on your own infrastructure using Redis and your database. Zero external dependencies, full data ownership.
|
|
10
10
|
|
|
11
|
+
📚 **Full documentation:** [findbug.dev/docs](https://findbug.dev/docs)
|
|
12
|
+
|
|
11
13
|
## Features
|
|
12
14
|
|
|
13
15
|
- **Error Tracking** - Capture exceptions with full context, stack traces, and request data
|
|
14
16
|
- **Performance Monitoring** - Track request timing, SQL queries, and automatic N+1 detection
|
|
15
|
-
- **Self-Hosted** - All data stays on your infrastructure (Redis +
|
|
17
|
+
- **Self-Hosted** - All data stays on your infrastructure (Redis + your existing database)
|
|
18
|
+
- **Database-Agnostic** - Works on PostgreSQL, MySQL, and SQLite — adapter detected automatically
|
|
16
19
|
- **Zero Performance Impact** - Async writes via Redis buffer, never blocks your requests
|
|
17
20
|
- **Built-in Dashboard** - Beautiful web UI for viewing errors and performance metrics
|
|
18
21
|
- **Multi-channel Alerts** - Email, Slack, Discord, and custom webhooks
|
|
19
22
|
- **Works Out of the Box** - Built-in background persister, no job scheduler required
|
|
20
23
|
- **Rails 7+ Native** - Designed for modern Rails applications
|
|
24
|
+
- **Tested** - Comprehensive RSpec suite covering every adapter path
|
|
21
25
|
|
|
22
26
|
## Why FindBug?
|
|
23
27
|
|
|
@@ -34,7 +38,7 @@ FindBug provides Sentry-like functionality with all data stored on your own infr
|
|
|
34
38
|
- Ruby 3.1+
|
|
35
39
|
- Rails 7.0+
|
|
36
40
|
- Redis 4.0+
|
|
37
|
-
- PostgreSQL or
|
|
41
|
+
- A relational database — PostgreSQL, MySQL, or SQLite
|
|
38
42
|
|
|
39
43
|
## Installation
|
|
40
44
|
|
|
@@ -142,34 +146,11 @@ Findbug.configure do |config|
|
|
|
142
146
|
# ===================
|
|
143
147
|
# Alerts (Optional)
|
|
144
148
|
# ===================
|
|
149
|
+
# Alert channels (Email, Slack, Discord, Webhook) are configured via the
|
|
150
|
+
# dashboard UI at /findbug/alerts — no code changes needed.
|
|
151
|
+
|
|
145
152
|
config.alerts do |alerts|
|
|
146
153
|
alerts.throttle_period = 5.minutes
|
|
147
|
-
|
|
148
|
-
# Slack
|
|
149
|
-
# alerts.slack(
|
|
150
|
-
# enabled: true,
|
|
151
|
-
# webhook_url: ENV["SLACK_WEBHOOK_URL"],
|
|
152
|
-
# channel: "#errors"
|
|
153
|
-
# )
|
|
154
|
-
|
|
155
|
-
# Email
|
|
156
|
-
# alerts.email(
|
|
157
|
-
# enabled: true,
|
|
158
|
-
# recipients: ["team@example.com"]
|
|
159
|
-
# )
|
|
160
|
-
|
|
161
|
-
# Discord
|
|
162
|
-
# alerts.discord(
|
|
163
|
-
# enabled: true,
|
|
164
|
-
# webhook_url: ENV["DISCORD_WEBHOOK_URL"]
|
|
165
|
-
# )
|
|
166
|
-
|
|
167
|
-
# Custom Webhook
|
|
168
|
-
# alerts.webhook(
|
|
169
|
-
# enabled: true,
|
|
170
|
-
# url: "https://your-service.com/webhook",
|
|
171
|
-
# headers: { "Authorization" => "Bearer token" }
|
|
172
|
-
# )
|
|
173
154
|
end
|
|
174
155
|
end
|
|
175
156
|
```
|
|
@@ -263,7 +244,7 @@ end
|
|
|
263
244
|
│ └────────┬─────────┘ │
|
|
264
245
|
│ │ │
|
|
265
246
|
│ ▼ │
|
|
266
|
-
│ Dashboard ◄──────────────────── Database (PostgreSQL/MySQL)
|
|
247
|
+
│ Dashboard ◄──────────────────── Database (PostgreSQL/MySQL/SQLite) │
|
|
267
248
|
│ (/findbug) │
|
|
268
249
|
│ │
|
|
269
250
|
└─────────────────────────────────────────────────────────────────┘
|
|
@@ -297,11 +278,44 @@ rails findbug:clear_buffers
|
|
|
297
278
|
rails findbug:db:stats
|
|
298
279
|
```
|
|
299
280
|
|
|
300
|
-
##
|
|
281
|
+
## Database Support
|
|
282
|
+
|
|
283
|
+
As of v0.5.0 FindBug is database-agnostic. The install generator's migrations and the model layer detect your `ActiveRecord::Base.connection.adapter_name` at runtime and pick the right column types and SQL functions automatically.
|
|
284
|
+
|
|
285
|
+
| Adapter | JSON column | Time bucketing SQL |
|
|
286
|
+
|--------------------|-------------|------------------------------------|
|
|
287
|
+
| PostgreSQL/PostGIS | `jsonb` | `date_trunc(...)` |
|
|
288
|
+
| MySQL/Mysql2 | `json` | `DATE_FORMAT(...)` / `DATE(...)` |
|
|
289
|
+
| SQLite | `text` (with JSON serialisation in the model) | `strftime(...)` / `DATE(...)` |
|
|
290
|
+
|
|
291
|
+
The JSON accessors on the models normalise reads to a native Ruby `Hash` / `Array` regardless of the underlying column type, so your application code is identical across adapters.
|
|
292
|
+
|
|
293
|
+
If you're writing your own migrations against the same multi-DB strategy, the `Findbug::AdapterHelper` module is part of the public API:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
Findbug::AdapterHelper.json_column_type # :jsonb / :json / :text
|
|
297
|
+
Findbug::AdapterHelper.json_default({}) # adapter-appropriate default
|
|
298
|
+
Findbug::AdapterHelper.date_trunc_sql("hour", "captured_at")
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Multi-Tenant Applications
|
|
302
|
+
|
|
303
|
+
Multi-tenant Rails apps are supported, but the setup depends on *how* your app isolates tenants. The matrix below shows what's possible per adapter:
|
|
304
|
+
|
|
305
|
+
| Adapter | Tenancy model | FindBug support |
|
|
306
|
+
|--------------------------|--------------------------------------------------------------|----------------------------------------------------------------------------------|
|
|
307
|
+
| PostgreSQL | Schema-per-tenant (Apartment's default) | ✅ First-class — keep FindBug tables in the `public` schema (see below). |
|
|
308
|
+
| PostgreSQL/MySQL/SQLite | Row-level (`tenant_id` column) | ✅ Nothing special — FindBug's tables aren't tenant-scoped. |
|
|
309
|
+
| MySQL | Database-per-tenant (Apartment with `use_schemas = false`) | ⚠️ Doable but awkward — point FindBug at a separate connection via `connects_to`. |
|
|
310
|
+
| SQLite | File-per-tenant | ❌ Not practical — use row-level scoping instead. |
|
|
311
|
+
|
|
312
|
+
For the full discussion (including MySQL setup options), see the [Multi-tenant section in the docs](https://findbug.dev/docs#multi-tenant).
|
|
313
|
+
|
|
314
|
+
### PostgreSQL + Apartment (schema-per-tenant)
|
|
301
315
|
|
|
302
|
-
If you're using [ros-apartment](https://github.com/rails-on-services/apartment)
|
|
316
|
+
If you're using [ros-apartment](https://github.com/rails-on-services/apartment) with PostgreSQL schemas, FindBug's tables need to stay in the public schema and the dashboard path should be excluded from tenant switching.
|
|
303
317
|
|
|
304
|
-
|
|
318
|
+
#### 1. Exclude FindBug Models
|
|
305
319
|
|
|
306
320
|
Add FindBug models to the `excluded_models` list in `config/initializers/apartment.rb`:
|
|
307
321
|
|
|
@@ -311,11 +325,12 @@ Apartment.configure do |config|
|
|
|
311
325
|
# Your existing excluded models...
|
|
312
326
|
Findbug::ErrorEvent
|
|
313
327
|
Findbug::PerformanceEvent
|
|
328
|
+
Findbug::AlertChannel
|
|
314
329
|
]
|
|
315
330
|
end
|
|
316
331
|
```
|
|
317
332
|
|
|
318
|
-
|
|
333
|
+
#### 2. Exclude FindBug Dashboard Path
|
|
319
334
|
|
|
320
335
|
Add `/findbug` to your tenant switching middleware's excluded paths:
|
|
321
336
|
|
|
@@ -339,7 +354,7 @@ class SwitchTenantMiddleware < Apartment::Elevators::Generic
|
|
|
339
354
|
end
|
|
340
355
|
```
|
|
341
356
|
|
|
342
|
-
|
|
357
|
+
#### 3. Run Migrations in Public Schema
|
|
343
358
|
|
|
344
359
|
Ensure FindBug migrations run in the public schema:
|
|
345
360
|
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ostruct"
|
|
4
|
+
|
|
5
|
+
module Findbug
|
|
6
|
+
# AlertsController manages alert channel configuration via the dashboard.
|
|
7
|
+
#
|
|
8
|
+
# Users can create, edit, enable/disable, delete, and test alert channels
|
|
9
|
+
# directly from the UI instead of editing the Rails initializer.
|
|
10
|
+
#
|
|
11
|
+
class AlertsController < ApplicationController
|
|
12
|
+
before_action :set_alert_channel, only: [:edit, :update, :destroy, :toggle, :test]
|
|
13
|
+
|
|
14
|
+
# GET /findbug/alerts
|
|
15
|
+
#
|
|
16
|
+
# List all configured alert channels.
|
|
17
|
+
#
|
|
18
|
+
def index
|
|
19
|
+
@channels = Findbug::AlertChannel.order(created_at: :asc)
|
|
20
|
+
@enabled_count = @channels.count(&:enabled?)
|
|
21
|
+
|
|
22
|
+
render template: "findbug/alerts/index", layout: "findbug/application"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# GET /findbug/alerts/new
|
|
26
|
+
#
|
|
27
|
+
# Form to create a new alert channel.
|
|
28
|
+
#
|
|
29
|
+
def new
|
|
30
|
+
@channel = Findbug::AlertChannel.new
|
|
31
|
+
render template: "findbug/alerts/new", layout: "findbug/application"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# POST /findbug/alerts
|
|
35
|
+
#
|
|
36
|
+
# Save a new alert channel.
|
|
37
|
+
#
|
|
38
|
+
def create
|
|
39
|
+
@channel = Findbug::AlertChannel.new(channel_params)
|
|
40
|
+
@channel.config = build_config_from_params
|
|
41
|
+
|
|
42
|
+
if @channel.save
|
|
43
|
+
flash_success "#{@channel.display_type} alert channel created"
|
|
44
|
+
redirect_to findbug.alerts_path
|
|
45
|
+
else
|
|
46
|
+
flash_error @channel.errors.full_messages.join(", ")
|
|
47
|
+
render template: "findbug/alerts/new", layout: "findbug/application", status: :unprocessable_entity
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# GET /findbug/alerts/:id/edit
|
|
52
|
+
#
|
|
53
|
+
# Form to edit an existing alert channel.
|
|
54
|
+
#
|
|
55
|
+
def edit
|
|
56
|
+
render template: "findbug/alerts/edit", layout: "findbug/application"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# PATCH /findbug/alerts/:id
|
|
60
|
+
#
|
|
61
|
+
# Update an existing alert channel.
|
|
62
|
+
#
|
|
63
|
+
def update
|
|
64
|
+
@channel.assign_attributes(channel_params)
|
|
65
|
+
@channel.config = build_config_from_params
|
|
66
|
+
|
|
67
|
+
if @channel.save
|
|
68
|
+
flash_success "#{@channel.display_type} alert channel updated"
|
|
69
|
+
redirect_to findbug.alerts_path
|
|
70
|
+
else
|
|
71
|
+
flash_error @channel.errors.full_messages.join(", ")
|
|
72
|
+
render template: "findbug/alerts/edit", layout: "findbug/application", status: :unprocessable_entity
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# DELETE /findbug/alerts/:id
|
|
77
|
+
#
|
|
78
|
+
# Delete an alert channel.
|
|
79
|
+
#
|
|
80
|
+
def destroy
|
|
81
|
+
name = @channel.name
|
|
82
|
+
@channel.destroy
|
|
83
|
+
flash_success "Alert channel \"#{name}\" deleted"
|
|
84
|
+
redirect_to findbug.alerts_path
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# POST /findbug/alerts/:id/toggle
|
|
88
|
+
#
|
|
89
|
+
# Toggle enable/disable for an alert channel.
|
|
90
|
+
#
|
|
91
|
+
def toggle
|
|
92
|
+
@channel.enabled = !@channel.enabled?
|
|
93
|
+
|
|
94
|
+
if @channel.save
|
|
95
|
+
status = @channel.enabled? ? "enabled" : "disabled"
|
|
96
|
+
flash_success "#{@channel.name} #{status}"
|
|
97
|
+
else
|
|
98
|
+
flash_error @channel.errors.full_messages.join(", ")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
redirect_to findbug.alerts_path
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# POST /findbug/alerts/:id/test
|
|
105
|
+
#
|
|
106
|
+
# Send a test alert to this channel.
|
|
107
|
+
#
|
|
108
|
+
# Creates a synthetic error event (not persisted to DB) and sends it
|
|
109
|
+
# directly to the channel, bypassing throttling.
|
|
110
|
+
#
|
|
111
|
+
def test
|
|
112
|
+
unless @channel.enabled?
|
|
113
|
+
flash_error "Cannot test a disabled channel. Enable it first."
|
|
114
|
+
redirect_to findbug.alerts_path and return
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
error_event = build_test_error_event
|
|
118
|
+
channel_instance = @channel.channel_class.new(@channel.config.symbolize_keys)
|
|
119
|
+
|
|
120
|
+
begin
|
|
121
|
+
channel_instance.send_alert(error_event)
|
|
122
|
+
flash_success "Test alert sent to #{@channel.name} successfully!"
|
|
123
|
+
rescue StandardError => e
|
|
124
|
+
flash_error "Failed to send test alert: #{e.message}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
redirect_to findbug.alerts_path
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def set_alert_channel
|
|
133
|
+
@channel = Findbug::AlertChannel.find(params[:id])
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def channel_params
|
|
137
|
+
params.require(:alert_channel).permit(:name, :channel_type, :enabled)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Build the config hash from channel-type-specific form params
|
|
141
|
+
#
|
|
142
|
+
# Each channel type has different fields. We extract them from
|
|
143
|
+
# params[:config] and build a clean hash.
|
|
144
|
+
#
|
|
145
|
+
def build_config_from_params
|
|
146
|
+
config_params = params[:config] || {}
|
|
147
|
+
channel_type = params.dig(:alert_channel, :channel_type) || @channel&.channel_type
|
|
148
|
+
|
|
149
|
+
case channel_type
|
|
150
|
+
when "email"
|
|
151
|
+
recipients = (config_params[:recipients] || "").split(/[\n,]/).map(&:strip).compact_blank
|
|
152
|
+
{
|
|
153
|
+
"recipients" => recipients,
|
|
154
|
+
"from" => config_params[:from].presence
|
|
155
|
+
}.compact
|
|
156
|
+
when "slack"
|
|
157
|
+
{
|
|
158
|
+
"webhook_url" => config_params[:webhook_url],
|
|
159
|
+
"channel" => config_params[:channel].presence,
|
|
160
|
+
"username" => config_params[:username].presence,
|
|
161
|
+
"icon_emoji" => config_params[:icon_emoji].presence
|
|
162
|
+
}.compact
|
|
163
|
+
when "discord"
|
|
164
|
+
{
|
|
165
|
+
"webhook_url" => config_params[:webhook_url],
|
|
166
|
+
"username" => config_params[:username].presence,
|
|
167
|
+
"avatar_url" => config_params[:avatar_url].presence
|
|
168
|
+
}.compact
|
|
169
|
+
when "webhook"
|
|
170
|
+
headers = parse_headers(config_params[:headers])
|
|
171
|
+
{
|
|
172
|
+
"url" => config_params[:url],
|
|
173
|
+
"method" => config_params[:method].presence || "POST",
|
|
174
|
+
"headers" => headers.presence
|
|
175
|
+
}.compact
|
|
176
|
+
else
|
|
177
|
+
{}
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Parse headers from textarea format ("Key: Value" per line) into a hash
|
|
182
|
+
def parse_headers(raw)
|
|
183
|
+
return {} if raw.blank?
|
|
184
|
+
|
|
185
|
+
raw.split("\n").each_with_object({}) do |line, hash|
|
|
186
|
+
key, value = line.split(":", 2).map(&:strip)
|
|
187
|
+
hash[key] = value if key.present? && value.present?
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Build a synthetic error event for testing alerts
|
|
192
|
+
#
|
|
193
|
+
# Uses OpenStruct to duck-type ErrorEvent without touching the database.
|
|
194
|
+
# Includes all attributes that channel implementations access.
|
|
195
|
+
#
|
|
196
|
+
def build_test_error_event
|
|
197
|
+
now = Time.current
|
|
198
|
+
|
|
199
|
+
OpenStruct.new(
|
|
200
|
+
id: 0,
|
|
201
|
+
fingerprint: "findbug-test-alert-#{now.to_i}",
|
|
202
|
+
exception_class: "Findbug::TestAlert",
|
|
203
|
+
message: "This is a test alert from the Findbug dashboard. If you see this, your alert channel is working correctly!",
|
|
204
|
+
severity: "error",
|
|
205
|
+
status: "unresolved",
|
|
206
|
+
handled: false,
|
|
207
|
+
occurrence_count: 1,
|
|
208
|
+
first_seen_at: now,
|
|
209
|
+
last_seen_at: now,
|
|
210
|
+
environment: Findbug.config.environment || "production",
|
|
211
|
+
release_version: Findbug::VERSION,
|
|
212
|
+
backtrace_lines: [
|
|
213
|
+
"app/controllers/findbug/alerts_controller.rb:42:in `test'",
|
|
214
|
+
"lib/findbug/alerts/dispatcher.rb:57:in `send_alerts'",
|
|
215
|
+
"lib/findbug/alerts/channels/base.rb:34:in `send_alert'"
|
|
216
|
+
],
|
|
217
|
+
context: {},
|
|
218
|
+
user: nil,
|
|
219
|
+
request: { "method" => "POST", "path" => "/findbug/alerts/test" },
|
|
220
|
+
tags: { "source" => "test_alert" }
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../../lib/findbug/alerts/channels/base"
|
|
4
|
+
require_relative "../../../lib/findbug/alerts/channels/email"
|
|
5
|
+
require_relative "../../../lib/findbug/alerts/channels/slack"
|
|
6
|
+
require_relative "../../../lib/findbug/alerts/channels/discord"
|
|
7
|
+
require_relative "../../../lib/findbug/alerts/channels/webhook"
|
|
8
|
+
|
|
9
|
+
module Findbug
|
|
10
|
+
# AlertChannel stores alert channel configurations in the database.
|
|
11
|
+
#
|
|
12
|
+
# DATABASE SCHEMA
|
|
13
|
+
# ===============
|
|
14
|
+
#
|
|
15
|
+
# This model expects a table created by the install generator:
|
|
16
|
+
#
|
|
17
|
+
# create_table :findbug_alert_channels do |t|
|
|
18
|
+
# t.string :channel_type, null: false
|
|
19
|
+
# t.string :name, null: false
|
|
20
|
+
# t.boolean :enabled, default: false
|
|
21
|
+
# t.text :config_data
|
|
22
|
+
# t.timestamps
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# WHY DB INSTEAD OF INITIALIZER?
|
|
26
|
+
# ==============================
|
|
27
|
+
#
|
|
28
|
+
# Storing alert config in the database lets users create, edit, and
|
|
29
|
+
# manage alert channels from the dashboard UI without code changes
|
|
30
|
+
# or redeployment.
|
|
31
|
+
#
|
|
32
|
+
# WHY TEXT + SERIALIZE INSTEAD OF JSONB?
|
|
33
|
+
# ======================================
|
|
34
|
+
#
|
|
35
|
+
# ActiveRecord::Encryption works on text columns. We serialize the
|
|
36
|
+
# config hash as JSON and encrypt the entire blob at rest, so webhook
|
|
37
|
+
# URLs and tokens are never stored in plain text.
|
|
38
|
+
#
|
|
39
|
+
class AlertChannel < ActiveRecord::Base
|
|
40
|
+
self.table_name = "findbug_alert_channels"
|
|
41
|
+
|
|
42
|
+
# Channel types
|
|
43
|
+
CHANNEL_TYPES = %w[email slack discord webhook].freeze
|
|
44
|
+
|
|
45
|
+
# Check if Rails encryption is configured
|
|
46
|
+
def self.encryption_available?
|
|
47
|
+
return false unless defined?(ActiveRecord::Encryption)
|
|
48
|
+
|
|
49
|
+
ActiveRecord::Encryption.config.primary_key.present?
|
|
50
|
+
rescue StandardError
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Serialize config as JSON
|
|
55
|
+
serialize :config_data, coder: JSON
|
|
56
|
+
|
|
57
|
+
# Encrypt config at rest if Rails encryption is configured
|
|
58
|
+
encrypts :config_data if encryption_available?
|
|
59
|
+
|
|
60
|
+
# Validations
|
|
61
|
+
validates :name, presence: true
|
|
62
|
+
validates :channel_type, presence: true, inclusion: { in: CHANNEL_TYPES }
|
|
63
|
+
validate :validate_required_config
|
|
64
|
+
|
|
65
|
+
# Scopes
|
|
66
|
+
scope :enabled, -> { where(enabled: true) }
|
|
67
|
+
scope :by_type, ->(type) { where(channel_type: type) }
|
|
68
|
+
|
|
69
|
+
# Convenience accessor for config
|
|
70
|
+
def config
|
|
71
|
+
config_data || {}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def config=(value)
|
|
75
|
+
self.config_data = value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns the channel class for sending alerts
|
|
79
|
+
#
|
|
80
|
+
# Maps channel_type to the corresponding Alerts::Channels class.
|
|
81
|
+
#
|
|
82
|
+
def channel_class
|
|
83
|
+
case channel_type
|
|
84
|
+
when "email" then Findbug::Alerts::Channels::Email
|
|
85
|
+
when "slack" then Findbug::Alerts::Channels::Slack
|
|
86
|
+
when "discord" then Findbug::Alerts::Channels::Discord
|
|
87
|
+
when "webhook" then Findbug::Alerts::Channels::Webhook
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Human-readable channel type
|
|
92
|
+
def display_type
|
|
93
|
+
channel_type&.titleize
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Returns config with sensitive values masked for display
|
|
97
|
+
#
|
|
98
|
+
# Shows scheme + host for URLs, masks everything else.
|
|
99
|
+
# Email recipients are shown in full (not sensitive).
|
|
100
|
+
#
|
|
101
|
+
def masked_config
|
|
102
|
+
masked = {}
|
|
103
|
+
|
|
104
|
+
case channel_type
|
|
105
|
+
when "email"
|
|
106
|
+
masked["Recipients"] = Array(config["recipients"]).join(", ").presence || "None"
|
|
107
|
+
masked["From"] = config["from"] || "findbug@localhost"
|
|
108
|
+
when "slack"
|
|
109
|
+
masked["Webhook URL"] = mask_url(config["webhook_url"])
|
|
110
|
+
masked["Channel"] = config["channel"] || "Default"
|
|
111
|
+
masked["Username"] = config["username"] || "Findbug"
|
|
112
|
+
when "discord"
|
|
113
|
+
masked["Webhook URL"] = mask_url(config["webhook_url"])
|
|
114
|
+
masked["Username"] = config["username"] || "Findbug"
|
|
115
|
+
when "webhook"
|
|
116
|
+
masked["URL"] = mask_url(config["url"])
|
|
117
|
+
masked["Method"] = (config["method"] || "POST").upcase
|
|
118
|
+
headers_count = (config["headers"] || {}).size
|
|
119
|
+
masked["Custom Headers"] = "#{headers_count} configured" if headers_count > 0
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
masked
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
# Mask a URL for safe display
|
|
128
|
+
#
|
|
129
|
+
# Shows scheme + host but masks the path (which typically contains
|
|
130
|
+
# secret tokens in webhook URLs).
|
|
131
|
+
#
|
|
132
|
+
def mask_url(url)
|
|
133
|
+
return "Not configured" if url.blank?
|
|
134
|
+
|
|
135
|
+
uri = URI.parse(url)
|
|
136
|
+
path = uri.path.to_s
|
|
137
|
+
masked_path = path.length > 8 ? "#{path[0..7]}********" : "********"
|
|
138
|
+
"#{uri.scheme}://#{uri.host}#{masked_path}"
|
|
139
|
+
rescue URI::InvalidURIError
|
|
140
|
+
"#{url[0..15]}********"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Validate that required config fields are present for each channel type
|
|
144
|
+
def validate_required_config
|
|
145
|
+
return if config.blank? && !enabled?
|
|
146
|
+
|
|
147
|
+
case channel_type
|
|
148
|
+
when "email"
|
|
149
|
+
if enabled? && Array(config["recipients"]).compact_blank.empty?
|
|
150
|
+
errors.add(:base, "Email channel requires at least one recipient")
|
|
151
|
+
end
|
|
152
|
+
when "slack"
|
|
153
|
+
if enabled? && config["webhook_url"].blank?
|
|
154
|
+
errors.add(:base, "Slack channel requires a webhook URL")
|
|
155
|
+
end
|
|
156
|
+
when "discord"
|
|
157
|
+
if enabled? && config["webhook_url"].blank?
|
|
158
|
+
errors.add(:base, "Discord channel requires a webhook URL")
|
|
159
|
+
end
|
|
160
|
+
when "webhook"
|
|
161
|
+
if enabled? && config["url"].blank?
|
|
162
|
+
errors.add(:base, "Webhook channel requires a URL")
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -27,14 +27,15 @@ module Findbug
|
|
|
27
27
|
# t.timestamps
|
|
28
28
|
# end
|
|
29
29
|
#
|
|
30
|
-
# WHY
|
|
31
|
-
#
|
|
30
|
+
# WHY OVERRIDE JSON ACCESSORS?
|
|
31
|
+
# =============================
|
|
32
32
|
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
33
|
+
# The column type for context/request_data varies by adapter:
|
|
34
|
+
# PostgreSQL → jsonb (AR returns Hash natively)
|
|
35
|
+
# MySQL → json (AR returns Hash natively)
|
|
36
|
+
# SQLite → text (AR returns raw JSON String)
|
|
36
37
|
#
|
|
37
|
-
#
|
|
38
|
+
# The overrides below normalise both cases so callers always get a Hash.
|
|
38
39
|
#
|
|
39
40
|
class ErrorEvent < ActiveRecord::Base
|
|
40
41
|
self.table_name = "findbug_error_events"
|
|
@@ -55,6 +56,33 @@ module Findbug
|
|
|
55
56
|
validates :status, inclusion: { in: [STATUS_UNRESOLVED, STATUS_RESOLVED, STATUS_IGNORED] }
|
|
56
57
|
validates :severity, inclusion: { in: [SEVERITY_ERROR, SEVERITY_WARNING, SEVERITY_INFO] }
|
|
57
58
|
|
|
59
|
+
# JSON field accessors — normalise across adapters (jsonb/json/text).
|
|
60
|
+
# Reader always returns a Hash; writer stores a JSON string on text columns
|
|
61
|
+
# and the native object on json/jsonb columns.
|
|
62
|
+
%i[context request_data].each do |field|
|
|
63
|
+
define_method(field) do
|
|
64
|
+
val = read_attribute(field)
|
|
65
|
+
return {} if val.nil?
|
|
66
|
+
val.is_a?(String) ? JSON.parse(val) : val
|
|
67
|
+
rescue JSON::ParserError
|
|
68
|
+
{}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
define_method(:"#{field}=") do |val|
|
|
72
|
+
col_type = self.class.columns_hash[field.to_s]&.type
|
|
73
|
+
if col_type == :text
|
|
74
|
+
serialized = case val
|
|
75
|
+
when nil then nil
|
|
76
|
+
when String then val
|
|
77
|
+
else val.to_json
|
|
78
|
+
end
|
|
79
|
+
write_attribute(field, serialized)
|
|
80
|
+
else
|
|
81
|
+
write_attribute(field, val)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
58
86
|
# Scopes
|
|
59
87
|
scope :unresolved, -> { where(status: STATUS_UNRESOLVED) }
|
|
60
88
|
scope :resolved, -> { where(status: STATUS_RESOLVED) }
|