diff --git a/config/config.exs b/config/config.exs index cc3e1044a707be3885687ded6b7e1d7e0991288f..5532692400807a59583fc0e75ff5e3d490ec844e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -118,7 +118,9 @@ extra_cron = [ # Daily at midday {"0 12 * * *", Plausible.Workers.SendCheckStatsEmails}, # Every 10 minutes - {"*/10 * * * *", Plausible.Workers.ProvisionSslCertificates} + {"*/10 * * * *", Plausible.Workers.ProvisionSslCertificates}, + # Every 15 minutes + {"*/15 * * * *", Plausible.Workers.SpikeNotifier} ] base_queues = [rotate_salts: 1] @@ -130,7 +132,8 @@ extra_queues = [ site_setup_emails: 1, trial_notification_emails: 1, schedule_email_reports: 1, - send_email_reports: 1 + send_email_reports: 1, + spike_notifications: 1 ] config :plausible, Oban, diff --git a/config/releases.exs b/config/releases.exs index a84b9306d45fa68f7952b4a07cbcce9e9785e6df..78fa2f57c2d9968605c2a0249c9f7852274ad3ae 100644 --- a/config/releases.exs +++ b/config/releases.exs @@ -157,7 +157,9 @@ extra_cron = [ # Daily at midday {"0 12 * * *", Plausible.Workers.SendCheckStatsEmails}, # Every 10 minutes - {"*/10 * * * *", Plausible.Workers.ProvisionSslCertificates} + {"*/10 * * * *", Plausible.Workers.ProvisionSslCertificates}, + # Every 15 minutes + {"*/15 * * * *", Plausible.Workers.SpikeNotifier} ] base_queues = [rotate_salts: 1] @@ -169,7 +171,8 @@ extra_queues = [ site_setup_emails: 1, trial_notification_emails: 1, schedule_email_reports: 1, - send_email_reports: 1 + send_email_reports: 1, + spike_notifications: 1 ] config :plausible, Oban, diff --git a/lib/plausible/site/schema.ex b/lib/plausible/site/schema.ex index 8fe6582c74636f6ca507d1bb63835c5082e8a3e2..985296dce572bd3d2c54529a3f01f5136ecadd36 100644 --- a/lib/plausible/site/schema.ex +++ b/lib/plausible/site/schema.ex @@ -14,6 +14,7 @@ defmodule Plausible.Site do has_one :weekly_report, Plausible.Site.WeeklyReport has_one :monthly_report, Plausible.Site.MonthlyReport has_one :custom_domain, Plausible.Site.CustomDomain + has_one :spike_notification, Plausible.Site.SpikeNotification timestamps() end diff --git a/lib/plausible/site/spike_notification.ex b/lib/plausible/site/spike_notification.ex new file mode 100644 index 0000000000000000000000000000000000000000..9cca56cddf013276f922a37a61915ea26a829c34 --- /dev/null +++ b/lib/plausible/site/spike_notification.ex @@ -0,0 +1,30 @@ +defmodule Plausible.Site.SpikeNotification do + use Ecto.Schema + import Ecto.Changeset + + schema "spike_notifications" do + field :recipients, {:array, :string} + field :threshold, :integer + field :last_sent, :naive_datetime + belongs_to :site, Plausible.Site + + timestamps() + end + + def changeset(settings, attrs \\ %{}) do + settings + |> cast(attrs, [:site_id, :recipients]) + |> validate_required([:site_id, :recipients]) + |> unique_constraint(:site) + end + + def add_recipient(report, recipient) do + report + |> change(recipients: report.recipients ++ [recipient]) + end + + def remove_recipient(report, recipient) do + report + |> change(recipients: List.delete(report.recipients, recipient)) + end +end diff --git a/lib/plausible_web/email.ex b/lib/plausible_web/email.ex index d6ac2a1c6dd0b8ad472529774f915c7952fa5c81..490d99658ee9ff69c3c96d5b2a506af7187c0032 100644 --- a/lib/plausible_web/email.ex +++ b/lib/plausible_web/email.ex @@ -13,7 +13,6 @@ defmodule PlausibleWeb.Email do |> subject("Activate your Plausible free trial") |> render("activation_email.html", name: user.name, link: link) end - def welcome_email(user) do base_email() |> to(user) @@ -94,6 +93,14 @@ defmodule PlausibleWeb.Email do |> render("weekly_report.html", Keyword.put(assigns, :site, site)) end + def spike_notification(email, site, current_visitors) do + base_email() + |> to(email) + |> tag("spike-notification") + |> subject("Traffic spike on #{site.domain}") + |> render("spike_notification.html", %{current_visitors: current_visitors}) + end + def cancellation_email(user) do base_email() |> to(user.email) diff --git a/lib/plausible_web/templates/email/spike_notification.html.eex b/lib/plausible_web/templates/email/spike_notification.html.eex new file mode 100644 index 0000000000000000000000000000000000000000..906bb3a656e2a82c42456d40201d3d05ee25dafd --- /dev/null +++ b/lib/plausible_web/templates/email/spike_notification.html.eex @@ -0,0 +1 @@ +Yours site has <%= @current_visitors %> visitors. Nice! diff --git a/lib/workers/spike_notifier.ex b/lib/workers/spike_notifier.ex new file mode 100644 index 0000000000000000000000000000000000000000..1faa9060ef95c3ca7f5d8f9bc5dc7ea032a6dced --- /dev/null +++ b/lib/workers/spike_notifier.ex @@ -0,0 +1,39 @@ +defmodule Plausible.Workers.SpikeNotifier do + use Plausible.Repo + alias Plausible.Stats.Query + use Oban.Worker, queue: :spike_notifications + @at_most_every "12 hours" + + @impl Oban.Worker + def perform(_args, _job, clickhouse \\ Plausible.Stats.Clickhouse) do + notifications = Repo.all( + from sn in Plausible.Site.SpikeNotification, + where: is_nil(sn.last_sent), + or_where: sn.last_sent < fragment("now() - INTERVAL ?", @at_most_every) + ) + + for notification <- notifications do + notification = Repo.preload(notification, :site) + query = Query.from(notification.site.timezone, %{"period" => "realtime"}) + current_visitors = clickhouse.current_visitors(notification.site, query) + notify(notification, current_visitors) + end + end + + def notify(notification, current_visitors) do + if current_visitors >= notification.threshold do + for recipient <- notification.recipients do + send_notification(recipient, notification.site, current_visitors) + end + end + end + + defp send_notification(recipient, site, current_visitors) do + template = PlausibleWeb.Email.spike_notification(recipient, site, current_visitors) + try do + Plausible.Mailer.send_email(template) + rescue + _ -> nil + end + end +end diff --git a/mix.exs b/mix.exs index 017c2982da3947d3442ecd9d38853e51239a66a9..146b1a4ccd4a2f88ce851028e45194526ec03eac 100644 --- a/mix.exs +++ b/mix.exs @@ -90,7 +90,8 @@ defmodule Plausible.MixProject do {:sshex, "2.2.1"}, {:geolix, "~> 1.0"}, {:clickhouse_ecto, git: "https://github.com/plausible/clickhouse_ecto.git"}, - {:geolix_adapter_mmdb2, "~> 0.5.0"} + {:geolix_adapter_mmdb2, "~> 0.5.0"}, + {:mix_test_watch, "~> 1.0", only: :dev} ] end diff --git a/mix.lock b/mix.lock index 90c62b087325ecd78b9bbc04650cdb1915a54ce2..b4a89c0b180aa5e5943316580c41a8b1fd43fd8a 100644 --- a/mix.lock +++ b/mix.lock @@ -44,6 +44,7 @@ "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mix_test_watch": {:hex, :mix_test_watch, "1.0.2", "34900184cbbbc6b6ed616ed3a8ea9b791f9fd2088419352a6d3200525637f785", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "47ac558d8b06f684773972c6d04fcc15590abdb97aeb7666da19fcbfdc441a07"}, "mmdb2_decoder": {:hex, :mmdb2_decoder, "3.0.0", "54828676a36e75e9a25bc9a0bb0598d4c7fcc767bf0b40674850b22e05b7b6cc", [:mix], [], "hexpm", "359dc9242915538d1dceb9f6d96c72201dca76ce62e49d22e2ed1e86f20bea8e"}, "nanoid": {:hex, :nanoid, "2.0.2", "f3f7b4bf103ab6667f22beb00b6315825ee3f30100dd2c93d534e5c02164e857", [:mix], [], "hexpm", "3095cb1fac7bbc78843a8ccd99f1af375d0da1d3ebaa8552e846b73438c0c44f"}, "oauther": {:hex, :oauther, "1.1.1", "7d8b16167bb587ecbcddd3f8792beb9ec3e7b65c1f8ebd86b8dd25318d535752", [:mix], [], "hexpm", "9374f4302045321874cccdc57eb975893643bd69c3b22bf1312dab5f06e5788e"}, diff --git a/priv/repo/migrations/20201208173543_add_spike_notifications.exs b/priv/repo/migrations/20201208173543_add_spike_notifications.exs new file mode 100644 index 0000000000000000000000000000000000000000..a5386470e468ff689072b4a7db55e166ded87f1e --- /dev/null +++ b/priv/repo/migrations/20201208173543_add_spike_notifications.exs @@ -0,0 +1,14 @@ +defmodule Plausible.Repo.Migrations.AddSpikeNotifications do + use Ecto.Migration + + def change do + create table(:spike_notifications) do + add :site_id, references(:sites), null: false + add :threshold, :integer, null: false + add :last_sent, :naive_datetime + add :recipients, {:array, :citext}, null: false, default: [] + + timestamps() + end + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index ec7cde25fc53d96c2d3bfcc951b62cdbea8260e8..c0a341157e6b7ffd3980b145ca4da7728381cbed 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -20,6 +20,10 @@ defmodule Plausible.Factory do merge_attributes(user, attrs) end + def spike_notification_factory do + %Plausible.Site.SpikeNotification{} + end + def site_factory do domain = sequence(:domain, &"example-#{&1}.com") diff --git a/test/workers/spike_notifier_test.exs b/test/workers/spike_notifier_test.exs new file mode 100644 index 0000000000000000000000000000000000000000..fb673cb62ab7bdecbbbd098cdd28266000a8b2b7 --- /dev/null +++ b/test/workers/spike_notifier_test.exs @@ -0,0 +1,44 @@ +defmodule Plausible.Workers.SpikeNotifierTest do + use Plausible.DataCase + 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, _query -> 5 end) + SpikeNotifier.perform(nil, 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, _query -> 10 end) + SpikeNotifier.perform(nil, 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 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: ["jerod@example.com", "uku@example.com"], last_sent: Timex.now()) + + clickhouse_stub = stub(Plausible.Stats.Clickhouse, :current_visitors, fn _site, _query -> 10 end) + SpikeNotifier.perform(nil, nil, clickhouse_stub) + + assert_no_emails_delivered() + end +end