defmodule PlausibleWeb.Tracker do @moduledoc """ Helper module for building the dynamic tracker script. Used by PlausibleWeb.TrackerPlug. """ use Plausible use Plausible.Repo alias Plausible.Site.TrackerScriptConfiguration import Ecto.Query path = Application.app_dir(:plausible, "priv/tracker/js/plausible-web.js") # On CI, the file might not be present for static checks so we create an empty one File.touch!(path) @plausible_main_script File.read!(path) @external_resource "priv/tracker/js/plausible-web.js" @spec get_plausible_main_script(String.t(), Keyword.t()) :: String.t() | nil def get_plausible_main_script(id, cache_opts \\ []) do on_ee do # On cloud: # 1. Check if tracker script ID is in the cache # 2. If it is, generate the script on the fly # # Note that EE is relying on CDN caching the script if Plausible.Site.TrackerScriptIdCache.get(id, cache_opts) do get_tracker_script_configuration_by_id(id) |> build_script() end else # On self-hosted, we have a pre-warmed cache for the script Plausible.Site.TrackerScriptCache.get(id, cache_opts) end end # Exposed for testing def plausible_main_config( %TrackerScriptConfiguration{site: %{domain: _domain}} = tracker_script_configuration ) do %{ domain: tracker_script_configuration.site.domain, endpoint: tracker_ingestion_endpoint(), outboundLinks: tracker_script_configuration.outbound_links, fileDownloads: tracker_script_configuration.file_downloads, formSubmissions: tracker_script_configuration.form_submissions } end def build_script( %TrackerScriptConfiguration{site: %{domain: _domain}} = tracker_script_configuration ) do config_js_content = tracker_script_configuration |> plausible_main_config() |> Enum.flat_map(fn {key, value} when is_binary(value) -> ["#{key}:#{JSON.encode!(value)}"] # :TRICKY: Save bytes by using short-hand for true {key, true} -> ["#{key}:!0"] # Not enabled values can be omitted {_key, false} -> [] end) |> Enum.sort_by(&String.length/1, :desc) |> Enum.join(",") @plausible_main_script |> String.replace("\"<%= @config_js %>\"", "{#{config_js_content}}") end def build_script(nil), do: nil on_ee do defp broadcast_script_insert(tracker_script_configuration), do: Plausible.Site.TrackerScriptIdCache.broadcast_put(tracker_script_configuration.id, true) end on_ce do defp broadcast_script_upsert(tracker_script_configuration) do case Plausible.Site.TrackerScriptCache.put( tracker_script_configuration.id, Plausible.Site.TrackerScriptCache.cache_content(tracker_script_configuration) ) do script when is_binary(script) -> :ok end end end def update_script_configuration(site, config_update, changeset_type) do Repo.transact(fn -> with {:ok, original_config} <- get_or_create_tracker_script_configuration(site), changeset <- changeset(original_config, config_update, changeset_type), {:ok, updated_config} <- Repo.update(changeset), %TrackerScriptConfiguration{} = reloaded_config <- maybe_reload_tracker_script_configuration(updated_config) do sync_goals(site, original_config, reloaded_config) on_ee do if should_purge_cache?(changeset) do purge_cache!(reloaded_config.id) end else :ok = broadcast_script_upsert(reloaded_config) end {:ok, reloaded_config} end end) end on_ee do def purge_tracker_script_cache!(site) do tracker_script_configuration = get_or_create_tracker_script_configuration!(site) purge_cache!(tracker_script_configuration.id) end defp should_purge_cache?(changeset) do Map.keys(changeset.changes) != [:installation_type] end defp purge_cache!(config_id) do Plausible.Workers.PurgeCDNCache.new( %{id: config_id}, # See PurgeCDNCache.ex for more details schedule_in: 10, replace: [scheduled: [:scheduled_at]] ) |> Oban.insert!() end else def purge_tracker_script_cache!(_site), do: nil end def get_tracker_script_configuration(site) do Repo.get_by(TrackerScriptConfiguration, site_id: site.id) end def update_script_configuration!(site, config_update, changeset_type) do {:ok, updated_config} = update_script_configuration(site, config_update, changeset_type) updated_config end def get_or_create_tracker_script_configuration(site, params \\ %{}) do configuration = get_tracker_script_configuration(site) if configuration do {:ok, configuration} else Repo.transact(fn -> with {:ok, created_config} <- Repo.insert( TrackerScriptConfiguration.installation_changeset( %TrackerScriptConfiguration{site_id: site.id}, params ) ), %TrackerScriptConfiguration{} = reloaded_config <- maybe_reload_tracker_script_configuration(created_config) do sync_goals(site, %{}, reloaded_config) on_ee do :ok = broadcast_script_insert(reloaded_config) else :ok = broadcast_script_upsert(reloaded_config) end {:ok, reloaded_config} end end) end end def get_or_create_tracker_script_configuration!(site, params \\ %{}) do {:ok, config} = get_or_create_tracker_script_configuration(site, params) config end on_ee do def supported_installation_types do ["manual", "wordpress", "gtm", "npm"] end else def supported_installation_types do ["manual", "wordpress", "npm"] end end def fallback_installation_type do "manual" end def get_tracker_script_configuration_base_query() do from(t in TrackerScriptConfiguration, join: s in assoc(t, :site), select: %{t | site: %{domain: s.domain}} ) end def get_tracker_script_configuration_by_id(id) do get_tracker_script_configuration_base_query() |> where([t], t.id == ^id) |> Plausible.Repo.one() end on_ee do defp maybe_reload_tracker_script_configuration(tracker_script_configuration), do: tracker_script_configuration else # This loads the necessary associations (:site), that aren't returned with inserts and updates defp maybe_reload_tracker_script_configuration(tracker_script_configuration), do: get_tracker_script_configuration_by_id(tracker_script_configuration.id) end # Sync plausible goals with the updated script config defp sync_goals(site, original_config, updated_config) do [:track_404_pages, :outbound_links, :file_downloads, :form_submissions] |> Enum.map(fn key -> {key, Map.get(original_config, key, false), Map.get(updated_config, key, false)} end) |> Enum.each(fn {:track_404_pages, false, true} -> Plausible.Goals.create_404(site) {:track_404_pages, true, false} -> Plausible.Goals.delete_404(site) {:outbound_links, false, true} -> Plausible.Goals.create_outbound_links(site) {:outbound_links, true, false} -> Plausible.Goals.delete_outbound_links(site) {:file_downloads, false, true} -> Plausible.Goals.create_file_downloads(site) {:file_downloads, true, false} -> Plausible.Goals.delete_file_downloads(site) {:form_submissions, false, true} -> Plausible.Goals.create_form_submissions(site) {:form_submissions, true, false} -> Plausible.Goals.delete_form_submissions(site) _ -> nil end) end defp changeset(tracker_script_configuration, config_update, :installation) do TrackerScriptConfiguration.installation_changeset(tracker_script_configuration, config_update) end defp changeset(tracker_script_configuration, config_update, :plugins_api) do TrackerScriptConfiguration.plugins_api_changeset(tracker_script_configuration, config_update) end defp tracker_ingestion_endpoint() do # :TRICKY: Normally we would use PlausibleWeb.Endpoint.url() here, but # that requires the endpoint to be started. We start the TrackerScriptCache # before the endpoint is started, so we need to use the base_url directly. endpoint_config = Application.fetch_env!(:plausible, PlausibleWeb.Endpoint) base_url = Keyword.get(endpoint_config, :base_url) "#{base_url}/api/event" end end