diff --git a/Gemfile b/Gemfile index 48340d6..06bd7cd 100644 --- a/Gemfile +++ b/Gemfile @@ -8,7 +8,7 @@ gem "propshaft" gem "pg", "~> 1.1" # Use the Puma web server [https://github.com/puma/puma] gem "puma", ">= 5.0" -gem "rails_error_dashboard" +gem "rails_error_dashboard", github: "j4rs/rails_error_dashboard", branch: "feature/mute-errors" gem "solid_queue" gem "mission_control-jobs" diff --git a/Gemfile.lock b/Gemfile.lock index 233878f..cf91bf1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,14 @@ +GIT + remote: https://github.com/j4rs/rails_error_dashboard.git + revision: 090963e11b0dc13132dae4f10170f69c2f2fa462 + branch: feature/mute-errors + specs: + rails_error_dashboard (0.4.1) + concurrent-ruby (~> 1.3.0, < 1.3.7) + groupdate (~> 6.0) + pagy (~> 43.0) + rails (>= 7.0.0) + GEM remote: https://rubygems.org/ specs: @@ -221,11 +232,6 @@ GEM rails-html-sanitizer (1.7.0) loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - rails_error_dashboard (0.4.1) - concurrent-ruby (~> 1.3.0, < 1.3.7) - groupdate (~> 6.0) - pagy (~> 43.0) - rails (>= 7.0.0) railties (8.1.2) actionpack (= 8.1.2) activesupport (= 8.1.2) @@ -311,7 +317,7 @@ DEPENDENCIES propshaft puma (>= 5.0) rails (~> 8.1.2) - rails_error_dashboard + rails_error_dashboard! rspec-rails solid_queue tzinfo-data diff --git a/app/controllers/slack_interactions_controller.rb b/app/controllers/slack_interactions_controller.rb index f280d73..50ad10c 100644 --- a/app/controllers/slack_interactions_controller.rb +++ b/app/controllers/slack_interactions_controller.rb @@ -14,6 +14,8 @@ def create case action&.dig("action_id") when /^resolve_error_(\d+)$/ resolve_error($1.to_i, payload) + when /^mute_error_(\d+)$/ + mute_error($1.to_i, payload) else head :ok end @@ -61,6 +63,47 @@ def resolve_error(error_id, payload) replace_original_message(payload, updated_blocks) end + def mute_error(error_id, payload) + error = RailsErrorDashboard::ErrorLog.find_by(id: error_id) + + unless error + respond_to_slack(payload, "Error ##{error_id} not found.") + return + end + + if error.muted? + respond_to_slack(payload, "Already muted.") + return + end + + username = payload.dig("user", "username") || "someone" + RailsErrorDashboard::Commands::MuteError.call(error_id, muted_by: username, reason: "Muted from Slack") + + user_mention = "@#{username}" + + # Replace the Mute button with a muted label + original_blocks = payload.dig("message", "blocks") || [] + updated_blocks = original_blocks.flat_map do |block| + if block["type"] == "actions" + # Keep View Source and Resolve buttons, remove Mute button + remaining = block["elements"]&.reject { |e| e["action_id"]&.start_with?("mute_error_") } || [] + [ + { type: "actions", elements: remaining }, + { + type: "context", + elements: [ + { type: "mrkdwn", text: ":bell_slash: *Muted* by #{user_mention}" } + ] + } + ] + else + [block] + end + end + + replace_original_message(payload, updated_blocks) + end + def respond_to_slack(payload, text) response_url = payload["response_url"] return head :ok unless response_url diff --git a/config/initializers/rails_error_dashboard.rb b/config/initializers/rails_error_dashboard.rb index f25738f..e5c0b67 100644 --- a/config/initializers/rails_error_dashboard.rb +++ b/config/initializers/rails_error_dashboard.rb @@ -217,6 +217,21 @@ def actions_block(error_log) deny: { type: "plain_text", text: "Cancel" } } } + block[:elements] << { + type: "button", + text: { + type: "plain_text", + text: "Mute", + emoji: true + }, + action_id: "mute_error_#{error_log.id}", + confirm: { + title: { type: "plain_text", text: "Mute notifications?" }, + text: { type: "mrkdwn", text: "Stop notifications for *#{error_log.error_type}*? The error will still be tracked in the dashboard." }, + confirm: { type: "plain_text", text: "Mute" }, + deny: { type: "plain_text", text: "Cancel" } + } + } block end end diff --git a/spec/integration/mute_notification_spec.rb b/spec/integration/mute_notification_spec.rb new file mode 100644 index 0000000..301906e --- /dev/null +++ b/spec/integration/mute_notification_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Muted error notification suppression", type: :request do + let(:token) { "test-token" } + let(:headers) { { "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" } } + let(:application) { RailsErrorDashboard::Application.find_or_create_by!(name: "getonbrd") } + + before do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with("API_BEARER_TOKEN").and_return(token) + + RailsErrorDashboard.configure do |config| + config.enable_slack_notifications = true + config.slack_webhook_url = "https://hooks.slack.com/test" + config.notification_cooldown_minutes = 0 + config.async_logging = false + end + end + + let(:error_payload) do + { + error: { + error_type: "TestMuteError", + message: "This error should be muted", + severity: "error", + platform: "ruby", + backtrace: ["app/test.rb:1:in `test`"], + occurred_at: Time.current.iso8601 + } + } + end + + it "sends Slack notification for unmuted errors" do + expect(RailsErrorDashboard::SlackErrorNotificationJob).to receive(:perform_later).at_least(:once) + + post "/api/v1/errors", params: error_payload.to_json, headers: headers + expect(response).to have_http_status(:created) + end + + it "does NOT send Slack notification for muted errors" do + # Create the error first + post "/api/v1/errors", params: error_payload.to_json, headers: headers + error_log = RailsErrorDashboard::ErrorLog.last + + # Mute it + error_log.update!(muted: true, muted_at: Time.current) + + # Send the same error again — deduplication increments occurrence_count on the muted record + expect(RailsErrorDashboard::SlackErrorNotificationJob).not_to receive(:perform_later) + + post "/api/v1/errors", params: error_payload.to_json, headers: headers + expect(response).to have_http_status(:created) + + # Verify it was deduplicated (same record, higher count) + expect(error_log.reload.occurrence_count).to be >= 2 + expect(error_log.muted).to be true + end + + it "resumes Slack notifications after unmuting" do + # Create and mute the error + post "/api/v1/errors", params: error_payload.to_json, headers: headers + error_log = RailsErrorDashboard::ErrorLog.last + error_log.update!(muted: true, muted_at: Time.current) + + # Unmute it + error_log.update!(muted: false, muted_at: nil) + + # Resolve it so the next occurrence triggers a "reopened" notification + error_log.update!(resolved: true, resolved_at: Time.current) + + expect(RailsErrorDashboard::SlackErrorNotificationJob).to receive(:perform_later).at_least(:once) + + post "/api/v1/errors", params: error_payload.to_json, headers: headers + expect(response).to have_http_status(:created) + end +end diff --git a/spec/requests/slack_interactions_spec.rb b/spec/requests/slack_interactions_spec.rb new file mode 100644 index 0000000..2fb5c6d --- /dev/null +++ b/spec/requests/slack_interactions_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "rails_helper" +require "net/http" + +RSpec.describe "Slack Interactions", type: :request do + let(:signing_secret) { "test-signing-secret" } + let(:application) { RailsErrorDashboard::Application.find_or_create_by!(name: "getonbrd") } + + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("SLACK_SIGNING_SECRET").and_return(signing_secret) + allow(RailsErrorDashboard::Commands::LogError).to receive(:call).and_return(nil) + + # Stub all outbound HTTP to Slack response_url + allow_any_instance_of(Net::HTTP).to receive(:request).and_return( + instance_double(Net::HTTPSuccess, code: "200", body: "ok") + ) + end + + def build_body(action_id:, error_id: 1, username: "testuser") + payload = { + actions: [{ action_id: action_id }], + user: { username: username }, + response_url: "https://hooks.slack.com/actions/test/response", + message: { + ts: "1234567890.123456", + blocks: [ + { type: "header", text: { type: "plain_text", text: "TestError" } }, + { + type: "actions", + elements: [ + { url: "https://github.com/test", type: "button", text: { type: "plain_text", text: "View Source" } }, + { action_id: "resolve_error_#{error_id}", type: "button", text: { type: "plain_text", text: "Resolve" } }, + { action_id: "mute_error_#{error_id}", type: "button", text: { type: "plain_text", text: "Mute" } } + ] + } + ] + } + } + "payload=#{CGI.escape(payload.to_json)}" + end + + def signed_headers(body) + timestamp = Time.now.to_i.to_s + sig_basestring = "v0:#{timestamp}:#{body}" + signature = "v0=#{OpenSSL::HMAC.hexdigest("SHA256", signing_secret, sig_basestring)}" + + { + "Content-Type" => "application/x-www-form-urlencoded", + "X-Slack-Request-Timestamp" => timestamp, + "X-Slack-Signature" => signature + } + end + + describe "mute action" do + let!(:error_log) do + RailsErrorDashboard::ErrorLog.create!( + application: application, + error_type: "TestError", + message: "test", + platform: "ruby", + occurred_at: Time.current, + occurrence_count: 1, + priority_level: 0 + ) + end + + it "mutes the error and records who muted it" do + body = build_body(action_id: "mute_error_#{error_log.id}", error_id: error_log.id) + + post "/slack/interactions", params: body, headers: signed_headers(body) + + expect(response).to have_http_status(:ok) + expect(error_log.reload.muted).to be true + expect(error_log.muted_by).to eq("testuser") + expect(error_log.muted_reason).to eq("Muted from Slack") + end + + it "responds with already muted when error is muted" do + error_log.update!(muted: true, muted_at: Time.current) + body = build_body(action_id: "mute_error_#{error_log.id}", error_id: error_log.id) + + post "/slack/interactions", params: body, headers: signed_headers(body) + + expect(response).to have_http_status(:ok) + end + + it "responds with not found for invalid error" do + body = build_body(action_id: "mute_error_999999", error_id: 999999) + + post "/slack/interactions", params: body, headers: signed_headers(body) + + expect(response).to have_http_status(:ok) + end + + it "rejects requests without valid signature" do + body = build_body(action_id: "mute_error_#{error_log.id}", error_id: error_log.id) + + post "/slack/interactions", params: body, headers: { + "Content-Type" => "application/x-www-form-urlencoded", + "X-Slack-Request-Timestamp" => Time.now.to_i.to_s, + "X-Slack-Signature" => "v0=invalid" + } + + expect(response).to have_http_status(:unauthorized) + end + end +end