Implement traffic drop notifications (#4300)

* Expose current visitors 12h aggregate

* Remove unused site association

* Distinct drop/spike notification factories

* Rename modules accordingly + implement drop handling

* Rename periodic oban service

* Implement drop email

* Rest of the owl

* Update changelog

* Update moduledoc

* Update moduledoc

* Min threshold to 1

* Threshold 1

* Remove merge artifact

* Put panel behind a feature flag

* Format
This commit is contained in:
hq1 2024-07-11 14:55:18 +02:00 committed by GitHub
parent 3ab47e6401
commit d56bb2b4d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 756 additions and 423 deletions

View File

@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
- Icons for browsers plausible/analytics#4239
- Automatic custom property selection in the dashboard Properties report
- Add `does_not_contain` filter support to dashboard
- Traffic drop notifications plausible/analytics#4300
### Removed
- Deprecate `ECTO_IPV6` and `ECTO_CH_IPV6` env vars in CE plausible/analytics#4245

View File

@ -528,7 +528,7 @@ base_cron = [
# Daily at midday
{"0 12 * * *", Plausible.Workers.SendCheckStatsEmails},
# Every 15 minutes
{"*/15 * * * *", Plausible.Workers.SpikeNotifier},
{"*/15 * * * *", Plausible.Workers.TrafficChangeNotifier},
# Every day at 1am
{"0 1 * * *", Plausible.Workers.CleanInvitations},
# Every 2 hours

View File

@ -40,7 +40,6 @@ defmodule Plausible.Site do
has_one :google_auth, GoogleAuth
has_one :weekly_report, Plausible.Site.WeeklyReport
has_one :monthly_report, Plausible.Site.MonthlyReport
has_one :spike_notification, Plausible.Site.SpikeNotification
has_one :ownership, Plausible.Site.Membership, where: [role: :owner]
has_one :owner, through: [:ownership, :user]

View File

@ -1,7 +1,11 @@
defmodule Plausible.Site.SpikeNotification do
defmodule Plausible.Site.TrafficChangeNotification do
@moduledoc """
Configuration schema for site-specific traffic change notifications.
"""
use Ecto.Schema
import Ecto.Changeset
# legacy table name since traffic drop notifications were introduced
schema "spike_notifications" do
field :recipients, {:array, :string}
field :threshold, :integer
@ -14,8 +18,9 @@ defmodule Plausible.Site.SpikeNotification do
def changeset(schema, attrs) do
schema
|> cast(attrs, [:site_id, :recipients, :threshold])
|> validate_required([:site_id, :recipients, :threshold])
|> cast(attrs, [:site_id, :recipients, :threshold, :type])
|> validate_required([:site_id, :recipients, :threshold, :type])
|> validate_number(:threshold, greater_than_or_equal_to: 1)
|> unique_constraint([:site_id, :type])
end

View File

@ -41,9 +41,9 @@ defmodule Plausible.Stats do
Timeseries.timeseries(site, query, metrics)
end
def current_visitors(site) do
def current_visitors(site, shift \\ [minutes: -5]) do
include_sentry_replay_info()
CurrentVisitors.current_visitors(site)
CurrentVisitors.current_visitors(site, shift)
end
on_ee do

View File

@ -107,6 +107,10 @@ defmodule Plausible.Stats.Clickhouse do
Plausible.Stats.current_visitors(site)
end
def current_visitors_12h(site) do
Plausible.Stats.current_visitors(site, hours: -12)
end
def has_pageviews?(site) do
ClickhouseRepo.exists?(
from(e in "events_v2",

View File

@ -2,10 +2,10 @@ defmodule Plausible.Stats.CurrentVisitors do
use Plausible.ClickhouseRepo
use Plausible.Stats.SQL.Fragments
def current_visitors(site) do
def current_visitors(site, shift \\ [minutes: -5]) do
first_datetime =
NaiveDateTime.utc_now()
|> Timex.shift(minutes: -5)
|> Timex.shift(shift)
|> NaiveDateTime.truncate(:second)
ClickhouseRepo.one(

View File

@ -211,7 +211,10 @@ defmodule PlausibleWeb.SiteController do
site: site,
weekly_report: Repo.get_by(Plausible.Site.WeeklyReport, site_id: site.id),
monthly_report: Repo.get_by(Plausible.Site.MonthlyReport, site_id: site.id),
spike_notification: Repo.get_by(Plausible.Site.SpikeNotification, site_id: site.id),
spike_notification:
Repo.get_by(Plausible.Site.TrafficChangeNotification, site_id: site.id, type: :spike),
drop_notification:
Repo.get_by(Plausible.Site.TrafficChangeNotification, site_id: site.id, type: :drop),
dogfood_page_path: "/:dashboard/settings/email-reports",
layout: {PlausibleWeb.LayoutView, "site_settings.html"}
)
@ -472,44 +475,58 @@ defmodule PlausibleWeb.SiteController do
|> redirect(external: Routes.site_path(conn, :settings_email_reports, site.domain))
end
def enable_spike_notification(conn, _params) do
def enable_traffic_change_notification(conn, %{"type" => type}) do
site = conn.assigns[:site]
res =
Plausible.Site.SpikeNotification.changeset(%Plausible.Site.SpikeNotification{}, %{
site_id: site.id,
threshold: 10,
recipients: [conn.assigns[:current_user].email]
})
Plausible.Site.TrafficChangeNotification.changeset(
%Plausible.Site.TrafficChangeNotification{},
%{
site_id: site.id,
type: type,
threshold: if(type == "spike", do: 10, else: 1),
recipients: [conn.assigns[:current_user].email]
}
)
|> Repo.insert()
case res do
{:ok, _} ->
conn
|> put_flash(:success, "You will a notification with traffic spikes going forward")
|> put_flash(:success, "Traffic #{type} notifications enabled")
|> redirect(external: Routes.site_path(conn, :settings_email_reports, site.domain))
{:error, _} ->
conn
|> put_flash(:error, "Unable to create a spike notification")
|> put_flash(:error, "Unable to create a #{type} notification")
|> redirect(external: Routes.site_path(conn, :settings_email_reports, site.domain))
end
end
def disable_spike_notification(conn, _params) do
def disable_traffic_change_notification(conn, %{"type" => type}) do
site = conn.assigns[:site]
Repo.delete_all(from(mr in Plausible.Site.SpikeNotification, where: mr.site_id == ^site.id))
Repo.delete_all(
from(mr in Plausible.Site.TrafficChangeNotification,
where: mr.site_id == ^site.id and mr.type == ^type
)
)
conn
|> put_flash(:success, "Spike notification disabled")
|> put_flash(:success, "Traffic #{type} notifications disabled")
|> redirect(external: Routes.site_path(conn, :settings_email_reports, site.domain))
end
def update_spike_notification(conn, %{"spike_notification" => params}) do
def update_traffic_change_notification(conn, %{
"traffic_change_notification" => params,
"type" => type
}) do
site = conn.assigns[:site]
notification = Repo.get_by(Plausible.Site.SpikeNotification, site_id: site.id)
Plausible.Site.SpikeNotification.changeset(notification, params)
notification =
Repo.get_by(Plausible.Site.TrafficChangeNotification, site_id: site.id, type: type)
Plausible.Site.TrafficChangeNotification.changeset(notification, params)
|> Repo.update!()
conn
@ -517,11 +534,11 @@ defmodule PlausibleWeb.SiteController do
|> redirect(external: Routes.site_path(conn, :settings_email_reports, site.domain))
end
def add_spike_notification_recipient(conn, %{"recipient" => recipient}) do
def add_traffic_change_notification_recipient(conn, %{"recipient" => recipient, "type" => type}) do
site = conn.assigns[:site]
Repo.get_by(Plausible.Site.SpikeNotification, site_id: site.id)
|> Plausible.Site.SpikeNotification.add_recipient(recipient)
Repo.get_by(Plausible.Site.TrafficChangeNotification, site_id: site.id, type: type)
|> Plausible.Site.TrafficChangeNotification.add_recipient(recipient)
|> Repo.update!()
conn
@ -529,11 +546,14 @@ defmodule PlausibleWeb.SiteController do
|> redirect(external: Routes.site_path(conn, :settings_email_reports, site.domain))
end
def remove_spike_notification_recipient(conn, %{"recipient" => recipient}) do
def remove_traffic_change_notification_recipient(conn, %{
"recipient" => recipient,
"type" => type
}) do
site = conn.assigns[:site]
Repo.get_by(Plausible.Site.SpikeNotification, site_id: site.id)
|> Plausible.Site.SpikeNotification.remove_recipient(recipient)
Repo.get_by(Plausible.Site.TrafficChangeNotification, site_id: site.id, type: type)
|> Plausible.Site.TrafficChangeNotification.remove_recipient(recipient)
|> Repo.update!()
conn

View File

@ -141,6 +141,19 @@ defmodule PlausibleWeb.Email do
})
end
def drop_notification(email, site, current_visitors, dashboard_link, settings_link) do
base_email()
|> to(email)
|> tag("drop-notification")
|> subject("Traffic Drop on #{site.domain}")
|> render("drop_notification.html", %{
site: site,
current_visitors: current_visitors,
dashboard_link: dashboard_link,
settings_link: settings_link
})
end
def over_limit_email(user, usage, suggested_plan) do
priority_email()
|> to(user)

View File

@ -331,17 +331,25 @@ defmodule PlausibleWeb.Router do
SiteController,
:remove_monthly_report_recipient
post "/sites/:website/spike-notification/enable", SiteController, :enable_spike_notification
post "/sites/:website/spike-notification/disable", SiteController, :disable_spike_notification
put "/sites/:website/spike-notification", SiteController, :update_spike_notification
post "/sites/:website/spike-notification/recipients",
post "/sites/:website/traffic-change-notification/:type/enable",
SiteController,
:add_spike_notification_recipient
:enable_traffic_change_notification
delete "/sites/:website/spike-notification/recipients/:recipient",
post "/sites/:website/traffic-change-notification/:type/disable",
SiteController,
:disable_traffic_change_notification
put "/sites/:website/traffic-change-notification/:type",
SiteController,
:update_traffic_change_notification
post "/sites/:website/traffic-change-notification/:type/recipients",
SiteController,
:add_traffic_change_notification_recipient
delete "/sites/:website/traffic-change-notification/:type/recipients/:recipient",
SiteController,
:remove_spike_notification_recipient
:remove_traffic_change_notification_recipient
get "/sites/:website/shared-links/new", SiteController, :new_shared_link
post "/sites/:website/shared-links", SiteController, :create_shared_link

View File

@ -0,0 +1,9 @@
We've recorded <%= @current_visitors %> visitors on <%= link(@site.domain, to: "https://" <> @site.domain) %> in the last 12 hours.
<%= if @dashboard_link do %>
<br /><br />
View dashboard: <%= link(@dashboard_link, to: @dashboard_link) %>
<br /><br />
Something looks off? Please use our integration testing tool to verify that Plausible has been integrated correctly:
<%= link(@settings_link, to: @settings_link) %>
<% end %>

View File

@ -195,135 +195,26 @@
<% end %>
</div>
<div class="shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden py-6 px-4 sm:p-6">
<header class="relative">
<h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">
Traffic Spike Notifications
</h2>
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">
Get notified when your site has unusually high number of current visitors
</p>
<%= render("traffic_change_form",
conn: @conn,
notification: @spike_notification,
site: @site,
heading: "Traffic Spike Notifications",
subtitle: "Get notified when your site has unusually high number of current visitors",
toggle_text: "Send notifications of traffic spikes",
threshold_label: "Current visitors threshold",
type: :spike
) %>
<PlausibleWeb.Components.Generic.docs_info slug="traffic-spikes" />
</header>
<div class="my-8 flex items-center">
<%= if @spike_notification do %>
<%= button(to: "/sites/#{URI.encode_www_form(@site.domain)}/spike-notification/disable", method: :post, class: "bg-indigo-600 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring") do %>
<span class="translate-x-5 inline-block h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow transform transition ease-in-out duration-200">
</span>
<% end %>
<% else %>
<%= button(to: "/sites/#{URI.encode_www_form(@site.domain)}/spike-notification/enable", method: :post, class: "bg-gray-200 dark:bg-gray-700 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring") do %>
<span class="translate-x-0 inline-block h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow transform transition ease-in-out duration-200">
</span>
<% end %>
<% end %>
<span class="ml-2 dark:text-gray-100">Send notifications of traffic spikes</span>
</div>
<%= if @spike_notification do %>
<div class="text-sm text-gray-700 dark:text-gray-300 mt-6">
<%= form_for Plausible.Site.SpikeNotification.changeset(@spike_notification, %{}), "/sites/#{URI.encode_www_form(@site.domain)}/spike-notification", fn f -> %>
<h4 class="font-bold my-2">Current visitor threshold</h4>
<div class="mt-1 flex rounded-md shadow-sm max-w-md">
<div class="relative flex items-stretch flex-grow focus-within:z-10">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<!-- Heroicon name: users -->
<svg
class="h-5 w-5 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
</div>
<%= number_input(f, :threshold,
class:
"focus:ring-indigo-500 dark:bg-gray-900 focus:border-indigo-500 block w-full rounded-none rounded-l-md pl-10 sm:text-sm border-gray-300 dark:border-gray-500 dark:text-gray-100"
) %>
</div>
<button class="-ml-px relative button rounded-l-none">
<span>Save threshold</span>
</button>
</div>
<% end %>
<h4 class="font-bold mt-6 dark:text-gray-100">Notification recipients</h4>
<%= for recipient <- @spike_notification.recipients do %>
<div class="p-2 pl-3 flex justify-between bg-gray-100 dark:bg-gray-900 rounded my-2 max-w-md">
<span>
<svg
class="h-5 w-5 text-gray-400 inline mr-3"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>
<%= recipient %>
</span>
<%= button(to: "/sites/#{URI.encode_www_form(@site.domain)}/spike-notification/recipients/#{recipient}", method: :delete) do %>
<svg
class="w-4 h-4 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
>
</path>
</svg>
<% end %>
</div>
<% end %>
<%= form_for @conn, "/sites/#{URI.encode_www_form(@site.domain)}/spike-notification/recipients", fn f -> %>
<div class="max-w-md mt-4">
<div class="mt-1 flex rounded-md shadow-sm">
<div class="relative flex items-stretch flex-grow focus-within:z-10">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg
class="h-5 w-5 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>
</div>
<%= email_input(f, :recipient,
class:
"focus:ring-indigo-500 dark:bg-gray-900 focus:border-indigo-500 block w-full rounded-none rounded-l-md pl-10 sm:text-sm border-gray-300 dark:border-gray-500 dark:placeholder-gray-400 dark:text-gray-100",
placeholder: "recipient@example.com",
required: "true"
) %>
</div>
<%= submit class: "-ml-px relative button rounded-l-none" do %>
<svg
class="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M8 9a3 3 0 100-6 3 3 0 000 6zM8 11a6 6 0 016 6H2a6 6 0 016-6zM16 7a1 1 0 10-2 0v1h-1a1 1 0 100 2h1v1a1 1 0 102 0v-1h1a1 1 0 100-2h-1V7z">
</path>
</svg>
<span>Add recipient</span>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
<%= if FunWithFlags.enabled?(:traffic_drop_notifications, for: @site) do %>
<%= render("traffic_change_form",
conn: @conn,
notification: @drop_notification,
site: @site,
heading: "Traffic Drop Notifications",
subtitle: "Get notified when your site has unusually low number of visitors within 12 hours",
toggle_text: "Send notifications of traffic drops",
threshold_label: "12 hours visitor threshold",
type: :drop
) %>
<% end %>

View File

@ -65,7 +65,7 @@
<%= form_for @conn, "/", [class: "shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden py-6 px-4 sm:p-6"], fn f -> %>
<header class="relative">
<h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">
JavaScript Snippet
<a id="snippet">JavaScript Snippet</a>
</h2>
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">
Include this Snippet in the <code>&lt;head&gt;</code> of your Website.

View File

@ -0,0 +1,133 @@
<div class="shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden py-6 px-4 sm:p-6">
<header class="relative">
<h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">
<%= @heading %>
</h2>
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">
<%= @subtitle %>
</p>
<PlausibleWeb.Components.Generic.docs_info slug="traffic-spikes" />
</header>
<div class="my-8 flex items-center">
<%= if @notification do %>
<%= button(to: "/sites/#{URI.encode_www_form(@site.domain)}/traffic-change-notification/#{@type}/disable", method: :post, class: "bg-indigo-600 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring") do %>
<span class="translate-x-5 inline-block h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow transform transition ease-in-out duration-200">
</span>
<% end %>
<% else %>
<%= button(to: "/sites/#{URI.encode_www_form(@site.domain)}/traffic-change-notification/#{@type}/enable", method: :post, class: "bg-gray-200 dark:bg-gray-700 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring") do %>
<span class="translate-x-0 inline-block h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow transform transition ease-in-out duration-200">
</span>
<% end %>
<% end %>
<span class="ml-2 dark:text-gray-100"><%= @toggle_text %></span>
</div>
<%= if @notification do %>
<div class="text-sm text-gray-700 dark:text-gray-300 mt-6">
<%= form_for Plausible.Site.TrafficChangeNotification.changeset(@notification, %{}), "/sites/#{URI.encode_www_form(@site.domain)}/traffic-change-notification/#{@type}", fn f -> %>
<h4 class="font-bold my-2"><%= @threshold_label %></h4>
<div class="mt-1 flex rounded-md shadow-sm max-w-md">
<div class="relative flex items-stretch flex-grow focus-within:z-10">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<!-- Heroicon name: users -->
<svg
class="h-5 w-5 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
</div>
<%= number_input(f, :threshold,
min: 1,
class:
"focus:ring-indigo-500 dark:bg-gray-900 focus:border-indigo-500 block w-full rounded-none rounded-l-md pl-10 sm:text-sm border-gray-300 dark:border-gray-500 dark:text-gray-100"
) %>
</div>
<button class="-ml-px relative button rounded-l-none">
<span>Save threshold</span>
</button>
</div>
<% end %>
<h4 class="font-bold mt-6 dark:text-gray-100">Notification recipients</h4>
<%= for recipient <- @notification.recipients do %>
<div class="p-2 pl-3 flex justify-between bg-gray-100 dark:bg-gray-900 rounded my-2 max-w-md">
<span>
<svg
class="h-5 w-5 text-gray-400 inline mr-3"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>
<%= recipient %>
</span>
<%= button(to: "/sites/#{URI.encode_www_form(@site.domain)}/traffic-change-notification/#{@type}/recipients/#{recipient}", method: :delete) do %>
<svg
class="w-4 h-4 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
>
</path>
</svg>
<% end %>
</div>
<% end %>
<%= form_for @conn, "/sites/#{URI.encode_www_form(@site.domain)}/traffic-change-notification/#{@type}/recipients", fn f -> %>
<div class="max-w-md mt-4">
<div class="mt-1 flex rounded-md shadow-sm">
<div class="relative flex items-stretch flex-grow focus-within:z-10">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg
class="h-5 w-5 text-gray-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>
</div>
<%= email_input(f, :recipient,
class:
"focus:ring-indigo-500 dark:bg-gray-900 focus:border-indigo-500 block w-full rounded-none rounded-l-md pl-10 sm:text-sm border-gray-300 dark:border-gray-500 dark:placeholder-gray-400 dark:text-gray-100",
placeholder: "recipient@example.com",
required: "true"
) %>
</div>
<%= submit class: "-ml-px relative button rounded-l-none" do %>
<svg
class="w-5 h-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M8 9a3 3 0 100-6 3 3 0 000 6zM8 11a6 6 0 016 6H2a6 6 0 016-6zM16 7a1 1 0 10-2 0v1h-1a1 1 0 100 2h1v1a1 1 0 102 0v-1h1a1 1 0 100-2h-1V7z">
</path>
</svg>
<span>Add recipient</span>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>
</div>

View File

@ -1,63 +0,0 @@
defmodule Plausible.Workers.SpikeNotifier do
use Plausible.Repo
alias Plausible.Stats.Query
alias Plausible.Site.SpikeNotification
use Oban.Worker, queue: :spike_notifications
@at_most_every "12 hours"
@impl Oban.Worker
def perform(_job, clickhouse \\ Plausible.Stats.Clickhouse) do
notifications =
Repo.all(
from sn in SpikeNotification,
where: is_nil(sn.last_sent),
or_where: sn.last_sent < fragment("now() - INTERVAL ?", @at_most_every),
join: s in Plausible.Site,
on: sn.site_id == s.id,
where: not s.locked,
preload: [site: s]
)
for notification <- notifications do
current_visitors = clickhouse.current_visitors(notification.site)
if current_visitors >= notification.threshold do
query = Query.from(notification.site, %{"period" => "realtime"})
sources = clickhouse.top_sources_for_spike(notification.site, query, 3, 1)
notify(notification, current_visitors, sources)
end
end
:ok
end
defp notify(notification, current_visitors, sources) do
for recipient <- notification.recipients do
send_notification(recipient, notification.site, current_visitors, sources)
end
notification
|> SpikeNotification.was_sent()
|> Repo.update()
end
defp send_notification(recipient, site, current_visitors, sources) do
site = Repo.preload(site, :members)
dashboard_link =
if Enum.any?(site.members, &(&1.email == recipient)) do
PlausibleWeb.Endpoint.url() <> "/" <> URI.encode_www_form(site.domain)
end
template =
PlausibleWeb.Email.spike_notification(
recipient,
site,
current_visitors,
sources,
dashboard_link
)
Plausible.Mailer.send(template)
end
end

View File

@ -0,0 +1,123 @@
defmodule Plausible.Workers.TrafficChangeNotifier do
@moduledoc """
Oban service sending out traffic drop/spike notifications
"""
use Plausible.Repo
alias Plausible.Stats.Query
alias Plausible.Site.TrafficChangeNotification
alias PlausibleWeb.Router.Helpers, as: Routes
use Oban.Worker, queue: :spike_notifications
@at_most_every "12 hours"
@impl Oban.Worker
def perform(_job, clickhouse \\ Plausible.Stats.Clickhouse) do
today = Date.utc_today()
notifications =
Repo.all(
from sn in TrafficChangeNotification,
where: is_nil(sn.last_sent),
or_where: sn.last_sent < fragment("now() - INTERVAL ?", @at_most_every),
join: s in Plausible.Site,
on: sn.site_id == s.id,
where: not s.locked,
join: sm in Plausible.Site.Membership,
on: sm.site_id == s.id,
where: sm.role == :owner,
join: u in Plausible.Auth.User,
on: u.id == sm.user_id,
where: is_nil(u.accept_traffic_until) or u.accept_traffic_until > ^today,
preload: [site: s]
)
for notification <- notifications do
case notification.type do
:spike ->
current_visitors = clickhouse.current_visitors(notification.site)
if current_visitors >= notification.threshold do
query = Query.from(notification.site, %{"period" => "realtime"})
sources = clickhouse.top_sources_for_spike(notification.site, query, 3, 1)
notify_spike(notification, current_visitors, sources)
end
:drop ->
current_visitors = clickhouse.current_visitors_12h(notification.site)
if current_visitors < notification.threshold do
notify_drop(notification, current_visitors)
end
end
end
:ok
end
defp notify_spike(notification, current_visitors, sources) do
for recipient <- notification.recipients do
send_spike_notification(recipient, notification.site, current_visitors, sources)
end
notification
|> TrafficChangeNotification.was_sent()
|> Repo.update()
end
defp notify_drop(notification, current_visitors) do
for recipient <- notification.recipients do
send_drop_notification(recipient, notification.site, current_visitors)
end
notification
|> TrafficChangeNotification.was_sent()
|> Repo.update()
end
defp send_spike_notification(recipient, site, current_visitors, sources) do
site = Repo.preload(site, :members)
dashboard_link =
if Enum.any?(site.members, &(&1.email == recipient)) do
Routes.stats_url(PlausibleWeb.Endpoint, :stats, site.domain, [])
end
template =
PlausibleWeb.Email.spike_notification(
recipient,
site,
current_visitors,
sources,
dashboard_link
)
Plausible.Mailer.send(template)
end
defp send_drop_notification(recipient, site, current_visitors) do
site = Repo.preload(site, :members)
{dashboard_link, verification_link} =
if Enum.any?(site.members, &(&1.email == recipient)) do
{
Routes.stats_url(PlausibleWeb.Endpoint, :stats, site.domain, []),
Routes.site_url(PlausibleWeb.Endpoint, :settings_general, site.domain)
}
else
{nil, nil}
end
template =
PlausibleWeb.Email.drop_notification(
recipient,
site,
current_visitors,
dashboard_link,
verification_link
)
Plausible.Mailer.send(template)
end
end

View File

@ -609,6 +609,7 @@ defmodule PlausibleWeb.SiteControllerTest do
site = insert(:site, members: [user])
insert(:google_auth, user: user, site: site)
insert(:spike_notification, site: site)
insert(:drop_notification, site: site)
delete(conn, "/#{site.domain}")
@ -1227,106 +1228,146 @@ defmodule PlausibleWeb.SiteControllerTest do
end
end
describe "POST /sites/:website/spike-notification/enable" do
setup [:create_user, :log_in, :create_site]
for type <- [:spike, :drop] do
describe "POST /sites/:website/traffic-change-notification/#{type}/enable" do
setup [:create_user, :log_in, :create_site]
test "creates a spike notification record with the user email", %{
conn: conn,
site: site,
user: user
} do
post(conn, "/sites/#{site.domain}/spike-notification/enable")
test "creates a #{type} notification record with the user email", %{
conn: conn,
site: site,
user: user
} do
post(conn, "/sites/#{site.domain}/traffic-change-notification/#{unquote(type)}/enable")
notification = Repo.get_by(Plausible.Site.SpikeNotification, site_id: site.id)
assert notification.recipients == [user.email]
notification =
Repo.get_by(Plausible.Site.TrafficChangeNotification,
site_id: site.id,
type: unquote(type)
)
assert notification.recipients == [user.email]
end
test "does not allow duplicate #{type} notification to be created", %{
conn: conn,
site: site
} do
post(conn, "/sites/#{site.domain}/traffic-change-notification/#{unquote(type)}/enable")
post(conn, "/sites/#{site.domain}/traffic-change-notification/#{unquote(type)}/enable")
assert Repo.aggregate(
from(s in Plausible.Site.TrafficChangeNotification,
where: s.site_id == ^site.id and s.type == ^unquote(type)
),
:count
) == 1
end
end
test "does not allow duplicate spike notification to be created", %{
conn: conn,
site: site
} do
post(conn, "/sites/#{site.domain}/spike-notification/enable")
post(conn, "/sites/#{site.domain}/spike-notification/enable")
describe "POST /sites/:website/traffic-change-notification/#{type}/disable" do
setup [:create_user, :log_in, :create_site]
assert Repo.aggregate(
from(s in Plausible.Site.SpikeNotification, where: s.site_id == ^site.id),
:count
) == 1
end
end
test "deletes the #{type} notification record", %{conn: conn, site: site} do
insert(:"#{unquote(type)}_notification", site: site)
describe "POST /sites/:website/spike-notification/disable" do
setup [:create_user, :log_in, :create_site]
post(conn, "/sites/#{site.domain}/traffic-change-notification/#{unquote(type)}/disable")
test "deletes the spike notification record", %{conn: conn, site: site} do
insert(:spike_notification, site: site)
post(conn, "/sites/#{site.domain}/spike-notification/disable")
refute Repo.get_by(Plausible.Site.SpikeNotification, site_id: site.id)
end
end
describe "PUT /sites/:website/spike-notification" do
setup [:create_user, :log_in, :create_site]
test "updates spike notification threshold", %{conn: conn, site: site} do
insert(:spike_notification, site: site, threshold: 10)
put(conn, "/sites/#{site.domain}/spike-notification", %{
"spike_notification" => %{"threshold" => "15"}
})
notification = Repo.get_by(Plausible.Site.SpikeNotification, site_id: site.id)
assert notification.threshold == 15
end
end
describe "POST /sites/:website/spike-notification/recipients" do
setup [:create_user, :log_in, :create_site]
test "adds a recipient to the spike notification", %{conn: conn, site: site} do
insert(:spike_notification, site: site)
post(conn, "/sites/#{site.domain}/spike-notification/recipients",
recipient: "user@email.com"
)
report = Repo.get_by(Plausible.Site.SpikeNotification, site_id: site.id)
assert report.recipients == ["user@email.com"]
end
end
describe "DELETE /sites/:website/spike-notification/recipients/:recipient" do
setup [:create_user, :log_in, :create_site]
test "removes a recipient from the spike notification", %{conn: conn, site: site} do
insert(:spike_notification, site: site, recipients: ["recipient@email.com"])
delete(conn, "/sites/#{site.domain}/spike-notification/recipients/recipient@email.com")
report = Repo.get_by(Plausible.Site.SpikeNotification, site_id: site.id)
assert report.recipients == []
refute Repo.get_by(Plausible.Site.TrafficChangeNotification, site_id: site.id)
end
end
test "fails to remove a recipient from the spike notification in a foreign website", %{
conn: conn
} do
site = insert(:site)
insert(:spike_notification, site: site, recipients: ["recipient@email.com"])
describe "PUT /sites/:website/traffic-change-notification/#{type}" do
setup [:create_user, :log_in, :create_site]
conn =
delete(conn, "/sites/#{site.domain}/spike-notification/recipients/recipient@email.com")
test "updates #{type} notification threshold", %{conn: conn, site: site} do
insert(:"#{unquote(type)}_notification", site: site, threshold: 10)
assert conn.status == 404
put(conn, "/sites/#{site.domain}/traffic-change-notification/#{unquote(type)}", %{
"traffic_change_notification" => %{"threshold" => "15"}
})
conn =
delete(conn, "/sites/#{site.domain}/spike-notification/recipients/recipient%40email.com")
notification =
Repo.get_by(Plausible.Site.TrafficChangeNotification,
site_id: site.id,
type: unquote(type)
)
assert conn.status == 404
assert notification.threshold == 15
end
end
report = Repo.get_by(Plausible.Site.SpikeNotification, site_id: site.id)
assert [_] = report.recipients
describe "POST /sites/:website/traffic-change-notification/#{type}/recipients" do
setup [:create_user, :log_in, :create_site]
test "adds a recipient to the #{type} notification", %{conn: conn, site: site} do
insert(:"#{unquote(type)}_notification", site: site)
post(
conn,
"/sites/#{site.domain}/traffic-change-notification/#{unquote(type)}/recipients",
recipient: "user@email.com"
)
report =
Repo.get_by(Plausible.Site.TrafficChangeNotification,
site_id: site.id,
type: unquote(type)
)
assert report.recipients == ["user@email.com"]
end
end
describe "DELETE /sites/:website/traffic-change-notification/#{type}/recipients/:recipient" do
setup [:create_user, :log_in, :create_site]
test "removes a recipient from the #{type} notification", %{conn: conn, site: site} do
insert(:"#{unquote(type)}_notification", site: site, recipients: ["recipient@email.com"])
delete(
conn,
"/sites/#{site.domain}/traffic-change-notification/#{unquote(type)}/recipients/recipient@email.com"
)
report =
Repo.get_by(Plausible.Site.TrafficChangeNotification,
site_id: site.id,
type: unquote(type)
)
assert report.recipients == []
end
test "fails to remove a recipient from the #{type} notification in a foreign website", %{
conn: conn
} do
site = insert(:site)
insert(:"#{unquote(type)}_notification", site: site, recipients: ["recipient@email.com"])
conn =
delete(
conn,
"/sites/#{site.domain}/traffic-change-notification/#{unquote(type)}/recipients/recipient@email.com"
)
assert conn.status == 404
conn =
delete(
conn,
"/sites/#{site.domain}/traffic-change-notification/recipients/#{unquote(type)}/recipient%40email.com"
)
assert conn.status == 404
report =
Repo.get_by(Plausible.Site.TrafficChangeNotification,
site_id: site.id,
type: unquote(type)
)
assert [_] = report.recipients
end
end
end

View File

@ -18,8 +18,16 @@ defmodule Plausible.Factory do
end
def spike_notification_factory do
%Plausible.Site.SpikeNotification{
threshold: 10
%Plausible.Site.TrafficChangeNotification{
threshold: 10,
type: :spike
}
end
def drop_notification_factory do
%Plausible.Site.TrafficChangeNotification{
threshold: 1,
type: :drop
}
end

View File

@ -1,102 +0,0 @@
defmodule Plausible.Workers.SpikeNotifierTest do
use Plausible.DataCase, async: true
use Bamboo.Test
import Double
alias Plausible.Workers.SpikeNotifier
test "does not notify anyone if current visitors does not exceed notification threshold" do
site = insert(:site)
insert(:spike_notification,
site: site,
threshold: 10,
recipients: ["jerod@example.com", "uku@example.com"]
)
clickhouse_stub =
stub(Plausible.Stats.Clickhouse, :current_visitors, fn _site -> 5 end)
|> stub(:top_sources_for_spike, fn _site, _query, _limit, _page -> [] end)
SpikeNotifier.perform(nil, clickhouse_stub)
assert_no_emails_delivered()
end
test "notifies all recipients when traffic is higher than configured threshold" do
site = insert(:site)
insert(:spike_notification,
site: site,
threshold: 10,
recipients: ["jerod@example.com", "uku@example.com"]
)
clickhouse_stub =
stub(Plausible.Stats.Clickhouse, :current_visitors, fn _site -> 10 end)
|> stub(:top_sources_for_spike, fn _site, _query, _limit, _page -> [] end)
SpikeNotifier.perform(nil, clickhouse_stub)
assert_email_delivered_with(
subject: "Traffic Spike on #{site.domain}",
to: [nil: "jerod@example.com"]
)
assert_email_delivered_with(
subject: "Traffic Spike on #{site.domain}",
to: [nil: "uku@example.com"]
)
end
test "does not check site if it is locked" do
site = insert(:site, locked: true)
insert(:spike_notification,
site: site,
threshold: 10,
recipients: ["uku@example.com"]
)
clickhouse_stub =
stub(Plausible.Stats.Clickhouse, :current_visitors, fn _site -> 10 end)
|> stub(:top_sources_for_spike, fn _site, _query, _limit, _page -> [] end)
SpikeNotifier.perform(nil, clickhouse_stub)
assert_no_emails_delivered()
end
test "does not notify anyone if a notification already went out in the last 12 hours" do
site = insert(:site)
insert(:spike_notification, site: site, threshold: 10, recipients: ["uku@example.com"])
clickhouse_stub =
stub(Plausible.Stats.Clickhouse, :current_visitors, fn _site -> 10 end)
|> stub(:top_sources_for_spike, fn _site, _query, _limit, _page -> [] end)
SpikeNotifier.perform(nil, clickhouse_stub)
assert_email_delivered_with(
subject: "Traffic Spike on #{site.domain}",
to: [nil: "uku@example.com"]
)
SpikeNotifier.perform(nil, clickhouse_stub)
assert_no_emails_delivered()
end
test "adds a dashboard link if recipient has access to the site" do
user = insert(:user, email: "robert@example.com")
site = insert(:site, domain: "example.com", members: [user])
insert(:spike_notification, site: site, threshold: 10, recipients: ["robert@example.com"])
clickhouse_stub =
stub(Plausible.Stats.Clickhouse, :current_visitors, fn _site -> 10 end)
|> stub(:top_sources_for_spike, fn _site, _query, _limit, _page -> [] end)
SpikeNotifier.perform(nil, clickhouse_stub)
assert_email_delivered_with(html_body: ~r/View dashboard: <a href=\"http.+\/example.com/)
end
end

View File

@ -0,0 +1,243 @@
defmodule Plausible.Workers.TrafficChangeNotifierTest do
use Plausible.DataCase, async: true
use Bamboo.Test
import Double
alias Plausible.Workers.TrafficChangeNotifier
describe "drops" do
test "does not notify anyone if we've stopped accepting traffic for the owner" do
site =
insert(:site,
memberships: [
build(:site_membership,
user: build(:user, accept_traffic_until: Date.utc_today()),
role: :owner
)
]
)
insert(:drop_notification,
site: site,
threshold: 10,
recipients: ["jerod@example.com", "uku@example.com"]
)
clickhouse_stub =
stub(Plausible.Stats.Clickhouse, :current_visitors_12h, fn _site -> 1 end)
TrafficChangeNotifier.perform(nil, clickhouse_stub)
assert_no_emails_delivered()
end
test "does notify if threshold reached and we're accepting traffic" do
site =
insert(:site,
memberships: [
build(:site_membership,
user: build(:user, accept_traffic_until: Date.utc_today() |> Date.add(+1)),
role: :owner
)
]
)
insert(:drop_notification,
site: site,
threshold: 10,
recipients: ["jerod@example.com"]
)
clickhouse_stub =
stub(Plausible.Stats.Clickhouse, :current_visitors_12h, fn _site -> 1 end)
TrafficChangeNotifier.perform(nil, clickhouse_stub)
assert_email_delivered_with(
subject: "Traffic Drop on #{site.domain}",
to: [nil: "jerod@example.com"]
)
end
test "does not notify anyone if current visitors does not drop below notification threshold" do
site = insert(:site)
insert(:drop_notification,
site: site,
threshold: 10,
recipients: ["jerod@example.com", "uku@example.com"]
)
clickhouse_stub =
stub(Plausible.Stats.Clickhouse, :current_visitors_12h, fn _site -> 11 end)
TrafficChangeNotifier.perform(nil, clickhouse_stub)
assert_no_emails_delivered()
end
test "notifies all recipients when traffic drops under configured threshold" do
site = insert(:site)
insert(:drop_notification,
site: site,
threshold: 10,
recipients: ["jerod@example.com", "uku@example.com"]
)
clickhouse_stub =
stub(Plausible.Stats.Clickhouse, :current_visitors_12h, fn _site -> 7 end)
TrafficChangeNotifier.perform(nil, clickhouse_stub)
assert_email_delivered_with(
subject: "Traffic Drop on #{site.domain}",
to: [nil: "jerod@example.com"]
)
assert_email_delivered_with(
subject: "Traffic Drop on #{site.domain}",
to: [nil: "uku@example.com"]
)
end
test "does not notify anyone if a notification already went out in the last 12 hours" do
site = insert(:site)
insert(:drop_notification,
site: site,
threshold: 10,
recipients: ["uku@example.com"]
)
clickhouse_stub =
stub(Plausible.Stats.Clickhouse, :current_visitors_12h, fn _site -> 4 end)
TrafficChangeNotifier.perform(nil, clickhouse_stub)
assert_email_delivered_with(
subject: "Traffic Drop on #{site.domain}",
to: [nil: "uku@example.com"]
)
TrafficChangeNotifier.perform(nil, clickhouse_stub)
assert_no_emails_delivered()
end
test "adds settings link if recipient has access to the site" do
user = insert(:user, email: "robert@example.com")
site = insert(:site, domain: "example.com", members: [user])
insert(:drop_notification,
site: site,
threshold: 10,
recipients: ["robert@example.com"]
)
clickhouse_stub =
stub(Plausible.Stats.Clickhouse, :current_visitors_12h, fn _site -> 6 end)
TrafficChangeNotifier.perform(nil, clickhouse_stub)
assert_email_delivered_with(html_body: ~r|http://localhost:8000/example.com/settings|)
end
end
describe "spikes" do
test "does not notify anyone if current visitors does not exceed notification threshold" do
site = insert(:site)
insert(:spike_notification,
site: site,
threshold: 10,
recipients: ["jerod@example.com", "uku@example.com"]
)
clickhouse_stub =
stub(Plausible.Stats.Clickhouse, :current_visitors, fn _site -> 5 end)
|> stub(:top_sources_for_spike, fn _site, _query, _limit, _page -> [] end)
TrafficChangeNotifier.perform(nil, clickhouse_stub)
assert_no_emails_delivered()
end
test "notifies all recipients when traffic is higher than configured threshold" do
site = insert(:site)
insert(:spike_notification,
site: site,
threshold: 10,
recipients: ["jerod@example.com", "uku@example.com"]
)
clickhouse_stub =
stub(Plausible.Stats.Clickhouse, :current_visitors, fn _site -> 10 end)
|> stub(:top_sources_for_spike, fn _site, _query, _limit, _page -> [] end)
TrafficChangeNotifier.perform(nil, clickhouse_stub)
assert_email_delivered_with(
subject: "Traffic Spike on #{site.domain}",
to: [nil: "jerod@example.com"]
)
assert_email_delivered_with(
subject: "Traffic Spike on #{site.domain}",
to: [nil: "uku@example.com"]
)
end
test "does not check site if it is locked" do
site = insert(:site, locked: true)
insert(:spike_notification,
site: site,
threshold: 10,
recipients: ["uku@example.com"]
)
clickhouse_stub =
stub(Plausible.Stats.Clickhouse, :current_visitors, fn _site -> 10 end)
|> stub(:top_sources_for_spike, fn _site, _query, _limit, _page -> [] end)
TrafficChangeNotifier.perform(nil, clickhouse_stub)
assert_no_emails_delivered()
end
test "does not notify anyone if a notification already went out in the last 12 hours" do
site = insert(:site)
insert(:spike_notification, site: site, threshold: 10, recipients: ["uku@example.com"])
clickhouse_stub =
stub(Plausible.Stats.Clickhouse, :current_visitors, fn _site -> 10 end)
|> stub(:top_sources_for_spike, fn _site, _query, _limit, _page -> [] end)
TrafficChangeNotifier.perform(nil, clickhouse_stub)
assert_email_delivered_with(
subject: "Traffic Spike on #{site.domain}",
to: [nil: "uku@example.com"]
)
TrafficChangeNotifier.perform(nil, clickhouse_stub)
assert_no_emails_delivered()
end
test "adds a dashboard link if recipient has access to the site" do
user = insert(:user, email: "robert@example.com")
site = insert(:site, domain: "example.com", members: [user])
insert(:spike_notification, site: site, threshold: 10, recipients: ["robert@example.com"])
clickhouse_stub =
stub(Plausible.Stats.Clickhouse, :current_visitors, fn _site -> 10 end)
|> stub(:top_sources_for_spike, fn _site, _query, _limit, _page -> [] end)
TrafficChangeNotifier.perform(nil, clickhouse_stub)
assert_email_delivered_with(html_body: ~r/View dashboard: <a href=\"http.+\/example.com/)
end
end
end