diff --git a/CHANGELOG.md b/CHANGELOG.md
index 314fa850da83..3417aadc74b2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file.
### Added
+- Snippet integration verification
+
### Removed
### Changed
diff --git a/Makefile b/Makefile
index 3a9d6626854e..a85ee3b8f06b 100644
--- a/Makefile
+++ b/Makefile
@@ -37,6 +37,9 @@ postgres-prod: ## Start a container with the same version of postgres as the one
postgres-stop: ## Stop and remove the postgres container
docker stop plausible_db && docker rm plausible_db
+browserless:
+ docker run -e "TOKEN=dummy_token" -p 3000:3000 --network host ghcr.io/browserless/chromium
+
minio: ## Start a transient container with a recent version of minio (s3)
docker run -d --rm -p 10000:10000 -p 10001:10001 --name plausible_minio minio/minio server /data --address ":10000" --console-address ":10001"
while ! docker exec plausible_minio mc alias set local http://localhost:10000 minioadmin minioadmin; do sleep 1; done
diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js
index 2a0c16a75326..e665c42f87f4 100644
--- a/assets/tailwind.config.js
+++ b/assets/tailwind.config.js
@@ -5,7 +5,9 @@ module.exports = {
content: [
"./js/**/*.js",
"../lib/*_web.ex",
- "../lib/*_web/**/*.*ex"
+ "../lib/*_web/**/*.*ex",
+ "../extra/*_web.ex",
+ "../extra/*_web/**/*.*ex"
],
safelist: [
// PlausibleWeb.StatsView.stats_container_class/1 uses this class
diff --git a/config/.env.dev b/config/.env.dev
index 58e4ca827d15..d7625ed80679 100644
--- a/config/.env.dev
+++ b/config/.env.dev
@@ -28,3 +28,5 @@ S3_REGION=us-east-1
S3_ENDPOINT=http://localhost:10000
S3_EXPORTS_BUCKET=dev-exports
S3_IMPORTS_BUCKET=dev-imports
+
+VERIFICATION_ENABLED=true
diff --git a/config/runtime.exs b/config/runtime.exs
index 23d9e1b7f8f6..fd1eee218524 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -701,6 +701,15 @@ config :plausible, Plausible.PromEx,
grafana: :disabled,
metrics_server: :disabled
+config :plausible, Plausible.Verification,
+ enabled?:
+ get_var_from_path_or_env(config_dir, "VERIFICATION_ENABLED", "false")
+ |> String.to_existing_atom()
+
+config :plausible, Plausible.Verification.Checks.Installation,
+ token: get_var_from_path_or_env(config_dir, "BROWSERLESS_TOKEN", "dummy_token"),
+ endpoint: get_var_from_path_or_env(config_dir, "BROWSERLESS_ENDPOINT", "http://0.0.0.0:3000")
+
if not is_selfhost do
site_default_ingest_threshold =
case System.get_env("SITE_DEFAULT_INGEST_THRESHOLD") do
diff --git a/config/test.exs b/config/test.exs
index d1d9edfac067..5c76d8dff3e4 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -31,3 +31,13 @@ config :ex_money, api_module: Plausible.ExchangeRateMock
config :plausible, Plausible.Ingestion.Counters, enabled: false
config :plausible, Oban, testing: :manual
+
+config :plausible, Plausible.Verification.Checks.FetchBody,
+ req_opts: [
+ plug: {Req.Test, Plausible.Verification.Checks.FetchBody}
+ ]
+
+config :plausible, Plausible.Verification.Checks.Installation,
+ req_opts: [
+ plug: {Req.Test, Plausible.Verification.Checks.Installation}
+ ]
diff --git a/extra/lib/plausible/ingestion/event/revenue.ex b/extra/lib/plausible/ingestion/event/revenue.ex
index 94c4a3158043..96c38ab27fd4 100644
--- a/extra/lib/plausible/ingestion/event/revenue.ex
+++ b/extra/lib/plausible/ingestion/event/revenue.ex
@@ -22,7 +22,8 @@ defmodule Plausible.Ingestion.Event.Revenue do
}
matching_goal.currency != revenue_source.currency ->
- converted = Money.to_currency!(revenue_source, matching_goal.currency)
+ converted =
+ Money.to_currency!(revenue_source, matching_goal.currency)
%{
revenue_source_amount: Money.to_decimal(revenue_source),
diff --git a/lib/plausible/ingestion/event.ex b/lib/plausible/ingestion/event.ex
index ec3653b02a22..3f905416a202 100644
--- a/lib/plausible/ingestion/event.ex
+++ b/lib/plausible/ingestion/event.ex
@@ -21,6 +21,8 @@ defmodule Plausible.Ingestion.Event do
salts: nil,
changeset: nil
+ @verification_user_agent Plausible.Verification.user_agent()
+
@type drop_reason() ::
:bot
| :spam_referrer
@@ -31,6 +33,7 @@ defmodule Plausible.Ingestion.Event do
| :site_country_blocklist
| :site_page_blocklist
| :site_hostname_allowlist
+ | :verification_agent
@type t() :: %__MODULE__{
domain: String.t() | nil,
@@ -104,6 +107,7 @@ defmodule Plausible.Ingestion.Event do
defp pipeline() do
[
+ drop_verification_agent: &drop_verification_agent/1,
drop_datacenter_ip: &drop_datacenter_ip/1,
drop_shield_rule_hostname: &drop_shield_rule_hostname/1,
drop_shield_rule_page: &drop_shield_rule_page/1,
@@ -167,6 +171,16 @@ defmodule Plausible.Ingestion.Event do
struct!(event, clickhouse_session_attrs: Map.merge(event.clickhouse_session_attrs, attrs))
end
+ defp drop_verification_agent(%__MODULE__{} = event) do
+ case event.request.user_agent do
+ @verification_user_agent ->
+ drop(event, :verification_agent)
+
+ _ ->
+ event
+ end
+ end
+
defp drop_datacenter_ip(%__MODULE__{} = event) do
case event.request.ip_classification do
"dc_ip" ->
diff --git a/lib/plausible/verification.ex b/lib/plausible/verification.ex
new file mode 100644
index 000000000000..61df2b9afb80
--- /dev/null
+++ b/lib/plausible/verification.ex
@@ -0,0 +1,26 @@
+defmodule Plausible.Verification do
+ @moduledoc """
+ Module defining the user-agent used for site verification.
+ """
+ use Plausible
+
+ @feature_flag :verification
+
+ def enabled?(user) do
+ enabled_via_config? =
+ :plausible |> Application.fetch_env!(__MODULE__) |> Keyword.fetch!(:enabled?)
+
+ enabled_for_user? = not is_nil(user) and FunWithFlags.enabled?(@feature_flag, for: user)
+ enabled_via_config? or enabled_for_user?
+ end
+
+ on_ee do
+ def user_agent() do
+ "Plausible Verification Agent - if abused, contact support@plausible.io"
+ end
+ else
+ def user_agent() do
+ "Plausible Community Edition"
+ end
+ end
+end
diff --git a/lib/plausible/verification/check.ex b/lib/plausible/verification/check.ex
new file mode 100644
index 000000000000..f031eb18880d
--- /dev/null
+++ b/lib/plausible/verification/check.ex
@@ -0,0 +1,37 @@
+defmodule Plausible.Verification.Check do
+ @moduledoc """
+ Behaviour to be implemented by specific site verification checks.
+ `friendly_name()` doesn't necessarily reflect the actual check description,
+ it serves as a user-facing message grouping mechanism, to prevent frequent message flashing when checks rotate often.
+ Each check operates on `state()` and is expected to return it, optionally modified, by all means.
+ `perform_safe/1` is used to guarantee no exceptions are thrown by faulty implementations, not to interrupt LiveView.
+ """
+ @type state() :: Plausible.Verification.State.t()
+ @callback friendly_name() :: String.t()
+ @callback perform(state()) :: state()
+
+ defmacro __using__(_) do
+ quote do
+ import Plausible.Verification.State
+
+ alias Plausible.Verification.Checks
+ alias Plausible.Verification.State
+ alias Plausible.Verification.Diagnostics
+
+ require Logger
+
+ @behaviour Plausible.Verification.Check
+
+ def perform_safe(state) do
+ perform(state)
+ catch
+ _, e ->
+ Logger.error(
+ "Error running check #{inspect(__MODULE__)} on #{state.url}: #{inspect(e)}"
+ )
+
+ put_diagnostics(state, service_error: true)
+ end
+ end
+ end
+end
diff --git a/lib/plausible/verification/checks.ex b/lib/plausible/verification/checks.ex
new file mode 100644
index 000000000000..960055d7de36
--- /dev/null
+++ b/lib/plausible/verification/checks.ex
@@ -0,0 +1,75 @@
+defmodule Plausible.Verification.Checks do
+ @moduledoc """
+ Checks that are performed during site verification.
+ Each module defined in `@checks` implements the `Plausible.Verification.Check` behaviour.
+ Checks are normally run asynchronously, except when synchronous execution is optionally required
+ for tests. Slowdowns can be optionally added, the user doesn't benefit from running the checks too quickly.
+
+ In async execution, each check notifies the caller by sending a message to it.
+ """
+ alias Plausible.Verification.Checks
+ alias Plausible.Verification.State
+
+ require Logger
+
+ @checks [
+ Checks.FetchBody,
+ Checks.CSP,
+ Checks.ScanBody,
+ Checks.Snippet,
+ Checks.SnippetCacheBust,
+ Checks.Installation
+ ]
+
+ def run(url, data_domain, opts \\ []) do
+ checks = Keyword.get(opts, :checks, @checks)
+ report_to = Keyword.get(opts, :report_to, self())
+ async? = Keyword.get(opts, :async?, true)
+ slowdown = Keyword.get(opts, :slowdown, 500)
+
+ if async? do
+ Task.start_link(fn -> do_run(url, data_domain, checks, report_to, slowdown) end)
+ else
+ do_run(url, data_domain, checks, report_to, slowdown)
+ end
+ end
+
+ def interpret_diagnostics(%State{} = state) do
+ Plausible.Verification.Diagnostics.interpret(state.diagnostics, state.url)
+ end
+
+ defp do_run(url, data_domain, checks, report_to, slowdown) do
+ init_state = %State{url: url, data_domain: data_domain, report_to: report_to}
+
+ state =
+ Enum.reduce(
+ checks,
+ init_state,
+ fn check, state ->
+ state
+ |> notify_start(check, slowdown)
+ |> check.perform_safe()
+ end
+ )
+
+ notify_verification_end(state, slowdown)
+ end
+
+ defp notify_start(state, check, slowdown) do
+ if is_pid(state.report_to) do
+ if is_integer(slowdown) and slowdown > 0, do: :timer.sleep(slowdown)
+ send(state.report_to, {:verification_check_start, {check, state}})
+ end
+
+ state
+ end
+
+ defp notify_verification_end(state, slowdown) do
+ if is_pid(state.report_to) do
+ if is_integer(slowdown) and slowdown > 0, do: :timer.sleep(slowdown)
+ send(state.report_to, {:verification_end, state})
+ end
+
+ state
+ end
+end
diff --git a/lib/plausible/verification/checks/csp.ex b/lib/plausible/verification/checks/csp.ex
new file mode 100644
index 000000000000..5ed5fb9116e9
--- /dev/null
+++ b/lib/plausible/verification/checks/csp.ex
@@ -0,0 +1,34 @@
+defmodule Plausible.Verification.Checks.CSP do
+ @moduledoc """
+ Scans the Content Security Policy header to ensure that the Plausible domain is allowed.
+ See `Plausible.Verification.Checks` for the execution sequence.
+ """
+ use Plausible.Verification.Check
+
+ @impl true
+ def friendly_name, do: "We're visiting your site to ensure that everything is working correctly"
+
+ @impl true
+ def perform(%State{assigns: %{headers: headers}} = state) do
+ case headers["content-security-policy"] do
+ [policy] ->
+ directives = String.split(policy, ";")
+
+ allowed? =
+ Enum.any?(directives, fn directive ->
+ String.contains?(directive, PlausibleWeb.Endpoint.host())
+ end)
+
+ if allowed? do
+ state
+ else
+ put_diagnostics(state, disallowed_via_csp?: true)
+ end
+
+ _ ->
+ state
+ end
+ end
+
+ def perform(state), do: state
+end
diff --git a/lib/plausible/verification/checks/fetch_body.ex b/lib/plausible/verification/checks/fetch_body.ex
new file mode 100644
index 000000000000..206f1d2a2b4c
--- /dev/null
+++ b/lib/plausible/verification/checks/fetch_body.ex
@@ -0,0 +1,64 @@
+defmodule Plausible.Verification.Checks.FetchBody do
+ @moduledoc """
+ Fetches the body of the site and extracts the HTML document, if available, for
+ further processing.
+ See `Plausible.Verification.Checks` for the execution sequence.
+ """
+ use Plausible.Verification.Check
+
+ @impl true
+ def friendly_name, do: "We're visiting your site to ensure that everything is working correctly"
+
+ @impl true
+ def perform(%State{url: "https://" <> _ = url} = state) do
+ fetch_body_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || []
+
+ opts =
+ Keyword.merge(
+ [
+ base_url: url,
+ max_redirects: 2,
+ connect_options: [timeout: 4_000],
+ receive_timeout: 4_000,
+ max_retries: 3,
+ retry_log_level: :warning
+ ],
+ fetch_body_opts
+ )
+
+ req = Req.new(opts)
+
+ case Req.get(req) do
+ {:ok, %Req.Response{status: status, body: body} = response}
+ when is_binary(body) and status in 200..299 ->
+ extract_document(state, response)
+
+ _ ->
+ state
+ end
+ end
+
+ defp extract_document(state, response) when byte_size(response.body) <= 500_000 do
+ with true <- html?(response),
+ {:ok, document} <- Floki.parse_document(response.body) do
+ state
+ |> assign(raw_body: response.body, document: document, headers: response.headers)
+ |> put_diagnostics(body_fetched?: true)
+ else
+ _ ->
+ state
+ end
+ end
+
+ defp extract_document(state, response) when byte_size(response.body) > 500_000 do
+ state
+ end
+
+ defp html?(%Req.Response{headers: headers}) do
+ headers
+ |> Map.get("content-type", "")
+ |> List.wrap()
+ |> List.first()
+ |> String.contains?("text/html")
+ end
+end
diff --git a/lib/plausible/verification/checks/installation.ex b/lib/plausible/verification/checks/installation.ex
new file mode 100644
index 000000000000..163cf72e92bf
--- /dev/null
+++ b/lib/plausible/verification/checks/installation.ex
@@ -0,0 +1,69 @@
+defmodule Plausible.Verification.Checks.Installation do
+ @verification_script_filename "verification/verify_plausible_installed.js"
+ @verification_script_path Path.join(:code.priv_dir(:plausible), @verification_script_filename)
+ @external_resource @verification_script_path
+ @code File.read!(@verification_script_path)
+
+ @moduledoc """
+ Calls the browserless.io service (local instance can be spawned with `make browserless`)
+ and runs #{@verification_script_filename} via the [function API](https://docs.browserless.io/HTTP-APIs/function).
+
+ The successful execution assumes the following JSON payload:
+ - `data.plausibleInstalled` - boolean indicating whether the `plausible()` window function was found
+ - `data.callbackStatus` - integer. 202 indicates that the server acknowledged the test event.
+
+ The test event ingestion is discarded based on user-agent, see: `Plausible.Verification.user_agent/0`
+ """
+ use Plausible.Verification.Check
+
+ @impl true
+ def friendly_name, do: "We're verifying that your visitors are being counted correctly"
+
+ @impl true
+ def perform(%State{url: url} = state) do
+ opts = [
+ headers: %{content_type: "application/json"},
+ body:
+ Jason.encode!(%{
+ code: @code,
+ context: %{
+ url: Plausible.Verification.URL.bust_url(url),
+ userAgent: Plausible.Verification.user_agent(),
+ debug: Application.get_env(:plausible, :environment) == "dev"
+ }
+ }),
+ retry: :transient,
+ retry_log_level: :warning,
+ max_retries: 2,
+ receive_timeout: 6_000
+ ]
+
+ extra_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || []
+ opts = Keyword.merge(opts, extra_opts)
+
+ case Req.post(verification_endpoint(), opts) do
+ {:ok,
+ %{
+ status: 200,
+ body: %{
+ "data" => %{"plausibleInstalled" => installed?, "callbackStatus" => callback_status}
+ }
+ }}
+ when is_boolean(installed?) ->
+ put_diagnostics(state, plausible_installed?: installed?, callback_status: callback_status)
+
+ {:ok, %{status: status}} ->
+ put_diagnostics(state, plausible_installed?: false, service_error: status)
+
+ {:error, %{reason: reason}} ->
+ put_diagnostics(state, plausible_installed?: false, service_error: reason)
+ end
+ end
+
+ defp verification_endpoint() do
+ config = Application.fetch_env!(:plausible, __MODULE__)
+ token = Keyword.fetch!(config, :token)
+ endpoint = Keyword.fetch!(config, :endpoint)
+ Path.join(endpoint, "function?token=#{token}")
+ end
+end
diff --git a/lib/plausible/verification/checks/scan_body.ex b/lib/plausible/verification/checks/scan_body.ex
new file mode 100644
index 000000000000..1239ea8824c2
--- /dev/null
+++ b/lib/plausible/verification/checks/scan_body.ex
@@ -0,0 +1,65 @@
+defmodule Plausible.Verification.Checks.ScanBody do
+ @moduledoc """
+ Naive way of detecting GTM and WordPress powered sites.
+ """
+ use Plausible.Verification.Check
+
+ @impl true
+ def friendly_name, do: "We're visiting your site to ensure that everything is working correctly"
+
+ @impl true
+ def perform(%State{assigns: %{raw_body: body}} = state) when is_binary(body) do
+ state
+ |> scan_wp_plugin()
+ |> scan_gtm()
+ |> scan_wp()
+ end
+
+ def perform(state), do: state
+
+ defp scan_wp_plugin(%{assigns: %{document: document}} = state) do
+ case Floki.find(document, ~s|meta[name="plausible-analytics-version"]|) do
+ [] ->
+ state
+
+ [_] ->
+ state
+ |> assign(skip_wordpress_check: true)
+ |> put_diagnostics(wordpress_likely?: true, wordpress_plugin?: true)
+ end
+ end
+
+ defp scan_wp_plugin(state) do
+ state
+ end
+
+ @gtm_signatures [
+ "googletagmanager.com/gtm.js"
+ ]
+
+ defp scan_gtm(state) do
+ if Enum.any?(@gtm_signatures, &String.contains?(state.assigns.raw_body, &1)) do
+ put_diagnostics(state, gtm_likely?: true)
+ else
+ state
+ end
+ end
+
+ @wordpress_signatures [
+ "wp-content",
+ "wp-includes",
+ "wp-json"
+ ]
+
+ defp scan_wp(%{assigns: %{skip_wordpress_check: true}} = state) do
+ state
+ end
+
+ defp scan_wp(state) do
+ if Enum.any?(@wordpress_signatures, &String.contains?(state.assigns.raw_body, &1)) do
+ put_diagnostics(state, wordpress_likely?: true)
+ else
+ state
+ end
+ end
+end
diff --git a/lib/plausible/verification/checks/snippet.ex b/lib/plausible/verification/checks/snippet.ex
new file mode 100644
index 000000000000..f43950ec66cf
--- /dev/null
+++ b/lib/plausible/verification/checks/snippet.ex
@@ -0,0 +1,52 @@
+defmodule Plausible.Verification.Checks.Snippet do
+ @moduledoc """
+ The check looks for Plausible snippets and tries to address the common
+ integration issues, such as bad placement, data-domain typos, unknown
+ attributes frequently added by performance optimization plugins, etc.
+ """
+ use Plausible.Verification.Check
+
+ @impl true
+ def friendly_name, do: "We're looking for the Plausible snippet on your site"
+
+ @impl true
+ def perform(%State{assigns: %{document: document}} = state) do
+ in_head = Floki.find(document, "head script[data-domain]")
+ in_body = Floki.find(document, "body script[data-domain]")
+
+ all = in_head ++ in_body
+
+ put_diagnostics(state,
+ snippets_found_in_head: Enum.count(in_head),
+ snippets_found_in_body: Enum.count(in_body),
+ proxy_likely?: proxy_likely?(all),
+ snippet_unknown_attributes?: unknown_attributes?(all),
+ data_domain_mismatch?: data_domain_mismatch?(all, state.data_domain)
+ )
+ end
+
+ def perform(state), do: state
+
+ defp proxy_likely?(nodes) do
+ nodes
+ |> Floki.attribute("src")
+ |> Enum.any?(&(not String.starts_with?(&1, PlausibleWeb.Endpoint.url())))
+ end
+
+ @known_attributes ["data-domain", "src", "defer", "data-api", "data-exclude", "data-include"]
+ @known_prefix "event-"
+
+ defp unknown_attributes?(nodes) do
+ Enum.any?(nodes, fn {_, attrs, _} ->
+ Enum.any?(attrs, fn {key, _} ->
+ key not in @known_attributes and not String.starts_with?(key, @known_prefix)
+ end)
+ end)
+ end
+
+ defp data_domain_mismatch?(nodes, data_domain) do
+ nodes
+ |> Floki.attribute("data-domain")
+ |> Enum.any?(&(&1 != data_domain and data_domain not in String.split(&1, ",")))
+ end
+end
diff --git a/lib/plausible/verification/checks/snippet_cache_bust.ex b/lib/plausible/verification/checks/snippet_cache_bust.ex
new file mode 100644
index 000000000000..0b1123c981f4
--- /dev/null
+++ b/lib/plausible/verification/checks/snippet_cache_bust.ex
@@ -0,0 +1,40 @@
+defmodule Plausible.Verification.Checks.SnippetCacheBust do
+ @moduledoc """
+ A naive way of trying to figure out whether the latest site contents
+ is wrapped with some CDN/caching layer.
+ In case no snippets were found, we'll try to bust the cache by appending a random query parameter
+ and re-run `Plausible.Verification.Checks.FetchBody` and `Plausible.Verification.Checks.Snippet` checks.
+ If the result is different this time, we'll assume cache likely.
+ """
+ use Plausible.Verification.Check
+
+ @impl true
+ def friendly_name, do: "We're looking for the Plausible snippet on your site"
+
+ @impl true
+ def perform(
+ %State{
+ url: url,
+ diagnostics: %Diagnostics{
+ snippets_found_in_head: 0,
+ snippets_found_in_body: 0,
+ body_fetched?: true
+ }
+ } = state
+ ) do
+ state2 =
+ %{state | url: Plausible.Verification.URL.bust_url(url)}
+ |> Plausible.Verification.Checks.FetchBody.perform()
+ |> Plausible.Verification.Checks.ScanBody.perform()
+ |> Plausible.Verification.Checks.Snippet.perform()
+
+ if state2.diagnostics.snippets_found_in_head > 0 or
+ state2.diagnostics.snippets_found_in_body > 0 do
+ put_diagnostics(state2, snippet_found_after_busting_cache?: true)
+ else
+ state
+ end
+ end
+
+ def perform(state), do: state
+end
diff --git a/lib/plausible/verification/diagnostics.ex b/lib/plausible/verification/diagnostics.ex
new file mode 100644
index 000000000000..06f26d20f40a
--- /dev/null
+++ b/lib/plausible/verification/diagnostics.ex
@@ -0,0 +1,378 @@
+defmodule Plausible.Verification.Diagnostics do
+ @moduledoc """
+ Module responsible for translating diagnostics to user-friendly messages and recommendations.
+ """
+ require Logger
+
+ defstruct plausible_installed?: false,
+ snippets_found_in_head: 0,
+ snippets_found_in_body: 0,
+ snippet_found_after_busting_cache?: false,
+ snippet_unknown_attributes?: false,
+ disallowed_via_csp?: false,
+ service_error: nil,
+ body_fetched?: false,
+ wordpress_likely?: false,
+ gtm_likely?: false,
+ callback_status: -1,
+ proxy_likely?: false,
+ data_domain_mismatch?: false,
+ wordpress_plugin?: false
+
+ @type t :: %__MODULE__{}
+
+ defmodule Result do
+ @moduledoc """
+ Diagnostics interpretation result.
+ """
+ defstruct ok?: false, errors: [], recommendations: []
+ @type t :: %__MODULE__{}
+ end
+
+ @spec interpret(t(), String.t()) :: Result.t()
+ def interpret(
+ %__MODULE__{
+ plausible_installed?: true,
+ snippets_found_in_head: 1,
+ snippets_found_in_body: 0,
+ callback_status: 202,
+ snippet_found_after_busting_cache?: false,
+ service_error: nil,
+ data_domain_mismatch?: false
+ },
+ _url
+ ) do
+ %Result{ok?: true}
+ end
+
+ def interpret(%__MODULE__{plausible_installed?: false, gtm_likely?: true}, _url) do
+ %Result{
+ ok?: false,
+ errors: ["We encountered an issue with your Plausible integration"],
+ recommendations: [
+ {"As you're using Google Tag Manager, you'll need to use a GTM-specific Plausible snippet",
+ "https://plausible.io/docs/google-tag-manager"}
+ ]
+ }
+ end
+
+ def interpret(
+ %__MODULE__{
+ plausible_installed?: false,
+ snippets_found_in_head: 1,
+ disallowed_via_csp?: true,
+ proxy_likely?: false
+ },
+ _url
+ ) do
+ %Result{
+ ok?: false,
+ errors: ["We encountered an issue with your site's CSP"],
+ recommendations: [
+ {"Please add plausible.io domain specifically to the allowed list of domains in your Content Security Policy (CSP)",
+ "https://plausible.io/docs/troubleshoot-integration"}
+ ]
+ }
+ end
+
+ def interpret(
+ %__MODULE__{
+ plausible_installed?: false,
+ snippets_found_in_head: 0,
+ snippets_found_in_body: 0,
+ body_fetched?: true,
+ service_error: nil,
+ wordpress_likely?: false
+ },
+ _url
+ ) do
+ %Result{
+ ok?: false,
+ errors: ["We couldn't find the Plausible snippet on your site"],
+ recommendations: [
+ {"Please insert the snippet into your site", "https://plausible.io/docs/plausible-script"}
+ ]
+ }
+ end
+
+ def interpret(
+ %__MODULE__{
+ plausible_installed?: false,
+ body_fetched?: false
+ },
+ url
+ ) do
+ %Result{
+ ok?: false,
+ errors: ["We couldn't reach #{url}. Is your site up?"],
+ recommendations: [
+ {"If your site is running at a different location, please manually check your integration",
+ "https://plausible.io/docs/troubleshoot-integration"}
+ ]
+ }
+ end
+
+ def interpret(
+ %__MODULE__{
+ plausible_installed?: false,
+ service_error: service_error
+ },
+ _url
+ )
+ when not is_nil(service_error) do
+ %Result{
+ ok?: false,
+ errors: ["We encountered a temporary problem verifying your website"],
+ recommendations: [
+ {"Please try again in a few minutes or manually check your integration",
+ "https://plausible.io/docs/troubleshoot-integration"}
+ ]
+ }
+ end
+
+ def interpret(
+ %__MODULE__{
+ plausible_installed?: true,
+ service_error: nil,
+ body_fetched?: false
+ },
+ url
+ ) do
+ %Result{
+ ok?: false,
+ errors: ["We couldn't reach #{url}. Is your site up?"],
+ recommendations: [
+ {"If your site is running at a different location, please manually check your integration",
+ "https://plausible.io/docs/troubleshoot-integration"}
+ ]
+ }
+ end
+
+ def interpret(
+ %__MODULE__{
+ plausible_installed?: true,
+ callback_status: callback_status,
+ proxy_likely?: true
+ },
+ _url
+ )
+ when callback_status != 202 do
+ %Result{
+ ok?: false,
+ errors: ["We encountered an error with your Plausible proxy"],
+ recommendations: [
+ {"Please check whether you've configured the /event route correctly",
+ "https://plausible.io/docs/proxy/introduction"}
+ ]
+ }
+ end
+
+ def interpret(
+ %__MODULE__{
+ plausible_installed?: false,
+ snippets_found_in_head: 1,
+ proxy_likely?: true,
+ wordpress_likely?: true,
+ wordpress_plugin?: false
+ },
+ _url
+ ) do
+ %Result{
+ ok?: false,
+ errors: ["We encountered an error with your Plausible proxy"],
+ recommendations: [
+ {"Please re-enable the proxy in our WordPress plugin to start counting your visitors",
+ "https://plausible.io/wordpress-analytics-plugin"}
+ ]
+ }
+ end
+
+ def interpret(
+ %__MODULE__{
+ plausible_installed?: false,
+ snippets_found_in_head: 1,
+ proxy_likely?: true,
+ wordpress_likely?: false
+ },
+ _url
+ ) do
+ %Result{
+ ok?: false,
+ errors: ["We encountered an error with your Plausible proxy"],
+ recommendations: [
+ {"Please check your proxy configuration to make sure it's set up correctly",
+ "https://plausible.io/docs/proxy/introduction"}
+ ]
+ }
+ end
+
+ def interpret(
+ %__MODULE__{snippets_found_in_head: count_head, snippets_found_in_body: count_body},
+ _url
+ )
+ when count_head + count_body > 1 do
+ %Result{
+ ok?: false,
+ errors: ["We've found multiple Plausible snippets on your site."],
+ recommendations: [
+ {"Please ensure that only one snippet is used",
+ "https://plausible.io/docs/troubleshoot-integration"}
+ ]
+ }
+ end
+
+ def interpret(
+ %__MODULE__{
+ plausible_installed?: true,
+ callback_status: 202,
+ snippet_found_after_busting_cache?: true,
+ wordpress_likely?: true,
+ wordpress_plugin?: true
+ },
+ _url
+ ) do
+ %Result{
+ ok?: false,
+ errors: ["We encountered an issue with your site cache"],
+ recommendations: [
+ {"Please clear your WordPress cache to ensure that the latest version of your site is being displayed to all your visitors",
+ "https://plausible.io/wordpress-analytics-plugin"}
+ ]
+ }
+ end
+
+ def interpret(
+ %__MODULE__{
+ plausible_installed?: true,
+ callback_status: 202,
+ snippet_found_after_busting_cache?: true,
+ wordpress_likely?: true,
+ wordpress_plugin?: false
+ },
+ _url
+ ) do
+ %Result{
+ ok?: false,
+ errors: ["We encountered an issue with your site cache"],
+ recommendations: [
+ {"Please install and activate our WordPress plugin to start counting your visitors",
+ "https://plausible.io/wordpress-analytics-plugin"}
+ ]
+ }
+ end
+
+ def interpret(
+ %__MODULE__{
+ plausible_installed?: true,
+ callback_status: 202,
+ snippet_found_after_busting_cache?: true,
+ wordpress_likely?: false
+ },
+ _url
+ ) do
+ %Result{
+ ok?: false,
+ errors: ["We encountered an issue with your site cache"],
+ recommendations: [
+ {"Please clear your cache (or wait for your provider to clear it) to ensure that the latest version of your site is being displayed to all your visitors",
+ "https://plausible.io/docs/troubleshoot-integration"}
+ ]
+ }
+ end
+
+ def interpret(%__MODULE__{snippets_found_in_head: 0, snippets_found_in_body: n}, _url)
+ when n >= 1 do
+ %Result{
+ ok?: false,
+ errors: ["Plausible snippet is placed in the body of your site"],
+ recommendations: [
+ {"Please relocate the snippet to the header of your site",
+ "https://plausible.io/docs/troubleshoot-integration"}
+ ]
+ }
+ end
+
+ def interpret(%__MODULE__{data_domain_mismatch?: true}, url) do
+ %Result{
+ ok?: false,
+ errors: ["Your data-domain is different than #{url}"],
+ recommendations: [
+ {"Please ensure that the site in the data-domain attribute is an exact match to the site as you added it to your Plausible account",
+ "https://plausible.io/docs/troubleshoot-integration"}
+ ]
+ }
+ end
+
+ def interpret(
+ %__MODULE__{
+ plausible_installed?: false,
+ snippet_unknown_attributes?: true,
+ wordpress_likely?: true,
+ wordpress_plugin?: true
+ },
+ _url
+ ) do
+ %Result{
+ ok?: false,
+ errors: ["A performance optimization plugin seems to have altered our snippet"],
+ recommendations: [
+ {"Please whitelist our script in your performance optimization plugin to stop it from changing our snippet",
+ "https://plausible.io/wordpress-analytics-plugin "}
+ ]
+ }
+ end
+
+ def interpret(
+ %__MODULE__{
+ plausible_installed?: false,
+ snippet_unknown_attributes?: true,
+ wordpress_likely?: true,
+ wordpress_plugin?: false
+ },
+ _url
+ ) do
+ %Result{
+ ok?: false,
+ errors: ["A performance optimization plugin seems to have altered our snippet"],
+ recommendations: [
+ {"Please install and activate our WordPress plugin to avoid the most common plugin conflicts",
+ "https://plausible.io/wordpress-analytics-plugin "}
+ ]
+ }
+ end
+
+ def interpret(
+ %__MODULE__{
+ plausible_installed?: false,
+ snippet_unknown_attributes?: true,
+ wordpress_likely?: false
+ },
+ _url
+ ) do
+ %Result{
+ ok?: false,
+ errors: ["Something seems to have altered our snippet"],
+ recommendations: [
+ {"Please manually check your integration to make sure that nothing prevents our script from working",
+ "https://plausible.io/docs/troubleshoot-integration"}
+ ]
+ }
+ end
+
+ def interpret(rating, url) do
+ Sentry.capture_message("Unhandled case for site verification: #{url}",
+ extra: %{
+ message: inspect(rating)
+ }
+ )
+
+ %Result{
+ ok?: false,
+ errors: ["Your Plausible integration is not working"],
+ recommendations: [
+ {"Please manually check your integration to make sure that the Plausible snippet has been inserted correctly",
+ "https://plausible.io/docs/troubleshoot-integration"}
+ ]
+ }
+ end
+end
diff --git a/lib/plausible/verification/state.ex b/lib/plausible/verification/state.ex
new file mode 100644
index 000000000000..7142f25ca8d7
--- /dev/null
+++ b/lib/plausible/verification/state.ex
@@ -0,0 +1,27 @@
+defmodule Plausible.Verification.State do
+ @moduledoc """
+ The struct and interface describing the state of the site verification process.
+ Assigns are meant to be used to communicate between checks, while diagnostics
+ are later on interpreted (translated into user-friendly messages and recommendations)
+ via `Plausible.Verification.Diagnostics` module.
+ """
+ defstruct url: nil,
+ data_domain: nil,
+ report_to: nil,
+ assigns: %{},
+ diagnostics: %Plausible.Verification.Diagnostics{}
+
+ @type t() :: %__MODULE__{}
+
+ def assign(%__MODULE__{} = state, assigns) do
+ %{state | assigns: Map.merge(state.assigns, Enum.into(assigns, %{}))}
+ end
+
+ def put_diagnostics(%__MODULE__{} = state, diagnostics) when is_list(diagnostics) do
+ %{state | diagnostics: struct!(state.diagnostics, diagnostics)}
+ end
+
+ def put_diagnostics(%__MODULE__{} = state, diagnostics) do
+ put_diagnostics(state, List.wrap(diagnostics))
+ end
+end
diff --git a/lib/plausible/verification/url.ex b/lib/plausible/verification/url.ex
new file mode 100644
index 000000000000..1806c8d3b661
--- /dev/null
+++ b/lib/plausible/verification/url.ex
@@ -0,0 +1,25 @@
+defmodule Plausible.Verification.URL do
+ @moduledoc """
+ Busting some caches by appending ?plausible_verification=12345 to it.
+ """
+
+ def bust_url(url) do
+ cache_invalidator = abs(:erlang.unique_integer())
+ update_url(url, cache_invalidator)
+ end
+
+ defp update_url(url, invalidator) do
+ url
+ |> URI.parse()
+ |> then(fn uri ->
+ updated_query =
+ (uri.query || "")
+ |> URI.decode_query()
+ |> Map.put("plausible_verification", invalidator)
+ |> URI.encode_query()
+
+ struct!(uri, query: updated_query)
+ end)
+ |> to_string()
+ end
+end
diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex
index ce8c23d4cb3c..ee0f074b6b9e 100644
--- a/lib/plausible_web/components/generic.ex
+++ b/lib/plausible_web/components/generic.ex
@@ -356,8 +356,34 @@ defmodule PlausibleWeb.Components.Generic do
apply(Heroicons, assigns.name, [assigns])
end
+ attr :width, :integer, default: 100
+ attr :height, :integer, default: 100
+ attr :id, :string, default: "shuttle"
+
+ def shuttle(assigns) do
+ ~H"""
+
+ """
+ end
+
defp icon_class(link_assigns) do
- if String.contains?(link_assigns[:class], "text-sm") do
+ if String.contains?(link_assigns[:class], "text-sm") or
+ String.contains?(link_assigns[:class], "text-xs") do
["w-3 h-3"]
else
["w-4 h-4"]
diff --git a/lib/plausible_web/controllers/site_controller.ex b/lib/plausible_web/controllers/site_controller.ex
index 738ef40c3b81..887c68d0ebf5 100644
--- a/lib/plausible_web/controllers/site_controller.ex
+++ b/lib/plausible_web/controllers/site_controller.ex
@@ -129,6 +129,7 @@ defmodule PlausibleWeb.SiteController do
|> render("settings_general.html",
site: site,
changeset: Plausible.Site.changeset(site, %{}),
+ connect_live_socket: true,
dogfood_page_path: "/:dashboard/settings/general",
layout: {PlausibleWeb.LayoutView, "site_settings.html"}
)
diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex
index d19864743201..1325fe642a93 100644
--- a/lib/plausible_web/controllers/stats_controller.ex
+++ b/lib/plausible_web/controllers/stats_controller.ex
@@ -56,9 +56,10 @@ defmodule PlausibleWeb.StatsController do
can_see_stats? = not Sites.locked?(site) or conn.assigns[:current_user_role] == :super_admin
demo = site.domain == PlausibleWeb.Endpoint.host()
dogfood_page_path = if !demo, do: "/:dashboard"
+ skip_to_dashboard? = conn.params["skip_to_dashboard"] == "true"
cond do
- stats_start_date && can_see_stats? ->
+ (stats_start_date && can_see_stats?) || (can_see_stats? && skip_to_dashboard?) ->
conn
|> put_resp_header("x-robots-tag", "noindex, nofollow")
|> render("stats.html",
@@ -80,7 +81,8 @@ defmodule PlausibleWeb.StatsController do
!stats_start_date && can_see_stats? ->
render(conn, "waiting_first_pageview.html",
site: site,
- dogfood_page_path: dogfood_page_path
+ dogfood_page_path: dogfood_page_path,
+ connect_live_socket: true
)
Sites.locked?(site) ->
diff --git a/lib/plausible_web/live/components/verification.ex b/lib/plausible_web/live/components/verification.ex
new file mode 100644
index 000000000000..e483abe6a598
--- /dev/null
+++ b/lib/plausible_web/live/components/verification.ex
@@ -0,0 +1,145 @@
+defmodule PlausibleWeb.Live.Components.Verification do
+ @moduledoc """
+ This component is responsible for rendering the verification progress
+ and diagnostics.
+ """
+ use Phoenix.LiveComponent
+ use Plausible
+
+ import PlausibleWeb.Components.Generic
+
+ attr :domain, :string, required: true
+ attr :modal?, :boolean, default: false
+
+ attr :message, :string,
+ default: "We're visiting your site to ensure that everything is working correctly"
+
+ attr :finished?, :boolean, default: false
+ attr :success?, :boolean, default: false
+ attr :interpretation, Plausible.Verification.Diagnostics.Result, default: nil
+ attr :attempts, :integer, default: 0
+
+ def render(assigns) do
+ ~H"""
+
+
+ <%= if @success? && @finished? do %>
+ Success!
+ <% else %>
+ Verifying your integration
+ <% end %>
+
+
+ <%= if @finished? && @success? do %>
+ Your integration is working and visitors are being counted accurately
+ <% else %>
+ on <%= @domain %>
+ <% end %>
+
+ <.button_link :if={!@success?} href="#" phx-click="retry" class="text-xs font-bold">
+ Verify integration again
+
+ <.button_link
+ :if={@success?}
+ href={"/#{URI.encode_www_form(@domain)}?skip_to_dashboard=true"}
+ class="text-xs font-bold"
+ >
+ Go to the dashboard
+
+
+
+ <%= if ee?() && @finished? && !@success? && @attempts >= 3 do %>
+ Need further help with your integration? Do
+ <.styled_link href="https://plausible.io/contact">
+ contact us
+
+
+ <% end %>
+ <%= if !@modal? && !@success? do %>
+ Need to see the snippet again?
+ <.styled_link href={"/#{URI.encode_www_form(@domain)}/snippet"}>
+ Click here
+
+ Run verification later and go to Site Settings?
+ <.styled_link href={"/#{URI.encode_www_form(@domain)}/settings/general"}>
+ Click here
+
+
+ <% end %>
+
+
+ """
+ end
+
+ def recommendations(assigns) do
+ ~H"""
+
+ Include this snippet in the <head>
+ section of your website. To verify your integration, click the button below to confirm that everything is working correctly.
+
+
Paste this snippet in the <head> of your website.
@@ -60,7 +64,13 @@
- <%= link("Start collecting data →",
+ <% button_label =
+ if Plausible.Verification.enabled?(@current_user) do
+ "Verify your integration to start collecting data →"
+ else
+ "Start collecting data →"
+ end %>
+ <%= link(button_label,
class: "button mt-4 w-full",
to: "/#{URI.encode_www_form(@site.domain)}"
) %>
diff --git a/lib/plausible_web/templates/stats/waiting_first_pageview.html.heex b/lib/plausible_web/templates/stats/waiting_first_pageview.html.heex
index 5bc248735e1e..78b0a14fc0bf 100644
--- a/lib/plausible_web/templates/stats/waiting_first_pageview.html.heex
+++ b/lib/plausible_web/templates/stats/waiting_first_pageview.html.heex
@@ -11,7 +11,6 @@
setInterval(updateStatus, 5000)
-
<%= if @site.locked do %>
This dashboard is actually locked. You are viewing it with super-admin access