feat(super-admin): Add push diagnostics tool (#14105)

We're getting many customer reports saying "I'm not getting
notifications." We can't always identify the root cause since there are
multiple points of failure. Added a **Push Diagnostics** tool in Super
Admin to help us investigate mobile/web push issues.

Here's how it works:

- Look up a user by email/ID → see all their registered subscriptions
with device info (iOS/Android, brand, model), token freshness, and
last-updated time
- Send a customizable test push and read the raw FCM/web-push/relay
response to see if the customer is receiving push notifications—if not,
it will show proper errors.
- Delete broken subscriptions so the mobile app re-registers on next
launch
<img width="3816" height="1974" alt="CleanShot 2026-04-20 at 12 56
56@2x"
src="https://github.com/user-attachments/assets/08ecab6f-7ec3-44b3-a114-5e6eb8cf0879"
/>

Fixes https://linear.app/chatwoot/issue/CW-6892/push-diagnostics-tool

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Muhsin Keloth
2026-04-21 15:55:12 +04:00
committed by GitHub
co-authored by Muhsin Claude Opus 4.7
parent 6928f14a76
commit 6a9c44476e
7 changed files with 435 additions and 3 deletions
@@ -0,0 +1,68 @@
class SuperAdmin::PushDiagnosticsController < SuperAdmin::ApplicationController
def show
@query = params[:user_query].to_s.strip
@user = resolve_user(@query)
@subscriptions = @user ? @user.notification_subscriptions.order(:id) : []
@results = []
end
def create
@user = User.find_by(id: params[:user_id])
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if @user.nil?
ids = parsed_subscription_ids
if ids.empty?
return redirect_to super_admin_push_diagnostics_path(user_query: @user.id),
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_test')
end
run_test_and_render(ids)
end
def destroy_subscriptions
user = User.find_by(id: params[:user_id])
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if user.nil?
ids = parsed_subscription_ids
if ids.empty?
return redirect_to super_admin_push_diagnostics_path(user_query: user.id),
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_delete')
end
deleted_count = user.notification_subscriptions.where(id: ids).destroy_all.size
log_super_admin_action("deleted #{deleted_count} subscriptions for user #{user.id}: #{ids}")
redirect_to super_admin_push_diagnostics_path(user_query: user.id),
notice: I18n.t('super_admin.push_diagnostics.subscriptions_deleted', count: deleted_count)
end
private
def run_test_and_render(ids)
@query = @user.id.to_s
@subscriptions = @user.notification_subscriptions.order(:id)
@results = Notification::PushTestService.new(
user: @user, subscription_ids: ids,
title: params[:push_title], body: params[:push_body]
).perform
log_super_admin_action("test sent for user #{@user.id} subscriptions #{ids}")
render :show
end
def log_super_admin_action(message)
Rails.logger.info(
"[SuperAdmin] push diagnostics #{message} " \
"(actor_id=#{current_super_admin&.id}, actor_email=#{current_super_admin&.email})"
)
end
def resolve_user(query)
return if query.blank?
query.match?(/\A\d+\z/) ? User.find_by(id: query) : User.from_email(query)
end
def parsed_subscription_ids
Array(params[:subscription_ids]).reject(&:blank?).map(&:to_i)
end
end
@@ -0,0 +1,160 @@
class Notification::PushTestService
pattr_initialize [:user!, :subscription_ids!, :title, :body]
DEFAULT_TITLE = '%<installation_name>s notification test'.freeze
DEFAULT_BODY = 'This is a test from our team to check notification delivery on your device. No action needed.'.freeze
def self.default_title
format(DEFAULT_TITLE, installation_name: GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot'))
end
def self.default_body
DEFAULT_BODY
end
def perform
selected_subscriptions.map { |subscription| test_send(subscription) }
end
private
def resolved_title
title.presence || self.class.default_title
end
def resolved_body
body.presence || self.class.default_body
end
def selected_subscriptions
user.notification_subscriptions.where(id: subscription_ids).order(:id)
end
def test_send(subscription)
if subscription.browser_push?
test_browser_push(subscription)
elsif subscription.fcm?
test_fcm(subscription)
else
result(subscription, subscription.subscription_type.to_s, :skipped, 'Unknown subscription type')
end
end
def test_browser_push(subscription)
return result(subscription, 'browser_push', :skipped, 'VAPID keys not configured') unless VapidService.public_key
WebPush.payload_send(**browser_push_payload(subscription))
result(subscription, 'browser_push', :success, 'Web push accepted by endpoint')
rescue StandardError => e
result(subscription, 'browser_push', :failure, "#{e.class.name}: #{e.message}")
end
def test_fcm(subscription)
if firebase_credentials_present?
test_fcm_direct(subscription)
elsif chatwoot_hub_enabled?
test_fcm_via_hub(subscription)
else
result(subscription, 'fcm', :skipped, 'No Firebase credentials and push relay disabled')
end
end
def test_fcm_direct(subscription)
fcm_service = Notification::FcmService.new(
GlobalConfigService.load('FIREBASE_PROJECT_ID', nil),
GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
)
response = fcm_service.fcm_client.send_v1(fcm_options(subscription))
status_code = response[:status_code].to_i
status = status_code.between?(200, 299) ? :success : :failure
result(subscription, 'fcm', status, "HTTP #{status_code}#{response[:body]}")
rescue StandardError => e
result(subscription, 'fcm', :failure, "#{e.class.name}: #{e.message}")
end
def test_fcm_via_hub(subscription)
response = ChatwootHub.send_push_with_response(fcm_options(subscription))
result(subscription, 'fcm_via_hub', :success, "HTTP #{response.code}#{response.body}")
rescue RestClient::ExceptionWithResponse => e
result(subscription, 'fcm_via_hub', :failure, "HTTP #{e.response&.code}#{e.response&.body}")
rescue StandardError => e
result(subscription, 'fcm_via_hub', :failure, "#{e.class.name}: #{e.message}")
end
def firebase_credentials_present?
GlobalConfigService.load('FIREBASE_PROJECT_ID', nil) && GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
end
def chatwoot_hub_enabled?
ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_PUSH_RELAY_SERVER', true))
end
def browser_push_payload(subscription)
{
message: JSON.generate(
title: resolved_title,
tag: "super_admin_test_#{Time.zone.now.to_i}",
url: ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com')
),
endpoint: subscription.subscription_attributes['endpoint'],
p256dh: subscription.subscription_attributes['p256dh'],
auth: subscription.subscription_attributes['auth'],
vapid: {
subject: ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com'),
public_key: VapidService.public_key,
private_key: VapidService.private_key
},
ssl_timeout: 5,
open_timeout: 5,
read_timeout: 5
}
end
def fcm_options(subscription)
{
'token': subscription.subscription_attributes['push_token'],
'data': { payload: { data: { notification: { type: 'test' } } }.to_json },
'notification': { title: resolved_title, body: resolved_body },
'android': { priority: 'high' },
'apns': { payload: { aps: { sound: 'default', category: Time.zone.now.to_i.to_s } } },
'fcm_options': { analytics_label: 'SuperAdminTest' }
}
end
def result(subscription, type, status, message)
attrs = subscription.subscription_attributes || {}
{
id: subscription.id,
type: type.to_s,
device: device_label(subscription, attrs),
token_tail: token_tail(subscription, attrs),
status: status,
message: message
}
end
def device_label(subscription, attrs)
if subscription.browser_push?
endpoint_host(attrs['endpoint'].to_s)
else
attrs['device_id'].present? ? "#{attrs['device_id'].to_s.last(6)}" : '—'
end
end
def endpoint_host(endpoint)
return '—' if endpoint.blank?
URI.parse(endpoint).host.presence || endpoint
rescue URI::InvalidURIError
endpoint
end
def token_tail(subscription, attrs)
if subscription.browser_push?
endpoint = attrs['endpoint'].to_s
endpoint.present? ? "#{endpoint.last(6)}" : '—'
else
attrs['push_token'].present? ? "#{attrs['push_token'].to_s.last(6)}" : '—'
end
end
end
@@ -32,7 +32,7 @@ as defined by the routes in the `admin/` namespace
<ul class="my-4">
<%= render partial: "nav_item", locals: { icon: 'icon-grid-line', url: super_admin_root_url, label: 'Dashboard' } %>
<% Administrate::Namespace.new(namespace).resources.each do |resource| %>
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings"].include? resource.resource %>
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings", "push_diagnostics"].include? resource.resource %>
<%= render partial: "nav_item", locals: {
icon: sidebar_icons[resource.resource.to_sym],
url: resource_index_route(resource),
@@ -48,6 +48,7 @@ as defined by the routes in the `admin/` namespace
<ul class="my-4">
<%= render partial: "nav_item", locals: { icon: 'icon-mist-fill', url: sidekiq_web_url, label: 'Sidekiq Dashboard' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-health-book-line', url: super_admin_instance_status_url, label: 'Instance Health' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-mail-send-fill', url: super_admin_push_diagnostics_url, label: 'Push Diagnostics' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-dashboard-line', url: '/', label: 'Agent Dashboard' } %>
<%= render partial: "nav_item", locals: { icon: 'icon-logout-circle-r-line', url: super_admin_logout_url, label: 'Logout' } %>
</ul>
@@ -0,0 +1,190 @@
<% content_for(:title) do %>Push Diagnostics<% end %>
<header class="main-content__header" role="banner">
<h1 class="main-content__page-title" id="page-title">Push Diagnostics</h1>
</header>
<section class="main-content__body">
<p class="text-sm text-slate-600 mb-6">
Send a test push notification to a specific user's registered devices to diagnose delivery issues.
Results show the raw FCM / Web Push / relay response so you can see exactly what failed.
</p>
<div class="mb-8">
<h2 class="text-base font-semibold mb-2">1. Look up user</h2>
<%= form_with url: super_admin_push_diagnostics_path, method: :get, local: true, class: 'flex gap-2 items-center' do |f| %>
<%= f.text_field :user_query,
value: @query,
placeholder: 'user@example.com or numeric user ID',
class: 'border border-slate-100 p-1.5 rounded-md w-80' %>
<%= f.submit 'Look up', class: 'border border-slate-200 bg-slate-50 px-3 py-1.5 rounded-md cursor-pointer' %>
<% end %>
<% if @query.present? && @user.nil? %>
<p class="text-sm text-red-600 mt-2">No user found for "<%= @query %>".</p>
<% end %>
</div>
<% if @user %>
<div class="mb-6 border border-slate-100 rounded-md p-4 bg-slate-25">
<p class="text-sm">
<strong><%= @user.name %></strong>
&middot; <%= @user.email %>
&middot; ID <%= @user.id %>
</p>
<% if @user.accounts.any? %>
<p class="text-xs text-slate-500 mt-1">
Accounts:
<% @user.accounts.each_with_index do |account, index| %>
<%= ', ' if index.positive? %>
<%= account.name %> (ID <%= account.id %>)
<% end %>
</p>
<% end %>
</div>
<div class="mb-8">
<h2 class="text-base font-semibold mb-2">2. Select subscriptions (<%= @subscriptions.count %>)</h2>
<p class="text-xs text-amber-700 mb-3">
⚠️ This sends a real push to every selected device. Use only when diagnosing a reported issue.
</p>
<% if @subscriptions.empty? %>
<p class="text-sm text-slate-600">This user has no push subscriptions registered.</p>
<% else %>
<%= form_with url: super_admin_push_diagnostics_path, method: :post, local: true do |f| %>
<%= f.hidden_field :user_id, value: @user.id %>
<div class="mb-4 max-w-2xl">
<div class="mb-3">
<%= label_tag :push_title, 'Title', class: 'block text-xs font-medium text-slate-600 mb-1' %>
<%= text_field_tag :push_title,
params[:push_title].presence || Notification::PushTestService.default_title,
class: 'border border-slate-100 p-1.5 rounded-md w-full text-sm' %>
</div>
<div>
<%= label_tag :push_body, 'Body', class: 'block text-xs font-medium text-slate-600 mb-1' %>
<%= text_area_tag :push_body,
params[:push_body].presence || Notification::PushTestService.default_body,
rows: 2,
class: 'border border-slate-100 p-1.5 rounded-md w-full text-sm' %>
</div>
<p class="text-xs text-slate-400 mt-1">Customers will see this text as a real push notification on their device.</p>
</div>
<table class="w-full text-sm border border-slate-100 rounded-md">
<thead class="text-left text-xs text-slate-500 bg-slate-25">
<tr>
<th class="px-3 py-2 align-middle first:!pl-4 last:!pr-4"><input type="checkbox" id="toggle-all"></th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">ID</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Type</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Device / endpoint</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Push token</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Device details</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Created</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Last updated</th>
</tr>
</thead>
<tbody>
<% @subscriptions.each do |sub| %>
<% attrs = (sub.subscription_attributes || {}).stringify_keys %>
<% if sub.browser_push? %>
<% endpoint = attrs['endpoint'].to_s %>
<% host = (begin; URI.parse(endpoint).host; rescue URI::InvalidURIError; nil; end) %>
<% device_display = host.presence || endpoint.presence || '—' %>
<% token_display = endpoint.present? ? "…#{endpoint.last(6)}" : '—' %>
<% else %>
<% device_display = attrs['device_id'].present? ? "…#{attrs['device_id'].to_s.last(6)}" : '—' %>
<% token_display = attrs['push_token'].present? ? "…#{attrs['push_token'].to_s.last(6)}" : '—' %>
<% end %>
<% extra_attrs = attrs.except('endpoint', 'p256dh', 'auth', 'push_token', 'device_id') %>
<tr class="border-t border-slate-100 align-middle">
<td class="px-3 py-2 first:!pl-4 last:!pr-4">
<%= check_box_tag 'subscription_ids[]', sub.id, false, class: 'subscription-checkbox' %>
</td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= sub.id %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= sub.subscription_type %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= device_display %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= token_display %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 text-xs align-top">
<% if extra_attrs.present? %>
<% extra_attrs.each do |k, v| %>
<div><span class="text-slate-500"><%= k %>:</span> <span class="font-mono"><%= v.to_s.truncate(40) %></span></div>
<% end %>
<% else %>
<span class="text-slate-400"></span>
<% end %>
</td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 whitespace-nowrap"><%= sub.created_at.strftime('%Y-%m-%d %H:%M') %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 whitespace-nowrap">
<%= sub.updated_at.strftime('%Y-%m-%d %H:%M') %>
<span class="text-xs text-slate-400">(<%= time_ago_in_words(sub.updated_at) %> ago)</span>
</td>
</tr>
<% end %>
</tbody>
</table>
<div class="mt-4 flex gap-2">
<%= f.submit 'Send Test Push to Selected',
class: 'border border-slate-200 bg-slate-50 px-3 py-1.5 rounded-md cursor-pointer font-medium' %>
<%= submit_tag 'Delete Selected Subscriptions',
formaction: destroy_subscriptions_super_admin_push_diagnostics_path,
formmethod: 'post',
data: { confirm: "Delete the selected subscription(s)? The user won't receive pushes on those devices until their app re-registers." },
class: 'border border-red-200 bg-red-50 text-red-700 px-3 py-1.5 rounded-md cursor-pointer font-medium' %>
</div>
<% end %>
<% end %>
</div>
<% if @results.present? %>
<div class="mb-8">
<h2 class="text-base font-semibold mb-2">3. Results</h2>
<table class="w-full text-sm border border-slate-100 rounded-md">
<thead class="text-left text-xs text-slate-500 bg-slate-25">
<tr>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Sub ID</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Type</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Device</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Push token</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Status</th>
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Details</th>
</tr>
</thead>
<tbody>
<% @results.each do |r| %>
<tr class="border-t border-slate-100 align-top">
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= r[:id] %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= r[:type] %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= r[:device] %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= r[:token_tail] %></td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4">
<%
color = {
success: 'bg-green-100 text-green-800',
failure: 'bg-red-100 text-red-800',
skipped: 'bg-slate-100 text-slate-600'
}[r[:status]]
%>
<span class="px-2 py-0.5 rounded-full text-xs <%= color %>"><%= r[:status] %></span>
</td>
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all whitespace-pre-wrap"><%= r[:message] %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<% end %>
</section>
<% content_for :javascript do %>
<script>
document.addEventListener('DOMContentLoaded', () => {
const toggle = document.getElementById('toggle-all');
if (!toggle) return;
toggle.addEventListener('change', (event) => {
document.querySelectorAll('.subscription-checkbox').forEach((cb) => {
cb.checked = event.target.checked;
});
});
});
</script>
<% end %>
+6
View File
@@ -496,3 +496,9 @@ en:
subject: 'Finish setting up %{custom_domain}'
ssl_status:
custom_domain_not_configured: 'Custom domain is not configured'
super_admin:
push_diagnostics:
user_not_found: 'User not found.'
no_subscriptions_to_test: 'Select at least one subscription to test.'
no_subscriptions_to_delete: 'Select at least one subscription to delete.'
subscriptions_deleted: "Deleted %{count} subscription(s). The user's device(s) will re-register on next app launch."
+3
View File
@@ -625,6 +625,9 @@ Rails.application.routes.draw do
root to: 'dashboard#index'
resource :app_config, only: [:show, :create]
resource :push_diagnostics, only: [:show, :create] do
post :destroy_subscriptions, on: :collection
end
# order of resources affect the order of sidebar navigation in super admin
resources :accounts, only: [:index, :new, :create, :show, :edit, :update, :destroy] do
+6 -2
View File
@@ -106,14 +106,18 @@ class ChatwootHub
end
def self.send_push(fcm_options)
info = { fcm_options: fcm_options }
RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
send_push_with_response(fcm_options)
rescue *ExceptionList::REST_CLIENT_EXCEPTIONS => e
Rails.logger.error "Exception: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
end
def self.send_push_with_response(fcm_options)
info = { fcm_options: fcm_options }
RestClient.post(push_notification_url, info.merge(instance_config).to_json, { content_type: :json, accept: :json })
end
def self.emit_event(event_name, event_data)
return if ENV['DISABLE_TELEMETRY']