Upload folder using huggingface_hub (part 2)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- lib/plausible.ex +82 -0
- lib/plausible/data_migration.ex +127 -0
- lib/plausible/data_migration/postgres_repo.ex +22 -0
- lib/plausible/data_migration/prefix_tracker_script_configuration_id.ex +96 -0
- lib/plausible/data_migration/site_imports.ex +270 -0
- lib/plausible/data_migration/versioned_sessions.ex +80 -0
- lib/plausible/dns_lookup.ex +31 -0
- lib/plausible/ecto/types/compiled_regex.ex +14 -0
- lib/plausible/ecto/types/nanoid.ex +51 -0
- lib/plausible/event/system_events.ex +101 -0
- lib/plausible/event/write_buffer.ex +38 -0
- lib/plausible/exports.ex +715 -0
- lib/plausible/file.ex +27 -0
- lib/plausible/funnel/const.ex +26 -0
- lib/plausible/geo.ex +207 -0
- lib/plausible/goal.ex +317 -0
- lib/plausible/goals/goals.ex +490 -0
- lib/plausible/google/api.ex +201 -0
- lib/plausible/google/ga4/api.ex +215 -0
- lib/plausible/google/ga4/http.ex +330 -0
- lib/plausible/google/ga4/report_request.ex +168 -0
- lib/plausible/google/http.ex +110 -0
- lib/plausible/google/search_console/filters.ex +77 -0
- lib/plausible/helpers/changeset.ex +34 -0
- lib/plausible/helpers/config.ex +53 -0
- lib/plausible/helpers/json.ex +15 -0
- lib/plausible/http_client.ex +189 -0
- lib/plausible/import_deletion_repo.ex +17 -0
- lib/plausible/imported.ex +212 -0
- lib/plausible/imported/browser.ex +18 -0
- lib/plausible/imported/buffer.ex +115 -0
- lib/plausible/imported/csv_importer.ex +372 -0
- lib/plausible/imported/custom_event.ex +16 -0
- lib/plausible/imported/device.ex +17 -0
- lib/plausible/imported/entry_page.ex +17 -0
- lib/plausible/imported/exit_page.ex +17 -0
- lib/plausible/imported/google_analytics4.ex +357 -0
- lib/plausible/imported/import_sources.ex +24 -0
- lib/plausible/imported/importer.ex +261 -0
- lib/plausible/imported/location.ex +19 -0
- lib/plausible/imported/noop_importer.ex +46 -0
- lib/plausible/imported/operating_system.ex +18 -0
- lib/plausible/imported/page.ex +21 -0
- lib/plausible/imported/site_import.ex +73 -0
- lib/plausible/imported/source.ex +24 -0
- lib/plausible/imported/universal_analytics.ex +29 -0
- lib/plausible/imported/visitor.ex +16 -0
- lib/plausible/ingest_repo.ex +28 -0
- lib/plausible/ingestion/acquisition.ex +218 -0
- lib/plausible/ingestion/city_overrides.ex +202 -0
lib/plausible.ex
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Build-related macros
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
@ce_builds [:ce, :ce_test, :ce_dev]
|
| 7 |
+
|
| 8 |
+
defmacro __using__(_) do
|
| 9 |
+
quote do
|
| 10 |
+
require Plausible
|
| 11 |
+
import Plausible
|
| 12 |
+
end
|
| 13 |
+
end
|
| 14 |
+
|
| 15 |
+
defmacro on_ee(clauses) do
|
| 16 |
+
do_on_ee(clauses)
|
| 17 |
+
end
|
| 18 |
+
|
| 19 |
+
defmacro on_ce(clauses) do
|
| 20 |
+
do_on_ce(clauses)
|
| 21 |
+
end
|
| 22 |
+
|
| 23 |
+
# :erlang.phash2(1, 1) == 0 tricks dialyzer as per:
|
| 24 |
+
# https://github.com/elixir-lang/elixir/blob/v1.12.3/lib/elixir/lib/gen_server.ex#L771-L778
|
| 25 |
+
# and also tricks elixir >1.18 type checker
|
| 26 |
+
|
| 27 |
+
defmacro always(term) do
|
| 28 |
+
quote do
|
| 29 |
+
case :erlang.phash2(1, 1) do
|
| 30 |
+
0 ->
|
| 31 |
+
unquote(term)
|
| 32 |
+
|
| 33 |
+
1 ->
|
| 34 |
+
not unquote(term)
|
| 35 |
+
end
|
| 36 |
+
end
|
| 37 |
+
end
|
| 38 |
+
|
| 39 |
+
defmacro ee? do
|
| 40 |
+
quote do
|
| 41 |
+
always(unquote(Mix.env() not in @ce_builds))
|
| 42 |
+
end
|
| 43 |
+
end
|
| 44 |
+
|
| 45 |
+
defmacro ce? do
|
| 46 |
+
quote do
|
| 47 |
+
always(unquote(Mix.env() in @ce_builds))
|
| 48 |
+
end
|
| 49 |
+
end
|
| 50 |
+
|
| 51 |
+
defp do_on_ce(do: block) do
|
| 52 |
+
do_on_ee(do: nil, else: block)
|
| 53 |
+
end
|
| 54 |
+
|
| 55 |
+
defp do_on_ce(do: do_block, else: else_block) do
|
| 56 |
+
do_on_ee(do: else_block, else: do_block)
|
| 57 |
+
end
|
| 58 |
+
|
| 59 |
+
defp do_on_ee(do: block) do
|
| 60 |
+
do_on_ee(do: block, else: nil)
|
| 61 |
+
end
|
| 62 |
+
|
| 63 |
+
defp do_on_ee(do: do_block, else: else_block) do
|
| 64 |
+
if ee?() do
|
| 65 |
+
quote do
|
| 66 |
+
unquote(do_block)
|
| 67 |
+
end
|
| 68 |
+
else
|
| 69 |
+
quote do
|
| 70 |
+
unquote(else_block)
|
| 71 |
+
end
|
| 72 |
+
end
|
| 73 |
+
end
|
| 74 |
+
|
| 75 |
+
def product_name do
|
| 76 |
+
if ee?() do
|
| 77 |
+
"Plausible Analytics"
|
| 78 |
+
else
|
| 79 |
+
"Plausible CE"
|
| 80 |
+
end
|
| 81 |
+
end
|
| 82 |
+
end
|
lib/plausible/data_migration.ex
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.DataMigration do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Base module for coordinated Clickhouse data migrations
|
| 4 |
+
run via remote shell or otherwise (TBD).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
defmacro __using__(opts) do
|
| 8 |
+
dir = Keyword.fetch!(opts, :dir)
|
| 9 |
+
repo = Keyword.get(opts, :repo, Plausible.DataMigration.ClickhouseRepo)
|
| 10 |
+
|
| 11 |
+
quote bind_quoted: [dir: dir, repo: repo] do
|
| 12 |
+
@dir dir
|
| 13 |
+
@repo repo
|
| 14 |
+
|
| 15 |
+
def run_sql_confirm(name, assigns \\ [], options \\ []) do
|
| 16 |
+
query = unwrap_with_io(name, assigns)
|
| 17 |
+
message = Keyword.get(options, :prompt_message, "Execute?")
|
| 18 |
+
default_choice = Keyword.get(options, :prompt_default_choice, :yes)
|
| 19 |
+
confirm(message, fn -> do_run(name, query) end, default_choice)
|
| 20 |
+
end
|
| 21 |
+
|
| 22 |
+
def confirm(message, func, default_choice \\ :yes) do
|
| 23 |
+
choices =
|
| 24 |
+
case default_choice do
|
| 25 |
+
:yes -> " [Y/n]: "
|
| 26 |
+
:no -> " [y/N]: "
|
| 27 |
+
end
|
| 28 |
+
|
| 29 |
+
prompt = IO.ANSI.white() <> message <> choices <> IO.ANSI.reset()
|
| 30 |
+
answer = String.downcase(String.trim(IO.gets(prompt)))
|
| 31 |
+
|
| 32 |
+
skip = fn ->
|
| 33 |
+
IO.puts(" #{IO.ANSI.cyan()}Skipped.#{IO.ANSI.reset()}")
|
| 34 |
+
{:ok, :skip}
|
| 35 |
+
end
|
| 36 |
+
|
| 37 |
+
case answer do
|
| 38 |
+
"y" ->
|
| 39 |
+
func.()
|
| 40 |
+
|
| 41 |
+
"n" ->
|
| 42 |
+
skip.()
|
| 43 |
+
|
| 44 |
+
_ ->
|
| 45 |
+
case default_choice do
|
| 46 |
+
:yes -> func.()
|
| 47 |
+
:no -> skip.()
|
| 48 |
+
end
|
| 49 |
+
end
|
| 50 |
+
end
|
| 51 |
+
|
| 52 |
+
def unwrap(name, assigns \\ []) do
|
| 53 |
+
:plausible
|
| 54 |
+
|> :code.priv_dir()
|
| 55 |
+
|> Path.join("data_migrations")
|
| 56 |
+
|> Path.join(@dir)
|
| 57 |
+
|> Path.join("sql")
|
| 58 |
+
|> Path.join(name <> ".sql.eex")
|
| 59 |
+
|> EEx.eval_file(assigns: assigns)
|
| 60 |
+
end
|
| 61 |
+
|
| 62 |
+
@doc """
|
| 63 |
+
Runs a single SQL query in a file.
|
| 64 |
+
|
| 65 |
+
Valid options:
|
| 66 |
+
- `quiet` - reduces output from running the SQL
|
| 67 |
+
- `params` - List of query parameters.
|
| 68 |
+
- `query_options` - passed to Repo.query
|
| 69 |
+
"""
|
| 70 |
+
def run_sql(name, assigns \\ [], options \\ []) do
|
| 71 |
+
query = unwrap(name, assigns)
|
| 72 |
+
|
| 73 |
+
do_run(name, query, options)
|
| 74 |
+
end
|
| 75 |
+
|
| 76 |
+
@doc """
|
| 77 |
+
Runs multiple SQL queries from a single file.
|
| 78 |
+
|
| 79 |
+
Note that each query must be separated by semicolons.
|
| 80 |
+
"""
|
| 81 |
+
def run_sql_multi(name, assigns \\ [], options \\ []) do
|
| 82 |
+
unwrap(name, assigns)
|
| 83 |
+
|> String.trim()
|
| 84 |
+
|> String.split(";", trim: true)
|
| 85 |
+
|> Enum.with_index(1)
|
| 86 |
+
|> Enum.reduce_while(:ok, fn {query, index}, _ ->
|
| 87 |
+
case do_run("#{name}-#{index}", query, options) do
|
| 88 |
+
{:ok, _} -> {:cont, :ok}
|
| 89 |
+
error -> {:halt, error}
|
| 90 |
+
end
|
| 91 |
+
end)
|
| 92 |
+
end
|
| 93 |
+
|
| 94 |
+
def do_run(name, query, options \\ []) do
|
| 95 |
+
params = Keyword.get(options, :params, [])
|
| 96 |
+
query_options = Keyword.get(options, :query_options, [])
|
| 97 |
+
|
| 98 |
+
case @repo.query(query, params, [timeout: :infinity] ++ query_options) do
|
| 99 |
+
{:ok, res} ->
|
| 100 |
+
if not Keyword.get(options, :quiet, false) do
|
| 101 |
+
IO.puts(
|
| 102 |
+
" #{IO.ANSI.yellow()}#{name} #{IO.ANSI.green()}Done!#{IO.ANSI.reset()}\n"
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
IO.puts(String.duplicate("-", 78))
|
| 106 |
+
end
|
| 107 |
+
|
| 108 |
+
{:ok, res}
|
| 109 |
+
|
| 110 |
+
result ->
|
| 111 |
+
result
|
| 112 |
+
end
|
| 113 |
+
end
|
| 114 |
+
|
| 115 |
+
defp unwrap_with_io(name, assigns) do
|
| 116 |
+
IO.puts("#{IO.ANSI.yellow()}Running #{name}#{IO.ANSI.reset()}")
|
| 117 |
+
query = unwrap(name, assigns)
|
| 118 |
+
|
| 119 |
+
IO.puts("""
|
| 120 |
+
-> Query: #{IO.ANSI.blue()}#{String.trim(query)}#{IO.ANSI.reset()}
|
| 121 |
+
""")
|
| 122 |
+
|
| 123 |
+
query
|
| 124 |
+
end
|
| 125 |
+
end
|
| 126 |
+
end
|
| 127 |
+
end
|
lib/plausible/data_migration/postgres_repo.ex
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.DataMigration.PostgresRepo do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Ecto.Repo for Posrtgres data migrations, to be started manually,
|
| 4 |
+
outside of the main application supervision tree.
|
| 5 |
+
"""
|
| 6 |
+
use Ecto.Repo,
|
| 7 |
+
otp_app: :plausible,
|
| 8 |
+
adapter: Ecto.Adapters.Postgres
|
| 9 |
+
|
| 10 |
+
def start(url, opts \\ []) when is_binary(url) do
|
| 11 |
+
default_config = Plausible.Repo.config()
|
| 12 |
+
|
| 13 |
+
start_link(
|
| 14 |
+
url: url,
|
| 15 |
+
queue_target: 500,
|
| 16 |
+
queue_interval: 2000,
|
| 17 |
+
pool_size: opts[:pool_size] || 1,
|
| 18 |
+
ssl: opts[:ssl] || default_config[:ssl],
|
| 19 |
+
ssl_opts: opts[:ssl_opts] || default_config[:ssl_opts]
|
| 20 |
+
)
|
| 21 |
+
end
|
| 22 |
+
end
|
lib/plausible/data_migration/prefix_tracker_script_configuration_id.ex
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.DataMigration.PrefixTrackerScriptConfigurationId do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Migration to update tracker script configuration IDs to use the new prefixed format.
|
| 4 |
+
|
| 5 |
+
This migration:
|
| 6 |
+
1. Processes tracker configurations in batches to avoid memory issues
|
| 7 |
+
2. Uses batch updates for better performance
|
| 8 |
+
3. Handles the case where tracker configurations might not exist for all sites
|
| 9 |
+
4. Provides progress logging
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import Ecto.Query
|
| 13 |
+
alias Plausible.Repo
|
| 14 |
+
alias Plausible.Site.TrackerScriptConfiguration
|
| 15 |
+
|
| 16 |
+
def run(batch_size \\ 100) do
|
| 17 |
+
total_configs = count_total_configurations()
|
| 18 |
+
IO.puts("Found #{total_configs} total tracker configurations to process")
|
| 19 |
+
|
| 20 |
+
process_batch(nil, total_configs, batch_size, 0)
|
| 21 |
+
end
|
| 22 |
+
|
| 23 |
+
defp count_total_configurations do
|
| 24 |
+
Repo.aggregate(
|
| 25 |
+
from(config in TrackerScriptConfiguration,
|
| 26 |
+
where: not like(config.id, "pa-%")
|
| 27 |
+
),
|
| 28 |
+
:count,
|
| 29 |
+
:id
|
| 30 |
+
)
|
| 31 |
+
end
|
| 32 |
+
|
| 33 |
+
defp process_batch(last_id, total_configs, batch_size, processed_count) do
|
| 34 |
+
configs = get_configurations_batch(last_id, batch_size)
|
| 35 |
+
|
| 36 |
+
if length(configs) > 0 do
|
| 37 |
+
batch_num = div(processed_count, batch_size) + 1
|
| 38 |
+
start_pos = processed_count + 1
|
| 39 |
+
end_pos = processed_count + length(configs)
|
| 40 |
+
|
| 41 |
+
IO.puts("Processing batch #{batch_num} (#{start_pos}-#{end_pos} of #{total_configs})")
|
| 42 |
+
|
| 43 |
+
process_configurations_batch(configs, batch_num)
|
| 44 |
+
|
| 45 |
+
process_batch(
|
| 46 |
+
List.last(configs).id,
|
| 47 |
+
total_configs,
|
| 48 |
+
batch_size,
|
| 49 |
+
processed_count + length(configs)
|
| 50 |
+
)
|
| 51 |
+
else
|
| 52 |
+
IO.puts("Migration completed!")
|
| 53 |
+
end
|
| 54 |
+
end
|
| 55 |
+
|
| 56 |
+
defp get_configurations_batch(last_id, batch_size) do
|
| 57 |
+
query =
|
| 58 |
+
from(config in TrackerScriptConfiguration,
|
| 59 |
+
where: not like(config.id, "pa-%"),
|
| 60 |
+
order_by: [asc: config.id],
|
| 61 |
+
limit: ^batch_size,
|
| 62 |
+
select: %{
|
| 63 |
+
id: config.id
|
| 64 |
+
}
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
query =
|
| 68 |
+
if last_id do
|
| 69 |
+
from(config in query, where: config.id > ^last_id)
|
| 70 |
+
else
|
| 71 |
+
query
|
| 72 |
+
end
|
| 73 |
+
|
| 74 |
+
Repo.all(query)
|
| 75 |
+
end
|
| 76 |
+
|
| 77 |
+
defp process_configurations_batch(configs, batch_num) do
|
| 78 |
+
config_ids = Enum.map(configs, & &1.id)
|
| 79 |
+
|
| 80 |
+
case Repo.query(
|
| 81 |
+
"UPDATE tracker_script_configuration SET id = 'pa-' || id WHERE id = ANY($1)",
|
| 82 |
+
[config_ids]
|
| 83 |
+
) do
|
| 84 |
+
{:ok, %{num_rows: updated_count}} ->
|
| 85 |
+
if updated_count != length(configs) do
|
| 86 |
+
IO.puts(
|
| 87 |
+
"Warning: Batch #{batch_num} - Expected to update #{length(configs)} configurations, but updated #{updated_count}"
|
| 88 |
+
)
|
| 89 |
+
end
|
| 90 |
+
|
| 91 |
+
{:error, error} ->
|
| 92 |
+
IO.puts("Error updating batch #{batch_num}: #{inspect(error)}")
|
| 93 |
+
# Continue with next batch instead of crashing
|
| 94 |
+
end
|
| 95 |
+
end
|
| 96 |
+
end
|
lib/plausible/data_migration/site_imports.ex
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.DataMigration.SiteImports do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
!!!WARNING!!!: This script is used in migrations. Please take special care
|
| 4 |
+
when altering it.
|
| 5 |
+
|
| 6 |
+
Site imports migration backfilling SiteImport entries for old imports
|
| 7 |
+
and alters import end dates to match actual end date of respective import stats.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import Ecto.Query
|
| 11 |
+
|
| 12 |
+
alias Plausible.{Repo, ClickhouseRepo, Site}
|
| 13 |
+
|
| 14 |
+
defmodule SiteImportSnapshot do
|
| 15 |
+
@moduledoc """
|
| 16 |
+
A snapshot of the Plausible.Imported.SiteImport schema from April 2024.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
use Ecto.Schema
|
| 20 |
+
|
| 21 |
+
schema "site_imports" do
|
| 22 |
+
field :start_date, :date
|
| 23 |
+
field :end_date, :date
|
| 24 |
+
field :label, :string
|
| 25 |
+
field :source, Ecto.Enum, values: [:universal_analytics, :google_analytics_4, :csv, :noop]
|
| 26 |
+
field :status, Ecto.Enum, values: [:pending, :importing, :completed, :failed]
|
| 27 |
+
field :legacy, :boolean, default: false
|
| 28 |
+
|
| 29 |
+
belongs_to :site, Plausible.Site
|
| 30 |
+
belongs_to :imported_by, Plausible.Auth.User
|
| 31 |
+
|
| 32 |
+
timestamps()
|
| 33 |
+
end
|
| 34 |
+
end
|
| 35 |
+
|
| 36 |
+
@imported_tables_april_2024 [
|
| 37 |
+
"imported_visitors",
|
| 38 |
+
"imported_sources",
|
| 39 |
+
"imported_pages",
|
| 40 |
+
"imported_entry_pages",
|
| 41 |
+
"imported_exit_pages",
|
| 42 |
+
"imported_custom_events",
|
| 43 |
+
"imported_locations",
|
| 44 |
+
"imported_devices",
|
| 45 |
+
"imported_browsers",
|
| 46 |
+
"imported_operating_systems"
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
def imported_tables_april_2024(), do: @imported_tables_april_2024
|
| 50 |
+
|
| 51 |
+
def run(opts \\ []) do
|
| 52 |
+
dry_run? = Keyword.get(opts, :dry_run?, true)
|
| 53 |
+
|
| 54 |
+
site_import_query =
|
| 55 |
+
from(i in SiteImportSnapshot,
|
| 56 |
+
where: i.site_id == parent_as(:site).id and i.status == ^:completed,
|
| 57 |
+
select: 1
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
sites_with_only_legacy_import =
|
| 61 |
+
from(s in Site,
|
| 62 |
+
as: :site,
|
| 63 |
+
select: %{id: s.id, imported_data: s.imported_data},
|
| 64 |
+
where:
|
| 65 |
+
not is_nil(s.imported_data) and fragment("?->>'status'", s.imported_data) == "ok" and
|
| 66 |
+
not exists(site_import_query)
|
| 67 |
+
)
|
| 68 |
+
|> Repo.all(log: false)
|
| 69 |
+
|
| 70 |
+
site_imports =
|
| 71 |
+
from(i in SiteImportSnapshot, where: i.status == ^:completed)
|
| 72 |
+
|> Repo.all(log: false)
|
| 73 |
+
|
| 74 |
+
legacy_site_imports = backfill_legacy_site_imports(sites_with_only_legacy_import, dry_run?)
|
| 75 |
+
|
| 76 |
+
all_site_imports = Repo.preload(site_imports ++ legacy_site_imports, :site)
|
| 77 |
+
|
| 78 |
+
adjust_site_import_end_dates(all_site_imports, dry_run?)
|
| 79 |
+
|
| 80 |
+
IO.puts("Finished")
|
| 81 |
+
end
|
| 82 |
+
|
| 83 |
+
defp backfill_legacy_site_imports(sites, dry_run?) do
|
| 84 |
+
total = length(sites)
|
| 85 |
+
|
| 86 |
+
IO.puts("Backfilling legacy site import across #{total} sites (DRY RUN: #{dry_run?})...")
|
| 87 |
+
|
| 88 |
+
legacy_site_imports =
|
| 89 |
+
for {site, idx} <- Enum.with_index(sites) do
|
| 90 |
+
IO.puts("Creating legacy site import entry for site ID #{site.id} (#{idx + 1}/#{total})")
|
| 91 |
+
|
| 92 |
+
params =
|
| 93 |
+
site.imported_data
|
| 94 |
+
|> from_legacy()
|
| 95 |
+
|> Map.put(:site_id, site.id)
|
| 96 |
+
|> Map.take([:legacy, :start_date, :end_date, :source, :status, :site_id])
|
| 97 |
+
|
| 98 |
+
%SiteImportSnapshot{}
|
| 99 |
+
|> Ecto.Changeset.change(params)
|
| 100 |
+
|> insert!(dry_run?)
|
| 101 |
+
end
|
| 102 |
+
|
| 103 |
+
IO.puts("Finished backfilling sites.")
|
| 104 |
+
|
| 105 |
+
legacy_site_imports
|
| 106 |
+
end
|
| 107 |
+
|
| 108 |
+
defp adjust_site_import_end_dates(site_imports, dry_run?) do
|
| 109 |
+
total = length(site_imports)
|
| 110 |
+
|
| 111 |
+
IO.puts("Adjusting end dates of #{total} site imports (DRY RUN: #{dry_run?})...")
|
| 112 |
+
|
| 113 |
+
for {site_import, idx} <- Enum.with_index(site_imports) do
|
| 114 |
+
IO.puts(
|
| 115 |
+
"Adjusting end date for site import #{site_import.id} (#{idx + 1}/#{total}) (site ID #{site_import.site_id}, start date: #{site_import.start_date}, end date: #{site_import.end_date})"
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
import_ids =
|
| 119 |
+
if site_import.legacy do
|
| 120 |
+
[0, site_import.id]
|
| 121 |
+
else
|
| 122 |
+
[site_import.id]
|
| 123 |
+
end
|
| 124 |
+
|
| 125 |
+
end_date = imported_stats_end_date(site_import.site_id, import_ids)
|
| 126 |
+
|
| 127 |
+
if !end_date do
|
| 128 |
+
IO.puts(
|
| 129 |
+
"Site import #{site_import.id} (site ID #{site_import.site_id}) does not have any recorded stats. Removing it."
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
if site_import.legacy do
|
| 133 |
+
# sanity check that data is correct
|
| 134 |
+
"ok" = site_import.site.imported_data.status
|
| 135 |
+
|
| 136 |
+
clear_imported_data(site_import.site, dry_run?)
|
| 137 |
+
end
|
| 138 |
+
|
| 139 |
+
delete!(site_import, dry_run?)
|
| 140 |
+
else
|
| 141 |
+
case Date.compare(end_date, site_import.end_date) do
|
| 142 |
+
:lt ->
|
| 143 |
+
IO.puts(
|
| 144 |
+
"End date of site import #{site_import.id} (site ID #{site_import.site_id}) is adjusted from #{site_import.end_date} to #{end_date}."
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
site_import
|
| 148 |
+
|> Ecto.Changeset.change(end_date: end_date)
|
| 149 |
+
|> update!(dry_run?)
|
| 150 |
+
|
| 151 |
+
# credo:disable-for-next-line Credo.Check.Refactor.Nesting
|
| 152 |
+
if site_import.legacy do
|
| 153 |
+
# sanity check that data is correct
|
| 154 |
+
"ok" = site_import.site.imported_data.status
|
| 155 |
+
|
| 156 |
+
site_import.site
|
| 157 |
+
|> Ecto.Changeset.change(imported_data: %{end_date: end_date})
|
| 158 |
+
|> update!(dry_run?)
|
| 159 |
+
end
|
| 160 |
+
|
| 161 |
+
:eq ->
|
| 162 |
+
IO.puts(
|
| 163 |
+
"End date of site import #{site_import.id} (site ID #{site_import.site_id}) is left unadjusted."
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
:gt ->
|
| 167 |
+
IO.puts(
|
| 168 |
+
"Site import #{site_import.id} (site ID #{site_import.site_id}) computed end date is later than the current one. Skipping."
|
| 169 |
+
)
|
| 170 |
+
end
|
| 171 |
+
end
|
| 172 |
+
end
|
| 173 |
+
|
| 174 |
+
IO.puts("Finished adjusting end dates of site imports.")
|
| 175 |
+
end
|
| 176 |
+
|
| 177 |
+
# Exposed for testing purposes
|
| 178 |
+
@doc false
|
| 179 |
+
def imported_stats_end_date(site_id, import_ids) do
|
| 180 |
+
[first_table | tables] = @imported_tables_april_2024
|
| 181 |
+
|
| 182 |
+
query =
|
| 183 |
+
Enum.reduce(tables, max_date_query(first_table, site_id, import_ids), fn table, query ->
|
| 184 |
+
from(s in subquery(union_all(query, ^max_date_query(table, site_id, import_ids))))
|
| 185 |
+
end)
|
| 186 |
+
|
| 187 |
+
dates = ClickhouseRepo.all(from(q in query, select: q.max_date), log: false)
|
| 188 |
+
|
| 189 |
+
if dates != [] do
|
| 190 |
+
case Enum.max(dates, Date) do
|
| 191 |
+
# no stats for this domain yet
|
| 192 |
+
~D[1970-01-01] ->
|
| 193 |
+
nil
|
| 194 |
+
|
| 195 |
+
date ->
|
| 196 |
+
date
|
| 197 |
+
end
|
| 198 |
+
else
|
| 199 |
+
nil
|
| 200 |
+
end
|
| 201 |
+
end
|
| 202 |
+
|
| 203 |
+
defp insert!(changeset, false = _dry_run?) do
|
| 204 |
+
Repo.insert!(changeset)
|
| 205 |
+
end
|
| 206 |
+
|
| 207 |
+
defp insert!(changeset, true = _dry_run?) do
|
| 208 |
+
if changeset.valid? do
|
| 209 |
+
changeset
|
| 210 |
+
|> Ecto.Changeset.change(id: 0)
|
| 211 |
+
|> Ecto.Changeset.apply_changes()
|
| 212 |
+
else
|
| 213 |
+
raise "Invalid insert: #{inspect(changeset)}"
|
| 214 |
+
end
|
| 215 |
+
end
|
| 216 |
+
|
| 217 |
+
defp clear_imported_data(site, false = _dry_run?) do
|
| 218 |
+
Repo.update_all(from(s in Site, where: s.id == ^site.id), set: [imported_data: nil])
|
| 219 |
+
end
|
| 220 |
+
|
| 221 |
+
defp clear_imported_data(site, true = _dry_run?) do
|
| 222 |
+
%{site | imported_data: nil}
|
| 223 |
+
end
|
| 224 |
+
|
| 225 |
+
defp update!(changeset, false = _dry_run?) do
|
| 226 |
+
Repo.update!(changeset)
|
| 227 |
+
end
|
| 228 |
+
|
| 229 |
+
defp update!(changeset, true = _dry_run?) do
|
| 230 |
+
if changeset.valid? do
|
| 231 |
+
Ecto.Changeset.apply_changes(changeset)
|
| 232 |
+
else
|
| 233 |
+
raise "Invalid update: #{inspect(changeset)}"
|
| 234 |
+
end
|
| 235 |
+
end
|
| 236 |
+
|
| 237 |
+
defp delete!(entity, false = _dry_run?) do
|
| 238 |
+
Repo.delete!(entity)
|
| 239 |
+
end
|
| 240 |
+
|
| 241 |
+
defp delete!(entity, true = _dry_run?) do
|
| 242 |
+
entity
|
| 243 |
+
end
|
| 244 |
+
|
| 245 |
+
defp max_date_query(table, site_id, import_ids) do
|
| 246 |
+
from(q in table,
|
| 247 |
+
where: q.site_id == ^site_id,
|
| 248 |
+
where: q.import_id in ^import_ids,
|
| 249 |
+
select: %{max_date: fragment("max(?)", q.date)}
|
| 250 |
+
)
|
| 251 |
+
end
|
| 252 |
+
|
| 253 |
+
defp from_legacy(%Site.ImportedData{} = data) do
|
| 254 |
+
status =
|
| 255 |
+
case data.status do
|
| 256 |
+
"ok" -> :completed
|
| 257 |
+
"error" -> :failed
|
| 258 |
+
_ -> :importing
|
| 259 |
+
end
|
| 260 |
+
|
| 261 |
+
%SiteImportSnapshot{
|
| 262 |
+
id: 0,
|
| 263 |
+
legacy: true,
|
| 264 |
+
start_date: data.start_date,
|
| 265 |
+
end_date: data.end_date,
|
| 266 |
+
source: :universal_analytics,
|
| 267 |
+
status: status
|
| 268 |
+
}
|
| 269 |
+
end
|
| 270 |
+
end
|
lib/plausible/data_migration/versioned_sessions.ex
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.DataMigration.VersionedSessions do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
!!!WARNING!!!: This script is used in migrations. Please take special care
|
| 4 |
+
when altering it.
|
| 5 |
+
|
| 6 |
+
Sessions CollapsingMergeTree -> VersionedCollapsingMergeTree migration,
|
| 7 |
+
SQL files available at:
|
| 8 |
+
|
| 9 |
+
priv/data_migrations/VersionedSessions/sql
|
| 10 |
+
"""
|
| 11 |
+
use Plausible.DataMigration, dir: "VersionedSessions", repo: Plausible.IngestRepo
|
| 12 |
+
|
| 13 |
+
@suffix_format "%Y%m%d%H%M%S"
|
| 14 |
+
@versioned_table_engines [
|
| 15 |
+
"ReplicatedVersionedCollapsingMergeTree",
|
| 16 |
+
"VersionedCollapsingMergeTree"
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
def run(opts \\ []) do
|
| 20 |
+
run_exchange? = Keyword.get(opts, :run_exchange?, true)
|
| 21 |
+
|
| 22 |
+
unique_suffix = DateTime.utc_now() |> Calendar.strftime(@suffix_format)
|
| 23 |
+
|
| 24 |
+
cluster? = Plausible.IngestRepo.clustered_table?("sessions_v2")
|
| 25 |
+
|
| 26 |
+
{:ok, %{rows: partitions}} = run_sql("list-partitions")
|
| 27 |
+
partitions = Enum.map(partitions, fn [part] -> part end)
|
| 28 |
+
|
| 29 |
+
{:ok, %{rows: [[current_table_engine, table_settings]]}} =
|
| 30 |
+
run_sql("get-sessions-table-settings")
|
| 31 |
+
|
| 32 |
+
if Enum.member?(@versioned_table_engines, current_table_engine) do
|
| 33 |
+
IO.puts("sessions_v2 table is already versioned, no migration needed")
|
| 34 |
+
else
|
| 35 |
+
{:ok, _} = run_sql("drop-sessions-tmp-table", cluster?: cluster?)
|
| 36 |
+
|
| 37 |
+
{:ok, _} =
|
| 38 |
+
run_sql("create-sessions-tmp-table",
|
| 39 |
+
cluster?: cluster?,
|
| 40 |
+
table_settings: table_settings,
|
| 41 |
+
unique_suffix: unique_suffix
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
for partition <- partitions do
|
| 45 |
+
{:ok, _} = run_sql("attach-partition", partition: partition)
|
| 46 |
+
end
|
| 47 |
+
|
| 48 |
+
if run_exchange? do
|
| 49 |
+
run_exchange(cluster?)
|
| 50 |
+
end
|
| 51 |
+
|
| 52 |
+
IO.puts("Migration done!")
|
| 53 |
+
end
|
| 54 |
+
end
|
| 55 |
+
|
| 56 |
+
defp run_exchange(cluster?) do
|
| 57 |
+
case run_sql("exchange-sessions-tables", cluster?: cluster?) do
|
| 58 |
+
{:ok, _} ->
|
| 59 |
+
nil
|
| 60 |
+
|
| 61 |
+
# Docker containers don't seem to support EXCHANGE TABLE, hack around this with a non-atomic swap
|
| 62 |
+
{:error, %Ch.Error{code: code}} when code in [1, 48] ->
|
| 63 |
+
IO.puts("Exchanging sessions_v2 and sessions_v2_tmp_versioned non-atomically")
|
| 64 |
+
|
| 65 |
+
{:ok, _} =
|
| 66 |
+
run_sql("rename-table",
|
| 67 |
+
from: "sessions_v2",
|
| 68 |
+
to: "sessions_v2_backup",
|
| 69 |
+
cluster?: cluster?
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
{:ok, _} =
|
| 73 |
+
run_sql("rename-table",
|
| 74 |
+
from: "sessions_v2_tmp_versioned",
|
| 75 |
+
to: "sessions_v2",
|
| 76 |
+
cluster?: cluster?
|
| 77 |
+
)
|
| 78 |
+
end
|
| 79 |
+
end
|
| 80 |
+
end
|
lib/plausible/dns_lookup.ex
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.DnsLookupInterface do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Behaviour module for DNS lookup operations.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
@callback lookup(
|
| 7 |
+
name :: charlist(),
|
| 8 |
+
class :: atom(),
|
| 9 |
+
type :: atom(),
|
| 10 |
+
opts :: list(),
|
| 11 |
+
timeout :: integer()
|
| 12 |
+
) ::
|
| 13 |
+
list() | []
|
| 14 |
+
end
|
| 15 |
+
|
| 16 |
+
defmodule Plausible.DnsLookup do
|
| 17 |
+
@moduledoc """
|
| 18 |
+
Thin wrapper around `:inet_res.lookup/5`.
|
| 19 |
+
To use, call `Plausible.DnsLookup.impl().lookup/5`,
|
| 20 |
+
this allows for mocking DNS lookups in tests.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
@behaviour Plausible.DnsLookupInterface
|
| 24 |
+
|
| 25 |
+
@impl Plausible.DnsLookupInterface
|
| 26 |
+
def lookup(name, class, type, opts, timeout),
|
| 27 |
+
do: :inet_res.lookup(name, class, type, opts, timeout)
|
| 28 |
+
|
| 29 |
+
@spec impl() :: Plausible.DnsLookup
|
| 30 |
+
def impl(), do: Application.get_env(:plausible, :dns_lookup_impl, __MODULE__)
|
| 31 |
+
end
|
lib/plausible/ecto/types/compiled_regex.ex
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Ecto.Types.CompiledRegex do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Ensures that the regex is compiled on load
|
| 4 |
+
"""
|
| 5 |
+
use Ecto.Type
|
| 6 |
+
|
| 7 |
+
def type, do: :string
|
| 8 |
+
|
| 9 |
+
def cast(val) when is_binary(val), do: {:ok, val}
|
| 10 |
+
def cast(_), do: :error
|
| 11 |
+
|
| 12 |
+
def load(val), do: {:ok, Regex.compile!(val, "E")}
|
| 13 |
+
def dump(val), do: {:ok, val}
|
| 14 |
+
end
|
lib/plausible/ecto/types/nanoid.ex
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Ecto.Types.NanoidBase do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Base module for nanoid string types
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
use Ecto.Type
|
| 7 |
+
|
| 8 |
+
def type(), do: :string
|
| 9 |
+
|
| 10 |
+
def cast(value) when is_binary(value), do: {:ok, value}
|
| 11 |
+
def cast(_), do: :error
|
| 12 |
+
|
| 13 |
+
def load(value), do: {:ok, value}
|
| 14 |
+
|
| 15 |
+
def dump(value) when is_binary(value), do: {:ok, value}
|
| 16 |
+
def dump(_), do: :error
|
| 17 |
+
|
| 18 |
+
@callback autogenerate() :: String.t()
|
| 19 |
+
end
|
| 20 |
+
|
| 21 |
+
defmodule Plausible.Ecto.Types.Nanoid do
|
| 22 |
+
@moduledoc """
|
| 23 |
+
Custom column type for nanoid strings
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
@behaviour Plausible.Ecto.Types.NanoidBase
|
| 27 |
+
|
| 28 |
+
def type(), do: Plausible.Ecto.Types.NanoidBase.type()
|
| 29 |
+
def cast(value), do: Plausible.Ecto.Types.NanoidBase.cast(value)
|
| 30 |
+
def load(value), do: Plausible.Ecto.Types.NanoidBase.load(value)
|
| 31 |
+
def dump(value), do: Plausible.Ecto.Types.NanoidBase.dump(value)
|
| 32 |
+
|
| 33 |
+
@spec autogenerate() :: String.t()
|
| 34 |
+
def autogenerate(), do: Nanoid.generate()
|
| 35 |
+
end
|
| 36 |
+
|
| 37 |
+
defmodule Plausible.Ecto.Types.TrackerScriptNanoid do
|
| 38 |
+
@moduledoc """
|
| 39 |
+
Custom column type for tracker script configuration nanoid strings with pa- prefix
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
@behaviour Plausible.Ecto.Types.NanoidBase
|
| 43 |
+
|
| 44 |
+
def type(), do: Plausible.Ecto.Types.NanoidBase.type()
|
| 45 |
+
def cast(value), do: Plausible.Ecto.Types.NanoidBase.cast(value)
|
| 46 |
+
def load(value), do: Plausible.Ecto.Types.NanoidBase.load(value)
|
| 47 |
+
def dump(value), do: Plausible.Ecto.Types.NanoidBase.dump(value)
|
| 48 |
+
|
| 49 |
+
@spec autogenerate() :: String.t()
|
| 50 |
+
def autogenerate(), do: "pa-#{Nanoid.generate()}"
|
| 51 |
+
end
|
lib/plausible/event/system_events.ex
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Event.SystemEvents do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
System events are events that require at least one form of special treatment by the application.
|
| 4 |
+
They are distinguished by event name.
|
| 5 |
+
|
| 6 |
+
For some system events, the tracking API may reject events that don't match the expected format
|
| 7 |
+
(e.g. engagement events without scroll depth or engagement time defined).
|
| 8 |
+
|
| 9 |
+
For other system events, it may accept the event (e.g. "Outbound Link: Click" with the url prop not set).
|
| 10 |
+
|
| 11 |
+
System events may have corresponding system-managed goals, in which case the goal name and event name will be the same
|
| 12 |
+
(e.g. "Outbound Link: Click" goal is managed by the application). The system will only be able to manage these goals properly
|
| 13 |
+
if they are not renamed by the user.
|
| 14 |
+
"""
|
| 15 |
+
@pageview_event_name "pageview"
|
| 16 |
+
@engagement_event_name "engagement"
|
| 17 |
+
|
| 18 |
+
@outbound_link_click_event_name "Outbound Link: Click"
|
| 19 |
+
@cloaked_link_click_event_name "Cloaked Link: Click"
|
| 20 |
+
@file_download_link_click_event_name "File Download"
|
| 21 |
+
|
| 22 |
+
@error_404_event_name "404"
|
| 23 |
+
@wordpress_form_completions_event_name "WP Form Completions"
|
| 24 |
+
@form_submission_event_name "Form: Submission"
|
| 25 |
+
|
| 26 |
+
@all_system_events [
|
| 27 |
+
@pageview_event_name,
|
| 28 |
+
@engagement_event_name,
|
| 29 |
+
@outbound_link_click_event_name,
|
| 30 |
+
@cloaked_link_click_event_name,
|
| 31 |
+
@file_download_link_click_event_name,
|
| 32 |
+
@error_404_event_name,
|
| 33 |
+
@wordpress_form_completions_event_name,
|
| 34 |
+
@form_submission_event_name
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
@interactive_events @all_system_events
|
| 38 |
+
|
| 39 |
+
@events_with_url_prop [
|
| 40 |
+
@outbound_link_click_event_name,
|
| 41 |
+
@cloaked_link_click_event_name,
|
| 42 |
+
@file_download_link_click_event_name
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
@events_with_path_prop [
|
| 46 |
+
@error_404_event_name,
|
| 47 |
+
@wordpress_form_completions_event_name,
|
| 48 |
+
@form_submission_event_name
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
@events_with_engagement_props [
|
| 52 |
+
@engagement_event_name
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
def events() do
|
| 56 |
+
@all_system_events
|
| 57 |
+
end
|
| 58 |
+
|
| 59 |
+
def events_with_interactive_always_true() do
|
| 60 |
+
@interactive_events
|
| 61 |
+
end
|
| 62 |
+
|
| 63 |
+
def events_with_url_prop() do
|
| 64 |
+
@events_with_url_prop
|
| 65 |
+
end
|
| 66 |
+
|
| 67 |
+
def events_with_path_prop() do
|
| 68 |
+
@events_with_path_prop
|
| 69 |
+
end
|
| 70 |
+
|
| 71 |
+
def events_with_engagement_props() do
|
| 72 |
+
@events_with_engagement_props
|
| 73 |
+
end
|
| 74 |
+
|
| 75 |
+
@spec special_events_for_prop_key(String.t()) :: [String.t()]
|
| 76 |
+
def special_events_for_prop_key("url"), do: events_with_url_prop()
|
| 77 |
+
def special_events_for_prop_key("path"), do: events_with_path_prop()
|
| 78 |
+
|
| 79 |
+
@doc """
|
| 80 |
+
Checks if the event name is for a system event / system goal that should have the event.props.path synced with the event.pathname property.
|
| 81 |
+
|
| 82 |
+
### Examples
|
| 83 |
+
iex> sync_props_path_with_pathname?("404", [{"path", "/foo"}])
|
| 84 |
+
false
|
| 85 |
+
|
| 86 |
+
Note: Should not override event.props.path if it is set deliberately to nil
|
| 87 |
+
iex> sync_props_path_with_pathname?("404", [{"path", nil}])
|
| 88 |
+
false
|
| 89 |
+
|
| 90 |
+
iex> sync_props_path_with_pathname?("404", [{"other", "value"}])
|
| 91 |
+
true
|
| 92 |
+
|
| 93 |
+
iex> sync_props_path_with_pathname?("404", [])
|
| 94 |
+
true
|
| 95 |
+
"""
|
| 96 |
+
@spec sync_props_path_with_pathname?(String.t(), [{String.t(), String.t()}]) :: boolean()
|
| 97 |
+
def sync_props_path_with_pathname?(event_name, props_in_request) do
|
| 98 |
+
event_name in events_with_path_prop() and
|
| 99 |
+
not Enum.any?(props_in_request, fn {k, _} -> k == "path" end)
|
| 100 |
+
end
|
| 101 |
+
end
|
lib/plausible/event/write_buffer.ex
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Event.WriteBuffer do
|
| 2 |
+
@moduledoc false
|
| 3 |
+
|
| 4 |
+
%{
|
| 5 |
+
header: header,
|
| 6 |
+
insert_sql: insert_sql,
|
| 7 |
+
insert_opts: insert_opts,
|
| 8 |
+
fields: fields,
|
| 9 |
+
encoding_types: encoding_types
|
| 10 |
+
} =
|
| 11 |
+
Plausible.Ingestion.WriteBuffer.compile_time_prepare(Plausible.ClickhouseEventV2)
|
| 12 |
+
|
| 13 |
+
def child_spec(opts) do
|
| 14 |
+
opts =
|
| 15 |
+
Keyword.merge(opts,
|
| 16 |
+
name: __MODULE__,
|
| 17 |
+
header: unquote(header),
|
| 18 |
+
insert_sql: unquote(insert_sql),
|
| 19 |
+
insert_opts: unquote(insert_opts)
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
Plausible.Ingestion.WriteBuffer.child_spec(opts)
|
| 23 |
+
end
|
| 24 |
+
|
| 25 |
+
def insert(event) do
|
| 26 |
+
row_binary =
|
| 27 |
+
[Enum.map(unquote(fields), fn field -> Map.fetch!(event, field) end)]
|
| 28 |
+
|> Ch.RowBinary._encode_rows(unquote(encoding_types))
|
| 29 |
+
|> IO.iodata_to_binary()
|
| 30 |
+
|
| 31 |
+
:ok = Plausible.Ingestion.WriteBuffer.insert(__MODULE__, row_binary)
|
| 32 |
+
{:ok, event}
|
| 33 |
+
end
|
| 34 |
+
|
| 35 |
+
def flush do
|
| 36 |
+
Plausible.Ingestion.WriteBuffer.flush(__MODULE__)
|
| 37 |
+
end
|
| 38 |
+
end
|
lib/plausible/exports.ex
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Exports do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Contains functions to export data for events and sessions as Zip archives.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
use Plausible
|
| 7 |
+
use Plausible.Stats.SQL.Fragments
|
| 8 |
+
import Ecto.Query
|
| 9 |
+
|
| 10 |
+
@doc "Schedules CSV export job to S3 storage"
|
| 11 |
+
@spec schedule_s3_export(pos_integer, String.t()) ::
|
| 12 |
+
{:ok, Oban.Job.t()} | {:error, :no_data}
|
| 13 |
+
def schedule_s3_export(site_id, email_to) do
|
| 14 |
+
with :ok <- ensure_has_data(site_id) do
|
| 15 |
+
args = %{
|
| 16 |
+
"storage" => "s3",
|
| 17 |
+
"site_id" => site_id,
|
| 18 |
+
"email_to" => email_to,
|
| 19 |
+
"s3_bucket" => Plausible.S3.exports_bucket(),
|
| 20 |
+
"s3_path" => s3_export_key(site_id)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
{:ok, Oban.insert!(Plausible.Workers.ExportAnalytics.new(args))}
|
| 24 |
+
end
|
| 25 |
+
end
|
| 26 |
+
|
| 27 |
+
@doc "Schedules CSV export job to local storage"
|
| 28 |
+
@spec schedule_local_export(pos_integer, String.t()) :: {:ok, Oban.Job.t()} | {:error, :no_data}
|
| 29 |
+
def schedule_local_export(site_id, email_to) do
|
| 30 |
+
with :ok <- ensure_has_data(site_id) do
|
| 31 |
+
args = %{
|
| 32 |
+
"storage" => "local",
|
| 33 |
+
"site_id" => site_id,
|
| 34 |
+
"email_to" => email_to,
|
| 35 |
+
"local_path" => local_export_file(site_id)
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
{:ok, Oban.insert!(Plausible.Workers.ExportAnalytics.new(args))}
|
| 39 |
+
end
|
| 40 |
+
end
|
| 41 |
+
|
| 42 |
+
@spec ensure_has_data(pos_integer) :: :ok | {:error, :no_data}
|
| 43 |
+
defp ensure_has_data(site_id) do
|
| 44 |
+
# SELECT true FROM "events_v2" AS e0 WHERE (e0."site_id" = ^site_id) LIMIT 1
|
| 45 |
+
has_data? = Plausible.ClickhouseRepo.exists?(from "events_v2", where: [site_id: ^site_id])
|
| 46 |
+
if has_data?, do: :ok, else: {:error, :no_data}
|
| 47 |
+
end
|
| 48 |
+
|
| 49 |
+
@doc "Gets last CSV export job for a site"
|
| 50 |
+
@spec get_last_export_job(pos_integer) :: Oban.Job.t() | nil
|
| 51 |
+
def get_last_export_job(site_id) do
|
| 52 |
+
Plausible.Repo.one(
|
| 53 |
+
from e in Plausible.Workers.ExportAnalytics.base_query(site_id),
|
| 54 |
+
order_by: [desc: :id],
|
| 55 |
+
limit: 1
|
| 56 |
+
)
|
| 57 |
+
end
|
| 58 |
+
|
| 59 |
+
@doc "Subscribes to CSV export job notifications"
|
| 60 |
+
def oban_listen, do: Oban.Notifier.listen(__MODULE__)
|
| 61 |
+
@doc false
|
| 62 |
+
def oban_notify(site_id), do: Oban.Notifier.notify(__MODULE__, %{"site_id" => site_id})
|
| 63 |
+
|
| 64 |
+
@doc """
|
| 65 |
+
Renders export archive filename.
|
| 66 |
+
|
| 67 |
+
Examples:
|
| 68 |
+
|
| 69 |
+
iex> archive_filename("plausible.io", _created_on = ~D[2024-12-31])
|
| 70 |
+
"plausible_io_20241231.zip"
|
| 71 |
+
|
| 72 |
+
"""
|
| 73 |
+
def archive_filename(domain, %Date{} = created_on) do
|
| 74 |
+
String.replace(domain, ".", "_") <> "_" <> Calendar.strftime(created_on, "%Y%m%d") <> ".zip"
|
| 75 |
+
end
|
| 76 |
+
|
| 77 |
+
@doc ~S"""
|
| 78 |
+
Safely renders content disposition for an arbitrary export filename.
|
| 79 |
+
|
| 80 |
+
Examples:
|
| 81 |
+
|
| 82 |
+
iex> content_disposition("plausible_io_20241231.zip")
|
| 83 |
+
"attachment; filename=\"plausible_io_20241231.zip\""
|
| 84 |
+
|
| 85 |
+
iex> content_disposition("📊.zip")
|
| 86 |
+
"attachment; filename=\"plausible-export.zip\"; filename*=utf-8''%F0%9F%93%8A.zip"
|
| 87 |
+
|
| 88 |
+
"""
|
| 89 |
+
def content_disposition(filename) do
|
| 90 |
+
encoded_filename = URI.encode(filename)
|
| 91 |
+
|
| 92 |
+
if encoded_filename == filename do
|
| 93 |
+
~s[attachment; filename="#{filename}"]
|
| 94 |
+
else
|
| 95 |
+
~s[attachment; filename="plausible-export.zip"; filename*=utf-8''#{encoded_filename}]
|
| 96 |
+
end
|
| 97 |
+
end
|
| 98 |
+
|
| 99 |
+
@type export :: %{
|
| 100 |
+
path: Path.t(),
|
| 101 |
+
name: String.t(),
|
| 102 |
+
expires_at: DateTime.t() | nil,
|
| 103 |
+
size: pos_integer
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
@doc "Gets local export for a site"
|
| 107 |
+
@spec get_local_export(pos_integer, String.t(), String.t()) :: export | nil
|
| 108 |
+
def get_local_export(site_id, domain, timezone) do
|
| 109 |
+
path = local_export_file(site_id)
|
| 110 |
+
|
| 111 |
+
if File.exists?(path) do
|
| 112 |
+
%File.Stat{size: size, mtime: mtime} = File.stat!(path, time: :posix)
|
| 113 |
+
created_at = DateTime.from_unix!(mtime)
|
| 114 |
+
created_on_in_site_tz = Plausible.Timezones.to_date_in_timezone(created_at, timezone)
|
| 115 |
+
name = archive_filename(domain, created_on_in_site_tz)
|
| 116 |
+
|
| 117 |
+
%{path: path, name: name, expires_at: nil, size: size}
|
| 118 |
+
end
|
| 119 |
+
end
|
| 120 |
+
|
| 121 |
+
@doc "Deletes local export for a site"
|
| 122 |
+
@spec delete_local_export(pos_integer) :: :ok
|
| 123 |
+
def delete_local_export(site_id) do
|
| 124 |
+
file = local_export_file(site_id)
|
| 125 |
+
|
| 126 |
+
if File.exists?(file) do
|
| 127 |
+
File.rm!(file)
|
| 128 |
+
end
|
| 129 |
+
|
| 130 |
+
:ok
|
| 131 |
+
end
|
| 132 |
+
|
| 133 |
+
@spec local_export_file(pos_integer) :: Path.t()
|
| 134 |
+
defp local_export_file(site_id) do
|
| 135 |
+
data_dir = Application.get_env(:plausible, :data_dir)
|
| 136 |
+
Path.join([data_dir || System.tmp_dir!(), "plausible-exports", Integer.to_string(site_id)])
|
| 137 |
+
end
|
| 138 |
+
|
| 139 |
+
@doc "Gets S3 export for a site. Raises if object storage is unavailable."
|
| 140 |
+
@spec get_s3_export!(pos_integer, non_neg_integer) :: export | nil
|
| 141 |
+
def get_s3_export!(site_id, retries \\ 0) do
|
| 142 |
+
path = s3_export_key(site_id)
|
| 143 |
+
bucket = Plausible.S3.exports_bucket()
|
| 144 |
+
head_object_op = ExAws.S3.head_object(bucket, path)
|
| 145 |
+
|
| 146 |
+
case ExAws.request(head_object_op, retries: retries) do
|
| 147 |
+
{:ok, %{status_code: 200, headers: headers}} ->
|
| 148 |
+
"attachment; filename=" <> filename = :proplists.get_value("content-disposition", headers)
|
| 149 |
+
name = String.trim(filename, "\"")
|
| 150 |
+
size = :proplists.get_value("content-length", headers, nil)
|
| 151 |
+
|
| 152 |
+
expires_at =
|
| 153 |
+
if x_amz_expiration = :proplists.get_value("x-amz-expiration", headers, nil) do
|
| 154 |
+
["expiry-date=", expiry_date, ", rule-id=", _rule_id] =
|
| 155 |
+
String.split(x_amz_expiration, "\"", trim: true)
|
| 156 |
+
|
| 157 |
+
Plausible.Times.parse!(expiry_date, "{RFC1123}")
|
| 158 |
+
end
|
| 159 |
+
|
| 160 |
+
%{
|
| 161 |
+
path: path,
|
| 162 |
+
name: name,
|
| 163 |
+
expires_at: expires_at,
|
| 164 |
+
size: String.to_integer(size)
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
{:error, {:http_error, 404, _response}} ->
|
| 168 |
+
nil
|
| 169 |
+
|
| 170 |
+
{:error, %Finch.TransportError{} = e} ->
|
| 171 |
+
raise e
|
| 172 |
+
end
|
| 173 |
+
end
|
| 174 |
+
|
| 175 |
+
@doc "Deletes S3 export for a site. Raises if object storage is unavailable."
|
| 176 |
+
@spec delete_s3_export!(pos_integer) :: :ok
|
| 177 |
+
def delete_s3_export!(site_id) do
|
| 178 |
+
if export = get_s3_export!(site_id) do
|
| 179 |
+
exports_bucket = Plausible.S3.exports_bucket()
|
| 180 |
+
delete_op = ExAws.S3.delete_object(exports_bucket, export.path)
|
| 181 |
+
ExAws.request!(delete_op)
|
| 182 |
+
end
|
| 183 |
+
|
| 184 |
+
:ok
|
| 185 |
+
end
|
| 186 |
+
|
| 187 |
+
defp s3_export_key(site_id), do: Integer.to_string(site_id)
|
| 188 |
+
|
| 189 |
+
@doc "Returns the date range for the site's events data in site's timezone or `nil` if there is no data"
|
| 190 |
+
@spec date_range(pos_integer, String.t()) :: Date.Range.t() | nil
|
| 191 |
+
def date_range(site_id, timezone) do
|
| 192 |
+
[%Date{} = start_date, %Date{} = end_date] =
|
| 193 |
+
Plausible.ClickhouseRepo.one(
|
| 194 |
+
from e in "events_v2",
|
| 195 |
+
where: [site_id: ^site_id],
|
| 196 |
+
select: [
|
| 197 |
+
fragment("toDate(min(?),?)", e.timestamp, ^timezone),
|
| 198 |
+
fragment("toDate(max(?),?)", e.timestamp, ^timezone)
|
| 199 |
+
]
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
unless end_date == ~D[1970-01-01] do
|
| 203 |
+
Date.range(start_date, end_date)
|
| 204 |
+
end
|
| 205 |
+
end
|
| 206 |
+
|
| 207 |
+
@doc """
|
| 208 |
+
Builds Ecto queries to export data from `events_v2` and `sessions_v2`
|
| 209 |
+
tables into the format of `imported_*` tables for a website.
|
| 210 |
+
"""
|
| 211 |
+
@spec export_queries(Plausible.Site.t(),
|
| 212 |
+
extname: String.t(),
|
| 213 |
+
date_range: Date.Range.t(),
|
| 214 |
+
timezone: String.t()
|
| 215 |
+
) ::
|
| 216 |
+
%{String.t() => Ecto.Query.t()}
|
| 217 |
+
def export_queries(site, opts \\ []) do
|
| 218 |
+
extname = opts[:extname] || ".csv"
|
| 219 |
+
date_range = opts[:date_range]
|
| 220 |
+
timezone = opts[:timezone] || "UTC"
|
| 221 |
+
|
| 222 |
+
suffix =
|
| 223 |
+
if date_range do
|
| 224 |
+
first_date = Calendar.strftime(date_range.first, "%Y%m%d")
|
| 225 |
+
last_date = Calendar.strftime(date_range.last, "%Y%m%d")
|
| 226 |
+
"_#{first_date}_#{last_date}" <> extname
|
| 227 |
+
else
|
| 228 |
+
extname
|
| 229 |
+
end
|
| 230 |
+
|
| 231 |
+
filename = fn name -> name <> suffix end
|
| 232 |
+
|
| 233 |
+
%{
|
| 234 |
+
filename.("imported_visitors") => export_visitors_q(site, timezone, date_range),
|
| 235 |
+
filename.("imported_sources") => export_sources_q(site, timezone, date_range),
|
| 236 |
+
filename.("imported_pages") => export_pages_q(site, timezone, date_range),
|
| 237 |
+
filename.("imported_entry_pages") => export_entry_pages_q(site, timezone, date_range),
|
| 238 |
+
filename.("imported_exit_pages") => export_exit_pages_q(site, timezone, date_range),
|
| 239 |
+
filename.("imported_custom_events") => export_custom_events_q(site, timezone, date_range),
|
| 240 |
+
filename.("imported_locations") => export_locations_q(site, timezone, date_range),
|
| 241 |
+
filename.("imported_devices") => export_devices_q(site, timezone, date_range),
|
| 242 |
+
filename.("imported_browsers") => export_browsers_q(site, timezone, date_range),
|
| 243 |
+
filename.("imported_operating_systems") =>
|
| 244 |
+
export_operating_systems_q(site, timezone, date_range),
|
| 245 |
+
filename.("imported_custom_props") => export_custom_props_q(site, timezone, date_range)
|
| 246 |
+
}
|
| 247 |
+
end
|
| 248 |
+
|
| 249 |
+
on_ee do
|
| 250 |
+
defp sampled(table) do
|
| 251 |
+
Plausible.Stats.Sampling.add_query_hint(from(table))
|
| 252 |
+
end
|
| 253 |
+
else
|
| 254 |
+
defp sampled(table) do
|
| 255 |
+
table
|
| 256 |
+
end
|
| 257 |
+
end
|
| 258 |
+
|
| 259 |
+
defp export_filter(site_id, date_range) do
|
| 260 |
+
filter = dynamic([t], t.site_id == ^site_id)
|
| 261 |
+
|
| 262 |
+
if date_range do
|
| 263 |
+
dynamic(
|
| 264 |
+
^filter and
|
| 265 |
+
selected_as(:date) >= ^date_range.first and
|
| 266 |
+
selected_as(:date) <= ^date_range.last
|
| 267 |
+
)
|
| 268 |
+
else
|
| 269 |
+
filter
|
| 270 |
+
end
|
| 271 |
+
end
|
| 272 |
+
|
| 273 |
+
defmacrop date(timestamp, timezone) do
|
| 274 |
+
quote do
|
| 275 |
+
selected_as(
|
| 276 |
+
fragment("toDate(?,?)", unquote(timestamp), unquote(timezone)),
|
| 277 |
+
:date
|
| 278 |
+
)
|
| 279 |
+
end
|
| 280 |
+
end
|
| 281 |
+
|
| 282 |
+
defmacrop visit_duration(t) do
|
| 283 |
+
quote do
|
| 284 |
+
selected_as(
|
| 285 |
+
scale_sample(fragment("greatest(sum(?*?),0)", unquote(t).sign, unquote(t).duration)),
|
| 286 |
+
:visit_duration
|
| 287 |
+
)
|
| 288 |
+
end
|
| 289 |
+
end
|
| 290 |
+
|
| 291 |
+
defmacrop visitors(t) do
|
| 292 |
+
quote do
|
| 293 |
+
selected_as(
|
| 294 |
+
scale_sample(fragment("uniq(?)", unquote(t).user_id)),
|
| 295 |
+
:visitors
|
| 296 |
+
)
|
| 297 |
+
end
|
| 298 |
+
end
|
| 299 |
+
|
| 300 |
+
defmacrop visits(t) do
|
| 301 |
+
quote do
|
| 302 |
+
selected_as(
|
| 303 |
+
scale_sample(fragment("greatest(sum(?),0)", unquote(t).sign)),
|
| 304 |
+
:visits
|
| 305 |
+
)
|
| 306 |
+
end
|
| 307 |
+
end
|
| 308 |
+
|
| 309 |
+
defmacrop bounces(t) do
|
| 310 |
+
quote do
|
| 311 |
+
selected_as(
|
| 312 |
+
scale_sample(
|
| 313 |
+
fragment(
|
| 314 |
+
"greatest(sum(?*?),0)",
|
| 315 |
+
unquote(t).sign,
|
| 316 |
+
unquote(t).is_bounce
|
| 317 |
+
)
|
| 318 |
+
),
|
| 319 |
+
:bounces
|
| 320 |
+
)
|
| 321 |
+
end
|
| 322 |
+
end
|
| 323 |
+
|
| 324 |
+
defmacrop pageviews(t) do
|
| 325 |
+
quote do
|
| 326 |
+
selected_as(
|
| 327 |
+
scale_sample(
|
| 328 |
+
fragment(
|
| 329 |
+
"greatest(sum(?*?),0)",
|
| 330 |
+
unquote(t).sign,
|
| 331 |
+
unquote(t).pageviews
|
| 332 |
+
)
|
| 333 |
+
),
|
| 334 |
+
:pageviews
|
| 335 |
+
)
|
| 336 |
+
end
|
| 337 |
+
end
|
| 338 |
+
|
| 339 |
+
defp export_visitors_q(site, timezone, date_range) do
|
| 340 |
+
visitors_sessions_q =
|
| 341 |
+
from s in sampled("sessions_v2"),
|
| 342 |
+
where: ^export_filter(site.id, date_range),
|
| 343 |
+
group_by: selected_as(:date),
|
| 344 |
+
select: %{
|
| 345 |
+
date: date(s.timestamp, ^timezone),
|
| 346 |
+
bounces: bounces(s),
|
| 347 |
+
visits: visits(s),
|
| 348 |
+
visit_duration: visit_duration(s),
|
| 349 |
+
visitors: visitors(s)
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
visitors_events_q =
|
| 353 |
+
from e in sampled("events_v2"),
|
| 354 |
+
where: ^export_filter(site.id, date_range),
|
| 355 |
+
group_by: selected_as(:date),
|
| 356 |
+
select: %{
|
| 357 |
+
date: date(e.timestamp, ^timezone),
|
| 358 |
+
pageviews:
|
| 359 |
+
selected_as(
|
| 360 |
+
scale_sample(fragment("countIf(?='pageview')", e.name)),
|
| 361 |
+
:pageviews
|
| 362 |
+
)
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
visitors_q =
|
| 366 |
+
"e"
|
| 367 |
+
|> with_cte("e", as: ^visitors_events_q)
|
| 368 |
+
|> with_cte("s", as: ^visitors_sessions_q)
|
| 369 |
+
|
| 370 |
+
from e in visitors_q,
|
| 371 |
+
full_join: s in "s",
|
| 372 |
+
on: e.date == s.date,
|
| 373 |
+
order_by: selected_as(:date),
|
| 374 |
+
select: [
|
| 375 |
+
selected_as(fragment("greatest(?,?)", s.date, e.date), :date),
|
| 376 |
+
s.visitors,
|
| 377 |
+
e.pageviews,
|
| 378 |
+
s.bounces,
|
| 379 |
+
s.visits,
|
| 380 |
+
s.visit_duration
|
| 381 |
+
]
|
| 382 |
+
end
|
| 383 |
+
|
| 384 |
+
defp export_sources_q(site, timezone, date_range) do
|
| 385 |
+
from s in sampled("sessions_v2"),
|
| 386 |
+
where: ^export_filter(site.id, date_range),
|
| 387 |
+
group_by: [
|
| 388 |
+
selected_as(:date),
|
| 389 |
+
selected_as(:source),
|
| 390 |
+
s.referrer,
|
| 391 |
+
s.utm_source,
|
| 392 |
+
s.utm_medium,
|
| 393 |
+
s.utm_campaign,
|
| 394 |
+
s.utm_content,
|
| 395 |
+
s.utm_term
|
| 396 |
+
],
|
| 397 |
+
order_by: selected_as(:date),
|
| 398 |
+
select: [
|
| 399 |
+
date(s.timestamp, ^timezone),
|
| 400 |
+
selected_as(s.referrer_source, :source),
|
| 401 |
+
s.referrer,
|
| 402 |
+
s.utm_source,
|
| 403 |
+
s.utm_medium,
|
| 404 |
+
s.utm_campaign,
|
| 405 |
+
s.utm_content,
|
| 406 |
+
s.utm_term,
|
| 407 |
+
pageviews(s),
|
| 408 |
+
visitors(s),
|
| 409 |
+
visits(s),
|
| 410 |
+
visit_duration(s),
|
| 411 |
+
bounces(s)
|
| 412 |
+
]
|
| 413 |
+
end
|
| 414 |
+
|
| 415 |
+
defp export_pages_q(site, timezone, date_range) do
|
| 416 |
+
base_q =
|
| 417 |
+
from(e in sampled("events_v2"),
|
| 418 |
+
where: ^export_filter(site.id, date_range),
|
| 419 |
+
where: [name: "pageview"],
|
| 420 |
+
group_by: [selected_as(:date), selected_as(:page)],
|
| 421 |
+
order_by: selected_as(:date)
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
max_scroll_depth_per_session_q =
|
| 425 |
+
from(e in "events_v2",
|
| 426 |
+
where: ^export_filter(site.id, date_range),
|
| 427 |
+
where: e.name == "engagement" and e.scroll_depth <= 100,
|
| 428 |
+
select: %{
|
| 429 |
+
date: date(e.timestamp, ^timezone),
|
| 430 |
+
page: selected_as(e.pathname, :page),
|
| 431 |
+
session_id: e.session_id,
|
| 432 |
+
max_scroll_depth: max(e.scroll_depth)
|
| 433 |
+
},
|
| 434 |
+
group_by: [e.session_id, selected_as(:date), selected_as(:page)]
|
| 435 |
+
)
|
| 436 |
+
|
| 437 |
+
scroll_depth_q =
|
| 438 |
+
from(p in subquery(max_scroll_depth_per_session_q),
|
| 439 |
+
select: %{
|
| 440 |
+
date: p.date,
|
| 441 |
+
page: p.page,
|
| 442 |
+
total_scroll_depth: fragment("sum(?)", p.max_scroll_depth),
|
| 443 |
+
total_scroll_depth_visits: fragment("uniq(?)", p.session_id)
|
| 444 |
+
},
|
| 445 |
+
group_by: [:date, :page]
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
from(e in base_q,
|
| 449 |
+
left_join: s in subquery(scroll_depth_q),
|
| 450 |
+
on: s.date == selected_as(:date) and s.page == selected_as(:page),
|
| 451 |
+
select: %{
|
| 452 |
+
date: date(e.timestamp, ^timezone),
|
| 453 |
+
hostname: selected_as(fragment("any(?)", e.hostname), :hostname),
|
| 454 |
+
page: selected_as(e.pathname, :page),
|
| 455 |
+
visits:
|
| 456 |
+
selected_as(
|
| 457 |
+
scale_sample(fragment("uniq(?)", e.session_id)),
|
| 458 |
+
:visits
|
| 459 |
+
),
|
| 460 |
+
visitors: visitors(e),
|
| 461 |
+
pageviews: selected_as(scale_sample(fragment("count()")), :pageviews),
|
| 462 |
+
total_scroll_depth:
|
| 463 |
+
selected_as(fragment("any(?)", s.total_scroll_depth), :total_scroll_depth),
|
| 464 |
+
total_scroll_depth_visits:
|
| 465 |
+
selected_as(fragment("any(?)", s.total_scroll_depth_visits), :total_scroll_depth_visits)
|
| 466 |
+
}
|
| 467 |
+
)
|
| 468 |
+
|> add_time_on_page_columns(site.id, timezone, date_range)
|
| 469 |
+
end
|
| 470 |
+
|
| 471 |
+
defp add_time_on_page_columns(q, site_id, timezone, date_range) do
|
| 472 |
+
site = Plausible.Repo.get(Plausible.Site, site_id)
|
| 473 |
+
|
| 474 |
+
if Plausible.Stats.TimeOnPage.new_time_on_page_visible?(site) do
|
| 475 |
+
cutoff = Plausible.Stats.TimeOnPage.legacy_time_on_page_cutoff(site)
|
| 476 |
+
|
| 477 |
+
engagements_q =
|
| 478 |
+
from(e in sampled("events_v2"),
|
| 479 |
+
where: ^export_filter(site_id, date_range),
|
| 480 |
+
where: e.name == "engagement",
|
| 481 |
+
group_by: [selected_as(:date), selected_as(:page)],
|
| 482 |
+
order_by: selected_as(:date),
|
| 483 |
+
select: %{
|
| 484 |
+
date: date(e.timestamp, ^timezone),
|
| 485 |
+
page: selected_as(e.pathname, :page),
|
| 486 |
+
total_time_on_page:
|
| 487 |
+
fragment(
|
| 488 |
+
"toUInt64(round(sumIf(?, ? >= ?) / 1000))",
|
| 489 |
+
e.engagement_time,
|
| 490 |
+
e.timestamp,
|
| 491 |
+
^cutoff
|
| 492 |
+
),
|
| 493 |
+
total_time_on_page_visits:
|
| 494 |
+
fragment("uniqIf(?, ? >= ?)", e.session_id, e.timestamp, ^cutoff)
|
| 495 |
+
}
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
q
|
| 499 |
+
|> join(:left, [], s in subquery(engagements_q),
|
| 500 |
+
on: s.date == selected_as(:date) and s.page == selected_as(:page)
|
| 501 |
+
)
|
| 502 |
+
|> select_merge_as([..., s], %{
|
| 503 |
+
total_time_on_page: fragment("any(?)", s.total_time_on_page),
|
| 504 |
+
total_time_on_page_visits: fragment("any(?)", s.total_time_on_page_visits)
|
| 505 |
+
})
|
| 506 |
+
else
|
| 507 |
+
q
|
| 508 |
+
end
|
| 509 |
+
end
|
| 510 |
+
|
| 511 |
+
defp export_entry_pages_q(site, timezone, date_range) do
|
| 512 |
+
from s in sampled("sessions_v2"),
|
| 513 |
+
where: ^export_filter(site.id, date_range),
|
| 514 |
+
group_by: [selected_as(:date), s.entry_page],
|
| 515 |
+
order_by: selected_as(:date),
|
| 516 |
+
select: [
|
| 517 |
+
date(s.timestamp, ^timezone),
|
| 518 |
+
s.entry_page,
|
| 519 |
+
visitors(s),
|
| 520 |
+
selected_as(
|
| 521 |
+
scale_sample(fragment("greatest(sum(?),0)", s.sign)),
|
| 522 |
+
:entrances
|
| 523 |
+
),
|
| 524 |
+
visit_duration(s),
|
| 525 |
+
bounces(s),
|
| 526 |
+
pageviews(s)
|
| 527 |
+
]
|
| 528 |
+
end
|
| 529 |
+
|
| 530 |
+
defp export_exit_pages_q(site, timezone, date_range) do
|
| 531 |
+
from s in sampled("sessions_v2"),
|
| 532 |
+
where: ^export_filter(site.id, date_range),
|
| 533 |
+
group_by: [selected_as(:date), s.exit_page],
|
| 534 |
+
order_by: selected_as(:date),
|
| 535 |
+
select: [
|
| 536 |
+
date(s.timestamp, ^timezone),
|
| 537 |
+
s.exit_page,
|
| 538 |
+
visitors(s),
|
| 539 |
+
visit_duration(s),
|
| 540 |
+
selected_as(
|
| 541 |
+
scale_sample(fragment("greatest(sum(?),0)", s.sign)),
|
| 542 |
+
:exits
|
| 543 |
+
),
|
| 544 |
+
bounces(s),
|
| 545 |
+
pageviews(s)
|
| 546 |
+
]
|
| 547 |
+
end
|
| 548 |
+
|
| 549 |
+
defp export_custom_events_q(site, timezone, date_range) do
|
| 550 |
+
from e in sampled("events_v2"),
|
| 551 |
+
where: ^export_filter(site.id, date_range),
|
| 552 |
+
where: e.name != "pageview",
|
| 553 |
+
group_by: [
|
| 554 |
+
selected_as(:date),
|
| 555 |
+
e.name,
|
| 556 |
+
selected_as(:link_url),
|
| 557 |
+
selected_as(:path)
|
| 558 |
+
],
|
| 559 |
+
order_by: selected_as(:date),
|
| 560 |
+
select: [
|
| 561 |
+
date(e.timestamp, ^timezone),
|
| 562 |
+
e.name,
|
| 563 |
+
selected_as(
|
| 564 |
+
fragment(
|
| 565 |
+
"if(? in ?, ?, '')",
|
| 566 |
+
e.name,
|
| 567 |
+
^Plausible.Event.SystemEvents.events_with_url_prop(),
|
| 568 |
+
get_by_key(e, :meta, "url")
|
| 569 |
+
),
|
| 570 |
+
:link_url
|
| 571 |
+
),
|
| 572 |
+
selected_as(
|
| 573 |
+
fragment(
|
| 574 |
+
"if(? in ?, ?, '')",
|
| 575 |
+
e.name,
|
| 576 |
+
^Plausible.Event.SystemEvents.events_with_path_prop(),
|
| 577 |
+
get_by_key(e, :meta, "path")
|
| 578 |
+
),
|
| 579 |
+
:path
|
| 580 |
+
),
|
| 581 |
+
visitors(e),
|
| 582 |
+
selected_as(scale_sample(fragment("count()")), :events)
|
| 583 |
+
]
|
| 584 |
+
end
|
| 585 |
+
|
| 586 |
+
defp export_custom_props_q(site, timezone, date_range) do
|
| 587 |
+
query =
|
| 588 |
+
from e in sampled("events_v2"),
|
| 589 |
+
join: pv in fragment("arrayZip(`meta.key`, `meta.value`)"),
|
| 590 |
+
on: true,
|
| 591 |
+
hints: "ARRAY",
|
| 592 |
+
where: ^export_filter(site.id, date_range),
|
| 593 |
+
group_by: [
|
| 594 |
+
selected_as(:date),
|
| 595 |
+
selected_as(:property),
|
| 596 |
+
selected_as(:value)
|
| 597 |
+
],
|
| 598 |
+
order_by: selected_as(:date),
|
| 599 |
+
select: [
|
| 600 |
+
date(e.timestamp, ^timezone),
|
| 601 |
+
selected_as(fragment("tupleElement(?, 1)", pv), :property),
|
| 602 |
+
selected_as(fragment("tupleElement(?, 2)", pv), :value),
|
| 603 |
+
visitors(e),
|
| 604 |
+
selected_as(scale_sample(fragment("count()")), :events)
|
| 605 |
+
]
|
| 606 |
+
|
| 607 |
+
if Plausible.Billing.Feature.Props.enabled?(site) do
|
| 608 |
+
query
|
| 609 |
+
else
|
| 610 |
+
where(query, [], selected_as(:property) in ^Plausible.Props.internal_keys())
|
| 611 |
+
end
|
| 612 |
+
end
|
| 613 |
+
|
| 614 |
+
defp export_locations_q(site, timezone, date_range) do
|
| 615 |
+
from s in sampled("sessions_v2"),
|
| 616 |
+
where: ^export_filter(site.id, date_range),
|
| 617 |
+
where: s.country_code != "\0\0" and s.country_code != "ZZ",
|
| 618 |
+
group_by: [selected_as(:date), s.country_code, s.subdivision1_code, s.city_geoname_id],
|
| 619 |
+
order_by: selected_as(:date),
|
| 620 |
+
select: [
|
| 621 |
+
date(s.timestamp, ^timezone),
|
| 622 |
+
selected_as(s.country_code, :country),
|
| 623 |
+
selected_as(s.subdivision1_code, :region),
|
| 624 |
+
selected_as(s.city_geoname_id, :city),
|
| 625 |
+
visitors(s),
|
| 626 |
+
visits(s),
|
| 627 |
+
visit_duration(s),
|
| 628 |
+
bounces(s),
|
| 629 |
+
pageviews(s)
|
| 630 |
+
]
|
| 631 |
+
end
|
| 632 |
+
|
| 633 |
+
defp export_devices_q(site, timezone, date_range) do
|
| 634 |
+
from s in sampled("sessions_v2"),
|
| 635 |
+
where: ^export_filter(site.id, date_range),
|
| 636 |
+
group_by: [selected_as(:date), s.screen_size],
|
| 637 |
+
order_by: selected_as(:date),
|
| 638 |
+
select: [
|
| 639 |
+
date(s.timestamp, ^timezone),
|
| 640 |
+
selected_as(s.screen_size, :device),
|
| 641 |
+
visitors(s),
|
| 642 |
+
visits(s),
|
| 643 |
+
visit_duration(s),
|
| 644 |
+
bounces(s),
|
| 645 |
+
pageviews(s)
|
| 646 |
+
]
|
| 647 |
+
end
|
| 648 |
+
|
| 649 |
+
defp export_browsers_q(site, timezone, date_range) do
|
| 650 |
+
from s in sampled("sessions_v2"),
|
| 651 |
+
where: ^export_filter(site.id, date_range),
|
| 652 |
+
group_by: [selected_as(:date), s.browser, s.browser_version],
|
| 653 |
+
order_by: selected_as(:date),
|
| 654 |
+
select: [
|
| 655 |
+
date(s.timestamp, ^timezone),
|
| 656 |
+
s.browser,
|
| 657 |
+
s.browser_version,
|
| 658 |
+
visitors(s),
|
| 659 |
+
visits(s),
|
| 660 |
+
visit_duration(s),
|
| 661 |
+
bounces(s),
|
| 662 |
+
pageviews(s)
|
| 663 |
+
]
|
| 664 |
+
end
|
| 665 |
+
|
| 666 |
+
defp export_operating_systems_q(site, timezone, date_range) do
|
| 667 |
+
from s in sampled("sessions_v2"),
|
| 668 |
+
where: ^export_filter(site.id, date_range),
|
| 669 |
+
group_by: [selected_as(:date), s.operating_system, s.operating_system_version],
|
| 670 |
+
order_by: selected_as(:date),
|
| 671 |
+
select: [
|
| 672 |
+
date(s.timestamp, ^timezone),
|
| 673 |
+
s.operating_system,
|
| 674 |
+
s.operating_system_version,
|
| 675 |
+
visitors(s),
|
| 676 |
+
visits(s),
|
| 677 |
+
visit_duration(s),
|
| 678 |
+
bounces(s),
|
| 679 |
+
pageviews(s)
|
| 680 |
+
]
|
| 681 |
+
end
|
| 682 |
+
|
| 683 |
+
@doc """
|
| 684 |
+
Creates a streamable Zip archive from the provided (named) Ecto queries.
|
| 685 |
+
|
| 686 |
+
Example usage:
|
| 687 |
+
|
| 688 |
+
{:ok, pool} = Ch.start_link(pool_size: 1)
|
| 689 |
+
|
| 690 |
+
DBConnection.run(pool, fn conn ->
|
| 691 |
+
conn
|
| 692 |
+
|> stream_archive(export_queries(site), format: "CSVWithNames")
|
| 693 |
+
|> Stream.into(File.stream!("export.zip"))
|
| 694 |
+
|> Stream.run()
|
| 695 |
+
end)
|
| 696 |
+
|
| 697 |
+
"""
|
| 698 |
+
@spec stream_archive(DBConnection.t(), %{String.t() => Ecto.Query.t()}, [Ch.query_option()]) ::
|
| 699 |
+
Enumerable.t()
|
| 700 |
+
def stream_archive(conn, named_queries, opts \\ []) do
|
| 701 |
+
entries =
|
| 702 |
+
Enum.map(named_queries, fn {name, query} ->
|
| 703 |
+
{sql, params} = Plausible.ClickhouseRepo.to_sql(:all, query)
|
| 704 |
+
|
| 705 |
+
datastream =
|
| 706 |
+
conn
|
| 707 |
+
|> Ch.stream(sql, params, opts)
|
| 708 |
+
|> Stream.map(fn %Ch.Result{data: data} -> data end)
|
| 709 |
+
|
| 710 |
+
Zstream.entry(name, datastream, coder: Zstream.Coder.Stored)
|
| 711 |
+
end)
|
| 712 |
+
|
| 713 |
+
Zstream.zip(entries)
|
| 714 |
+
end
|
| 715 |
+
end
|
lib/plausible/file.ex
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.File do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
File helpers for Plausible.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
@doc """
|
| 7 |
+
Moves a file from one location to another.
|
| 8 |
+
|
| 9 |
+
Tries renaming first, and falls back to copying and deleting the original.
|
| 10 |
+
"""
|
| 11 |
+
@spec mv!(Path.t(), Path.t()) :: :ok
|
| 12 |
+
def mv!(source, destination) do
|
| 13 |
+
File.rename!(source, destination)
|
| 14 |
+
rescue
|
| 15 |
+
e in File.RenameError ->
|
| 16 |
+
try do
|
| 17 |
+
case e.reason do
|
| 18 |
+
# fallback to cp/rm for cross-device moves
|
| 19 |
+
# https://github.com/plausible/analytics/issues/4638
|
| 20 |
+
:exdev -> File.cp!(source, destination)
|
| 21 |
+
_ -> reraise(e, __STACKTRACE__)
|
| 22 |
+
end
|
| 23 |
+
after
|
| 24 |
+
File.rm(source)
|
| 25 |
+
end
|
| 26 |
+
end
|
| 27 |
+
end
|
lib/plausible/funnel/const.ex
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Funnel.Const do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Compile-time convenience constants for funnel characteristics.
|
| 4 |
+
"""
|
| 5 |
+
@min_steps 2
|
| 6 |
+
@max_steps 8
|
| 7 |
+
|
| 8 |
+
defmacro min_steps() do
|
| 9 |
+
quote do
|
| 10 |
+
unquote(@min_steps)
|
| 11 |
+
end
|
| 12 |
+
end
|
| 13 |
+
|
| 14 |
+
defmacro max_steps() do
|
| 15 |
+
quote do
|
| 16 |
+
unquote(@max_steps)
|
| 17 |
+
end
|
| 18 |
+
end
|
| 19 |
+
|
| 20 |
+
defmacro __using__(_opts \\ []) do
|
| 21 |
+
quote do
|
| 22 |
+
require Plausible.Funnel.Const
|
| 23 |
+
alias Plausible.Funnel
|
| 24 |
+
end
|
| 25 |
+
end
|
| 26 |
+
end
|
lib/plausible/geo.ex
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Geo do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
This module provides an API for fetching IP geolocation.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
require Logger
|
| 7 |
+
|
| 8 |
+
@db :geolocation
|
| 9 |
+
|
| 10 |
+
@doc """
|
| 11 |
+
Starts the geodatabase loading process. Two modes are supported: local file
|
| 12 |
+
and MaxMind license key.
|
| 13 |
+
|
| 14 |
+
## Options
|
| 15 |
+
|
| 16 |
+
* `:path` - the path to the .mmdb database local file. When present,
|
| 17 |
+
`:license_key` and `:edition` are not required.
|
| 18 |
+
|
| 19 |
+
* `:license_key` - the [license key](https://support.maxmind.com/hc/en-us/articles/4407111582235-Generate-a-License-Key)
|
| 20 |
+
from MaxMind to authenticate requests to MaxMind.
|
| 21 |
+
|
| 22 |
+
* `:edition` - the name of the MaxMind database to be downloaded from MaxMind
|
| 23 |
+
servers. Defaults to `GeoLite2-City`.
|
| 24 |
+
|
| 25 |
+
* `:cache_dir` - if set, the downloaded .mmdb files are cached there across
|
| 26 |
+
restarts.
|
| 27 |
+
|
| 28 |
+
* `:async` - when used, configures the database loading to run
|
| 29 |
+
asynchronously.
|
| 30 |
+
|
| 31 |
+
## Examples
|
| 32 |
+
|
| 33 |
+
Loading from a local file:
|
| 34 |
+
|
| 35 |
+
iex> load_db(path: "/etc/plausible/dbip-city.mmdb")
|
| 36 |
+
:ok
|
| 37 |
+
|
| 38 |
+
Downloading a MaxMind DB (this license key is no longer active):
|
| 39 |
+
|
| 40 |
+
iex> load_db(license_key: "LNpsJCCKPis6XvBP", edition: "GeoLite2-City", async: true)
|
| 41 |
+
:ok
|
| 42 |
+
|
| 43 |
+
"""
|
| 44 |
+
def load_db(opts) do
|
| 45 |
+
cond do
|
| 46 |
+
license_key = opts[:license_key] ->
|
| 47 |
+
edition = opts[:edition] || "GeoLite2-City"
|
| 48 |
+
maxmind_opts = [license_key: license_key]
|
| 49 |
+
|
| 50 |
+
loader_opts =
|
| 51 |
+
if is_binary(opts[:cache_dir]) do
|
| 52 |
+
[
|
| 53 |
+
database_cache_file:
|
| 54 |
+
String.to_charlist(Path.join(opts[:cache_dir], edition <> ".mmdb.gz"))
|
| 55 |
+
]
|
| 56 |
+
else
|
| 57 |
+
[:no_cache]
|
| 58 |
+
end
|
| 59 |
+
|
| 60 |
+
:ok = :locus.start_loader(@db, {:maxmind, edition}, maxmind_opts ++ loader_opts)
|
| 61 |
+
|
| 62 |
+
path = opts[:path] ->
|
| 63 |
+
:ok = :locus.start_loader(@db, path)
|
| 64 |
+
|
| 65 |
+
true ->
|
| 66 |
+
raise "failed to load geolocation db: need :path or :license_key to be provided"
|
| 67 |
+
end
|
| 68 |
+
|
| 69 |
+
unless opts[:async] do
|
| 70 |
+
{:ok, _version} = :locus.await_loader(@db)
|
| 71 |
+
end
|
| 72 |
+
|
| 73 |
+
:ok
|
| 74 |
+
end
|
| 75 |
+
|
| 76 |
+
@doc """
|
| 77 |
+
Waits for the database to start after calling `load_db/1` with the async option.
|
| 78 |
+
"""
|
| 79 |
+
def await_loader, do: :locus.await_loader(@db)
|
| 80 |
+
|
| 81 |
+
@doc """
|
| 82 |
+
Returns geodatabase type.
|
| 83 |
+
|
| 84 |
+
Used for deciding whether to show the DB-IP disclaimer or not.
|
| 85 |
+
|
| 86 |
+
## Examples
|
| 87 |
+
|
| 88 |
+
In the case of a DB-IP database:
|
| 89 |
+
|
| 90 |
+
iex> database_type()
|
| 91 |
+
"DBIP-City-Lite"
|
| 92 |
+
|
| 93 |
+
In the case of a MaxMind database:
|
| 94 |
+
|
| 95 |
+
iex> database_type()
|
| 96 |
+
"GeoLite2-City"
|
| 97 |
+
|
| 98 |
+
"""
|
| 99 |
+
def database_type do
|
| 100 |
+
case :locus.get_info(@db, :metadata) do
|
| 101 |
+
{:ok, %{database_type: type}} -> type
|
| 102 |
+
_other -> nil
|
| 103 |
+
end
|
| 104 |
+
end
|
| 105 |
+
|
| 106 |
+
@doc """
|
| 107 |
+
Looks up geo info about an IP address.
|
| 108 |
+
|
| 109 |
+
## Examples
|
| 110 |
+
|
| 111 |
+
iex> lookup("8.7.6.5")
|
| 112 |
+
%{
|
| 113 |
+
"city" => %{
|
| 114 |
+
"geoname_id" => 5349755,
|
| 115 |
+
"names" => %{
|
| 116 |
+
"de" => "Fontana",
|
| 117 |
+
"en" => "Fontana",
|
| 118 |
+
"ja" => "フォンタナ",
|
| 119 |
+
"ru" => "Фонтана"
|
| 120 |
+
}
|
| 121 |
+
},
|
| 122 |
+
"continent" => %{
|
| 123 |
+
"code" => "NA",
|
| 124 |
+
"geoname_id" => 6255149,
|
| 125 |
+
"names" => %{
|
| 126 |
+
"de" => "Nordamerika",
|
| 127 |
+
"en" => "North America",
|
| 128 |
+
"es" => "Norteamérica",
|
| 129 |
+
"fr" => "Amérique du Nord",
|
| 130 |
+
"ja" => "北アメリカ",
|
| 131 |
+
"pt-BR" => "América do Norte",
|
| 132 |
+
"ru" => "Северная Америка",
|
| 133 |
+
"zh-CN" => "北美洲"
|
| 134 |
+
}
|
| 135 |
+
},
|
| 136 |
+
"country" => %{
|
| 137 |
+
"geoname_id" => 6252001,
|
| 138 |
+
"iso_code" => "US",
|
| 139 |
+
"names" => %{
|
| 140 |
+
"de" => "Vereinigte Staaten",
|
| 141 |
+
"en" => "United States",
|
| 142 |
+
"es" => "Estados Unidos",
|
| 143 |
+
"fr" => "États Unis",
|
| 144 |
+
"ja" => "アメリカ",
|
| 145 |
+
"pt-BR" => "EUA",
|
| 146 |
+
"ru" => "США",
|
| 147 |
+
"zh-CN" => "美国"
|
| 148 |
+
}
|
| 149 |
+
},
|
| 150 |
+
"location" => %{
|
| 151 |
+
"accuracy_radius" => 50,
|
| 152 |
+
"latitude" => 34.1211,
|
| 153 |
+
"longitude" => -117.4362,
|
| 154 |
+
"metro_code" => 803,
|
| 155 |
+
"time_zone" => "America/Los_Angeles"
|
| 156 |
+
},
|
| 157 |
+
"postal" => %{"code" => "92336"},
|
| 158 |
+
"registered_country" => %{
|
| 159 |
+
"geoname_id" => 6252001,
|
| 160 |
+
"iso_code" => "US",
|
| 161 |
+
"names" => %{
|
| 162 |
+
"de" => "Vereinigte Staaten",
|
| 163 |
+
"en" => "United States",
|
| 164 |
+
"es" => "Estados Unidos",
|
| 165 |
+
"fr" => "États Unis",
|
| 166 |
+
"ja" => "アメリカ",
|
| 167 |
+
"pt-BR" => "EUA",
|
| 168 |
+
"ru" => "США",
|
| 169 |
+
"zh-CN" => "美国"
|
| 170 |
+
}
|
| 171 |
+
},
|
| 172 |
+
"subdivisions" => [
|
| 173 |
+
%{
|
| 174 |
+
"geoname_id" => 5332921,
|
| 175 |
+
"iso_code" => "CA",
|
| 176 |
+
"names" => %{
|
| 177 |
+
"de" => "Kalifornien",
|
| 178 |
+
"en" => "California",
|
| 179 |
+
"es" => "California",
|
| 180 |
+
"fr" => "Californie",
|
| 181 |
+
"ja" => "カリフォルニア州",
|
| 182 |
+
"pt-BR" => "Califórnia",
|
| 183 |
+
"ru" => "Калифорния",
|
| 184 |
+
"zh-CN" => "加州"
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
]
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
"""
|
| 191 |
+
def lookup(ip_address) do
|
| 192 |
+
case :locus.lookup(@db, ip_address) do
|
| 193 |
+
{:ok, entry} ->
|
| 194 |
+
entry
|
| 195 |
+
|
| 196 |
+
:not_found ->
|
| 197 |
+
nil
|
| 198 |
+
|
| 199 |
+
{:error, {:invalid_address, _address}} ->
|
| 200 |
+
nil
|
| 201 |
+
|
| 202 |
+
{:error, reason} ->
|
| 203 |
+
Logger.error("Failed to lookup IP address. Reason: " <> inspect(reason))
|
| 204 |
+
nil
|
| 205 |
+
end
|
| 206 |
+
end
|
| 207 |
+
end
|
lib/plausible/goal.ex
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Goal do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Goal schema.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
use Plausible
|
| 7 |
+
use Ecto.Schema
|
| 8 |
+
|
| 9 |
+
import Ecto.Changeset
|
| 10 |
+
|
| 11 |
+
@type t() :: %__MODULE__{}
|
| 12 |
+
|
| 13 |
+
on_ee do
|
| 14 |
+
@journey_end_event Plausible.Stats.Exploration.Journey.Step.journey_end_event()
|
| 15 |
+
else
|
| 16 |
+
@journey_end_event nil
|
| 17 |
+
end
|
| 18 |
+
|
| 19 |
+
schema "goals" do
|
| 20 |
+
field :event_name, :string
|
| 21 |
+
field :page_path, :string
|
| 22 |
+
field :scroll_threshold, :integer, default: -1
|
| 23 |
+
field :display_name, :string
|
| 24 |
+
|
| 25 |
+
on_ee do
|
| 26 |
+
field :currency, Ecto.Enum, values: Money.Currency.known_current_currencies()
|
| 27 |
+
many_to_many :funnels, Plausible.Funnel, join_through: Plausible.Funnel.Step
|
| 28 |
+
else
|
| 29 |
+
field :currency, :string, virtual: true, default: nil
|
| 30 |
+
field :funnels, {:array, :map}, virtual: true, default: []
|
| 31 |
+
end
|
| 32 |
+
|
| 33 |
+
field :custom_props, :map, default: %{}
|
| 34 |
+
|
| 35 |
+
belongs_to :site, Plausible.Site
|
| 36 |
+
|
| 37 |
+
timestamps()
|
| 38 |
+
end
|
| 39 |
+
|
| 40 |
+
@fields [
|
| 41 |
+
:event_name,
|
| 42 |
+
:page_path,
|
| 43 |
+
:scroll_threshold,
|
| 44 |
+
:display_name,
|
| 45 |
+
:custom_props
|
| 46 |
+
] ++
|
| 47 |
+
on_ee(do: [:currency], else: [])
|
| 48 |
+
|
| 49 |
+
@max_event_name_length 120
|
| 50 |
+
|
| 51 |
+
def max_event_name_length(), do: @max_event_name_length
|
| 52 |
+
|
| 53 |
+
@max_custom_props_per_goal 3
|
| 54 |
+
|
| 55 |
+
def max_custom_props_per_goal(), do: @max_custom_props_per_goal
|
| 56 |
+
|
| 57 |
+
@special_goals [
|
| 58 |
+
"404",
|
| 59 |
+
"Outbound Link: Click",
|
| 60 |
+
"Cloaked Link: Click",
|
| 61 |
+
"File Download",
|
| 62 |
+
"Form: Submission",
|
| 63 |
+
"WP Search Queries",
|
| 64 |
+
"WP Form Completions"
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
+
def special_goals(), do: @special_goals
|
| 68 |
+
|
| 69 |
+
@spec special_goal?(t() | String.t() | nil) :: boolean()
|
| 70 |
+
def special_goal?(%__MODULE__{event_name: event_name}), do: special_goal?(event_name)
|
| 71 |
+
def special_goal?(event_name) when is_binary(event_name), do: event_name in @special_goals
|
| 72 |
+
def special_goal?(_), do: false
|
| 73 |
+
|
| 74 |
+
def changeset(goal, attrs \\ %{}) do
|
| 75 |
+
goal
|
| 76 |
+
|> cast(attrs, @fields)
|
| 77 |
+
|> update_leading_slash()
|
| 78 |
+
|> validate_event_name_and_page_path()
|
| 79 |
+
|> validate_page_path_for_scroll_goal()
|
| 80 |
+
|> maybe_put_display_name()
|
| 81 |
+
|> validate_change(:custom_props, &validate_custom_props/2)
|
| 82 |
+
|> unique_constraint(:display_name, name: :goals_display_name_unique)
|
| 83 |
+
|> unique_constraint(:event_name, name: :goals_event_config_unique)
|
| 84 |
+
|> unique_constraint([:page_path, :scroll_threshold],
|
| 85 |
+
name: :goals_pageview_config_unique
|
| 86 |
+
)
|
| 87 |
+
|> validate_length(:event_name, max: @max_event_name_length)
|
| 88 |
+
|> validate_number(:scroll_threshold,
|
| 89 |
+
greater_than_or_equal_to: -1,
|
| 90 |
+
less_than_or_equal_to: 100,
|
| 91 |
+
message: "Should be -1 (missing) or in range [0, 100]"
|
| 92 |
+
)
|
| 93 |
+
|> check_constraint(:event_name,
|
| 94 |
+
name: :check_event_name_or_page_path,
|
| 95 |
+
message: "cannot co-exist with page_path"
|
| 96 |
+
)
|
| 97 |
+
|> maybe_drop_currency()
|
| 98 |
+
|> prevent_currency_change()
|
| 99 |
+
|> prevent_special_goal_renames()
|
| 100 |
+
end
|
| 101 |
+
|
| 102 |
+
@spec display_name(t()) :: String.t()
|
| 103 |
+
def display_name(goal) do
|
| 104 |
+
goal.display_name
|
| 105 |
+
end
|
| 106 |
+
|
| 107 |
+
@spec type(t()) :: :event | :scroll | :page
|
| 108 |
+
def type(goal) do
|
| 109 |
+
cond do
|
| 110 |
+
is_binary(goal.event_name) -> :event
|
| 111 |
+
is_binary(goal.page_path) && goal.scroll_threshold > -1 -> :scroll
|
| 112 |
+
is_binary(goal.page_path) -> :page
|
| 113 |
+
end
|
| 114 |
+
end
|
| 115 |
+
|
| 116 |
+
@spec has_custom_props?(t()) :: boolean()
|
| 117 |
+
def has_custom_props?(%__MODULE__{custom_props: custom_props})
|
| 118 |
+
when map_size(custom_props) > 0 do
|
| 119 |
+
true
|
| 120 |
+
end
|
| 121 |
+
|
| 122 |
+
def has_custom_props?(_), do: false
|
| 123 |
+
|
| 124 |
+
defp update_leading_slash(changeset) do
|
| 125 |
+
case get_field(changeset, :page_path) do
|
| 126 |
+
"/" <> _ ->
|
| 127 |
+
changeset
|
| 128 |
+
|
| 129 |
+
page_path when is_binary(page_path) ->
|
| 130 |
+
put_change(changeset, :page_path, "/" <> page_path)
|
| 131 |
+
|
| 132 |
+
_ ->
|
| 133 |
+
changeset
|
| 134 |
+
end
|
| 135 |
+
end
|
| 136 |
+
|
| 137 |
+
defp validate_event_name_and_page_path(changeset) do
|
| 138 |
+
case {validate_page_path(changeset), validate_event_name(changeset)} do
|
| 139 |
+
{:ok, _} ->
|
| 140 |
+
update_change(changeset, :page_path, &String.trim/1)
|
| 141 |
+
|
| 142 |
+
{_, :ok} ->
|
| 143 |
+
update_change(changeset, :event_name, &String.trim/1)
|
| 144 |
+
|
| 145 |
+
{{:error, page_path_error}, {:error, event_name_error}} ->
|
| 146 |
+
changeset
|
| 147 |
+
|> add_error(:event_name, event_name_error)
|
| 148 |
+
|> add_error(:page_path, page_path_error)
|
| 149 |
+
end
|
| 150 |
+
end
|
| 151 |
+
|
| 152 |
+
defp validate_page_path_for_scroll_goal(changeset) do
|
| 153 |
+
scroll_threshold = get_field(changeset, :scroll_threshold)
|
| 154 |
+
page_path = get_field(changeset, :page_path)
|
| 155 |
+
|
| 156 |
+
if scroll_threshold > -1 and is_nil(page_path) do
|
| 157 |
+
changeset
|
| 158 |
+
|> add_error(:scroll_threshold, "page_path field missing for page scroll goal")
|
| 159 |
+
else
|
| 160 |
+
changeset
|
| 161 |
+
end
|
| 162 |
+
end
|
| 163 |
+
|
| 164 |
+
defp validate_page_path(changeset) do
|
| 165 |
+
value = get_field(changeset, :page_path)
|
| 166 |
+
|
| 167 |
+
if value && String.match?(value, ~r/^\/.*/) do
|
| 168 |
+
:ok
|
| 169 |
+
else
|
| 170 |
+
{:error, "this field is required and must start with a /"}
|
| 171 |
+
end
|
| 172 |
+
end
|
| 173 |
+
|
| 174 |
+
defp validate_event_name(changeset) do
|
| 175 |
+
value = get_field(changeset, :event_name)
|
| 176 |
+
|
| 177 |
+
cond do
|
| 178 |
+
value == "engagement" ->
|
| 179 |
+
{:error, "The event name 'engagement' is reserved and cannot be used as a goal"}
|
| 180 |
+
|
| 181 |
+
journey_end_event?(value) ->
|
| 182 |
+
{:error,
|
| 183 |
+
"The event name '#{@journey_end_event}' is reserved and cannot be used as a goal"}
|
| 184 |
+
|
| 185 |
+
value && String.match?(value, ~r/^.+/) ->
|
| 186 |
+
:ok
|
| 187 |
+
|
| 188 |
+
true ->
|
| 189 |
+
{:error, "this field is required and cannot be blank"}
|
| 190 |
+
end
|
| 191 |
+
end
|
| 192 |
+
|
| 193 |
+
defp maybe_drop_currency(changeset) do
|
| 194 |
+
if ee?() and get_field(changeset, :page_path) do
|
| 195 |
+
delete_change(changeset, :currency)
|
| 196 |
+
else
|
| 197 |
+
changeset
|
| 198 |
+
end
|
| 199 |
+
end
|
| 200 |
+
|
| 201 |
+
defp prevent_currency_change(changeset) do
|
| 202 |
+
if (ee?() and changeset.data.id) && Map.has_key?(changeset.changes, :currency) do
|
| 203 |
+
add_error(changeset, :currency, "cannot change currency of existing goal")
|
| 204 |
+
else
|
| 205 |
+
changeset
|
| 206 |
+
end
|
| 207 |
+
end
|
| 208 |
+
|
| 209 |
+
defp maybe_put_display_name(changeset) do
|
| 210 |
+
clause =
|
| 211 |
+
Enum.map([:display_name, :page_path, :event_name], &get_field(changeset, &1))
|
| 212 |
+
|
| 213 |
+
case clause do
|
| 214 |
+
[nil, path, _] when is_binary(path) ->
|
| 215 |
+
put_change(changeset, :display_name, "Visit " <> path)
|
| 216 |
+
|
| 217 |
+
[nil, _, event_name] when is_binary(event_name) ->
|
| 218 |
+
put_change(changeset, :display_name, event_name)
|
| 219 |
+
|
| 220 |
+
_ ->
|
| 221 |
+
changeset
|
| 222 |
+
end
|
| 223 |
+
|> update_change(:display_name, &String.trim/1)
|
| 224 |
+
|> validate_required(:display_name)
|
| 225 |
+
end
|
| 226 |
+
|
| 227 |
+
defp validate_custom_props(:custom_props, custom_props) when is_map(custom_props) do
|
| 228 |
+
cond do
|
| 229 |
+
map_size(custom_props) > @max_custom_props_per_goal ->
|
| 230 |
+
[custom_props: "use at most #{@max_custom_props_per_goal} properties per goal"]
|
| 231 |
+
|
| 232 |
+
not Enum.all?(custom_props, fn {k, v} ->
|
| 233 |
+
is_binary(k) and is_binary(v)
|
| 234 |
+
end) ->
|
| 235 |
+
[custom_props: "must be a map with string keys and string values"]
|
| 236 |
+
|
| 237 |
+
Enum.any?(custom_props, fn {k, _v} ->
|
| 238 |
+
String.length(k) not in 1..Plausible.Props.max_prop_key_length()
|
| 239 |
+
end) ->
|
| 240 |
+
[
|
| 241 |
+
custom_props: "key length is 1..#{Plausible.Props.max_prop_key_length()} characters"
|
| 242 |
+
]
|
| 243 |
+
|
| 244 |
+
Enum.any?(custom_props, fn {_k, v} ->
|
| 245 |
+
String.length(v) not in 1..Plausible.Props.max_prop_value_length()
|
| 246 |
+
end) ->
|
| 247 |
+
[
|
| 248 |
+
custom_props: "value length is 1..#{Plausible.Props.max_prop_value_length()} characters"
|
| 249 |
+
]
|
| 250 |
+
|
| 251 |
+
true ->
|
| 252 |
+
[]
|
| 253 |
+
end
|
| 254 |
+
end
|
| 255 |
+
|
| 256 |
+
defp prevent_special_goal_renames(changeset) do
|
| 257 |
+
if changeset.data.id && special_goal?(changeset.data.event_name) do
|
| 258 |
+
changeset
|
| 259 |
+
|> reject_special_goal_field_change(:event_name)
|
| 260 |
+
|> reject_special_goal_field_change(:display_name)
|
| 261 |
+
else
|
| 262 |
+
changeset
|
| 263 |
+
end
|
| 264 |
+
end
|
| 265 |
+
|
| 266 |
+
defp reject_special_goal_field_change(changeset, :event_name) do
|
| 267 |
+
if Map.has_key?(changeset.changes, :event_name) do
|
| 268 |
+
add_error(changeset, :event_name, "cannot be changed for an automated goal")
|
| 269 |
+
else
|
| 270 |
+
changeset
|
| 271 |
+
end
|
| 272 |
+
end
|
| 273 |
+
|
| 274 |
+
defp reject_special_goal_field_change(changeset, :display_name) do
|
| 275 |
+
new_value = get_change(changeset, :display_name)
|
| 276 |
+
canonical = changeset.data.event_name
|
| 277 |
+
|
| 278 |
+
if new_value && new_value != canonical do
|
| 279 |
+
add_error(changeset, :display_name, "cannot be changed for an automated goal")
|
| 280 |
+
else
|
| 281 |
+
changeset
|
| 282 |
+
end
|
| 283 |
+
end
|
| 284 |
+
|
| 285 |
+
on_ee do
|
| 286 |
+
defp journey_end_event?(name) do
|
| 287 |
+
name == @journey_end_event
|
| 288 |
+
end
|
| 289 |
+
else
|
| 290 |
+
defp journey_end_event?(_name), do: always(false)
|
| 291 |
+
end
|
| 292 |
+
end
|
| 293 |
+
|
| 294 |
+
defimpl Jason.Encoder, for: Plausible.Goal do
|
| 295 |
+
def encode(value, opts) do
|
| 296 |
+
domain = value.site.domain
|
| 297 |
+
|
| 298 |
+
value
|
| 299 |
+
|> Map.put(:goal_type, Plausible.Goal.type(value))
|
| 300 |
+
|> Map.take([:id, :goal_type, :event_name, :page_path, :custom_props])
|
| 301 |
+
|> Map.put(:domain, domain)
|
| 302 |
+
|> Map.put(:display_name, value.display_name)
|
| 303 |
+
|> Jason.Encode.map(opts)
|
| 304 |
+
end
|
| 305 |
+
end
|
| 306 |
+
|
| 307 |
+
defimpl String.Chars, for: Plausible.Goal do
|
| 308 |
+
def to_string(goal) do
|
| 309 |
+
goal.display_name
|
| 310 |
+
end
|
| 311 |
+
end
|
| 312 |
+
|
| 313 |
+
defimpl Phoenix.HTML.Safe, for: Plausible.Goal do
|
| 314 |
+
def to_iodata(data) do
|
| 315 |
+
data |> to_string() |> Phoenix.HTML.Engine.html_escape()
|
| 316 |
+
end
|
| 317 |
+
end
|
lib/plausible/goals/goals.ex
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Goals do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Goals API.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
use Plausible
|
| 7 |
+
use Plausible.Repo
|
| 8 |
+
use Plausible.Funnel.Const
|
| 9 |
+
|
| 10 |
+
import Ecto.Query
|
| 11 |
+
|
| 12 |
+
alias Plausible.Goal
|
| 13 |
+
alias Ecto.Multi
|
| 14 |
+
alias Ecto.Changeset
|
| 15 |
+
|
| 16 |
+
@max_goals_per_site 1_000
|
| 17 |
+
@spec max_goals_per_site(Keyword.t()) :: pos_integer()
|
| 18 |
+
def max_goals_per_site(opts \\ []) do
|
| 19 |
+
override = Keyword.get(opts, :max_goals_per_site)
|
| 20 |
+
|
| 21 |
+
if override do
|
| 22 |
+
override
|
| 23 |
+
else
|
| 24 |
+
# see: config/test.exs - you can steer this limit for tests
|
| 25 |
+
# by providing `max_goals_per_site` option to e.g. create/3
|
| 26 |
+
Application.get_env(:plausible, :max_goals_per_site, @max_goals_per_site)
|
| 27 |
+
end
|
| 28 |
+
end
|
| 29 |
+
|
| 30 |
+
@spec get(Plausible.Site.t(), pos_integer()) :: nil | Plausible.Goal.t()
|
| 31 |
+
def get(site, id) when is_integer(id) do
|
| 32 |
+
q =
|
| 33 |
+
from g in Plausible.Goal,
|
| 34 |
+
where: g.site_id == ^site.id,
|
| 35 |
+
order_by: [desc: g.id],
|
| 36 |
+
where: g.id == ^id
|
| 37 |
+
|
| 38 |
+
Repo.one(q)
|
| 39 |
+
end
|
| 40 |
+
|
| 41 |
+
@spec create(Plausible.Site.t(), map(), Keyword.t()) ::
|
| 42 |
+
{:ok, Goal.t()}
|
| 43 |
+
| {:error, Changeset.t()}
|
| 44 |
+
| {:error, :upgrade_required}
|
| 45 |
+
| {:error, :revenue_goals_unavailable}
|
| 46 |
+
@doc """
|
| 47 |
+
Creates a Goal for a site.
|
| 48 |
+
|
| 49 |
+
If the created goal is a revenue goal, it sets site.updated_at to be
|
| 50 |
+
refreshed by the sites cache, as revenue goals are used during ingestion.
|
| 51 |
+
|
| 52 |
+
Returns `{:ok, goal}` or `{:error, changeset}` when creation fails due to
|
| 53 |
+
invalid fields. It can also return:
|
| 54 |
+
|
| 55 |
+
* `{:error, :upgrade_required}` - Adding a revenue goal is not allowed
|
| 56 |
+
for team's subscription.
|
| 57 |
+
|
| 58 |
+
* `{:error, :revenue_goals_unavailable}` - When the site is a consolidated
|
| 59 |
+
view and the goal created is a revenue goal. Revenue goal creation is not
|
| 60 |
+
allowed for consolidated views due to the inability to force a single
|
| 61 |
+
currency on a goal across all consolidated sites.
|
| 62 |
+
"""
|
| 63 |
+
def create(site, params, opts \\ []) do
|
| 64 |
+
Repo.transaction(fn ->
|
| 65 |
+
case insert_goal(site, params, opts) do
|
| 66 |
+
{:ok, :insert, goal} ->
|
| 67 |
+
on_ee do
|
| 68 |
+
now = Keyword.get(opts, :now, DateTime.utc_now())
|
| 69 |
+
# credo:disable-for-next-line Credo.Check.Refactor.Nesting
|
| 70 |
+
if Plausible.Goal.Revenue.revenue?(goal) do
|
| 71 |
+
Plausible.Site.Cache.touch_site!(site, now)
|
| 72 |
+
end
|
| 73 |
+
end
|
| 74 |
+
|
| 75 |
+
Repo.preload(goal, :site)
|
| 76 |
+
|
| 77 |
+
{:ok, :upsert, goal} ->
|
| 78 |
+
Repo.preload(goal, :site)
|
| 79 |
+
|
| 80 |
+
{:error, cause} ->
|
| 81 |
+
Repo.rollback(cause)
|
| 82 |
+
end
|
| 83 |
+
end)
|
| 84 |
+
end
|
| 85 |
+
|
| 86 |
+
@spec update(Plausible.Goal.t(), map()) ::
|
| 87 |
+
{:ok, Goal.t()} | {:error, Changeset.t()} | {:error, :upgrade_required}
|
| 88 |
+
def update(goal, params) do
|
| 89 |
+
changeset = Goal.changeset(goal, params)
|
| 90 |
+
|
| 91 |
+
Repo.transaction(fn ->
|
| 92 |
+
site = Repo.preload(goal, :site).site
|
| 93 |
+
|
| 94 |
+
with :ok <- maybe_check_feature_access(site, changeset),
|
| 95 |
+
{:ok, updated_goal} <- Repo.update(changeset),
|
| 96 |
+
:ok <- Plausible.Segments.update_goal_in_segments(site, goal, updated_goal) do
|
| 97 |
+
on_ee do
|
| 98 |
+
Repo.preload(updated_goal, :funnels)
|
| 99 |
+
else
|
| 100 |
+
updated_goal
|
| 101 |
+
end
|
| 102 |
+
else
|
| 103 |
+
{:error, %Changeset{} = changeset} ->
|
| 104 |
+
Repo.rollback(changeset)
|
| 105 |
+
|
| 106 |
+
{:error, :upgrade_required} ->
|
| 107 |
+
Repo.rollback(:upgrade_required)
|
| 108 |
+
end
|
| 109 |
+
end)
|
| 110 |
+
end
|
| 111 |
+
|
| 112 |
+
def find_or_create(site, params, opts \\ [])
|
| 113 |
+
|
| 114 |
+
def find_or_create(
|
| 115 |
+
site,
|
| 116 |
+
%{
|
| 117 |
+
"goal_type" => "event",
|
| 118 |
+
"event_name" => event_name,
|
| 119 |
+
"currency" => currency
|
| 120 |
+
} = params,
|
| 121 |
+
opts
|
| 122 |
+
)
|
| 123 |
+
when is_binary(event_name) and is_binary(currency) do
|
| 124 |
+
with {:ok, goal} <- create(site, params, do_upsert(opts)) do
|
| 125 |
+
if to_string(goal.currency) == currency do
|
| 126 |
+
{:ok, goal}
|
| 127 |
+
else
|
| 128 |
+
# we must disallow creation of the same goal name with different currency
|
| 129 |
+
changeset =
|
| 130 |
+
goal
|
| 131 |
+
|> Goal.changeset()
|
| 132 |
+
|> Changeset.add_error(
|
| 133 |
+
:event_name,
|
| 134 |
+
"'#{goal.event_name}' (with currency: #{goal.currency}) has already been taken"
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
{:error, changeset}
|
| 138 |
+
end
|
| 139 |
+
end
|
| 140 |
+
end
|
| 141 |
+
|
| 142 |
+
def find_or_create(site, %{"goal_type" => "event", "event_name" => event_name} = params, opts)
|
| 143 |
+
when is_binary(event_name) do
|
| 144 |
+
create(site, params, do_upsert(opts))
|
| 145 |
+
end
|
| 146 |
+
|
| 147 |
+
def find_or_create(_, %{"goal_type" => "event"}, _), do: {:missing, "event_name"}
|
| 148 |
+
|
| 149 |
+
def find_or_create(site, %{"goal_type" => "page", "page_path" => _} = params, opts) do
|
| 150 |
+
create(site, params, do_upsert(opts))
|
| 151 |
+
end
|
| 152 |
+
|
| 153 |
+
def find_or_create(_, %{"goal_type" => "page"}, _), do: {:missing, "page_path"}
|
| 154 |
+
|
| 155 |
+
def list_revenue_goals(site) do
|
| 156 |
+
from(g in Plausible.Goal,
|
| 157 |
+
where: g.site_id == ^site.id and not is_nil(g.currency),
|
| 158 |
+
select: %{display_name: g.display_name, currency: g.currency},
|
| 159 |
+
order_by: [desc: g.id],
|
| 160 |
+
limit: ^max_goals_per_site()
|
| 161 |
+
)
|
| 162 |
+
|> Plausible.Repo.all()
|
| 163 |
+
end
|
| 164 |
+
|
| 165 |
+
def for_site(site, opts \\ []) do
|
| 166 |
+
site
|
| 167 |
+
|> for_site_query(opts)
|
| 168 |
+
|> Repo.all()
|
| 169 |
+
|> Enum.map(&maybe_trim/1)
|
| 170 |
+
end
|
| 171 |
+
|
| 172 |
+
def for_site_query(site \\ nil, opts \\ []) do
|
| 173 |
+
query =
|
| 174 |
+
from g in Goal,
|
| 175 |
+
order_by: [desc: g.id],
|
| 176 |
+
limit: ^max_goals_per_site(opts)
|
| 177 |
+
|
| 178 |
+
query =
|
| 179 |
+
if site do
|
| 180 |
+
from g in query,
|
| 181 |
+
inner_join: assoc(g, :site),
|
| 182 |
+
where: g.site_id == ^site.id,
|
| 183 |
+
preload: [:site]
|
| 184 |
+
else
|
| 185 |
+
query
|
| 186 |
+
end
|
| 187 |
+
|
| 188 |
+
query =
|
| 189 |
+
if Keyword.get(opts, :include_goals_with_custom_props?, true) == false do
|
| 190 |
+
from g in query, where: g.custom_props == ^%{}
|
| 191 |
+
else
|
| 192 |
+
query
|
| 193 |
+
end
|
| 194 |
+
|
| 195 |
+
if ee?() and opts[:preload_funnels?] == true do
|
| 196 |
+
from(g in query,
|
| 197 |
+
left_join: assoc(g, :funnels),
|
| 198 |
+
group_by: g.id,
|
| 199 |
+
preload: [:funnels]
|
| 200 |
+
)
|
| 201 |
+
else
|
| 202 |
+
query
|
| 203 |
+
end
|
| 204 |
+
end
|
| 205 |
+
|
| 206 |
+
def batch_create_event_goals(names, site, opts \\ []) do
|
| 207 |
+
Enum.reduce(names, [], fn name, acc ->
|
| 208 |
+
case insert_goal(site, %{event_name: name}, do_upsert(opts)) do
|
| 209 |
+
{:ok, _, goal} -> acc ++ [goal]
|
| 210 |
+
_ -> acc
|
| 211 |
+
end
|
| 212 |
+
end)
|
| 213 |
+
end
|
| 214 |
+
|
| 215 |
+
@doc """
|
| 216 |
+
If a goal belongs to funnel(s), we need to inspect their number of steps.
|
| 217 |
+
|
| 218 |
+
If it exceeds the minimum allowed (defined via `Plausible.Funnel.min_steps/0`),
|
| 219 |
+
the funnel will be reduced (i.e. a step associated with the goal to be deleted
|
| 220 |
+
is removed), so that the minimum number of steps is preserved. This is done
|
| 221 |
+
implicitly, by postgres, as per on_delete: :delete_all.
|
| 222 |
+
|
| 223 |
+
Otherwise, for associated funnel(s) consisting of minimum number steps only,
|
| 224 |
+
funnel record(s) are removed completely along with the targeted goal.
|
| 225 |
+
"""
|
| 226 |
+
def delete(id, %Plausible.Site{id: site_id}) do
|
| 227 |
+
delete(id, site_id)
|
| 228 |
+
end
|
| 229 |
+
|
| 230 |
+
def delete(id, site_id) do
|
| 231 |
+
goal_query =
|
| 232 |
+
from(g in Goal,
|
| 233 |
+
where: g.id == ^id,
|
| 234 |
+
where: g.site_id == ^site_id
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
goal_query = on_ee(do: preload(goal_query, funnels: :steps), else: goal_query)
|
| 238 |
+
|
| 239 |
+
result =
|
| 240 |
+
Multi.new()
|
| 241 |
+
|> Multi.one(
|
| 242 |
+
:goal,
|
| 243 |
+
goal_query
|
| 244 |
+
)
|
| 245 |
+
|> Multi.run(:funnel_ids_to_wipe, fn
|
| 246 |
+
_, %{goal: nil} ->
|
| 247 |
+
{:error, :not_found}
|
| 248 |
+
|
| 249 |
+
_, %{goal: %{funnels: []}} ->
|
| 250 |
+
{:ok, []}
|
| 251 |
+
|
| 252 |
+
_, %{goal: %{funnels: funnels}} ->
|
| 253 |
+
funnels_to_wipe =
|
| 254 |
+
funnels
|
| 255 |
+
|> Enum.filter(&(Enum.count(&1.steps) == Funnel.Const.min_steps()))
|
| 256 |
+
|> Enum.map(& &1.id)
|
| 257 |
+
|
| 258 |
+
{:ok, funnels_to_wipe}
|
| 259 |
+
end)
|
| 260 |
+
|> Multi.merge(fn
|
| 261 |
+
%{funnel_ids_to_wipe: []} ->
|
| 262 |
+
Ecto.Multi.new()
|
| 263 |
+
|
| 264 |
+
%{funnel_ids_to_wipe: [_ | _] = funnel_ids} ->
|
| 265 |
+
Ecto.Multi.new()
|
| 266 |
+
|> Multi.delete_all(
|
| 267 |
+
:delete_funnels,
|
| 268 |
+
from(f in "funnels",
|
| 269 |
+
where: f.id in ^funnel_ids
|
| 270 |
+
)
|
| 271 |
+
)
|
| 272 |
+
end)
|
| 273 |
+
|> Multi.delete_all(
|
| 274 |
+
:delete_goals,
|
| 275 |
+
fn _ ->
|
| 276 |
+
from g in Goal,
|
| 277 |
+
where: g.id == ^id,
|
| 278 |
+
where: g.site_id == ^site_id
|
| 279 |
+
end
|
| 280 |
+
)
|
| 281 |
+
|> Repo.transaction()
|
| 282 |
+
|
| 283 |
+
case result do
|
| 284 |
+
{:ok, _} -> :ok
|
| 285 |
+
{:error, _step, reason, _context} -> {:error, reason}
|
| 286 |
+
end
|
| 287 |
+
end
|
| 288 |
+
|
| 289 |
+
@spec count(Plausible.Site.t()) :: non_neg_integer()
|
| 290 |
+
def count(site) do
|
| 291 |
+
Repo.aggregate(
|
| 292 |
+
from(
|
| 293 |
+
g in Goal,
|
| 294 |
+
where: g.site_id == ^site.id
|
| 295 |
+
),
|
| 296 |
+
:count
|
| 297 |
+
)
|
| 298 |
+
end
|
| 299 |
+
|
| 300 |
+
@spec create_outbound_links(Plausible.Site.t()) :: :ok
|
| 301 |
+
def create_outbound_links(%Plausible.Site{} = site) do
|
| 302 |
+
create(site, %{"event_name" => "Outbound Link: Click"}, do_upsert())
|
| 303 |
+
:ok
|
| 304 |
+
end
|
| 305 |
+
|
| 306 |
+
@spec create_file_downloads(Plausible.Site.t()) :: :ok
|
| 307 |
+
def create_file_downloads(%Plausible.Site{} = site) do
|
| 308 |
+
create(site, %{"event_name" => "File Download"}, do_upsert())
|
| 309 |
+
:ok
|
| 310 |
+
end
|
| 311 |
+
|
| 312 |
+
@spec create_form_submissions(Plausible.Site.t()) :: :ok
|
| 313 |
+
def create_form_submissions(%Plausible.Site{} = site) do
|
| 314 |
+
create(site, %{"event_name" => "Form: Submission"}, do_upsert())
|
| 315 |
+
:ok
|
| 316 |
+
end
|
| 317 |
+
|
| 318 |
+
@spec create_404(Plausible.Site.t()) :: :ok
|
| 319 |
+
def create_404(%Plausible.Site{} = site) do
|
| 320 |
+
create(site, %{"event_name" => "404"}, do_upsert())
|
| 321 |
+
:ok
|
| 322 |
+
end
|
| 323 |
+
|
| 324 |
+
@spec delete_outbound_links(Plausible.Site.t()) :: :ok
|
| 325 |
+
def delete_outbound_links(%Plausible.Site{} = site) do
|
| 326 |
+
q =
|
| 327 |
+
from g in Goal,
|
| 328 |
+
where: g.site_id == ^site.id,
|
| 329 |
+
where: g.event_name == "Outbound Link: Click"
|
| 330 |
+
|
| 331 |
+
Repo.delete_all(q)
|
| 332 |
+
:ok
|
| 333 |
+
end
|
| 334 |
+
|
| 335 |
+
@spec delete_file_downloads(Plausible.Site.t()) :: :ok
|
| 336 |
+
def delete_file_downloads(%Plausible.Site{} = site) do
|
| 337 |
+
q =
|
| 338 |
+
from g in Goal,
|
| 339 |
+
where: g.site_id == ^site.id,
|
| 340 |
+
where: g.event_name == "File Download"
|
| 341 |
+
|
| 342 |
+
Repo.delete_all(q)
|
| 343 |
+
:ok
|
| 344 |
+
end
|
| 345 |
+
|
| 346 |
+
@spec delete_404(Plausible.Site.t()) :: :ok
|
| 347 |
+
def delete_404(%Plausible.Site{} = site) do
|
| 348 |
+
q =
|
| 349 |
+
from g in Goal,
|
| 350 |
+
where: g.site_id == ^site.id,
|
| 351 |
+
where: g.event_name == "404"
|
| 352 |
+
|
| 353 |
+
Repo.delete_all(q)
|
| 354 |
+
:ok
|
| 355 |
+
end
|
| 356 |
+
|
| 357 |
+
@spec delete_form_submissions(Plausible.Site.t()) :: :ok
|
| 358 |
+
def delete_form_submissions(%Plausible.Site{} = site) do
|
| 359 |
+
q =
|
| 360 |
+
from g in Goal,
|
| 361 |
+
where: g.site_id == ^site.id,
|
| 362 |
+
where: g.event_name == "Form: Submission"
|
| 363 |
+
|
| 364 |
+
Repo.delete_all(q)
|
| 365 |
+
:ok
|
| 366 |
+
end
|
| 367 |
+
|
| 368 |
+
defp insert_goal(site, params, opts) do
|
| 369 |
+
params = Map.delete(params, "site_id")
|
| 370 |
+
|
| 371 |
+
insert_opts =
|
| 372 |
+
if upsert?(opts) do
|
| 373 |
+
[on_conflict: :nothing]
|
| 374 |
+
else
|
| 375 |
+
[]
|
| 376 |
+
end
|
| 377 |
+
|
| 378 |
+
changeset =
|
| 379 |
+
%Goal{}
|
| 380 |
+
|> Goal.changeset(params)
|
| 381 |
+
|> Ecto.Changeset.put_assoc(:site, site)
|
| 382 |
+
|
| 383 |
+
with :ok <- maybe_check_feature_access(site, changeset),
|
| 384 |
+
:ok <- check_no_currency_if_consolidated(site, changeset),
|
| 385 |
+
:ok <- check_goals_limit(site, changeset, opts),
|
| 386 |
+
{:ok, goal} <- Repo.insert(changeset, insert_opts) do
|
| 387 |
+
# Upsert with `on_conflict: :nothing` strategy
|
| 388 |
+
# will result in goal struct missing primary key field
|
| 389 |
+
# which is generated by the database.
|
| 390 |
+
if goal.id do
|
| 391 |
+
{:ok, :insert, goal}
|
| 392 |
+
else
|
| 393 |
+
get_params =
|
| 394 |
+
goal
|
| 395 |
+
|> Map.take([:site_id, :event_name, :page_path])
|
| 396 |
+
|> Enum.reject(fn {_, value} -> is_nil(value) end)
|
| 397 |
+
|
| 398 |
+
{:ok, :upsert, Repo.get_by!(Goal, get_params)}
|
| 399 |
+
end
|
| 400 |
+
end
|
| 401 |
+
end
|
| 402 |
+
|
| 403 |
+
defp maybe_check_feature_access(site, changeset) do
|
| 404 |
+
with :ok <- revenue_goals_access_check(site, changeset) do
|
| 405 |
+
custom_props_goals_access_check(site, changeset)
|
| 406 |
+
end
|
| 407 |
+
end
|
| 408 |
+
|
| 409 |
+
defp revenue_goals_access_check(site, changeset) do
|
| 410 |
+
if Changeset.get_field(changeset, :currency) do
|
| 411 |
+
site = Plausible.Repo.preload(site, :team)
|
| 412 |
+
Plausible.Billing.Feature.RevenueGoals.check_availability(site.team)
|
| 413 |
+
else
|
| 414 |
+
:ok
|
| 415 |
+
end
|
| 416 |
+
end
|
| 417 |
+
|
| 418 |
+
defp custom_props_goals_access_check(site, changeset) do
|
| 419 |
+
if map_size(Changeset.get_field(changeset, :custom_props)) > 0 do
|
| 420 |
+
site = Plausible.Repo.preload(site, :team)
|
| 421 |
+
Plausible.Billing.Feature.Props.check_availability(site.team)
|
| 422 |
+
else
|
| 423 |
+
:ok
|
| 424 |
+
end
|
| 425 |
+
end
|
| 426 |
+
|
| 427 |
+
defp check_goals_limit(site, changeset, opts) do
|
| 428 |
+
if upsert?(opts) and goal_exists_for_upsert?(site, changeset) do
|
| 429 |
+
:ok
|
| 430 |
+
else
|
| 431 |
+
if count(site) >= max_goals_per_site(opts) and changeset.valid? do
|
| 432 |
+
changeset
|
| 433 |
+
|> Changeset.add_error(:page_path, "Maximum number of goals reached")
|
| 434 |
+
|> Changeset.add_error(:event_name, "Maximum number of goals reached")
|
| 435 |
+
|> Changeset.apply_action(:insert)
|
| 436 |
+
else
|
| 437 |
+
:ok
|
| 438 |
+
end
|
| 439 |
+
end
|
| 440 |
+
end
|
| 441 |
+
|
| 442 |
+
defp check_no_currency_if_consolidated(site, changeset) do
|
| 443 |
+
if Plausible.Sites.consolidated?(site) && Changeset.get_field(changeset, :currency) do
|
| 444 |
+
{:error, :revenue_goals_unavailable}
|
| 445 |
+
else
|
| 446 |
+
:ok
|
| 447 |
+
end
|
| 448 |
+
end
|
| 449 |
+
|
| 450 |
+
defp maybe_trim(%Goal{} = goal) do
|
| 451 |
+
# we make sure that even if we saved goals erroneously with trailing
|
| 452 |
+
# space, it's removed during fetch
|
| 453 |
+
goal
|
| 454 |
+
|> Map.update!(:event_name, &maybe_trim/1)
|
| 455 |
+
|> Map.update!(:page_path, &maybe_trim/1)
|
| 456 |
+
end
|
| 457 |
+
|
| 458 |
+
defp maybe_trim(s) when is_binary(s) do
|
| 459 |
+
String.trim(s)
|
| 460 |
+
end
|
| 461 |
+
|
| 462 |
+
defp maybe_trim(other) do
|
| 463 |
+
other
|
| 464 |
+
end
|
| 465 |
+
|
| 466 |
+
defp upsert?(opts) do
|
| 467 |
+
Keyword.get(opts, :upsert?, false)
|
| 468 |
+
end
|
| 469 |
+
|
| 470 |
+
defp do_upsert(opts \\ []) do
|
| 471 |
+
Keyword.put(opts, :upsert?, true)
|
| 472 |
+
end
|
| 473 |
+
|
| 474 |
+
defp goal_exists_for_upsert?(site, changeset) do
|
| 475 |
+
event_name = Changeset.get_field(changeset, :event_name)
|
| 476 |
+
page_path = Changeset.get_field(changeset, :page_path)
|
| 477 |
+
scroll_threshold = Changeset.get_field(changeset, :scroll_threshold)
|
| 478 |
+
|
| 479 |
+
query_params =
|
| 480 |
+
[site_id: site.id]
|
| 481 |
+
|> maybe_add_filter(:event_name, event_name)
|
| 482 |
+
|> maybe_add_filter(:page_path, page_path)
|
| 483 |
+
|> maybe_add_filter(:scroll_threshold, scroll_threshold)
|
| 484 |
+
|
| 485 |
+
Repo.exists?(from(g in Goal, where: ^query_params))
|
| 486 |
+
end
|
| 487 |
+
|
| 488 |
+
defp maybe_add_filter(params, _key, nil), do: params
|
| 489 |
+
defp maybe_add_filter(params, key, value), do: Keyword.put(params, key, value)
|
| 490 |
+
end
|
lib/plausible/google/api.ex
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Google.API do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
API to Google services.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
alias Plausible.Google.HTTP
|
| 7 |
+
alias Plausible.Google.SearchConsole
|
| 8 |
+
alias Plausible.Stats.Query
|
| 9 |
+
|
| 10 |
+
@search_console_scope URI.encode_www_form(
|
| 11 |
+
"email https://www.googleapis.com/auth/webmasters.readonly"
|
| 12 |
+
)
|
| 13 |
+
@import_scope URI.encode_www_form("email https://www.googleapis.com/auth/analytics.readonly")
|
| 14 |
+
|
| 15 |
+
@verified_permission_levels ["siteOwner", "siteFullUser", "siteRestrictedUser"]
|
| 16 |
+
|
| 17 |
+
@valid_oauth_contexts ["search-console", "import"]
|
| 18 |
+
|
| 19 |
+
@oauth_salt "google-oauth-state"
|
| 20 |
+
|
| 21 |
+
def search_console_authorize_url(site) do
|
| 22 |
+
"https://accounts.google.com/o/oauth2/v2/auth?client_id=#{client_id()}&redirect_uri=#{redirect_uri()}&prompt=consent&response_type=code&access_type=offline&scope=#{@search_console_scope}&state=" <>
|
| 23 |
+
sign_oauth_state(site, "search-console")
|
| 24 |
+
end
|
| 25 |
+
|
| 26 |
+
def import_authorize_url(site) do
|
| 27 |
+
"https://accounts.google.com/o/oauth2/v2/auth?client_id=#{client_id()}&redirect_uri=#{redirect_uri()}&prompt=consent&response_type=code&access_type=offline&scope=#{@import_scope}&state=" <>
|
| 28 |
+
sign_oauth_state(site, "import")
|
| 29 |
+
end
|
| 30 |
+
|
| 31 |
+
@doc false
|
| 32 |
+
def sign_oauth_state(site, context) do
|
| 33 |
+
Phoenix.Token.sign(PlausibleWeb.Endpoint, @oauth_salt, [site.id, context])
|
| 34 |
+
end
|
| 35 |
+
|
| 36 |
+
def verify_oauth_state(state) do
|
| 37 |
+
case Phoenix.Token.verify(PlausibleWeb.Endpoint, @oauth_salt, state, max_age: 3600) do
|
| 38 |
+
{:ok, [site_id, context]} when context in @valid_oauth_contexts ->
|
| 39 |
+
if site = Plausible.Repo.get(Plausible.Site, site_id) do
|
| 40 |
+
{:ok, %{site: site, context: context}}
|
| 41 |
+
else
|
| 42 |
+
{:error, {:invalid_oauth_state, :missing_site}}
|
| 43 |
+
end
|
| 44 |
+
|
| 45 |
+
{:error, token_error} ->
|
| 46 |
+
{:error, {:invalid_oauth_state, token_error}}
|
| 47 |
+
end
|
| 48 |
+
end
|
| 49 |
+
|
| 50 |
+
def fetch_access_token!(code) do
|
| 51 |
+
HTTP.fetch_access_token!(code)
|
| 52 |
+
end
|
| 53 |
+
|
| 54 |
+
def list_properties(access_token) do
|
| 55 |
+
Plausible.Google.GA4.API.list_properties(access_token)
|
| 56 |
+
end
|
| 57 |
+
|
| 58 |
+
def get_property(access_token, property) do
|
| 59 |
+
Plausible.Google.GA4.API.get_property(access_token, property)
|
| 60 |
+
end
|
| 61 |
+
|
| 62 |
+
def get_analytics_start_date(access_token, property) do
|
| 63 |
+
Plausible.Google.GA4.API.get_analytics_start_date(access_token, property)
|
| 64 |
+
end
|
| 65 |
+
|
| 66 |
+
def get_analytics_end_date(access_token, property) do
|
| 67 |
+
Plausible.Google.GA4.API.get_analytics_end_date(access_token, property)
|
| 68 |
+
end
|
| 69 |
+
|
| 70 |
+
def fetch_verified_properties(auth) do
|
| 71 |
+
with {:ok, access_token} <- maybe_refresh_token(auth),
|
| 72 |
+
{:ok, sites} <- Plausible.Google.HTTP.list_sites(access_token) do
|
| 73 |
+
sites
|
| 74 |
+
|> Map.get("siteEntry", [])
|
| 75 |
+
|> Enum.filter(fn site -> site["permissionLevel"] in @verified_permission_levels end)
|
| 76 |
+
|> Enum.map(fn site -> site["siteUrl"] end)
|
| 77 |
+
|> Enum.map(fn url -> String.trim_trailing(url, "/") end)
|
| 78 |
+
|> then(&{:ok, &1})
|
| 79 |
+
end
|
| 80 |
+
end
|
| 81 |
+
|
| 82 |
+
def fetch_stats(site, query, pagination, search) do
|
| 83 |
+
with {:ok, site} <- ensure_search_console_property(site),
|
| 84 |
+
{:ok, access_token} <- maybe_refresh_token(site.google_auth),
|
| 85 |
+
{:ok, gsc_filters} <-
|
| 86 |
+
SearchConsole.Filters.transform(site.google_auth.property, query.filters, search),
|
| 87 |
+
{:ok, stats} <-
|
| 88 |
+
HTTP.list_stats(
|
| 89 |
+
access_token,
|
| 90 |
+
site.google_auth.property,
|
| 91 |
+
Query.date_range(query),
|
| 92 |
+
pagination,
|
| 93 |
+
gsc_filters
|
| 94 |
+
) do
|
| 95 |
+
stats
|
| 96 |
+
|> Map.get("rows", [])
|
| 97 |
+
|> Enum.map(&search_console_row/1)
|
| 98 |
+
|> then(&{:ok, &1})
|
| 99 |
+
else
|
| 100 |
+
:google_property_not_configured -> {:error, :google_property_not_configured}
|
| 101 |
+
:unsupported_filters -> {:error, :unsupported_filters}
|
| 102 |
+
{:error, error} -> {:error, error}
|
| 103 |
+
end
|
| 104 |
+
end
|
| 105 |
+
|
| 106 |
+
def maybe_refresh_token(%Plausible.Site.GoogleAuth{} = auth) do
|
| 107 |
+
with true <- needs_to_refresh_token?(auth.expires),
|
| 108 |
+
{:ok, {new_access_token, expires_at}} <- do_refresh_token(auth.refresh_token),
|
| 109 |
+
changeset <-
|
| 110 |
+
Plausible.Site.GoogleAuth.changeset(auth, %{
|
| 111 |
+
access_token: new_access_token,
|
| 112 |
+
expires: expires_at
|
| 113 |
+
}),
|
| 114 |
+
{:ok, _google_auth} <- Plausible.Repo.update(changeset) do
|
| 115 |
+
{:ok, new_access_token}
|
| 116 |
+
else
|
| 117 |
+
false -> {:ok, auth.access_token}
|
| 118 |
+
{:error, cause} -> {:error, cause}
|
| 119 |
+
end
|
| 120 |
+
end
|
| 121 |
+
|
| 122 |
+
def maybe_refresh_token({access_token, refresh_token, expires_at}) do
|
| 123 |
+
with true <- needs_to_refresh_token?(expires_at),
|
| 124 |
+
{:ok, {new_access_token, _expires_at}} <- do_refresh_token(refresh_token) do
|
| 125 |
+
{:ok, new_access_token}
|
| 126 |
+
else
|
| 127 |
+
false -> {:ok, access_token}
|
| 128 |
+
{:error, cause} -> {:error, cause}
|
| 129 |
+
end
|
| 130 |
+
end
|
| 131 |
+
|
| 132 |
+
def property?(value), do: String.starts_with?(value, "properties/")
|
| 133 |
+
|
| 134 |
+
defp do_refresh_token(refresh_token) do
|
| 135 |
+
case HTTP.refresh_auth_token(refresh_token) do
|
| 136 |
+
{:ok, %{"access_token" => new_access_token, "expires_in" => expires_in}} ->
|
| 137 |
+
expires_at = NaiveDateTime.add(NaiveDateTime.utc_now(), expires_in)
|
| 138 |
+
{:ok, {new_access_token, expires_at}}
|
| 139 |
+
|
| 140 |
+
{:error, cause} ->
|
| 141 |
+
{:error, cause}
|
| 142 |
+
end
|
| 143 |
+
end
|
| 144 |
+
|
| 145 |
+
defp needs_to_refresh_token?(expires_at) when is_binary(expires_at) do
|
| 146 |
+
expires_at
|
| 147 |
+
|> NaiveDateTime.from_iso8601!()
|
| 148 |
+
|> needs_to_refresh_token?()
|
| 149 |
+
end
|
| 150 |
+
|
| 151 |
+
defp needs_to_refresh_token?(%NaiveDateTime{} = expires_at) do
|
| 152 |
+
thirty_seconds_ago = DateTime.shift(DateTime.utc_now(), second: 30)
|
| 153 |
+
NaiveDateTime.before?(expires_at, thirty_seconds_ago)
|
| 154 |
+
end
|
| 155 |
+
|
| 156 |
+
defp ensure_search_console_property(site) do
|
| 157 |
+
site = Plausible.Repo.preload(site, :google_auth)
|
| 158 |
+
|
| 159 |
+
if site.google_auth && site.google_auth.property do
|
| 160 |
+
{:ok, site}
|
| 161 |
+
else
|
| 162 |
+
:google_property_not_configured
|
| 163 |
+
end
|
| 164 |
+
end
|
| 165 |
+
|
| 166 |
+
defp search_console_row(row) do
|
| 167 |
+
%{
|
| 168 |
+
# We always request just one dimension at a time (`query`)
|
| 169 |
+
name: row["keys"] |> List.first(),
|
| 170 |
+
visitors: row["clicks"],
|
| 171 |
+
impressions: row["impressions"],
|
| 172 |
+
ctr: rounded_ctr(row["ctr"]),
|
| 173 |
+
position: rounded_position(row["position"])
|
| 174 |
+
}
|
| 175 |
+
end
|
| 176 |
+
|
| 177 |
+
defp rounded_ctr(ctr) do
|
| 178 |
+
{:ok, decimal} = Decimal.cast(ctr)
|
| 179 |
+
|
| 180 |
+
decimal
|
| 181 |
+
|> Decimal.mult(100)
|
| 182 |
+
|> Decimal.round(1)
|
| 183 |
+
|> Decimal.to_float()
|
| 184 |
+
end
|
| 185 |
+
|
| 186 |
+
defp rounded_position(position) do
|
| 187 |
+
{:ok, decimal} = Decimal.cast(position)
|
| 188 |
+
|
| 189 |
+
decimal
|
| 190 |
+
|> Decimal.round(1)
|
| 191 |
+
|> Decimal.to_float()
|
| 192 |
+
end
|
| 193 |
+
|
| 194 |
+
defp client_id() do
|
| 195 |
+
Keyword.fetch!(Application.get_env(:plausible, :google), :client_id)
|
| 196 |
+
end
|
| 197 |
+
|
| 198 |
+
defp redirect_uri() do
|
| 199 |
+
PlausibleWeb.Endpoint.url() <> "/auth/google/callback"
|
| 200 |
+
end
|
| 201 |
+
end
|
lib/plausible/google/ga4/api.ex
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Google.GA4.API do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
API for Google Analytics 4.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
alias Plausible.Google
|
| 7 |
+
alias Plausible.Google.GA4
|
| 8 |
+
|
| 9 |
+
require Logger
|
| 10 |
+
|
| 11 |
+
@type import_auth :: {
|
| 12 |
+
access_token :: String.t(),
|
| 13 |
+
refresh_token :: String.t(),
|
| 14 |
+
expires_at :: String.t()
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
@per_page 200_000
|
| 18 |
+
@backoff_factor :timer.seconds(10)
|
| 19 |
+
@max_attempts 5
|
| 20 |
+
|
| 21 |
+
def list_properties(access_token) do
|
| 22 |
+
case GA4.HTTP.list_accounts_for_user(access_token) do
|
| 23 |
+
{:ok, %{"accountSummaries" => accounts}} ->
|
| 24 |
+
accounts =
|
| 25 |
+
accounts
|
| 26 |
+
|> Enum.filter(& &1["propertySummaries"])
|
| 27 |
+
|> Enum.map(fn account ->
|
| 28 |
+
%{"account" => account_id, "displayName" => account_name} = account
|
| 29 |
+
|
| 30 |
+
{"#{account_name} (#{account_id})",
|
| 31 |
+
Enum.map(account["propertySummaries"], fn property ->
|
| 32 |
+
%{"displayName" => property_name, "property" => property_id} = property
|
| 33 |
+
|
| 34 |
+
{"#{property_name} (#{property_id})", property_id}
|
| 35 |
+
end)}
|
| 36 |
+
end)
|
| 37 |
+
|
| 38 |
+
{:ok, accounts}
|
| 39 |
+
|
| 40 |
+
{:ok, _} ->
|
| 41 |
+
{:ok, []}
|
| 42 |
+
|
| 43 |
+
{:error, cause} ->
|
| 44 |
+
{:error, cause}
|
| 45 |
+
end
|
| 46 |
+
end
|
| 47 |
+
|
| 48 |
+
def get_property(access_token, lookup_property) do
|
| 49 |
+
case GA4.HTTP.get_property(access_token, lookup_property) do
|
| 50 |
+
{:ok, property} ->
|
| 51 |
+
%{"displayName" => property_name, "name" => property_id, "account" => account_id} =
|
| 52 |
+
property
|
| 53 |
+
|
| 54 |
+
{:ok,
|
| 55 |
+
%{
|
| 56 |
+
id: property_id,
|
| 57 |
+
name: "#{property_name} (#{property_id})",
|
| 58 |
+
account_id: account_id
|
| 59 |
+
}}
|
| 60 |
+
|
| 61 |
+
{:error, cause} ->
|
| 62 |
+
{:error, cause}
|
| 63 |
+
end
|
| 64 |
+
end
|
| 65 |
+
|
| 66 |
+
def get_analytics_start_date(access_token, property) do
|
| 67 |
+
GA4.HTTP.get_analytics_start_date(access_token, property)
|
| 68 |
+
end
|
| 69 |
+
|
| 70 |
+
def get_analytics_end_date(access_token, property) do
|
| 71 |
+
GA4.HTTP.get_analytics_end_date(access_token, property)
|
| 72 |
+
end
|
| 73 |
+
|
| 74 |
+
def import_analytics(date_range, property, auth, opts) do
|
| 75 |
+
persist_fn = Keyword.fetch!(opts, :persist_fn)
|
| 76 |
+
fetch_opts = Keyword.get(opts, :fetch_opts, [])
|
| 77 |
+
resume_opts = Keyword.get(opts, :resume_opts, [])
|
| 78 |
+
|
| 79 |
+
Logger.debug(
|
| 80 |
+
"[#{inspect(__MODULE__)}:#{property}] Starting import from #{date_range.first} to #{date_range.last}"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
with {:ok, access_token} <- Google.API.maybe_refresh_token(auth) do
|
| 84 |
+
do_import_analytics(date_range, property, access_token, persist_fn, fetch_opts, resume_opts)
|
| 85 |
+
end
|
| 86 |
+
end
|
| 87 |
+
|
| 88 |
+
defp do_import_analytics(
|
| 89 |
+
date_range,
|
| 90 |
+
property,
|
| 91 |
+
access_token,
|
| 92 |
+
persist_fn,
|
| 93 |
+
fetch_opts,
|
| 94 |
+
[] = _resume_opts
|
| 95 |
+
) do
|
| 96 |
+
Enum.reduce_while(GA4.ReportRequest.full_report(), :ok, fn report_request, :ok ->
|
| 97 |
+
Logger.debug(
|
| 98 |
+
"[#{inspect(__MODULE__)}:#{property}] Starting to import #{report_request.dataset}"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
report_request = prepare_request(report_request, date_range, property, access_token)
|
| 102 |
+
|
| 103 |
+
case fetch_and_persist(report_request, persist_fn: persist_fn, fetch_opts: fetch_opts) do
|
| 104 |
+
:ok -> {:cont, :ok}
|
| 105 |
+
{:error, _} = error -> {:halt, error}
|
| 106 |
+
end
|
| 107 |
+
end)
|
| 108 |
+
end
|
| 109 |
+
|
| 110 |
+
defp do_import_analytics(
|
| 111 |
+
date_range,
|
| 112 |
+
property,
|
| 113 |
+
access_token,
|
| 114 |
+
persist_fn,
|
| 115 |
+
fetch_opts,
|
| 116 |
+
resume_opts
|
| 117 |
+
) do
|
| 118 |
+
dataset = Keyword.fetch!(resume_opts, :dataset)
|
| 119 |
+
offset = Keyword.fetch!(resume_opts, :offset)
|
| 120 |
+
|
| 121 |
+
GA4.ReportRequest.full_report()
|
| 122 |
+
|> Enum.drop_while(&(&1.dataset != dataset))
|
| 123 |
+
|> Enum.reduce_while(:ok, fn report_request, :ok ->
|
| 124 |
+
Logger.debug(
|
| 125 |
+
"[#{inspect(__MODULE__)}:#{property}] Starting to import #{report_request.dataset}"
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
request_offset =
|
| 129 |
+
if report_request.dataset == dataset do
|
| 130 |
+
offset
|
| 131 |
+
else
|
| 132 |
+
0
|
| 133 |
+
end
|
| 134 |
+
|
| 135 |
+
report_request =
|
| 136 |
+
report_request
|
| 137 |
+
|> prepare_request(date_range, property, access_token)
|
| 138 |
+
|> Map.put(:offset, request_offset)
|
| 139 |
+
|
| 140 |
+
case fetch_and_persist(report_request, persist_fn: persist_fn, fetch_opts: fetch_opts) do
|
| 141 |
+
:ok -> {:cont, :ok}
|
| 142 |
+
{:error, _} = error -> {:halt, error}
|
| 143 |
+
end
|
| 144 |
+
end)
|
| 145 |
+
end
|
| 146 |
+
|
| 147 |
+
@spec fetch_and_persist(GA4.ReportRequest.t(), Keyword.t()) ::
|
| 148 |
+
:ok | {:error, term()}
|
| 149 |
+
def fetch_and_persist(%GA4.ReportRequest{} = report_request, opts \\ []) do
|
| 150 |
+
persist_fn = Keyword.fetch!(opts, :persist_fn)
|
| 151 |
+
attempt = Keyword.get(opts, :attempt, 1)
|
| 152 |
+
fetch_opts = Keyword.get(opts, :fetch_opts, [])
|
| 153 |
+
max_attempts = Keyword.get(fetch_opts, :max_attempts, @max_attempts)
|
| 154 |
+
sleep_time = Keyword.get(fetch_opts, :sleep_time, @backoff_factor)
|
| 155 |
+
|
| 156 |
+
case GA4.HTTP.get_report(report_request) do
|
| 157 |
+
{:ok, {_, 0}} ->
|
| 158 |
+
Logger.debug(
|
| 159 |
+
"[#{inspect(__MODULE__)}:#{report_request.property}] Fetched empty response for #{report_request.dataset}"
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
:ok
|
| 163 |
+
|
| 164 |
+
{:ok, {rows, row_count}} ->
|
| 165 |
+
Logger.debug(
|
| 166 |
+
"[#{inspect(__MODULE__)}:#{report_request.property}] Fetched #{length(rows)} rows of total #{row_count} with offset #{report_request.offset} for #{report_request.dataset}"
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
:ok = persist_fn.(report_request.dataset, rows)
|
| 170 |
+
|
| 171 |
+
Logger.debug(
|
| 172 |
+
"[#{inspect(__MODULE__)}:#{report_request.property}] Persisted #{length(rows)} for #{report_request.dataset}"
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
if report_request.offset + @per_page < row_count do
|
| 176 |
+
fetch_and_persist(
|
| 177 |
+
%GA4.ReportRequest{report_request | offset: report_request.offset + @per_page},
|
| 178 |
+
opts
|
| 179 |
+
)
|
| 180 |
+
else
|
| 181 |
+
:ok
|
| 182 |
+
end
|
| 183 |
+
|
| 184 |
+
{:error, {:rate_limit_exceeded, details}} ->
|
| 185 |
+
{:error, {:rate_limit_exceeded, details}}
|
| 186 |
+
|
| 187 |
+
{:error, cause} ->
|
| 188 |
+
if attempt >= max_attempts do
|
| 189 |
+
Logger.debug(
|
| 190 |
+
"[#{inspect(__MODULE__)}:#{report_request.property}] Request failed for #{report_request.dataset}. Terminating."
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
{:error, cause}
|
| 194 |
+
else
|
| 195 |
+
Logger.debug(
|
| 196 |
+
"[#{inspect(__MODULE__)}:#{report_request.property}] Request failed for #{report_request.dataset}. Will retry."
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
Process.sleep(attempt * sleep_time)
|
| 200 |
+
fetch_and_persist(report_request, Keyword.merge(opts, attempt: attempt + 1))
|
| 201 |
+
end
|
| 202 |
+
end
|
| 203 |
+
end
|
| 204 |
+
|
| 205 |
+
defp prepare_request(%GA4.ReportRequest{} = report_request, date_range, property, access_token) do
|
| 206 |
+
%GA4.ReportRequest{
|
| 207 |
+
report_request
|
| 208 |
+
| date_range: date_range,
|
| 209 |
+
property: property,
|
| 210 |
+
access_token: access_token,
|
| 211 |
+
offset: 0,
|
| 212 |
+
limit: @per_page
|
| 213 |
+
}
|
| 214 |
+
end
|
| 215 |
+
end
|
lib/plausible/google/ga4/http.ex
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Google.GA4.HTTP do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
HTTP client implementation for Google Analytics 4 API.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
use Plausible
|
| 7 |
+
|
| 8 |
+
alias Plausible.HTTPClient
|
| 9 |
+
|
| 10 |
+
require Logger
|
| 11 |
+
|
| 12 |
+
@spec get_report(Plausible.Google.GA4.ReportRequest.t()) ::
|
| 13 |
+
{:ok, {[map()], non_neg_integer()}} | {:error, any()}
|
| 14 |
+
def get_report(%Plausible.Google.GA4.ReportRequest{} = report_request) do
|
| 15 |
+
params = %{
|
| 16 |
+
requests: [
|
| 17 |
+
%{
|
| 18 |
+
property: report_request.property,
|
| 19 |
+
dateRanges: [
|
| 20 |
+
%{
|
| 21 |
+
startDate: report_request.date_range.first,
|
| 22 |
+
endDate: report_request.date_range.last
|
| 23 |
+
}
|
| 24 |
+
],
|
| 25 |
+
dimensions: Enum.map(report_request.dimensions, &%{name: &1}),
|
| 26 |
+
metrics: Enum.map(report_request.metrics, &build_metric/1),
|
| 27 |
+
orderBys: [
|
| 28 |
+
%{
|
| 29 |
+
dimension: %{
|
| 30 |
+
dimensionName: "date",
|
| 31 |
+
orderType: "ALPHANUMERIC"
|
| 32 |
+
},
|
| 33 |
+
desc: true
|
| 34 |
+
}
|
| 35 |
+
],
|
| 36 |
+
dimensionFilter: report_request.dimension_filter,
|
| 37 |
+
limit: report_request.limit,
|
| 38 |
+
offset: report_request.offset
|
| 39 |
+
}
|
| 40 |
+
]
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
url =
|
| 44 |
+
"#{reporting_api_url()}/v1beta/#{report_request.property}:batchRunReports"
|
| 45 |
+
|
| 46 |
+
response =
|
| 47 |
+
HTTPClient.impl().post(
|
| 48 |
+
url,
|
| 49 |
+
[{"Authorization", "Bearer #{report_request.access_token}"}],
|
| 50 |
+
params,
|
| 51 |
+
receive_timeout: 80_000
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
with {:ok, %{body: body}} <- response,
|
| 55 |
+
{:ok, report} <- parse_report_from_response(body),
|
| 56 |
+
row_count <- Map.get(report, "rowCount", 0),
|
| 57 |
+
{:ok, report} <- convert_to_maps(report) do
|
| 58 |
+
{:ok, {report, row_count}}
|
| 59 |
+
else
|
| 60 |
+
{:error, %{reason: %{status: 429, body: body}} = error} ->
|
| 61 |
+
log_ce_error("retrieving report for #{report_request.dataset}", error)
|
| 62 |
+
|
| 63 |
+
Logger.debug(
|
| 64 |
+
"[#{inspect(__MODULE__)}:#{report_request.property}] Request failed for #{report_request.dataset} due to exceeding rate limit."
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
Sentry.Context.set_extra_context(%{ga_response: %{body: body, status: 429}})
|
| 68 |
+
|
| 69 |
+
{:error,
|
| 70 |
+
{:rate_limit_exceeded, dataset: report_request.dataset, offset: report_request.offset}}
|
| 71 |
+
|
| 72 |
+
{:error, %{reason: %{status: status, body: body}} = error} when status >= 500 ->
|
| 73 |
+
log_ce_error("retrieving report for #{report_request.dataset}", error)
|
| 74 |
+
|
| 75 |
+
Logger.warning(
|
| 76 |
+
"[#{inspect(__MODULE__)}:#{report_request.property}] Request failed for #{report_request.dataset} with code #{status}: #{inspect(body)}"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
Sentry.Context.set_extra_context(%{ga_response: %{body: body, status: status}})
|
| 80 |
+
|
| 81 |
+
{:error, {:server_failed, dataset: report_request.dataset, offset: report_request.offset}}
|
| 82 |
+
|
| 83 |
+
{:error, %{reason: %{status: status, body: body}} = error} ->
|
| 84 |
+
log_ce_error("retrieving report for #{report_request.dataset}", error)
|
| 85 |
+
|
| 86 |
+
Logger.warning(
|
| 87 |
+
"[#{inspect(__MODULE__)}:#{report_request.property}] Request failed for #{report_request.dataset} with code #{status}: #{inspect(body)}"
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
Sentry.Context.set_extra_context(%{ga_response: %{body: body, status: status}})
|
| 91 |
+
|
| 92 |
+
{:error, :request_failed}
|
| 93 |
+
|
| 94 |
+
{:error, reason} ->
|
| 95 |
+
log_ce_error("retrieving report for #{report_request.dataset}", reason)
|
| 96 |
+
|
| 97 |
+
Logger.warning(
|
| 98 |
+
"[#{inspect(__MODULE__)}:#{report_request.property}] Request failed for #{report_request.dataset}: #{inspect(reason)}"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
Sentry.Context.set_extra_context(%{ga_response: %{body: inspect(reason), status: 0}})
|
| 102 |
+
|
| 103 |
+
{:error, {:socket_failed, dataset: report_request.dataset, offset: report_request.offset}}
|
| 104 |
+
end
|
| 105 |
+
end
|
| 106 |
+
|
| 107 |
+
defp build_metric(expression) do
|
| 108 |
+
case String.split(expression, " = ") do
|
| 109 |
+
[name, expression] ->
|
| 110 |
+
%{
|
| 111 |
+
name: name,
|
| 112 |
+
expression: expression
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
[name] ->
|
| 116 |
+
%{name: name}
|
| 117 |
+
end
|
| 118 |
+
end
|
| 119 |
+
|
| 120 |
+
defp parse_report_from_response(%{"reports" => [report | _]}) do
|
| 121 |
+
{:ok, report}
|
| 122 |
+
end
|
| 123 |
+
|
| 124 |
+
defp parse_report_from_response(body) do
|
| 125 |
+
Sentry.Context.set_extra_context(%{google_analytics4_response: body})
|
| 126 |
+
|
| 127 |
+
Logger.error(
|
| 128 |
+
"Google Analytics 4: Failed to find report in response. Reason: #{inspect(body)}"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
{:error, {:invalid_response, body}}
|
| 132 |
+
end
|
| 133 |
+
|
| 134 |
+
defp convert_to_maps(%{
|
| 135 |
+
"rows" => rows,
|
| 136 |
+
"dimensionHeaders" => dimension_headers,
|
| 137 |
+
"metricHeaders" => metric_headers
|
| 138 |
+
})
|
| 139 |
+
when is_list(rows) do
|
| 140 |
+
dimension_headers = Enum.map(dimension_headers, & &1["name"])
|
| 141 |
+
metric_headers = Enum.map(metric_headers, & &1["name"])
|
| 142 |
+
|
| 143 |
+
report =
|
| 144 |
+
Enum.map(rows, fn %{"dimensionValues" => dimensions, "metricValues" => metrics} ->
|
| 145 |
+
dimension_values = Enum.map(dimensions, & &1["value"])
|
| 146 |
+
metric_values = Enum.map(metrics, & &1["value"])
|
| 147 |
+
metrics = Enum.zip(metric_headers, metric_values)
|
| 148 |
+
dimensions = Enum.zip(dimension_headers, dimension_values)
|
| 149 |
+
%{metrics: Map.new(metrics), dimensions: Map.new(dimensions)}
|
| 150 |
+
end)
|
| 151 |
+
|
| 152 |
+
{:ok, report}
|
| 153 |
+
end
|
| 154 |
+
|
| 155 |
+
defp convert_to_maps(%{"dimensionHeaders" => _, "metricHeaders" => _}) do
|
| 156 |
+
{:ok, []}
|
| 157 |
+
end
|
| 158 |
+
|
| 159 |
+
defp convert_to_maps(response) do
|
| 160 |
+
Logger.error(
|
| 161 |
+
"Google Analytics 4: Failed to read report in response. Reason: #{inspect(response)}"
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
Sentry.Context.set_extra_context(%{google_analytics4_response: response})
|
| 165 |
+
{:error, {:invalid_response, response}}
|
| 166 |
+
end
|
| 167 |
+
|
| 168 |
+
def list_accounts_for_user(access_token) do
|
| 169 |
+
url = "#{admin_api_url()}/v1beta/accountSummaries?pageSize=200"
|
| 170 |
+
|
| 171 |
+
headers = [{"Authorization", "Bearer #{access_token}"}]
|
| 172 |
+
|
| 173 |
+
case HTTPClient.impl().get(url, headers) do
|
| 174 |
+
{:ok, %Finch.Response{body: body, status: 200}} ->
|
| 175 |
+
{:ok, body}
|
| 176 |
+
|
| 177 |
+
{:error, %HTTPClient.Non200Error{reason: %{status: 429}} = error} ->
|
| 178 |
+
log_ce_error("listing accounts for user", error)
|
| 179 |
+
{:error, :rate_limit_exceeded}
|
| 180 |
+
|
| 181 |
+
{:error, %HTTPClient.Non200Error{} = error} when error.reason.status in [401, 403] ->
|
| 182 |
+
log_ce_error("listing accounts for user", error)
|
| 183 |
+
{:error, authentication_failed(error)}
|
| 184 |
+
|
| 185 |
+
{:error, %{reason: :timeout} = error} ->
|
| 186 |
+
log_ce_error("listing accounts for user", error)
|
| 187 |
+
{:error, :timeout}
|
| 188 |
+
|
| 189 |
+
{:error, error} ->
|
| 190 |
+
log_ce_error("listing accounts for user", error)
|
| 191 |
+
Sentry.capture_message("Error listing GA4 accounts for user", extra: %{error: error})
|
| 192 |
+
{:error, :unknown}
|
| 193 |
+
end
|
| 194 |
+
end
|
| 195 |
+
|
| 196 |
+
def get_property(access_token, property) do
|
| 197 |
+
url = "#{admin_api_url()}/v1beta/#{property}"
|
| 198 |
+
|
| 199 |
+
headers = [{"Authorization", "Bearer #{access_token}"}]
|
| 200 |
+
|
| 201 |
+
case HTTPClient.impl().get(url, headers) do
|
| 202 |
+
{:ok, %Finch.Response{body: body, status: 200}} ->
|
| 203 |
+
{:ok, body}
|
| 204 |
+
|
| 205 |
+
{:error, %HTTPClient.Non200Error{reason: %{status: 429}} = error} ->
|
| 206 |
+
log_ce_error("retrieving property #{property}", error)
|
| 207 |
+
{:error, :rate_limit_exceeded}
|
| 208 |
+
|
| 209 |
+
{:error, %HTTPClient.Non200Error{} = error} when error.reason.status in [401, 403] ->
|
| 210 |
+
log_ce_error("retrieving property #{property}", error)
|
| 211 |
+
{:error, authentication_failed(error)}
|
| 212 |
+
|
| 213 |
+
{:error, %HTTPClient.Non200Error{} = error} when error.reason.status in [404] ->
|
| 214 |
+
log_ce_error("retrieving property #{property}", error)
|
| 215 |
+
{:error, :not_found}
|
| 216 |
+
|
| 217 |
+
{:error, %{reason: :timeout} = error} ->
|
| 218 |
+
log_ce_error("retrieving property #{property}", error)
|
| 219 |
+
{:error, :timeout}
|
| 220 |
+
|
| 221 |
+
{:error, error} ->
|
| 222 |
+
log_ce_error("retrieving property #{property}", error)
|
| 223 |
+
|
| 224 |
+
Sentry.capture_message("Error retrieving GA4 property #{property}",
|
| 225 |
+
extra: %{error: error}
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
{:error, :unknown}
|
| 229 |
+
end
|
| 230 |
+
end
|
| 231 |
+
|
| 232 |
+
@earliest_valid_date "2015-08-14"
|
| 233 |
+
|
| 234 |
+
def get_analytics_start_date(access_token, property) do
|
| 235 |
+
get_analytics_boundary_date(access_token, property, :start)
|
| 236 |
+
end
|
| 237 |
+
|
| 238 |
+
def get_analytics_end_date(access_token, property) do
|
| 239 |
+
get_analytics_boundary_date(access_token, property, :end)
|
| 240 |
+
end
|
| 241 |
+
|
| 242 |
+
defp get_analytics_boundary_date(access_token, property, edge) do
|
| 243 |
+
descending? = edge == :end
|
| 244 |
+
|
| 245 |
+
params = %{
|
| 246 |
+
requests: [
|
| 247 |
+
%{
|
| 248 |
+
property: "#{property}",
|
| 249 |
+
dateRanges: [
|
| 250 |
+
%{startDate: @earliest_valid_date, endDate: Date.to_iso8601(Date.utc_today())}
|
| 251 |
+
],
|
| 252 |
+
dimensions: [%{name: "date"}],
|
| 253 |
+
metrics: [%{name: "screenPageViews"}],
|
| 254 |
+
orderBys: [
|
| 255 |
+
%{dimension: %{dimensionName: "date", orderType: "ALPHANUMERIC"}, desc: descending?}
|
| 256 |
+
],
|
| 257 |
+
limit: 1
|
| 258 |
+
}
|
| 259 |
+
]
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
url = "#{reporting_api_url()}/v1beta/#{property}:batchRunReports"
|
| 263 |
+
headers = [{"Authorization", "Bearer #{access_token}"}]
|
| 264 |
+
|
| 265 |
+
case HTTPClient.impl().post(url, headers, params) do
|
| 266 |
+
{:ok, %Finch.Response{body: body, status: 200}} ->
|
| 267 |
+
report = List.first(body["reports"])
|
| 268 |
+
|
| 269 |
+
date =
|
| 270 |
+
case report["rows"] do
|
| 271 |
+
[%{"dimensionValues" => [%{"value" => date_str}]}] ->
|
| 272 |
+
Plausible.Times.parse!(date_str, "%Y%m%d", :strftime) |> NaiveDateTime.to_date()
|
| 273 |
+
|
| 274 |
+
_ ->
|
| 275 |
+
nil
|
| 276 |
+
end
|
| 277 |
+
|
| 278 |
+
{:ok, date}
|
| 279 |
+
|
| 280 |
+
{:error, %HTTPClient.Non200Error{reason: %{status: 429}} = error} ->
|
| 281 |
+
log_ce_error("retrieving #{edge} date", error)
|
| 282 |
+
{:error, :rate_limit_exceeded}
|
| 283 |
+
|
| 284 |
+
{:error, %HTTPClient.Non200Error{} = error} when error.reason.status in [401, 403] ->
|
| 285 |
+
log_ce_error("retrieving #{edge} date", error)
|
| 286 |
+
{:error, authentication_failed(error)}
|
| 287 |
+
|
| 288 |
+
{:error, %{reason: :timeout} = error} ->
|
| 289 |
+
log_ce_error("retrieving #{edge} date", error)
|
| 290 |
+
{:error, :timeout}
|
| 291 |
+
|
| 292 |
+
{:error, error} ->
|
| 293 |
+
log_ce_error("retrieving #{edge} date", error)
|
| 294 |
+
|
| 295 |
+
Sentry.capture_message("Error retrieving GA4 #{edge} date",
|
| 296 |
+
extra: %{error: error}
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
{:error, :unknown}
|
| 300 |
+
end
|
| 301 |
+
end
|
| 302 |
+
|
| 303 |
+
defp reporting_api_url, do: "https://analyticsdata.googleapis.com"
|
| 304 |
+
defp admin_api_url, do: "https://analyticsadmin.googleapis.com"
|
| 305 |
+
|
| 306 |
+
@spec authentication_failed(HTTPClient.Non200Error.t()) ::
|
| 307 |
+
{:authentication_failed, String.t() | nil}
|
| 308 |
+
defp authentication_failed(error) do
|
| 309 |
+
message =
|
| 310 |
+
case error.reason.body do
|
| 311 |
+
%{"error" => %{"message" => message}} when is_binary(message) -> message
|
| 312 |
+
_ -> nil
|
| 313 |
+
end
|
| 314 |
+
|
| 315 |
+
{:authentication_failed, message}
|
| 316 |
+
end
|
| 317 |
+
|
| 318 |
+
@spec log_ce_error(String.t(), any) :: :ok
|
| 319 |
+
defp log_ce_error(action, error)
|
| 320 |
+
|
| 321 |
+
on_ce do
|
| 322 |
+
defp log_ce_error(action, error) do
|
| 323 |
+
Logger.error("Google Analytics 4: Failed when #{action}. Reason: #{inspect(error)}")
|
| 324 |
+
end
|
| 325 |
+
end
|
| 326 |
+
|
| 327 |
+
on_ee do
|
| 328 |
+
defp log_ce_error(_action, _error), do: :ok
|
| 329 |
+
end
|
| 330 |
+
end
|
lib/plausible/google/ga4/report_request.ex
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Google.GA4.ReportRequest do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Report request struct for Google Analytics 4 API
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
@excluded_event_names [
|
| 7 |
+
"page_view",
|
| 8 |
+
"session_start",
|
| 9 |
+
"first_visit",
|
| 10 |
+
"user_engagement"
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
defstruct [
|
| 14 |
+
:dataset,
|
| 15 |
+
:dimensions,
|
| 16 |
+
:metrics,
|
| 17 |
+
:date_range,
|
| 18 |
+
:property,
|
| 19 |
+
:access_token,
|
| 20 |
+
:offset,
|
| 21 |
+
:dimension_filter,
|
| 22 |
+
:limit
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
@type t() :: %__MODULE__{
|
| 26 |
+
dataset: String.t(),
|
| 27 |
+
dimensions: [String.t()],
|
| 28 |
+
metrics: [String.t()],
|
| 29 |
+
date_range: Date.Range.t(),
|
| 30 |
+
property: term(),
|
| 31 |
+
access_token: String.t(),
|
| 32 |
+
offset: non_neg_integer(),
|
| 33 |
+
limit: non_neg_integer()
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
def full_report do
|
| 37 |
+
[
|
| 38 |
+
%__MODULE__{
|
| 39 |
+
dataset: "imported_visitors",
|
| 40 |
+
dimensions: ["date"],
|
| 41 |
+
metrics: [
|
| 42 |
+
"totalUsers",
|
| 43 |
+
"screenPageViews",
|
| 44 |
+
"bounces = sessions - engagedSessions",
|
| 45 |
+
"sessions",
|
| 46 |
+
"userEngagementDuration"
|
| 47 |
+
]
|
| 48 |
+
},
|
| 49 |
+
%__MODULE__{
|
| 50 |
+
dataset: "imported_sources",
|
| 51 |
+
dimensions: [
|
| 52 |
+
"date",
|
| 53 |
+
"sessionSource",
|
| 54 |
+
"sessionDefaultChannelGroup",
|
| 55 |
+
"sessionMedium",
|
| 56 |
+
"sessionCampaignName",
|
| 57 |
+
"sessionManualAdContent",
|
| 58 |
+
"sessionGoogleAdsKeyword"
|
| 59 |
+
],
|
| 60 |
+
metrics: [
|
| 61 |
+
"screenPageViews",
|
| 62 |
+
"totalUsers",
|
| 63 |
+
"sessions",
|
| 64 |
+
"bounces = sessions - engagedSessions",
|
| 65 |
+
"userEngagementDuration"
|
| 66 |
+
]
|
| 67 |
+
},
|
| 68 |
+
%__MODULE__{
|
| 69 |
+
dataset: "imported_pages",
|
| 70 |
+
dimensions: ["date", "hostName", "pagePath"],
|
| 71 |
+
# NOTE: no exits as GA4 DATA API does not provide that metric
|
| 72 |
+
metrics: [
|
| 73 |
+
"totalUsers",
|
| 74 |
+
"activeUsers",
|
| 75 |
+
"screenPageViews",
|
| 76 |
+
"sessions",
|
| 77 |
+
"userEngagementDuration"
|
| 78 |
+
]
|
| 79 |
+
},
|
| 80 |
+
%__MODULE__{
|
| 81 |
+
dataset: "imported_entry_pages",
|
| 82 |
+
dimensions: ["date", "landingPage"],
|
| 83 |
+
metrics: [
|
| 84 |
+
"screenPageViews",
|
| 85 |
+
"totalUsers",
|
| 86 |
+
"sessions",
|
| 87 |
+
"userEngagementDuration",
|
| 88 |
+
"bounces = sessions - engagedSessions"
|
| 89 |
+
]
|
| 90 |
+
},
|
| 91 |
+
# NOTE: Skipping for now as there's no dimension directly mapping to exit page path
|
| 92 |
+
# %__MODULE__{
|
| 93 |
+
# dataset: "imported_exit_pages",
|
| 94 |
+
# dimensions: ["date", "ga:exitPagePath"],
|
| 95 |
+
# metrics: [
|
| 96 |
+
# "totalUsers",
|
| 97 |
+
# "sessions",
|
| 98 |
+
# "screenPageViews",
|
| 99 |
+
# "userEngagementDuration",
|
| 100 |
+
# "bounces = sessions - engagedSessions"
|
| 101 |
+
# ]
|
| 102 |
+
# },
|
| 103 |
+
%__MODULE__{
|
| 104 |
+
dataset: "imported_custom_events",
|
| 105 |
+
dimensions: ["date", "eventName", "linkUrl"],
|
| 106 |
+
metrics: [
|
| 107 |
+
"totalUsers",
|
| 108 |
+
"eventCount"
|
| 109 |
+
],
|
| 110 |
+
dimension_filter: %{
|
| 111 |
+
"notExpression" => %{
|
| 112 |
+
"filter" => %{
|
| 113 |
+
"fieldName" => "eventName",
|
| 114 |
+
"inListFilter" => %{
|
| 115 |
+
"values" => @excluded_event_names,
|
| 116 |
+
"caseSensitive" => true
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
},
|
| 122 |
+
%__MODULE__{
|
| 123 |
+
dataset: "imported_locations",
|
| 124 |
+
dimensions: ["date", "countryId", "region", "city"],
|
| 125 |
+
metrics: [
|
| 126 |
+
"screenPageViews",
|
| 127 |
+
"totalUsers",
|
| 128 |
+
"sessions",
|
| 129 |
+
"bounces = sessions - engagedSessions",
|
| 130 |
+
"userEngagementDuration"
|
| 131 |
+
]
|
| 132 |
+
},
|
| 133 |
+
%__MODULE__{
|
| 134 |
+
dataset: "imported_devices",
|
| 135 |
+
dimensions: ["date", "deviceCategory"],
|
| 136 |
+
metrics: [
|
| 137 |
+
"screenPageViews",
|
| 138 |
+
"totalUsers",
|
| 139 |
+
"sessions",
|
| 140 |
+
"bounces = sessions - engagedSessions",
|
| 141 |
+
"userEngagementDuration"
|
| 142 |
+
]
|
| 143 |
+
},
|
| 144 |
+
%__MODULE__{
|
| 145 |
+
dataset: "imported_browsers",
|
| 146 |
+
dimensions: ["date", "browser"],
|
| 147 |
+
metrics: [
|
| 148 |
+
"screenPageViews",
|
| 149 |
+
"totalUsers",
|
| 150 |
+
"sessions",
|
| 151 |
+
"bounces = sessions - engagedSessions",
|
| 152 |
+
"userEngagementDuration"
|
| 153 |
+
]
|
| 154 |
+
},
|
| 155 |
+
%__MODULE__{
|
| 156 |
+
dataset: "imported_operating_systems",
|
| 157 |
+
dimensions: ["date", "operatingSystem", "operatingSystemVersion"],
|
| 158 |
+
metrics: [
|
| 159 |
+
"screenPageViews",
|
| 160 |
+
"totalUsers",
|
| 161 |
+
"sessions",
|
| 162 |
+
"bounces = sessions - engagedSessions",
|
| 163 |
+
"userEngagementDuration"
|
| 164 |
+
]
|
| 165 |
+
}
|
| 166 |
+
]
|
| 167 |
+
end
|
| 168 |
+
end
|
lib/plausible/google/http.ex
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Google.HTTP do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
HTTP client implementation for Google APIs.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
require Logger
|
| 7 |
+
alias Plausible.HTTPClient
|
| 8 |
+
|
| 9 |
+
def list_sites(access_token) do
|
| 10 |
+
url = "#{api_url()}/webmasters/v3/sites"
|
| 11 |
+
headers = [{"Content-Type", "application/json"}, {"Authorization", "Bearer #{access_token}"}]
|
| 12 |
+
|
| 13 |
+
case HTTPClient.impl().get(url, headers) do
|
| 14 |
+
{:ok, %{body: body}} ->
|
| 15 |
+
{:ok, body}
|
| 16 |
+
|
| 17 |
+
{:error, %{reason: %{status: s}}} when s in [401, 403] ->
|
| 18 |
+
{:error, "google_auth_error"}
|
| 19 |
+
|
| 20 |
+
{:error, %{reason: %{body: %{"error" => error}}}} ->
|
| 21 |
+
{:error, error}
|
| 22 |
+
|
| 23 |
+
{:error, reason} ->
|
| 24 |
+
Logger.error("Google Analytics: failed to list sites: #{inspect(reason)}")
|
| 25 |
+
{:error, "failed_to_list_sites"}
|
| 26 |
+
end
|
| 27 |
+
end
|
| 28 |
+
|
| 29 |
+
def fetch_access_token!(code) do
|
| 30 |
+
url = "#{api_url()}/oauth2/v4/token"
|
| 31 |
+
headers = [{"Content-Type", "application/x-www-form-urlencoded"}]
|
| 32 |
+
|
| 33 |
+
params = %{
|
| 34 |
+
client_id: client_id(),
|
| 35 |
+
client_secret: client_secret(),
|
| 36 |
+
code: code,
|
| 37 |
+
grant_type: :authorization_code,
|
| 38 |
+
redirect_uri: redirect_uri()
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
{:ok, response} = HTTPClient.impl().post(url, headers, params)
|
| 42 |
+
|
| 43 |
+
response.body
|
| 44 |
+
end
|
| 45 |
+
|
| 46 |
+
def list_stats(access_token, property, date_range, pagination, search_console_filters) do
|
| 47 |
+
{limit, page} = pagination
|
| 48 |
+
|
| 49 |
+
params = %{
|
| 50 |
+
startDate: Date.to_iso8601(date_range.first),
|
| 51 |
+
endDate: Date.to_iso8601(date_range.last),
|
| 52 |
+
dimensions: ["query"],
|
| 53 |
+
rowLimit: limit,
|
| 54 |
+
startRow: page * limit,
|
| 55 |
+
dimensionFilterGroups: search_console_filters
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
url =
|
| 59 |
+
"#{api_url()}/webmasters/v3/sites/#{URI.encode_www_form(property)}/searchAnalytics/query"
|
| 60 |
+
|
| 61 |
+
headers = [{"Authorization", "Bearer #{access_token}"}]
|
| 62 |
+
|
| 63 |
+
case HTTPClient.impl().post(url, headers, params) do
|
| 64 |
+
{:ok, %Finch.Response{body: body, status: 200}} ->
|
| 65 |
+
{:ok, body}
|
| 66 |
+
|
| 67 |
+
{:error, %{reason: %Finch.Response{body: _body, status: status}}}
|
| 68 |
+
when status in [401, 403] ->
|
| 69 |
+
{:error, "google_auth_error"}
|
| 70 |
+
|
| 71 |
+
{:error, %{reason: %{body: %{"error" => error}}}} ->
|
| 72 |
+
{:error, error}
|
| 73 |
+
|
| 74 |
+
{:error, reason} ->
|
| 75 |
+
Logger.error("Google Search Console: failed to list stats: #{inspect(reason)}")
|
| 76 |
+
{:error, "failed_to_list_stats"}
|
| 77 |
+
end
|
| 78 |
+
end
|
| 79 |
+
|
| 80 |
+
def refresh_auth_token(refresh_token) do
|
| 81 |
+
url = "#{api_url()}/oauth2/v4/token"
|
| 82 |
+
headers = [{"content-type", "application/x-www-form-urlencoded"}]
|
| 83 |
+
|
| 84 |
+
params = %{
|
| 85 |
+
client_id: client_id(),
|
| 86 |
+
client_secret: client_secret(),
|
| 87 |
+
refresh_token: refresh_token,
|
| 88 |
+
grant_type: :refresh_token,
|
| 89 |
+
redirect_uri: redirect_uri()
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
case HTTPClient.impl().post(url, headers, params) do
|
| 93 |
+
{:ok, %Finch.Response{body: body, status: 200}} ->
|
| 94 |
+
{:ok, body}
|
| 95 |
+
|
| 96 |
+
{:error, %{reason: %Finch.Response{body: %{"error" => error}, status: _non_http_200}}} ->
|
| 97 |
+
{:error, error}
|
| 98 |
+
|
| 99 |
+
{:error, %{reason: _} = e} ->
|
| 100 |
+
Sentry.capture_message("Error fetching Google queries", extra: %{error: inspect(e)})
|
| 101 |
+
{:error, :unknown_error}
|
| 102 |
+
end
|
| 103 |
+
end
|
| 104 |
+
|
| 105 |
+
defp config, do: Application.get_env(:plausible, :google)
|
| 106 |
+
defp client_id, do: Keyword.fetch!(config(), :client_id)
|
| 107 |
+
defp client_secret, do: Keyword.fetch!(config(), :client_secret)
|
| 108 |
+
defp api_url, do: Keyword.fetch!(config(), :api_url)
|
| 109 |
+
defp redirect_uri, do: PlausibleWeb.Endpoint.url() <> "/auth/google/callback"
|
| 110 |
+
end
|
lib/plausible/google/search_console/filters.ex
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Google.SearchConsole.Filters do
|
| 2 |
+
@moduledoc false
|
| 3 |
+
import Plausible.Stats.Filters.Utils, only: [page_regex: 1]
|
| 4 |
+
|
| 5 |
+
def transform(property, plausible_filters, search) do
|
| 6 |
+
gsc_filters =
|
| 7 |
+
Enum.reduce_while(plausible_filters, [], fn plausible_filter, gsc_filters ->
|
| 8 |
+
case transform_filter(property, plausible_filter) do
|
| 9 |
+
:unsupported -> {:halt, :unsupported_filters}
|
| 10 |
+
:ignore -> {:cont, gsc_filters}
|
| 11 |
+
gsc_filter -> {:cont, [gsc_filter | gsc_filters]}
|
| 12 |
+
end
|
| 13 |
+
end)
|
| 14 |
+
|> maybe_add_search_filter(search)
|
| 15 |
+
|
| 16 |
+
case gsc_filters do
|
| 17 |
+
:unsupported_filters -> :unsupported_filters
|
| 18 |
+
[] -> {:ok, []}
|
| 19 |
+
filters when is_list(filters) -> {:ok, [%{filters: filters}]}
|
| 20 |
+
end
|
| 21 |
+
end
|
| 22 |
+
|
| 23 |
+
defp transform_filter(property, [op, "event:page" | rest]) do
|
| 24 |
+
transform_filter(property, [op, "visit:entry_page" | rest])
|
| 25 |
+
end
|
| 26 |
+
|
| 27 |
+
# :TODO: Should also work case-insensitive, if not, blacklist.
|
| 28 |
+
defp transform_filter(property, [:is, "visit:entry_page", pages | _]) when is_list(pages) do
|
| 29 |
+
expression =
|
| 30 |
+
Enum.map_join(pages, "|", fn page -> property_url(property, Regex.escape(page)) end)
|
| 31 |
+
|
| 32 |
+
%{dimension: "page", operator: "includingRegex", expression: expression}
|
| 33 |
+
end
|
| 34 |
+
|
| 35 |
+
defp transform_filter(property, [:matches_wildcard, "visit:entry_page", pages | _])
|
| 36 |
+
when is_list(pages) do
|
| 37 |
+
expression =
|
| 38 |
+
Enum.map_join(pages, "|", fn page -> page_regex(property_url(property, page)) end)
|
| 39 |
+
|
| 40 |
+
%{dimension: "page", operator: "includingRegex", expression: expression}
|
| 41 |
+
end
|
| 42 |
+
|
| 43 |
+
defp transform_filter(_property, [:is, "visit:screen", devices | _]) when is_list(devices) do
|
| 44 |
+
expression = Enum.map_join(devices, "|", &search_console_device/1)
|
| 45 |
+
%{dimension: "device", operator: "includingRegex", expression: expression}
|
| 46 |
+
end
|
| 47 |
+
|
| 48 |
+
defp transform_filter(_property, [:is, "visit:country", countries | _])
|
| 49 |
+
when is_list(countries) do
|
| 50 |
+
expression = Enum.map_join(countries, "|", &search_console_country/1)
|
| 51 |
+
%{dimension: "country", operator: "includingRegex", expression: expression}
|
| 52 |
+
end
|
| 53 |
+
|
| 54 |
+
defp transform_filter(_, [_, "visit:source" | _rest]), do: :ignore
|
| 55 |
+
|
| 56 |
+
defp transform_filter(_, [_, "visit:channel" | _rest]), do: :ignore
|
| 57 |
+
|
| 58 |
+
defp transform_filter(_, _filter), do: :unsupported
|
| 59 |
+
|
| 60 |
+
defp property_url("sc-domain:" <> domain, page), do: "https://" <> domain <> page
|
| 61 |
+
defp property_url(url, page), do: url <> page
|
| 62 |
+
|
| 63 |
+
defp search_console_device("Desktop"), do: "DESKTOP"
|
| 64 |
+
defp search_console_device("Mobile"), do: "MOBILE"
|
| 65 |
+
defp search_console_device("Tablet"), do: "TABLET"
|
| 66 |
+
|
| 67 |
+
defp search_console_country(alpha_2) do
|
| 68 |
+
country = Location.Country.get_country(alpha_2)
|
| 69 |
+
country.alpha_3
|
| 70 |
+
end
|
| 71 |
+
|
| 72 |
+
defp maybe_add_search_filter(gsc_filters, search) when byte_size(search) > 0 do
|
| 73 |
+
[%{operator: "includingRegex", expression: search, dimension: "query"} | gsc_filters]
|
| 74 |
+
end
|
| 75 |
+
|
| 76 |
+
defp maybe_add_search_filter(gsc_filters, _search), do: gsc_filters
|
| 77 |
+
end
|
lib/plausible/helpers/changeset.ex
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.ChangesetHelpers do
|
| 2 |
+
@moduledoc "Helper function for working with Ecto changesets"
|
| 3 |
+
|
| 4 |
+
def traverse_errors(changeset) do
|
| 5 |
+
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
|
| 6 |
+
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
|
| 7 |
+
opts
|
| 8 |
+
|> Keyword.get(String.to_existing_atom(key), key)
|
| 9 |
+
|> to_string()
|
| 10 |
+
end)
|
| 11 |
+
end)
|
| 12 |
+
end
|
| 13 |
+
|
| 14 |
+
@doc """
|
| 15 |
+
iex> serialize_first_error([{"name", {"should be at most %{count} byte(s)", [count: 255]}}])
|
| 16 |
+
"name should be at most 255 byte(s)"
|
| 17 |
+
"""
|
| 18 |
+
def serialize_first_error(errors) do
|
| 19 |
+
{field, {message, opts}} = List.first(errors)
|
| 20 |
+
|
| 21 |
+
formatted_message =
|
| 22 |
+
Enum.reduce(opts, message, fn {key, value}, acc ->
|
| 23 |
+
placeholder = "%{#{key}}"
|
| 24 |
+
|
| 25 |
+
if String.contains?(acc, placeholder) do
|
| 26 |
+
String.replace(acc, placeholder, to_string(value))
|
| 27 |
+
else
|
| 28 |
+
acc
|
| 29 |
+
end
|
| 30 |
+
end)
|
| 31 |
+
|
| 32 |
+
"#{field} #{formatted_message}"
|
| 33 |
+
end
|
| 34 |
+
end
|
lib/plausible/helpers/config.ex
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.ConfigHelpers do
|
| 2 |
+
def get_var_from_path_or_env(config_dir, var_name, default \\ nil) do
|
| 3 |
+
var_path = Path.join(config_dir, var_name)
|
| 4 |
+
|
| 5 |
+
if File.exists?(var_path) do
|
| 6 |
+
File.read!(var_path) |> String.trim()
|
| 7 |
+
else
|
| 8 |
+
System.get_env(var_name, default)
|
| 9 |
+
end
|
| 10 |
+
end
|
| 11 |
+
|
| 12 |
+
def get_int_from_path_or_env(config_dir, var_name, default \\ nil) do
|
| 13 |
+
var = get_var_from_path_or_env(config_dir, var_name)
|
| 14 |
+
|
| 15 |
+
case var do
|
| 16 |
+
nil ->
|
| 17 |
+
default
|
| 18 |
+
|
| 19 |
+
var ->
|
| 20 |
+
case Integer.parse(var) do
|
| 21 |
+
{int, ""} -> int
|
| 22 |
+
_ -> raise "Config variable #{var_name} must be an integer. Got #{var}"
|
| 23 |
+
end
|
| 24 |
+
end
|
| 25 |
+
end
|
| 26 |
+
|
| 27 |
+
def get_bool_from_path_or_env(config_dir, var_name, default \\ nil) do
|
| 28 |
+
case get_var_from_path_or_env(config_dir, var_name) do
|
| 29 |
+
nil -> default
|
| 30 |
+
var -> parse_bool(var)
|
| 31 |
+
end
|
| 32 |
+
end
|
| 33 |
+
|
| 34 |
+
@var_true ["1", "t", "true", "y", "yes", "on"]
|
| 35 |
+
@var_false ["0", "f", "false", "n", "no", "off"]
|
| 36 |
+
@var_bool_message Enum.zip_with(@var_true, @var_false, fn t, f -> [t, f] end)
|
| 37 |
+
|> List.flatten()
|
| 38 |
+
|> Enum.join(", ")
|
| 39 |
+
|
| 40 |
+
defp parse_bool(var) do
|
| 41 |
+
case String.downcase(var) do
|
| 42 |
+
t when t in @var_true ->
|
| 43 |
+
true
|
| 44 |
+
|
| 45 |
+
f when f in @var_false ->
|
| 46 |
+
false
|
| 47 |
+
|
| 48 |
+
_ ->
|
| 49 |
+
raise ArgumentError,
|
| 50 |
+
"Invalid boolean value: #{inspect(var)}. Expected one of: " <> @var_bool_message
|
| 51 |
+
end
|
| 52 |
+
end
|
| 53 |
+
end
|
lib/plausible/helpers/json.ex
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Helpers.JSON do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Common helpers for JSON handling
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
def decode_or_fallback(raw) do
|
| 7 |
+
with raw when is_binary(raw) <- raw,
|
| 8 |
+
{:ok, %{} = decoded} <- Jason.decode(raw) do
|
| 9 |
+
decoded
|
| 10 |
+
else
|
| 11 |
+
already_a_map when is_map(already_a_map) -> already_a_map
|
| 12 |
+
_any -> %{}
|
| 13 |
+
end
|
| 14 |
+
end
|
| 15 |
+
end
|
lib/plausible/http_client.ex
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.HTTPClient.Non200Error do
|
| 2 |
+
@moduledoc false
|
| 3 |
+
|
| 4 |
+
defstruct reason: nil
|
| 5 |
+
|
| 6 |
+
@type t :: %__MODULE__{reason: Finch.Response.t()}
|
| 7 |
+
|
| 8 |
+
@spec new(Finch.Response.t()) :: t()
|
| 9 |
+
def new(%Finch.Response{status: status} = response)
|
| 10 |
+
when is_integer(status) and (status < 200 or status >= 300) do
|
| 11 |
+
%__MODULE__{reason: response}
|
| 12 |
+
end
|
| 13 |
+
end
|
| 14 |
+
|
| 15 |
+
defmodule Plausible.HTTPClient.Interface do
|
| 16 |
+
@moduledoc false
|
| 17 |
+
|
| 18 |
+
@type t() :: module()
|
| 19 |
+
|
| 20 |
+
@type finch_request_opts() :: Keyword.t()
|
| 21 |
+
@type url() :: Finch.Request.url()
|
| 22 |
+
@type headers() :: Finch.Request.headers()
|
| 23 |
+
@type params() :: Finch.Request.body() | map()
|
| 24 |
+
@type response() ::
|
| 25 |
+
{:ok, Finch.Response.t()}
|
| 26 |
+
| {:error, Finch.error() | Plausible.HTTPClient.Non200Error.t()}
|
| 27 |
+
|
| 28 |
+
@callback get(url(), headers(), params()) :: response()
|
| 29 |
+
@callback get(url(), headers()) :: response()
|
| 30 |
+
@callback get(url()) :: response()
|
| 31 |
+
@callback post(url(), headers(), params()) :: response()
|
| 32 |
+
@callback post(url(), headers(), params(), finch_request_opts()) :: response()
|
| 33 |
+
end
|
| 34 |
+
|
| 35 |
+
defmodule Plausible.HTTPClient do
|
| 36 |
+
@moduledoc """
|
| 37 |
+
HTTP Client built on top of Finch.
|
| 38 |
+
|
| 39 |
+
By default, request parameters are json-encoded.
|
| 40 |
+
|
| 41 |
+
If a raw binary value is supplied, no encoding is performed.
|
| 42 |
+
If x-www-form-urlencoded content-type is set in headers,
|
| 43 |
+
URL encoding is invoked.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
require OpenTelemetry.Tracer
|
| 47 |
+
alias Plausible.HTTPClient.Non200Error
|
| 48 |
+
|
| 49 |
+
@doc """
|
| 50 |
+
Make a POST request
|
| 51 |
+
"""
|
| 52 |
+
@behaviour Plausible.HTTPClient.Interface
|
| 53 |
+
|
| 54 |
+
@impl Plausible.HTTPClient.Interface
|
| 55 |
+
def post(url, headers \\ [], params \\ nil, finch_req_opts \\ []) do
|
| 56 |
+
call(:post, url, headers, params, finch_req_opts)
|
| 57 |
+
end
|
| 58 |
+
|
| 59 |
+
@doc """
|
| 60 |
+
Make a GET request
|
| 61 |
+
"""
|
| 62 |
+
@impl Plausible.HTTPClient.Interface
|
| 63 |
+
def get(url, headers \\ [], params \\ nil) do
|
| 64 |
+
call(:get, url, headers, params)
|
| 65 |
+
end
|
| 66 |
+
|
| 67 |
+
@spec impl() :: Plausible.HTTPClient.Interface.t()
|
| 68 |
+
def impl() do
|
| 69 |
+
Application.get_env(:plausible, :http_impl, __MODULE__)
|
| 70 |
+
end
|
| 71 |
+
|
| 72 |
+
defp call(method, url, headers, params, finch_req_opts \\ []) do
|
| 73 |
+
OpenTelemetry.Tracer.with_span "http_client.request" do
|
| 74 |
+
{params, headers} = maybe_encode_params(params, headers)
|
| 75 |
+
|
| 76 |
+
method
|
| 77 |
+
|> build_request(url, headers, params)
|
| 78 |
+
|> trace_request()
|
| 79 |
+
|> do_request(finch_req_opts)
|
| 80 |
+
|> maybe_decode_body()
|
| 81 |
+
|> tag_error()
|
| 82 |
+
|> trace_response()
|
| 83 |
+
end
|
| 84 |
+
end
|
| 85 |
+
|
| 86 |
+
defp build_request(method, url, headers, params) do
|
| 87 |
+
Finch.build(method, url, headers, params)
|
| 88 |
+
end
|
| 89 |
+
|
| 90 |
+
defp do_request(request, finch_req_opts) do
|
| 91 |
+
Finch.request(request, Plausible.Finch, finch_req_opts)
|
| 92 |
+
end
|
| 93 |
+
|
| 94 |
+
defp maybe_encode_params(params, headers) when is_binary(params) or is_nil(params) do
|
| 95 |
+
{params, headers}
|
| 96 |
+
end
|
| 97 |
+
|
| 98 |
+
defp maybe_encode_params(params, headers) when is_map(params) do
|
| 99 |
+
content_type =
|
| 100 |
+
Enum.find_value(headers, "", fn {k, v} ->
|
| 101 |
+
if String.downcase(k) == "content-type" do
|
| 102 |
+
v
|
| 103 |
+
end
|
| 104 |
+
end)
|
| 105 |
+
|
| 106 |
+
case String.downcase(content_type) do
|
| 107 |
+
"application/x-www-form-urlencoded" ->
|
| 108 |
+
{URI.encode_query(params), headers}
|
| 109 |
+
|
| 110 |
+
"application/json" ->
|
| 111 |
+
{Jason.encode!(params), headers}
|
| 112 |
+
|
| 113 |
+
_ ->
|
| 114 |
+
{Jason.encode!(params), [{"content-type", "application/json"} | headers]}
|
| 115 |
+
end
|
| 116 |
+
end
|
| 117 |
+
|
| 118 |
+
defp tag_error({:ok, %Finch.Response{status: status}} = ok)
|
| 119 |
+
when is_integer(status) and status >= 200 and status < 300 do
|
| 120 |
+
ok
|
| 121 |
+
end
|
| 122 |
+
|
| 123 |
+
defp tag_error({:ok, %Finch.Response{status: _} = response}) do
|
| 124 |
+
{:error, Non200Error.new(response)}
|
| 125 |
+
end
|
| 126 |
+
|
| 127 |
+
defp tag_error({:error, _} = error) do
|
| 128 |
+
error
|
| 129 |
+
end
|
| 130 |
+
|
| 131 |
+
defp maybe_decode_body({:ok, %{headers: headers, body: body} = resp})
|
| 132 |
+
when is_binary(body) and body != "" do
|
| 133 |
+
if json?(headers) do
|
| 134 |
+
{:ok, update_in(resp.body, &Jason.decode!/1)}
|
| 135 |
+
else
|
| 136 |
+
{:ok, resp}
|
| 137 |
+
end
|
| 138 |
+
end
|
| 139 |
+
|
| 140 |
+
defp maybe_decode_body(response), do: response
|
| 141 |
+
|
| 142 |
+
defp json?(headers) do
|
| 143 |
+
found =
|
| 144 |
+
Enum.find(headers, fn
|
| 145 |
+
{"content-type", "application/json" <> _} ->
|
| 146 |
+
true
|
| 147 |
+
|
| 148 |
+
_ ->
|
| 149 |
+
false
|
| 150 |
+
end)
|
| 151 |
+
|
| 152 |
+
is_tuple(found)
|
| 153 |
+
end
|
| 154 |
+
|
| 155 |
+
defp trace_request(%Finch.Request{} = request) do
|
| 156 |
+
OpenTelemetry.Tracer.set_attributes([
|
| 157 |
+
{"http_client.request.host", request.host},
|
| 158 |
+
{"http_client.request.method", request.method},
|
| 159 |
+
{"http_client.request.path", request.path}
|
| 160 |
+
])
|
| 161 |
+
|
| 162 |
+
request
|
| 163 |
+
end
|
| 164 |
+
|
| 165 |
+
defp trace_response(response) do
|
| 166 |
+
case response do
|
| 167 |
+
{:ok, %{status: status}} ->
|
| 168 |
+
OpenTelemetry.Tracer.set_attributes([
|
| 169 |
+
{"http_client.response.status", status}
|
| 170 |
+
])
|
| 171 |
+
|
| 172 |
+
{:error, %Non200Error{reason: %{status: status}}} ->
|
| 173 |
+
OpenTelemetry.Tracer.set_attributes([
|
| 174 |
+
{"http_client.request.failure", "non_200"},
|
| 175 |
+
{"http_client.response.status", status}
|
| 176 |
+
])
|
| 177 |
+
|
| 178 |
+
{:error, exception} when is_exception(exception) ->
|
| 179 |
+
OpenTelemetry.Tracer.set_attributes([
|
| 180 |
+
{"http_client.request.failure", Exception.message(exception)}
|
| 181 |
+
])
|
| 182 |
+
|
| 183 |
+
_any ->
|
| 184 |
+
:skip
|
| 185 |
+
end
|
| 186 |
+
|
| 187 |
+
response
|
| 188 |
+
end
|
| 189 |
+
end
|
lib/plausible/import_deletion_repo.ex
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.ImportDeletionRepo do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
A dedicated repo for import related mutations
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
use Ecto.Repo,
|
| 7 |
+
otp_app: :plausible,
|
| 8 |
+
adapter: Ecto.Adapters.ClickHouse
|
| 9 |
+
|
| 10 |
+
defmacro __using__(_) do
|
| 11 |
+
quote do
|
| 12 |
+
alias Plausible.ImportDeletionRepo
|
| 13 |
+
import Ecto
|
| 14 |
+
import Ecto.Query, only: [from: 1, from: 2]
|
| 15 |
+
end
|
| 16 |
+
end
|
| 17 |
+
end
|
lib/plausible/imported.ex
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Context for managing site statistics imports.
|
| 4 |
+
|
| 5 |
+
For list of currently supported import sources see `Plausible.Imported.ImportSources`.
|
| 6 |
+
|
| 7 |
+
For more information on implementing importers, see `Plausible.Imported.Importer`.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import Ecto.Query
|
| 11 |
+
|
| 12 |
+
alias Plausible.{Site, Repo, Imported}
|
| 13 |
+
alias Plausible.Imported.SiteImport
|
| 14 |
+
alias Plausible.Stats.Query
|
| 15 |
+
|
| 16 |
+
require Plausible.Imported.SiteImport
|
| 17 |
+
|
| 18 |
+
@tables [
|
| 19 |
+
Imported.Visitor,
|
| 20 |
+
Imported.Source,
|
| 21 |
+
Imported.Page,
|
| 22 |
+
Imported.EntryPage,
|
| 23 |
+
Imported.ExitPage,
|
| 24 |
+
Imported.CustomEvent,
|
| 25 |
+
Imported.Location,
|
| 26 |
+
Imported.Device,
|
| 27 |
+
Imported.Browser,
|
| 28 |
+
Imported.OperatingSystem
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
@table_names Enum.map(@tables, & &1.__schema__(:source))
|
| 32 |
+
# Maximum number of complete imports to account for when querying stats
|
| 33 |
+
@max_complete_imports 5
|
| 34 |
+
|
| 35 |
+
@spec schemas() :: [module()]
|
| 36 |
+
def schemas, do: @tables
|
| 37 |
+
|
| 38 |
+
@spec tables() :: [String.t()]
|
| 39 |
+
def tables, do: @table_names
|
| 40 |
+
|
| 41 |
+
@spec max_complete_imports() :: non_neg_integer()
|
| 42 |
+
def max_complete_imports() do
|
| 43 |
+
@max_complete_imports
|
| 44 |
+
end
|
| 45 |
+
|
| 46 |
+
@spec imported_custom_props() :: [String.t()]
|
| 47 |
+
def imported_custom_props do
|
| 48 |
+
# NOTE: Keep up to date with `Plausible.Props.internal_keys/1`,
|
| 49 |
+
# but _ignore_ unsupported keys. Currently, `search_query` is
|
| 50 |
+
# not supported in imported queries.
|
| 51 |
+
Enum.map(~w(url path), &("event:props:" <> &1))
|
| 52 |
+
end
|
| 53 |
+
|
| 54 |
+
@spec any_completed_imports?(Site.t()) :: boolean()
|
| 55 |
+
def any_completed_imports?(site) do
|
| 56 |
+
get_completed_imports(site) != []
|
| 57 |
+
end
|
| 58 |
+
|
| 59 |
+
@spec earliest_import_start_date(Site.t()) :: Date.t() | nil
|
| 60 |
+
def earliest_import_start_date(site) do
|
| 61 |
+
site
|
| 62 |
+
|> get_completed_imports()
|
| 63 |
+
|> Enum.map(& &1.start_date)
|
| 64 |
+
|> Enum.min(Date, fn -> nil end)
|
| 65 |
+
end
|
| 66 |
+
|
| 67 |
+
@spec complete_import_ids(Site.t()) :: [non_neg_integer()]
|
| 68 |
+
def complete_import_ids(site) do
|
| 69 |
+
imports = get_completed_imports(site)
|
| 70 |
+
has_legacy? = Enum.any?(imports, fn %{legacy: legacy?} -> legacy? end)
|
| 71 |
+
ids = Enum.map(imports, fn %{id: id} -> id end)
|
| 72 |
+
|
| 73 |
+
# account for legacy imports as well
|
| 74 |
+
if has_legacy? do
|
| 75 |
+
[0 | ids]
|
| 76 |
+
else
|
| 77 |
+
ids
|
| 78 |
+
end
|
| 79 |
+
end
|
| 80 |
+
|
| 81 |
+
@spec completed_imports_in_query_range(Site.t(), Query.t()) :: [SiteImport.t()]
|
| 82 |
+
def completed_imports_in_query_range(%Site{} = site, %Query{} = query) do
|
| 83 |
+
date_range = Query.date_range(query)
|
| 84 |
+
|
| 85 |
+
site
|
| 86 |
+
|> get_completed_imports()
|
| 87 |
+
|> Enum.reject(fn site_import ->
|
| 88 |
+
Date.after?(site_import.start_date, date_range.last) or
|
| 89 |
+
Date.before?(site_import.end_date, date_range.first)
|
| 90 |
+
end)
|
| 91 |
+
end
|
| 92 |
+
|
| 93 |
+
@spec get_import(Site.t(), non_neg_integer()) :: SiteImport.t() | nil
|
| 94 |
+
def get_import(site, import_id) do
|
| 95 |
+
Repo.get_by(SiteImport, id: import_id, site_id: site.id)
|
| 96 |
+
end
|
| 97 |
+
|
| 98 |
+
defdelegate listen(), to: Imported.Importer
|
| 99 |
+
|
| 100 |
+
@spec list_all_imports(Site.t(), atom()) :: [SiteImport.t()]
|
| 101 |
+
def list_all_imports(site, status \\ nil) do
|
| 102 |
+
from(i in SiteImport, where: i.site_id == ^site.id, order_by: [desc: i.inserted_at])
|
| 103 |
+
|> maybe_filter_by_status(status)
|
| 104 |
+
|> Repo.all()
|
| 105 |
+
end
|
| 106 |
+
|
| 107 |
+
@spec other_imports_in_progress?(SiteImport.t()) :: boolean()
|
| 108 |
+
def other_imports_in_progress?(site_import) do
|
| 109 |
+
Repo.exists?(
|
| 110 |
+
from(i in SiteImport,
|
| 111 |
+
where: i.site_id == ^site_import.site_id and i.id != ^site_import.id,
|
| 112 |
+
where: i.status in ^[SiteImport.pending(), SiteImport.importing()]
|
| 113 |
+
)
|
| 114 |
+
)
|
| 115 |
+
end
|
| 116 |
+
|
| 117 |
+
defp maybe_filter_by_status(query, nil), do: query
|
| 118 |
+
|
| 119 |
+
defp maybe_filter_by_status(query, status) do
|
| 120 |
+
where(query, [i], i.status == ^status)
|
| 121 |
+
end
|
| 122 |
+
|
| 123 |
+
@spec delete_imports_for_site(Site.t()) :: :ok
|
| 124 |
+
def delete_imports_for_site(site) do
|
| 125 |
+
Repo.delete_all(from i in SiteImport, where: i.site_id == ^site.id)
|
| 126 |
+
|
| 127 |
+
:ok
|
| 128 |
+
end
|
| 129 |
+
|
| 130 |
+
@spec clamp_dates(Site.t(), Date.t(), Date.t()) ::
|
| 131 |
+
{:ok, Date.t(), Date.t()} | {:error, :no_time_window}
|
| 132 |
+
def clamp_dates(site, start_date, end_date) do
|
| 133 |
+
cutoff_date = get_cutoff_date(site)
|
| 134 |
+
occupied_ranges = get_occupied_date_ranges(site)
|
| 135 |
+
|
| 136 |
+
clamp_dates(occupied_ranges, cutoff_date, start_date, end_date)
|
| 137 |
+
end
|
| 138 |
+
|
| 139 |
+
@spec clamp_dates([Date.Range.t()], Date.t(), Date.t(), Date.t()) ::
|
| 140 |
+
{:ok, Date.t(), Date.t()} | {:error, :no_time_window}
|
| 141 |
+
def clamp_dates(occupied_ranges, cutoff_date, start_date, end_date) do
|
| 142 |
+
end_date = Enum.min([end_date, cutoff_date], Date)
|
| 143 |
+
|
| 144 |
+
with true <- Date.diff(end_date, start_date) >= 2,
|
| 145 |
+
[_ | _] = free_ranges <- find_free_ranges(start_date, end_date, occupied_ranges) do
|
| 146 |
+
longest = Enum.max_by(free_ranges, &Date.diff(&1.last, &1.first))
|
| 147 |
+
{:ok, longest.first, longest.last}
|
| 148 |
+
else
|
| 149 |
+
_ -> {:error, :no_time_window}
|
| 150 |
+
end
|
| 151 |
+
end
|
| 152 |
+
|
| 153 |
+
@spec get_occupied_date_ranges(Site.t()) :: [Date.Range.t()]
|
| 154 |
+
def get_occupied_date_ranges(site) do
|
| 155 |
+
site
|
| 156 |
+
|> Imported.list_all_imports(Imported.SiteImport.completed())
|
| 157 |
+
|> Enum.reject(&(Date.diff(&1.end_date, &1.start_date) < 2))
|
| 158 |
+
|> Enum.map(&Date.range(&1.start_date, &1.end_date))
|
| 159 |
+
|> Enum.sort_by(& &1.first, Date)
|
| 160 |
+
end
|
| 161 |
+
|
| 162 |
+
@spec get_cutoff_date(Site.t()) :: Date.t()
|
| 163 |
+
def get_cutoff_date(site) do
|
| 164 |
+
Plausible.Sites.native_stats_start_date(site) ||
|
| 165 |
+
DateTime.to_date(DateTime.now!(site.timezone))
|
| 166 |
+
end
|
| 167 |
+
|
| 168 |
+
defp find_free_ranges(start_date, end_date, occupied_ranges) do
|
| 169 |
+
Date.range(start_date, end_date)
|
| 170 |
+
|> free_ranges(start_date, occupied_ranges, [])
|
| 171 |
+
end
|
| 172 |
+
|
| 173 |
+
# This function recursively finds open ranges that are not yet occupied
|
| 174 |
+
# by existing imported data. The idea is that we keep moving a dynamic
|
| 175 |
+
# date index `d` from start until the end of `imported_range`, hopping
|
| 176 |
+
# over each occupied range, and capturing the open ranges step-by-step
|
| 177 |
+
# in the `result` array.
|
| 178 |
+
defp free_ranges(import_range, d, [occupied_range | rest_of_occupied_ranges], result) do
|
| 179 |
+
cond do
|
| 180 |
+
Date.diff(occupied_range.last, d) <= 0 ->
|
| 181 |
+
free_ranges(import_range, d, rest_of_occupied_ranges, result)
|
| 182 |
+
|
| 183 |
+
in_range?(d, occupied_range) || Date.diff(occupied_range.first, d) < 2 ->
|
| 184 |
+
d = occupied_range.last
|
| 185 |
+
free_ranges(import_range, d, rest_of_occupied_ranges, result)
|
| 186 |
+
|
| 187 |
+
true ->
|
| 188 |
+
free_range = Date.range(d, occupied_range.first)
|
| 189 |
+
result = result ++ [free_range]
|
| 190 |
+
d = occupied_range.last
|
| 191 |
+
free_ranges(import_range, d, rest_of_occupied_ranges, result)
|
| 192 |
+
end
|
| 193 |
+
end
|
| 194 |
+
|
| 195 |
+
defp free_ranges(import_range, d, [], result) do
|
| 196 |
+
if Date.diff(import_range.last, d) < 2 do
|
| 197 |
+
result
|
| 198 |
+
else
|
| 199 |
+
result ++ [Date.range(d, import_range.last)]
|
| 200 |
+
end
|
| 201 |
+
end
|
| 202 |
+
|
| 203 |
+
defp in_range?(date, range) do
|
| 204 |
+
Date.before?(range.first, date) && Date.after?(range.last, date)
|
| 205 |
+
end
|
| 206 |
+
|
| 207 |
+
defp get_completed_imports(site) do
|
| 208 |
+
site
|
| 209 |
+
|> Repo.preload(:completed_imports)
|
| 210 |
+
|> Map.fetch!(:completed_imports)
|
| 211 |
+
end
|
| 212 |
+
end
|
lib/plausible/imported/browser.ex
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.Browser do
|
| 2 |
+
@moduledoc false
|
| 3 |
+
use Ecto.Schema
|
| 4 |
+
|
| 5 |
+
@primary_key false
|
| 6 |
+
schema "imported_browsers" do
|
| 7 |
+
field :site_id, Ch, type: "UInt64"
|
| 8 |
+
field :import_id, Ch, type: "UInt64"
|
| 9 |
+
field :date, :date
|
| 10 |
+
field :browser, :string
|
| 11 |
+
field :browser_version, :string
|
| 12 |
+
field :visitors, Ch, type: "UInt64"
|
| 13 |
+
field :visits, Ch, type: "UInt64"
|
| 14 |
+
field :visit_duration, Ch, type: "UInt64"
|
| 15 |
+
field :pageviews, Ch, type: "UInt64"
|
| 16 |
+
field :bounces, Ch, type: "UInt32"
|
| 17 |
+
end
|
| 18 |
+
end
|
lib/plausible/imported/buffer.ex
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.Buffer do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
This GenServer inserts records into Clickhouse `imported_*` tables. Multiple buffers are
|
| 4 |
+
automatically created for each table. Records are flushed when the table buffer reaches the
|
| 5 |
+
maximum size, defined by `max_buffer_size/0`.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
use GenServer
|
| 9 |
+
require Logger
|
| 10 |
+
|
| 11 |
+
def start_link(opts \\ []) do
|
| 12 |
+
GenServer.start_link(__MODULE__, opts)
|
| 13 |
+
end
|
| 14 |
+
|
| 15 |
+
def init(opts) do
|
| 16 |
+
flush_interval = Keyword.get(opts, :flush_interval_ms, 1000)
|
| 17 |
+
{:ok, %{flush_interval: flush_interval, buffers: %{}}}
|
| 18 |
+
end
|
| 19 |
+
|
| 20 |
+
@spec insert_many(pid(), term(), [map()]) :: :ok
|
| 21 |
+
@doc """
|
| 22 |
+
Puts the given records into the table buffer.
|
| 23 |
+
"""
|
| 24 |
+
def insert_many(pid, table_name, records) do
|
| 25 |
+
GenServer.call(pid, {:insert_many, table_name, records})
|
| 26 |
+
end
|
| 27 |
+
|
| 28 |
+
@spec size(pid(), term()) :: non_neg_integer()
|
| 29 |
+
@doc """
|
| 30 |
+
Returns the total count of items in the given table buffer.
|
| 31 |
+
"""
|
| 32 |
+
def size(pid, table_name) do
|
| 33 |
+
GenServer.call(pid, {:get_size, table_name})
|
| 34 |
+
end
|
| 35 |
+
|
| 36 |
+
@spec flush(pid()) :: :ok
|
| 37 |
+
@doc """
|
| 38 |
+
Flushes all table buffers to Clickhouse.
|
| 39 |
+
"""
|
| 40 |
+
def flush(pid, timeout \\ :infinity) do
|
| 41 |
+
GenServer.call(pid, :flush_all_buffers, timeout)
|
| 42 |
+
end
|
| 43 |
+
|
| 44 |
+
def stop(pid) do
|
| 45 |
+
GenServer.stop(pid)
|
| 46 |
+
end
|
| 47 |
+
|
| 48 |
+
def handle_call({:get_size, table_name}, _from, %{buffers: buffers} = state) do
|
| 49 |
+
size =
|
| 50 |
+
buffers
|
| 51 |
+
|> Map.get(table_name, [])
|
| 52 |
+
|> length()
|
| 53 |
+
|
| 54 |
+
{:reply, size, state}
|
| 55 |
+
end
|
| 56 |
+
|
| 57 |
+
def handle_call({:insert_many, table_name, records}, _from, %{buffers: buffers} = state) do
|
| 58 |
+
Logger.notice("Import: Adding #{length(records)} to #{table_name} buffer")
|
| 59 |
+
|
| 60 |
+
new_buffer = Map.get(buffers, table_name, []) ++ records
|
| 61 |
+
new_state = put_in(state.buffers[table_name], new_buffer)
|
| 62 |
+
|
| 63 |
+
if length(new_buffer) >= max_buffer_size() do
|
| 64 |
+
{:reply, :ok, new_state, {:continue, {:flush, table_name}}}
|
| 65 |
+
else
|
| 66 |
+
{:reply, :ok, new_state}
|
| 67 |
+
end
|
| 68 |
+
end
|
| 69 |
+
|
| 70 |
+
def handle_call(:flush_all_buffers, _from, state) do
|
| 71 |
+
Enum.each(state.buffers, fn {table_name, records} ->
|
| 72 |
+
flush_buffer(records, table_name, state.flush_interval)
|
| 73 |
+
end)
|
| 74 |
+
|
| 75 |
+
{:reply, :ok, put_in(state.buffers, %{})}
|
| 76 |
+
end
|
| 77 |
+
|
| 78 |
+
def handle_continue({:flush, table_name}, state) do
|
| 79 |
+
flush_buffer(state.buffers[table_name], table_name, state.flush_interval)
|
| 80 |
+
{:noreply, put_in(state.buffers[table_name], [])}
|
| 81 |
+
end
|
| 82 |
+
|
| 83 |
+
defp max_buffer_size do
|
| 84 |
+
:plausible
|
| 85 |
+
|> Application.fetch_env!(:imported)
|
| 86 |
+
|> Keyword.fetch!(:max_buffer_size)
|
| 87 |
+
end
|
| 88 |
+
|
| 89 |
+
defp flush_buffer(records, table_name, flush_interval) do
|
| 90 |
+
# Clickhouse does not recommend sending more than 1 INSERT operation per second, and this
|
| 91 |
+
# sleep call slows down the flushing
|
| 92 |
+
Process.sleep(flush_interval)
|
| 93 |
+
|
| 94 |
+
Logger.notice("Import: Flushing #{length(records)} from #{table_name} buffer")
|
| 95 |
+
insert_all(table_name, records)
|
| 96 |
+
end
|
| 97 |
+
|
| 98 |
+
# used in tests `setup`
|
| 99 |
+
@doc false
|
| 100 |
+
def insert_all(table_name, records) do
|
| 101 |
+
schema = table_schema(table_name)
|
| 102 |
+
Plausible.IngestRepo.insert_all(schema, records)
|
| 103 |
+
end
|
| 104 |
+
|
| 105 |
+
defp table_schema("imported_visitors"), do: Plausible.Imported.Visitor
|
| 106 |
+
defp table_schema("imported_sources"), do: Plausible.Imported.Source
|
| 107 |
+
defp table_schema("imported_pages"), do: Plausible.Imported.Page
|
| 108 |
+
defp table_schema("imported_entry_pages"), do: Plausible.Imported.EntryPage
|
| 109 |
+
defp table_schema("imported_exit_pages"), do: Plausible.Imported.ExitPage
|
| 110 |
+
defp table_schema("imported_custom_events"), do: Plausible.Imported.CustomEvent
|
| 111 |
+
defp table_schema("imported_locations"), do: Plausible.Imported.Location
|
| 112 |
+
defp table_schema("imported_devices"), do: Plausible.Imported.Device
|
| 113 |
+
defp table_schema("imported_browsers"), do: Plausible.Imported.Browser
|
| 114 |
+
defp table_schema("imported_operating_systems"), do: Plausible.Imported.OperatingSystem
|
| 115 |
+
end
|
lib/plausible/imported/csv_importer.ex
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.CSVImporter do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
CSV importer from either S3 for which it uses ClickHouse [s3 table function](https://clickhouse.com/docs/en/sql-reference/table-functions/s3)
|
| 4 |
+
or from local storage for which it uses [input function.](https://clickhouse.com/docs/en/sql-reference/table-functions/input)
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
use Plausible.Imported.Importer
|
| 8 |
+
import Ecto.Query, only: [from: 2]
|
| 9 |
+
|
| 10 |
+
@impl true
|
| 11 |
+
def name(), do: :csv
|
| 12 |
+
|
| 13 |
+
@impl true
|
| 14 |
+
def label(), do: "CSV"
|
| 15 |
+
|
| 16 |
+
@impl true
|
| 17 |
+
def email_template(), do: "csv_import.html"
|
| 18 |
+
|
| 19 |
+
@impl true
|
| 20 |
+
def parse_args(%{"uploads" => uploads, "storage" => storage}) do
|
| 21 |
+
uploads = Enum.reject(uploads, &String.starts_with?(&1["filename"], "imported_custom_props_"))
|
| 22 |
+
|
| 23 |
+
[uploads: uploads, storage: storage]
|
| 24 |
+
end
|
| 25 |
+
|
| 26 |
+
@impl true
|
| 27 |
+
def import_data(site_import, opts) do
|
| 28 |
+
storage = Keyword.fetch!(opts, :storage)
|
| 29 |
+
uploads = Keyword.fetch!(opts, :uploads)
|
| 30 |
+
|
| 31 |
+
if storage == "local" do
|
| 32 |
+
# we need to remove the imported files from local storage
|
| 33 |
+
# after the importer has completed or ran out of attempts
|
| 34 |
+
paths = Enum.map(uploads, &Map.fetch!(&1, "local_path"))
|
| 35 |
+
|
| 36 |
+
Oban.insert!(
|
| 37 |
+
Plausible.Workers.LocalImportAnalyticsCleaner.new(
|
| 38 |
+
%{"import_id" => site_import.id, "paths" => paths},
|
| 39 |
+
schedule_in: _one_hour = 3600
|
| 40 |
+
)
|
| 41 |
+
)
|
| 42 |
+
end
|
| 43 |
+
|
| 44 |
+
{:ok, ch} =
|
| 45 |
+
Plausible.IngestRepo.config()
|
| 46 |
+
|> Keyword.replace!(:pool_size, 1)
|
| 47 |
+
|> Ch.start_link()
|
| 48 |
+
|
| 49 |
+
case storage do
|
| 50 |
+
"s3" -> import_s3(ch, site_import, uploads)
|
| 51 |
+
"local" -> import_local(ch, site_import, uploads)
|
| 52 |
+
end
|
| 53 |
+
rescue
|
| 54 |
+
# we are cancelling on any argument or ClickHouse errors, assuming they are permanent
|
| 55 |
+
e in [ArgumentError, Ch.Error] ->
|
| 56 |
+
# see Plausible.Imported.Importer for more details on transient vs permanent errors
|
| 57 |
+
{:error, Exception.message(e)}
|
| 58 |
+
end
|
| 59 |
+
|
| 60 |
+
def on_success(site_import, _extra_data) do
|
| 61 |
+
has_scroll_depth? =
|
| 62 |
+
Plausible.ClickhouseRepo.exists?(
|
| 63 |
+
from(i in "imported_pages",
|
| 64 |
+
where: i.site_id == ^site_import.site_id,
|
| 65 |
+
where: i.import_id == ^site_import.id,
|
| 66 |
+
where: i.total_scroll_depth_visits > 0,
|
| 67 |
+
select: 1
|
| 68 |
+
)
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
if has_scroll_depth? do
|
| 72 |
+
site_import
|
| 73 |
+
|> Ecto.Changeset.change(%{has_scroll_depth: true})
|
| 74 |
+
|> Plausible.Repo.update!()
|
| 75 |
+
end
|
| 76 |
+
|
| 77 |
+
:ok
|
| 78 |
+
end
|
| 79 |
+
|
| 80 |
+
defp import_s3(ch, site_import, uploads) do
|
| 81 |
+
%{
|
| 82 |
+
id: import_id,
|
| 83 |
+
site_id: site_id,
|
| 84 |
+
start_date: start_date,
|
| 85 |
+
end_date: end_date
|
| 86 |
+
} = site_import
|
| 87 |
+
|
| 88 |
+
%{access_key_id: s3_access_key_id, secret_access_key: s3_secret_access_key} =
|
| 89 |
+
Plausible.S3.import_clickhouse_credentials()
|
| 90 |
+
|
| 91 |
+
Enum.each(uploads, fn upload ->
|
| 92 |
+
%{"filename" => filename, "s3_url" => s3_url} = upload
|
| 93 |
+
|
| 94 |
+
{table, _, _} = parse_filename!(filename)
|
| 95 |
+
s3_structure = input_structure!(table)
|
| 96 |
+
s3_columns = input_columns!(table)
|
| 97 |
+
|
| 98 |
+
statement =
|
| 99 |
+
"""
|
| 100 |
+
INSERT INTO {table:Identifier}(site_id,import_id,#{s3_columns}) \
|
| 101 |
+
SELECT {site_id:UInt64}, {import_id:UInt64}, #{s3_columns} \
|
| 102 |
+
FROM s3({s3_url:String},{s3_access_key_id:String},{s3_secret_access_key:String},{s3_format:String},{s3_structure:String}) \
|
| 103 |
+
WHERE date >= {start_date:Date} AND date <= {end_date:Date}\
|
| 104 |
+
"""
|
| 105 |
+
|
| 106 |
+
params =
|
| 107 |
+
%{
|
| 108 |
+
"table" => table,
|
| 109 |
+
"site_id" => site_id,
|
| 110 |
+
"import_id" => import_id,
|
| 111 |
+
"s3_url" => s3_url,
|
| 112 |
+
"s3_access_key_id" => s3_access_key_id,
|
| 113 |
+
"s3_secret_access_key" => s3_secret_access_key,
|
| 114 |
+
"s3_format" => "CSVWithNames",
|
| 115 |
+
"s3_structure" => s3_structure,
|
| 116 |
+
"start_date" => start_date,
|
| 117 |
+
"end_date" => end_date
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
Ch.query!(ch, statement, params, timeout: :infinity)
|
| 121 |
+
end)
|
| 122 |
+
end
|
| 123 |
+
|
| 124 |
+
defp import_local(ch, site_import, uploads) do
|
| 125 |
+
%{
|
| 126 |
+
id: import_id,
|
| 127 |
+
site_id: site_id,
|
| 128 |
+
start_date: start_date,
|
| 129 |
+
end_date: end_date
|
| 130 |
+
} = site_import
|
| 131 |
+
|
| 132 |
+
DBConnection.run(
|
| 133 |
+
ch,
|
| 134 |
+
fn conn ->
|
| 135 |
+
Enum.each(uploads, fn upload ->
|
| 136 |
+
%{"filename" => filename, "local_path" => local_path} = upload
|
| 137 |
+
|
| 138 |
+
{table, _, _} = parse_filename!(filename)
|
| 139 |
+
input_structure = input_structure!(table)
|
| 140 |
+
input_columns = input_columns!(table)
|
| 141 |
+
|
| 142 |
+
statement =
|
| 143 |
+
"""
|
| 144 |
+
INSERT INTO {table:Identifier}(site_id,import_id,#{input_columns}) \
|
| 145 |
+
SELECT {site_id:UInt64}, {import_id:UInt64}, #{input_columns} \
|
| 146 |
+
FROM input({input_structure:String}) \
|
| 147 |
+
WHERE date >= {start_date:Date} AND date <= {end_date:Date} \
|
| 148 |
+
FORMAT CSVWithNames
|
| 149 |
+
"""
|
| 150 |
+
|
| 151 |
+
params = %{
|
| 152 |
+
"table" => table,
|
| 153 |
+
"site_id" => site_id,
|
| 154 |
+
"import_id" => import_id,
|
| 155 |
+
"input_structure" => input_structure,
|
| 156 |
+
"start_date" => start_date,
|
| 157 |
+
"end_date" => end_date
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
# we are reading in 512KB chunks for better performance
|
| 161 |
+
# the default would've been line by line (not great for a CSV)
|
| 162 |
+
File.stream!(local_path, 512_000)
|
| 163 |
+
|> Stream.into(Ch.stream(conn, statement, params))
|
| 164 |
+
|> Stream.run()
|
| 165 |
+
end)
|
| 166 |
+
end,
|
| 167 |
+
timeout: :infinity
|
| 168 |
+
)
|
| 169 |
+
end
|
| 170 |
+
|
| 171 |
+
input_structures = %{
|
| 172 |
+
"imported_browsers" =>
|
| 173 |
+
"date Date, browser String, browser_version String, visitors UInt64, visits UInt64, visit_duration UInt64, bounces UInt32, pageviews UInt64",
|
| 174 |
+
"imported_devices" =>
|
| 175 |
+
"date Date, device String, visitors UInt64, visits UInt64, visit_duration UInt64, bounces UInt32, pageviews UInt64",
|
| 176 |
+
"imported_entry_pages" =>
|
| 177 |
+
"date Date, entry_page String, visitors UInt64, entrances UInt64, visit_duration UInt64, bounces UInt32, pageviews UInt64",
|
| 178 |
+
"imported_exit_pages" =>
|
| 179 |
+
"date Date, exit_page String, visitors UInt64, visit_duration UInt64, exits UInt64, bounces UInt32, pageviews UInt64",
|
| 180 |
+
"imported_custom_events" =>
|
| 181 |
+
"date Date, name String, link_url String, path String, visitors UInt64, events UInt64",
|
| 182 |
+
"imported_locations" =>
|
| 183 |
+
"date Date, country String, region String, city UInt64, visitors UInt64, visits UInt64, visit_duration UInt64, bounces UInt32, pageviews UInt64",
|
| 184 |
+
"imported_operating_systems" =>
|
| 185 |
+
"date Date, operating_system String, operating_system_version String, visitors UInt64, visits UInt64, visit_duration UInt64, bounces UInt32, pageviews UInt64",
|
| 186 |
+
"imported_pages" =>
|
| 187 |
+
"date Date, hostname String, page String, visits UInt64, visitors UInt64, pageviews UInt64, total_scroll_depth UInt64, total_scroll_depth_visits UInt64, total_time_on_page UInt64, total_time_on_page_visits UInt64",
|
| 188 |
+
"imported_sources" =>
|
| 189 |
+
"date Date, source String, referrer String, utm_source String, utm_medium String, utm_campaign String, utm_content String, utm_term String, pageviews UInt64, visitors UInt64, visits UInt64, visit_duration UInt64, bounces UInt32",
|
| 190 |
+
"imported_visitors" =>
|
| 191 |
+
"date Date, visitors UInt64, pageviews UInt64, bounces UInt64, visits UInt64, visit_duration UInt64"
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
@doc """
|
| 195 |
+
Extracts min/max date range from a list of uploads.
|
| 196 |
+
|
| 197 |
+
Examples:
|
| 198 |
+
|
| 199 |
+
iex> date_range([
|
| 200 |
+
...> %{"filename" => "imported_devices_20190101_20210101.csv"},
|
| 201 |
+
...> "pages_20200101_20220101.csv"
|
| 202 |
+
...> ])
|
| 203 |
+
Date.range(~D[2019-01-01], ~D[2022-01-01])
|
| 204 |
+
|
| 205 |
+
iex> date_range([])
|
| 206 |
+
nil
|
| 207 |
+
|
| 208 |
+
"""
|
| 209 |
+
@spec date_range([String.t() | %{String.t() => String.t()}, ...]) :: Date.Range.t() | nil
|
| 210 |
+
def date_range([_ | _] = uploads), do: date_range(uploads, _start_date = nil, _end_date = nil)
|
| 211 |
+
def date_range([]), do: nil
|
| 212 |
+
|
| 213 |
+
defp date_range(
|
| 214 |
+
[%{"filename" => "imported_custom_props_" <> _} | uploads],
|
| 215 |
+
prev_start_date,
|
| 216 |
+
prev_end_date
|
| 217 |
+
) do
|
| 218 |
+
date_range(uploads, prev_start_date, prev_end_date)
|
| 219 |
+
end
|
| 220 |
+
|
| 221 |
+
defp date_range([upload | uploads], prev_start_date, prev_end_date) do
|
| 222 |
+
filename =
|
| 223 |
+
case upload do
|
| 224 |
+
%{"filename" => filename} -> filename
|
| 225 |
+
filename when is_binary(filename) -> filename
|
| 226 |
+
end
|
| 227 |
+
|
| 228 |
+
{_table, start_date, end_date} = parse_filename!(filename)
|
| 229 |
+
|
| 230 |
+
start_date =
|
| 231 |
+
if prev_start_date do
|
| 232 |
+
Enum.min([start_date, prev_start_date], Date)
|
| 233 |
+
else
|
| 234 |
+
start_date
|
| 235 |
+
end
|
| 236 |
+
|
| 237 |
+
end_date =
|
| 238 |
+
if prev_end_date do
|
| 239 |
+
Enum.max([end_date, prev_end_date], Date)
|
| 240 |
+
else
|
| 241 |
+
end_date
|
| 242 |
+
end
|
| 243 |
+
|
| 244 |
+
date_range(uploads, start_date, end_date)
|
| 245 |
+
end
|
| 246 |
+
|
| 247 |
+
defp date_range([], first, last), do: Date.range(first, last)
|
| 248 |
+
|
| 249 |
+
@spec parse_date!(String.t()) :: Date.t()
|
| 250 |
+
defp parse_date!(<<yyyy::bytes-4, mm::bytes-2, dd::bytes-2>>) do
|
| 251 |
+
Date.from_iso8601!("#{yyyy}-#{mm}-#{dd}")
|
| 252 |
+
end
|
| 253 |
+
|
| 254 |
+
@doc """
|
| 255 |
+
Extracts table name and min/max dates from the filename.
|
| 256 |
+
|
| 257 |
+
Examples:
|
| 258 |
+
|
| 259 |
+
iex> parse_filename!("my_data.csv")
|
| 260 |
+
** (ArgumentError) invalid filename
|
| 261 |
+
|
| 262 |
+
iex> parse_filename!("imported_devices_00010101_20250101.csv")
|
| 263 |
+
{"imported_devices", ~D[0001-01-01], ~D[2025-01-01]}
|
| 264 |
+
|
| 265 |
+
iex> parse_filename!("devices_00010101_20250101.csv")
|
| 266 |
+
{"imported_devices", ~D[0001-01-01], ~D[2025-01-01]}
|
| 267 |
+
|
| 268 |
+
"""
|
| 269 |
+
@spec parse_filename!(String.t()) ::
|
| 270 |
+
{table :: String.t(), start_date :: Date.t(), end_date :: Date.t()}
|
| 271 |
+
def parse_filename!(filename)
|
| 272 |
+
|
| 273 |
+
for {table, input_structure} <- input_structures do
|
| 274 |
+
defp input_structure!(unquote(table)), do: unquote(input_structure)
|
| 275 |
+
|
| 276 |
+
input_columns =
|
| 277 |
+
input_structure
|
| 278 |
+
|> String.split(",", trim: true)
|
| 279 |
+
|> Enum.map_join(",", fn kv ->
|
| 280 |
+
[col, _type] = String.split(kv)
|
| 281 |
+
String.trim(col)
|
| 282 |
+
end)
|
| 283 |
+
|
| 284 |
+
defp input_columns!(unquote(table)), do: unquote(input_columns)
|
| 285 |
+
|
| 286 |
+
def parse_filename!(
|
| 287 |
+
<<unquote(table)::bytes, ?_, start_date::8-bytes, ?_, end_date::8-bytes, ".csv">>
|
| 288 |
+
) do
|
| 289 |
+
{unquote(table), parse_date!(start_date), parse_date!(end_date)}
|
| 290 |
+
end
|
| 291 |
+
|
| 292 |
+
"imported_" <> name = table
|
| 293 |
+
|
| 294 |
+
def parse_filename!(
|
| 295 |
+
<<unquote(name)::bytes, ?_, start_date::8-bytes, ?_, end_date::8-bytes, ".csv">>
|
| 296 |
+
) do
|
| 297 |
+
{unquote(table), parse_date!(start_date), parse_date!(end_date)}
|
| 298 |
+
end
|
| 299 |
+
end
|
| 300 |
+
|
| 301 |
+
def parse_filename!(_filename) do
|
| 302 |
+
raise ArgumentError, "invalid filename"
|
| 303 |
+
end
|
| 304 |
+
|
| 305 |
+
@doc """
|
| 306 |
+
Checks if the provided filename conforms to the expected format.
|
| 307 |
+
|
| 308 |
+
Examples:
|
| 309 |
+
|
| 310 |
+
iex> valid_filename?("my_data.csv")
|
| 311 |
+
false
|
| 312 |
+
|
| 313 |
+
iex> valid_filename?("imported_devices_00010101_20250101.csv")
|
| 314 |
+
true
|
| 315 |
+
|
| 316 |
+
iex> valid_filename?("devices_00010101_20250101.csv")
|
| 317 |
+
true
|
| 318 |
+
|
| 319 |
+
"""
|
| 320 |
+
@spec valid_filename?(String.t()) :: boolean
|
| 321 |
+
def valid_filename?(filename) do
|
| 322 |
+
try do
|
| 323 |
+
parse_filename!(filename)
|
| 324 |
+
else
|
| 325 |
+
_ -> true
|
| 326 |
+
rescue
|
| 327 |
+
_ -> false
|
| 328 |
+
end
|
| 329 |
+
end
|
| 330 |
+
|
| 331 |
+
@doc """
|
| 332 |
+
Extracts the table name from the provided filename.
|
| 333 |
+
|
| 334 |
+
Raises if the filename doesn't conform to the expected format.
|
| 335 |
+
|
| 336 |
+
Examples:
|
| 337 |
+
|
| 338 |
+
iex> extract_table("my_data.csv")
|
| 339 |
+
** (ArgumentError) invalid filename
|
| 340 |
+
|
| 341 |
+
iex> extract_table("imported_devices_00010101_20250101.csv")
|
| 342 |
+
"imported_devices"
|
| 343 |
+
|
| 344 |
+
iex> extract_table("devices_00010101_20250101.csv")
|
| 345 |
+
"imported_devices"
|
| 346 |
+
|
| 347 |
+
"""
|
| 348 |
+
@spec extract_table(String.t()) :: String.t()
|
| 349 |
+
def extract_table(filename) do
|
| 350 |
+
{table, _start_date, _end_date} = parse_filename!(filename)
|
| 351 |
+
table
|
| 352 |
+
end
|
| 353 |
+
|
| 354 |
+
@doc """
|
| 355 |
+
Returns local directory for CSV imports storage.
|
| 356 |
+
|
| 357 |
+
Builds upon `$DATA_DIR`, `$PERSISTENT_CACHE_DIR` or `$DEFAULT_DATA_DIR` (if set) and falls back to /tmp.
|
| 358 |
+
|
| 359 |
+
`$DEFAULT_DATA_DIR` is set to `/var/lib/plausible` in container images.
|
| 360 |
+
|
| 361 |
+
Examples:
|
| 362 |
+
|
| 363 |
+
iex> local_dir = local_dir(_site_id = 37)
|
| 364 |
+
iex> String.ends_with?(local_dir, "/plausible-imports/37")
|
| 365 |
+
true
|
| 366 |
+
|
| 367 |
+
"""
|
| 368 |
+
def local_dir(site_id) do
|
| 369 |
+
data_dir = Application.get_env(:plausible, :data_dir)
|
| 370 |
+
Path.join([data_dir || System.tmp_dir!(), "plausible-imports", Integer.to_string(site_id)])
|
| 371 |
+
end
|
| 372 |
+
end
|
lib/plausible/imported/custom_event.ex
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.CustomEvent do
|
| 2 |
+
@moduledoc false
|
| 3 |
+
use Ecto.Schema
|
| 4 |
+
|
| 5 |
+
@primary_key false
|
| 6 |
+
schema "imported_custom_events" do
|
| 7 |
+
field :site_id, Ch, type: "UInt64"
|
| 8 |
+
field :import_id, Ch, type: "UInt64"
|
| 9 |
+
field :date, :date
|
| 10 |
+
field :name, :string
|
| 11 |
+
field :link_url, :string
|
| 12 |
+
field :path, :string
|
| 13 |
+
field :visitors, Ch, type: "UInt64"
|
| 14 |
+
field :events, Ch, type: "UInt64"
|
| 15 |
+
end
|
| 16 |
+
end
|
lib/plausible/imported/device.ex
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.Device do
|
| 2 |
+
@moduledoc false
|
| 3 |
+
use Ecto.Schema
|
| 4 |
+
|
| 5 |
+
@primary_key false
|
| 6 |
+
schema "imported_devices" do
|
| 7 |
+
field :site_id, Ch, type: "UInt64"
|
| 8 |
+
field :import_id, Ch, type: "UInt64"
|
| 9 |
+
field :date, :date
|
| 10 |
+
field :device, :string
|
| 11 |
+
field :visitors, Ch, type: "UInt64"
|
| 12 |
+
field :visits, Ch, type: "UInt64"
|
| 13 |
+
field :visit_duration, Ch, type: "UInt64"
|
| 14 |
+
field :pageviews, Ch, type: "UInt64"
|
| 15 |
+
field :bounces, Ch, type: "UInt32"
|
| 16 |
+
end
|
| 17 |
+
end
|
lib/plausible/imported/entry_page.ex
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.EntryPage do
|
| 2 |
+
@moduledoc false
|
| 3 |
+
use Ecto.Schema
|
| 4 |
+
|
| 5 |
+
@primary_key false
|
| 6 |
+
schema "imported_entry_pages" do
|
| 7 |
+
field :site_id, Ch, type: "UInt64"
|
| 8 |
+
field :import_id, Ch, type: "UInt64"
|
| 9 |
+
field :date, :date
|
| 10 |
+
field :entry_page, :string
|
| 11 |
+
field :visitors, Ch, type: "UInt64"
|
| 12 |
+
field :entrances, Ch, type: "UInt64"
|
| 13 |
+
field :visit_duration, Ch, type: "UInt64"
|
| 14 |
+
field :pageviews, Ch, type: "UInt64"
|
| 15 |
+
field :bounces, Ch, type: "UInt32"
|
| 16 |
+
end
|
| 17 |
+
end
|
lib/plausible/imported/exit_page.ex
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.ExitPage do
|
| 2 |
+
@moduledoc false
|
| 3 |
+
use Ecto.Schema
|
| 4 |
+
|
| 5 |
+
@primary_key false
|
| 6 |
+
schema "imported_exit_pages" do
|
| 7 |
+
field :site_id, Ch, type: "UInt64"
|
| 8 |
+
field :import_id, Ch, type: "UInt64"
|
| 9 |
+
field :date, :date
|
| 10 |
+
field :exit_page, :string
|
| 11 |
+
field :exits, Ch, type: "UInt64"
|
| 12 |
+
field :visitors, Ch, type: "UInt64"
|
| 13 |
+
field :visit_duration, Ch, type: "UInt64"
|
| 14 |
+
field :pageviews, Ch, type: "UInt64"
|
| 15 |
+
field :bounces, Ch, type: "UInt32"
|
| 16 |
+
end
|
| 17 |
+
end
|
lib/plausible/imported/google_analytics4.ex
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.GoogleAnalytics4 do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Import implementation for Google Analytics 4.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
use Plausible.Imported.Importer
|
| 7 |
+
|
| 8 |
+
alias Plausible.Imported
|
| 9 |
+
alias Plausible.Repo
|
| 10 |
+
|
| 11 |
+
@recoverable_errors [:rate_limit_exceeded, :socket_failed, :server_failed]
|
| 12 |
+
@missing_values ["(none)", "(not set)", "(not provided)", "(other)"]
|
| 13 |
+
|
| 14 |
+
@impl true
|
| 15 |
+
def name(), do: :google_analytics_4
|
| 16 |
+
|
| 17 |
+
@impl true
|
| 18 |
+
def label(), do: "Google Analytics 4"
|
| 19 |
+
|
| 20 |
+
@impl true
|
| 21 |
+
def email_template(), do: "google_analytics_import.html"
|
| 22 |
+
|
| 23 |
+
@impl true
|
| 24 |
+
def before_start(site_import, opts) do
|
| 25 |
+
site_import = Repo.preload(site_import, :site)
|
| 26 |
+
|
| 27 |
+
if import_id = Keyword.get(opts, :resume_from_import_id) do
|
| 28 |
+
if existing_site_import = Imported.get_import(site_import.site, import_id) do
|
| 29 |
+
Repo.delete!(site_import)
|
| 30 |
+
{:ok, existing_site_import}
|
| 31 |
+
else
|
| 32 |
+
# NOTE: shouldn't happen under normal circumsatnces
|
| 33 |
+
{:error, {:no_import_to_resume, import_id}}
|
| 34 |
+
end
|
| 35 |
+
else
|
| 36 |
+
{:ok, site_import}
|
| 37 |
+
end
|
| 38 |
+
end
|
| 39 |
+
|
| 40 |
+
@impl true
|
| 41 |
+
def parse_args(%{"resume_from_dataset" => dataset, "resume_from_offset" => offset} = args) do
|
| 42 |
+
args
|
| 43 |
+
|> Map.drop(["resume_from_dataset", "resume_from_offset"])
|
| 44 |
+
|> parse_args()
|
| 45 |
+
|> Keyword.put(:dataset, dataset)
|
| 46 |
+
|> Keyword.put(:offset, offset)
|
| 47 |
+
end
|
| 48 |
+
|
| 49 |
+
def parse_args(
|
| 50 |
+
%{"property" => property, "start_date" => start_date, "end_date" => end_date} = args
|
| 51 |
+
) do
|
| 52 |
+
start_date = Date.from_iso8601!(start_date)
|
| 53 |
+
end_date = Date.from_iso8601!(end_date)
|
| 54 |
+
date_range = Date.range(start_date, end_date)
|
| 55 |
+
|
| 56 |
+
auth = {
|
| 57 |
+
Map.fetch!(args, "access_token"),
|
| 58 |
+
Map.fetch!(args, "refresh_token"),
|
| 59 |
+
Map.fetch!(args, "token_expires_at")
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
[
|
| 63 |
+
property: property,
|
| 64 |
+
date_range: date_range,
|
| 65 |
+
auth: auth
|
| 66 |
+
]
|
| 67 |
+
end
|
| 68 |
+
|
| 69 |
+
@doc """
|
| 70 |
+
Imports stats from a Google Analytics 4 property to a Plausible site.
|
| 71 |
+
|
| 72 |
+
This function fetches Google Analytics 4 reports which are then passed in batches
|
| 73 |
+
to Clickhouse by the `Plausible.Imported.Buffer` process.
|
| 74 |
+
"""
|
| 75 |
+
@impl true
|
| 76 |
+
def import_data(site_import, opts) do
|
| 77 |
+
date_range = Keyword.fetch!(opts, :date_range)
|
| 78 |
+
property = Keyword.fetch!(opts, :property)
|
| 79 |
+
auth = Keyword.fetch!(opts, :auth)
|
| 80 |
+
flush_interval_ms = Keyword.get(opts, :flush_interval_ms, 1000)
|
| 81 |
+
|
| 82 |
+
{:ok, buffer} = Plausible.Imported.Buffer.start_link(flush_interval_ms: flush_interval_ms)
|
| 83 |
+
|
| 84 |
+
persist_fn = fn table, rows ->
|
| 85 |
+
records = from_report(rows, site_import.site_id, site_import.id, table)
|
| 86 |
+
Plausible.Imported.Buffer.insert_many(buffer, table, records)
|
| 87 |
+
end
|
| 88 |
+
|
| 89 |
+
resume_opts = Keyword.take(opts, [:dataset, :offset])
|
| 90 |
+
fetch_opts = Keyword.get(opts, :fetch_opts, [])
|
| 91 |
+
|
| 92 |
+
try do
|
| 93 |
+
result =
|
| 94 |
+
Plausible.Google.GA4.API.import_analytics(date_range, property, auth,
|
| 95 |
+
persist_fn: persist_fn,
|
| 96 |
+
fetch_opts: fetch_opts,
|
| 97 |
+
resume_opts: resume_opts
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
case result do
|
| 101 |
+
{:error, {error, details}} when error in @recoverable_errors ->
|
| 102 |
+
site_import = Repo.preload(site_import, [:site, :imported_by])
|
| 103 |
+
dataset = Keyword.fetch!(details, :dataset)
|
| 104 |
+
offset = Keyword.fetch!(details, :offset)
|
| 105 |
+
{access_token, refresh_token, token_expires_at} = auth
|
| 106 |
+
|
| 107 |
+
resume_import_opts = [
|
| 108 |
+
property: property,
|
| 109 |
+
label: property,
|
| 110 |
+
start_date: date_range.first,
|
| 111 |
+
end_date: date_range.last,
|
| 112 |
+
access_token: access_token,
|
| 113 |
+
refresh_token: refresh_token,
|
| 114 |
+
token_expires_at: token_expires_at,
|
| 115 |
+
resume_from_import_id: site_import.id,
|
| 116 |
+
resume_from_dataset: dataset,
|
| 117 |
+
resume_from_offset: offset,
|
| 118 |
+
job_opts: [schedule_in: {65, :minutes}, unique: nil]
|
| 119 |
+
]
|
| 120 |
+
|
| 121 |
+
new_import(
|
| 122 |
+
site_import.site,
|
| 123 |
+
site_import.imported_by,
|
| 124 |
+
resume_import_opts
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
{:error, error, skip_purge?: true, skip_mark_failed?: true}
|
| 128 |
+
|
| 129 |
+
other ->
|
| 130 |
+
other
|
| 131 |
+
end
|
| 132 |
+
after
|
| 133 |
+
Plausible.Imported.Buffer.flush(buffer)
|
| 134 |
+
Plausible.Imported.Buffer.stop(buffer)
|
| 135 |
+
end
|
| 136 |
+
end
|
| 137 |
+
|
| 138 |
+
def from_report(nil, _site_id, _import_id, _metric), do: nil
|
| 139 |
+
|
| 140 |
+
def from_report(data, site_id, import_id, table) do
|
| 141 |
+
Enum.reduce(data, [], fn row, acc ->
|
| 142 |
+
if Map.get(row.dimensions, "date") in @missing_values do
|
| 143 |
+
acc
|
| 144 |
+
else
|
| 145 |
+
[new_from_report(site_id, import_id, table, row) | acc]
|
| 146 |
+
end
|
| 147 |
+
end)
|
| 148 |
+
end
|
| 149 |
+
|
| 150 |
+
defp parse_number(nr) do
|
| 151 |
+
{float, ""} = Float.parse(nr)
|
| 152 |
+
round(float)
|
| 153 |
+
end
|
| 154 |
+
|
| 155 |
+
defp maybe_override_event_name("file_download"), do: "File Download"
|
| 156 |
+
defp maybe_override_event_name("click"), do: "Outbound Link: Click"
|
| 157 |
+
defp maybe_override_event_name(name), do: name
|
| 158 |
+
|
| 159 |
+
defp new_from_report(site_id, import_id, "imported_visitors", row) do
|
| 160 |
+
%{
|
| 161 |
+
site_id: site_id,
|
| 162 |
+
import_id: import_id,
|
| 163 |
+
date: get_date(row),
|
| 164 |
+
visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(),
|
| 165 |
+
pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(),
|
| 166 |
+
bounces: row.metrics |> Map.fetch!("bounces") |> parse_number(),
|
| 167 |
+
visits: row.metrics |> Map.fetch!("sessions") |> parse_number(),
|
| 168 |
+
visit_duration: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number()
|
| 169 |
+
}
|
| 170 |
+
end
|
| 171 |
+
|
| 172 |
+
defp new_from_report(site_id, import_id, "imported_sources", row) do
|
| 173 |
+
%{
|
| 174 |
+
site_id: site_id,
|
| 175 |
+
import_id: import_id,
|
| 176 |
+
date: get_date(row),
|
| 177 |
+
source: row.dimensions |> Map.fetch!("sessionSource") |> parse_source(),
|
| 178 |
+
# GA4 channels map 1-1 to Plausible channels
|
| 179 |
+
channel: row.dimensions |> Map.fetch!("sessionDefaultChannelGroup"),
|
| 180 |
+
referrer: nil,
|
| 181 |
+
# Only `source` exists in GA4 API
|
| 182 |
+
utm_source: nil,
|
| 183 |
+
utm_medium: row.dimensions |> Map.fetch!("sessionMedium") |> default_if_missing(),
|
| 184 |
+
utm_campaign: row.dimensions |> Map.fetch!("sessionCampaignName") |> default_if_missing(),
|
| 185 |
+
utm_content: row.dimensions |> Map.fetch!("sessionManualAdContent") |> default_if_missing(),
|
| 186 |
+
utm_term: row.dimensions |> Map.fetch!("sessionGoogleAdsKeyword") |> default_if_missing(),
|
| 187 |
+
visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(),
|
| 188 |
+
visits: row.metrics |> Map.fetch!("sessions") |> parse_number(),
|
| 189 |
+
pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(),
|
| 190 |
+
bounces: row.metrics |> Map.fetch!("bounces") |> parse_number(),
|
| 191 |
+
visit_duration: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number()
|
| 192 |
+
}
|
| 193 |
+
end
|
| 194 |
+
|
| 195 |
+
defp new_from_report(site_id, import_id, "imported_pages", row) do
|
| 196 |
+
%{
|
| 197 |
+
site_id: site_id,
|
| 198 |
+
import_id: import_id,
|
| 199 |
+
date: get_date(row),
|
| 200 |
+
hostname: row.dimensions |> Map.fetch!("hostName") |> String.replace_prefix("www.", ""),
|
| 201 |
+
page: row.dimensions |> Map.fetch!("pagePath") |> URI.parse() |> Map.get(:path),
|
| 202 |
+
visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(),
|
| 203 |
+
visits: row.metrics |> Map.fetch!("sessions") |> parse_number(),
|
| 204 |
+
pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(),
|
| 205 |
+
# NOTE: no exits metric in GA4 API currently
|
| 206 |
+
exits: 0,
|
| 207 |
+
total_time_on_page: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number(),
|
| 208 |
+
total_time_on_page_visits: row.metrics |> Map.fetch!("sessions") |> parse_number()
|
| 209 |
+
}
|
| 210 |
+
end
|
| 211 |
+
|
| 212 |
+
defp new_from_report(site_id, import_id, "imported_entry_pages", row) do
|
| 213 |
+
%{
|
| 214 |
+
site_id: site_id,
|
| 215 |
+
import_id: import_id,
|
| 216 |
+
date: get_date(row),
|
| 217 |
+
entry_page: row.dimensions |> Map.fetch!("landingPage"),
|
| 218 |
+
visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(),
|
| 219 |
+
entrances: row.metrics |> Map.fetch!("sessions") |> parse_number(),
|
| 220 |
+
visit_duration: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number(),
|
| 221 |
+
pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(),
|
| 222 |
+
bounces: row.metrics |> Map.fetch!("bounces") |> parse_number()
|
| 223 |
+
}
|
| 224 |
+
end
|
| 225 |
+
|
| 226 |
+
# NOTE: no exit pages metrics in GA4 API available for now
|
| 227 |
+
# defp new_from_report(site_id, import_id, "imported_exit_pages", row) do
|
| 228 |
+
# %{
|
| 229 |
+
# site_id: site_id,
|
| 230 |
+
# import_id: import_id,
|
| 231 |
+
# date: get_date(row),
|
| 232 |
+
# exit_page: Map.fetch!(row.dimensions, "exitPage"),
|
| 233 |
+
# visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(),
|
| 234 |
+
# exits: row.metrics |> Map.fetch!("sessions") |> parse_number(),
|
| 235 |
+
# visit_duration: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number(),
|
| 236 |
+
# pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(),
|
| 237 |
+
# bounces: row.metrics |> Map.fetch!("bounces") |> parse_number()
|
| 238 |
+
# }
|
| 239 |
+
# end
|
| 240 |
+
|
| 241 |
+
defp new_from_report(site_id, import_id, "imported_custom_events", row) do
|
| 242 |
+
%{
|
| 243 |
+
site_id: site_id,
|
| 244 |
+
import_id: import_id,
|
| 245 |
+
date: get_date(row),
|
| 246 |
+
name: row.dimensions |> Map.fetch!("eventName") |> maybe_override_event_name(),
|
| 247 |
+
link_url: row.dimensions |> Map.fetch!("linkUrl"),
|
| 248 |
+
visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(),
|
| 249 |
+
events: row.metrics |> Map.fetch!("eventCount") |> parse_number()
|
| 250 |
+
}
|
| 251 |
+
end
|
| 252 |
+
|
| 253 |
+
defp new_from_report(site_id, import_id, "imported_locations", row) do
|
| 254 |
+
country_code = row.dimensions |> Map.fetch!("countryId") |> default_if_missing("")
|
| 255 |
+
city_name = row.dimensions |> Map.fetch!("city") |> default_if_missing("")
|
| 256 |
+
city_data = Location.get_city(city_name, country_code)
|
| 257 |
+
|
| 258 |
+
%{
|
| 259 |
+
site_id: site_id,
|
| 260 |
+
import_id: import_id,
|
| 261 |
+
date: get_date(row),
|
| 262 |
+
country: country_code,
|
| 263 |
+
region: row.dimensions |> Map.fetch!("region") |> default_if_missing(""),
|
| 264 |
+
city: city_data && city_data.id,
|
| 265 |
+
visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(),
|
| 266 |
+
visits: row.metrics |> Map.fetch!("sessions") |> parse_number(),
|
| 267 |
+
pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(),
|
| 268 |
+
bounces: row.metrics |> Map.fetch!("bounces") |> parse_number(),
|
| 269 |
+
visit_duration: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number()
|
| 270 |
+
}
|
| 271 |
+
end
|
| 272 |
+
|
| 273 |
+
defp new_from_report(site_id, import_id, "imported_devices", row) do
|
| 274 |
+
%{
|
| 275 |
+
site_id: site_id,
|
| 276 |
+
import_id: import_id,
|
| 277 |
+
date: get_date(row),
|
| 278 |
+
device: row.dimensions |> Map.fetch!("deviceCategory") |> String.capitalize(),
|
| 279 |
+
visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(),
|
| 280 |
+
visits: row.metrics |> Map.fetch!("sessions") |> parse_number(),
|
| 281 |
+
pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(),
|
| 282 |
+
bounces: row.metrics |> Map.fetch!("bounces") |> parse_number(),
|
| 283 |
+
visit_duration: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number()
|
| 284 |
+
}
|
| 285 |
+
end
|
| 286 |
+
|
| 287 |
+
@browser_google_to_plausible %{
|
| 288 |
+
"User-Agent:Opera" => "Opera",
|
| 289 |
+
"Mozilla Compatible Agent" => "Mobile App",
|
| 290 |
+
"Android Webview" => "Mobile App",
|
| 291 |
+
"Android Browser" => "Mobile App",
|
| 292 |
+
"Safari (in-app)" => "Mobile App",
|
| 293 |
+
"User-Agent: Mozilla" => "Firefox",
|
| 294 |
+
"(not set)" => ""
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
defp new_from_report(site_id, import_id, "imported_browsers", row) do
|
| 298 |
+
browser = Map.fetch!(row.dimensions, "browser")
|
| 299 |
+
|
| 300 |
+
%{
|
| 301 |
+
site_id: site_id,
|
| 302 |
+
import_id: import_id,
|
| 303 |
+
date: get_date(row),
|
| 304 |
+
browser: Map.get(@browser_google_to_plausible, browser, browser),
|
| 305 |
+
# Does not exist in GA4 API
|
| 306 |
+
browser_version: nil,
|
| 307 |
+
visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(),
|
| 308 |
+
visits: row.metrics |> Map.fetch!("sessions") |> parse_number(),
|
| 309 |
+
pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(),
|
| 310 |
+
bounces: row.metrics |> Map.fetch!("bounces") |> parse_number(),
|
| 311 |
+
visit_duration: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number()
|
| 312 |
+
}
|
| 313 |
+
end
|
| 314 |
+
|
| 315 |
+
@os_google_to_plausible %{
|
| 316 |
+
"Macintosh" => "Mac",
|
| 317 |
+
"Linux" => "GNU/Linux",
|
| 318 |
+
"(not set)" => ""
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
defp new_from_report(site_id, import_id, "imported_operating_systems", row) do
|
| 322 |
+
os = Map.fetch!(row.dimensions, "operatingSystem")
|
| 323 |
+
|
| 324 |
+
%{
|
| 325 |
+
site_id: site_id,
|
| 326 |
+
import_id: import_id,
|
| 327 |
+
date: get_date(row),
|
| 328 |
+
operating_system: Map.get(@os_google_to_plausible, os, os),
|
| 329 |
+
operating_system_version: row.dimensions |> Map.fetch!("operatingSystemVersion"),
|
| 330 |
+
visitors: row.metrics |> Map.fetch!("totalUsers") |> parse_number(),
|
| 331 |
+
visits: row.metrics |> Map.fetch!("sessions") |> parse_number(),
|
| 332 |
+
pageviews: row.metrics |> Map.fetch!("screenPageViews") |> parse_number(),
|
| 333 |
+
bounces: row.metrics |> Map.fetch!("bounces") |> parse_number(),
|
| 334 |
+
visit_duration: row.metrics |> Map.fetch!("userEngagementDuration") |> parse_number()
|
| 335 |
+
}
|
| 336 |
+
end
|
| 337 |
+
|
| 338 |
+
defp get_date(%{dimensions: %{"date" => date}}) do
|
| 339 |
+
date
|
| 340 |
+
|> Plausible.Times.parse!("%Y%m%d", :strftime)
|
| 341 |
+
|> NaiveDateTime.to_date()
|
| 342 |
+
end
|
| 343 |
+
|
| 344 |
+
defp default_if_missing(value, default \\ nil)
|
| 345 |
+
defp default_if_missing(value, default) when value in @missing_values, do: default
|
| 346 |
+
defp default_if_missing(value, _default), do: value
|
| 347 |
+
|
| 348 |
+
defp parse_source(nil), do: nil
|
| 349 |
+
defp parse_source("(direct)"), do: nil
|
| 350 |
+
defp parse_source("google"), do: "Google"
|
| 351 |
+
defp parse_source("bing"), do: "Bing"
|
| 352 |
+
defp parse_source("duckduckgo"), do: "DuckDuckGo"
|
| 353 |
+
|
| 354 |
+
defp parse_source(ref) do
|
| 355 |
+
Plausible.Ingestion.Source.from_referrer("https://" <> ref)
|
| 356 |
+
end
|
| 357 |
+
end
|
lib/plausible/imported/import_sources.ex
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.ImportSources do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Definitions of import sources.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
@sources [
|
| 7 |
+
Plausible.Imported.GoogleAnalytics4,
|
| 8 |
+
Plausible.Imported.UniversalAnalytics,
|
| 9 |
+
Plausible.Imported.NoopImporter,
|
| 10 |
+
Plausible.Imported.CSVImporter
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
@sources_map Map.new(@sources, &{&1.name(), &1})
|
| 14 |
+
|
| 15 |
+
@source_names Enum.map(@sources, & &1.name())
|
| 16 |
+
|
| 17 |
+
@spec names() :: [atom()]
|
| 18 |
+
def names(), do: @source_names
|
| 19 |
+
|
| 20 |
+
@spec by_name(atom()) :: module()
|
| 21 |
+
def by_name(name) do
|
| 22 |
+
Map.fetch!(@sources_map, name)
|
| 23 |
+
end
|
| 24 |
+
end
|
lib/plausible/imported/importer.ex
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.Importer do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Behaviour that should be implemented for each import source.
|
| 4 |
+
|
| 5 |
+
All imports are executed as background jobs run via `Plausible.Workers.ImportAnalytics`
|
| 6 |
+
Oban worker. Each import source must define a module conforming `Importer` behaviour.
|
| 7 |
+
|
| 8 |
+
The callbacks that need to be implemented:
|
| 9 |
+
|
| 10 |
+
* `name/0` - Returns import source name as an atom. Example: `:universal_analytics`.
|
| 11 |
+
* `label/0` - Descriptive, display friendly name of the source.
|
| 12 |
+
Example: "Google Analytics".
|
| 13 |
+
* `email_template/0` - Name of the email template to use for notifications in
|
| 14 |
+
`PlausibleWeb.Email` (`import_success` and `import_failure`). The template
|
| 15 |
+
should have content customized for a particular source.
|
| 16 |
+
* `parse_args/1` - Receives Oban job arguments coming from `new_import/3`. Whatever
|
| 17 |
+
options were passed to `new_import/3` will be present in the input map with string
|
| 18 |
+
keys and values serialized to primitives. If, for instance `start_date: ~D[2024-01-03]`
|
| 19 |
+
is passed as an option, `parse_args/1` receives `%{..., "start_date" => "2024-01-03"}`.
|
| 20 |
+
The expectation is parsing the map values producing a keyword list of options to
|
| 21 |
+
pass to `import_data/2`.
|
| 22 |
+
* `import_data/2` - Receives site import struct and options produced by `parse_args/1`.
|
| 23 |
+
This is where all the import processing is done. The way the import is implemented
|
| 24 |
+
is entirely arbitrary except the requirement that the process as a whole must
|
| 25 |
+
by synchronous. The callback is expected to return either `:ok` or `{:ok, %{...}}`
|
| 26 |
+
on successful import or `{:error, ...}` on failure. The map in success tuple is
|
| 27 |
+
used for updating site import struct and is passed to `on_success/2` callback.
|
| 28 |
+
Please note that error tuple should be only returned on errors that can't be
|
| 29 |
+
recovered from. For transient errors, the import should throw an exception or
|
| 30 |
+
simply crash. The error tuple has an alternative `{error, reason, opts}` form,
|
| 31 |
+
where `opts` allow to skip purging imported data so far via `skip_purge?` flag
|
| 32 |
+
and skip marking the import as failed and notifying the user via `skip_mark_failed?`
|
| 33 |
+
flag. Both flags are booleans.
|
| 34 |
+
* `before_start/2` - Optional callback run right before scheduling import job. It's
|
| 35 |
+
expected to either return `{:ok, site_import}` for the import to proceed
|
| 36 |
+
or `{:error, ...}` tuple, which will be returned from `new_import/3` call.
|
| 37 |
+
The `site_import` can be altered or replaced at this stage. The second argument
|
| 38 |
+
are opts passed to `new_import/3`.
|
| 39 |
+
* `on_success/2` - Optional callback run once site import is completed. Receives map
|
| 40 |
+
returned from `import_data/2`. Expected to always return `:ok`.
|
| 41 |
+
* `on_failure/1` - Optional callback run when import job fails permanently.
|
| 42 |
+
|
| 43 |
+
All sources must be added to the list in `Plausible.Imported.ImportSources`.
|
| 44 |
+
|
| 45 |
+
In order to schedule a new import job using a given source, respective importer's
|
| 46 |
+
`new_import/3` function must be called. It accepts site, user who is doing the import
|
| 47 |
+
and any options necessary to carry out the import.
|
| 48 |
+
|
| 49 |
+
There's an expectation that `start_date` and `end_date` are provided either as options
|
| 50 |
+
passed to `new_import/3` or data in map returned from `import_data/2`. If these parameters
|
| 51 |
+
are not provided, the import will eventually crash. These parameters define time range
|
| 52 |
+
of imported data which is in turn used for efficient querying.
|
| 53 |
+
|
| 54 |
+
Logic running inside `import_data/2` is expected to populated all `imported_*` tables
|
| 55 |
+
in ClickHouse with `import_id` column set to site import's ID.
|
| 56 |
+
|
| 57 |
+
Managing any configuration or authentication prior to running import is outside of
|
| 58 |
+
scope of importer logic and is expected to be implemented separately.
|
| 59 |
+
|
| 60 |
+
## Running import fully synchronously
|
| 61 |
+
|
| 62 |
+
In case it's necessary to run the whole import job fully synchronously, the
|
| 63 |
+
`Plausible.Workers.ImportAnalytics` worker sends an `Oban.Notifier` message
|
| 64 |
+
on completion, failure or transient failure of the import.
|
| 65 |
+
|
| 66 |
+
A basic usage scenario looks like this:
|
| 67 |
+
|
| 68 |
+
```elixir
|
| 69 |
+
{:ok, job} = Plausible.Imported.NoopImporter.new_import(
|
| 70 |
+
site,
|
| 71 |
+
user,
|
| 72 |
+
start_date: ~D[2005-01-01],
|
| 73 |
+
end_date: Date.utc_today(),
|
| 74 |
+
# this option is necessary to setup the calling process as listener
|
| 75 |
+
listen?: true
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
import_id = job.args[:import_id]
|
| 79 |
+
|
| 80 |
+
receive do
|
| 81 |
+
{:notification, :analytics_imports_jobs, %{"event" => "complete", "import_id" => ^import_id}} ->
|
| 82 |
+
IO.puts("Job completed")
|
| 83 |
+
|
| 84 |
+
{:notification, :analytics_imports_jobs, %{"event" => "transient_fail", "import_id" => ^import_id}} ->
|
| 85 |
+
IO.puts("Job failed transiently")
|
| 86 |
+
|
| 87 |
+
{:notification, :analytics_imports_jobs, %{"event" => "fail", "import_id" => ^import_id}} ->
|
| 88 |
+
IO.puts("Job failed permanently")
|
| 89 |
+
after
|
| 90 |
+
15_000 ->
|
| 91 |
+
IO.puts("Job didn't finish in 15 seconds")
|
| 92 |
+
end
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
In a more realistic scenario, job scheduling will be done inside a GenServer process
|
| 96 |
+
like LiveView, where notifications can be listened for via `handle_info/2`.
|
| 97 |
+
"""
|
| 98 |
+
|
| 99 |
+
alias Plausible.Imported
|
| 100 |
+
alias Plausible.Imported.SiteImport
|
| 101 |
+
alias Plausible.Repo
|
| 102 |
+
|
| 103 |
+
@callback name() :: atom()
|
| 104 |
+
@callback label() :: String.t()
|
| 105 |
+
@callback email_template() :: String.t()
|
| 106 |
+
@callback parse_args(map()) :: Keyword.t()
|
| 107 |
+
@callback import_data(SiteImport.t(), Keyword.t()) ::
|
| 108 |
+
:ok | {:error, any()} | {:error, any(), Keyword.t()}
|
| 109 |
+
@callback before_start(SiteImport.t(), Keyword.t()) :: {:ok, SiteImport.t()} | {:error, any()}
|
| 110 |
+
@callback on_success(SiteImport.t(), map()) :: :ok
|
| 111 |
+
@callback on_failure(SiteImport.t()) :: :ok
|
| 112 |
+
|
| 113 |
+
defmacro __using__(_opts) do
|
| 114 |
+
quote do
|
| 115 |
+
@behaviour Plausible.Imported.Importer
|
| 116 |
+
|
| 117 |
+
@spec new_import(Plausible.Site.t(), Plausible.Auth.User.t(), Keyword.t()) ::
|
| 118 |
+
{:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t() | :import_in_progress | any()}
|
| 119 |
+
def new_import(site, user, opts) do
|
| 120 |
+
Plausible.Imported.Importer.new_import(name(), site, user, opts, &before_start/2)
|
| 121 |
+
end
|
| 122 |
+
|
| 123 |
+
@doc false
|
| 124 |
+
@spec run_import(SiteImport.t(), map()) :: {:ok, SiteImport.t()} | {:error, :any}
|
| 125 |
+
def run_import(site_import, args) do
|
| 126 |
+
Plausible.Imported.Importer.run_import(
|
| 127 |
+
site_import,
|
| 128 |
+
args,
|
| 129 |
+
&parse_args/1,
|
| 130 |
+
&import_data/2,
|
| 131 |
+
&on_success/2
|
| 132 |
+
)
|
| 133 |
+
end
|
| 134 |
+
|
| 135 |
+
@doc false
|
| 136 |
+
@spec mark_failed(SiteImport.t()) :: SiteImport.t()
|
| 137 |
+
def mark_failed(site_import) do
|
| 138 |
+
site_import =
|
| 139 |
+
site_import
|
| 140 |
+
|> SiteImport.fail_changeset()
|
| 141 |
+
|> Repo.update!()
|
| 142 |
+
|
| 143 |
+
:ok = on_failure(site_import)
|
| 144 |
+
|
| 145 |
+
site_import
|
| 146 |
+
end
|
| 147 |
+
|
| 148 |
+
@impl true
|
| 149 |
+
def before_start(site_import, _opts), do: {:ok, site_import}
|
| 150 |
+
|
| 151 |
+
@impl true
|
| 152 |
+
def on_success(_site_import, _extra_data), do: :ok
|
| 153 |
+
|
| 154 |
+
@impl true
|
| 155 |
+
def on_failure(_site_import), do: :ok
|
| 156 |
+
|
| 157 |
+
defoverridable before_start: 2, on_success: 2, on_failure: 1
|
| 158 |
+
end
|
| 159 |
+
end
|
| 160 |
+
|
| 161 |
+
@doc false
|
| 162 |
+
def new_import(source, site, user, opts, before_start_fun) do
|
| 163 |
+
import_params =
|
| 164 |
+
opts
|
| 165 |
+
|> Keyword.take([:label, :start_date, :end_date, :legacy])
|
| 166 |
+
|> Keyword.put(:source, source)
|
| 167 |
+
|> Map.new()
|
| 168 |
+
|
| 169 |
+
Repo.transaction(fn ->
|
| 170 |
+
result =
|
| 171 |
+
site
|
| 172 |
+
|> SiteImport.create_changeset(user, import_params)
|
| 173 |
+
|> Repo.insert()
|
| 174 |
+
|
| 175 |
+
with {:ok, site_import} <- result,
|
| 176 |
+
{:ok, site_import} <- before_start_fun.(site_import, opts),
|
| 177 |
+
{:ok, job} <- schedule_job(site_import, opts) do
|
| 178 |
+
job
|
| 179 |
+
else
|
| 180 |
+
{:error, error} ->
|
| 181 |
+
Repo.rollback(error)
|
| 182 |
+
end
|
| 183 |
+
end)
|
| 184 |
+
end
|
| 185 |
+
|
| 186 |
+
@doc false
|
| 187 |
+
def run_import(site_import, args, parse_args_fun, import_fun, on_success_fun) do
|
| 188 |
+
site_import =
|
| 189 |
+
site_import
|
| 190 |
+
|> SiteImport.start_changeset()
|
| 191 |
+
|> Repo.update!()
|
| 192 |
+
|> Repo.preload(:site)
|
| 193 |
+
|
| 194 |
+
import_opts = parse_args_fun.(args)
|
| 195 |
+
|
| 196 |
+
case import_fun.(site_import, import_opts) do
|
| 197 |
+
:ok ->
|
| 198 |
+
{:ok, mark_complete(site_import, %{}, on_success_fun)}
|
| 199 |
+
|
| 200 |
+
{:ok, extra_data} ->
|
| 201 |
+
{:ok, mark_complete(site_import, extra_data, on_success_fun)}
|
| 202 |
+
|
| 203 |
+
{:error, error} ->
|
| 204 |
+
{:error, error, []}
|
| 205 |
+
|
| 206 |
+
{:error, error, opts} ->
|
| 207 |
+
{:error, error, opts}
|
| 208 |
+
end
|
| 209 |
+
end
|
| 210 |
+
|
| 211 |
+
@oban_channel :analytics_imports_jobs
|
| 212 |
+
|
| 213 |
+
@doc false
|
| 214 |
+
def notify(site_import, event) do
|
| 215 |
+
Oban.Notifier.notify(Oban, @oban_channel, %{
|
| 216 |
+
"event" => event,
|
| 217 |
+
"import_id" => site_import.id,
|
| 218 |
+
"site_id" => site_import.site_id
|
| 219 |
+
})
|
| 220 |
+
end
|
| 221 |
+
|
| 222 |
+
@doc """
|
| 223 |
+
Allows to explicitly start listening for importer job notifications.
|
| 224 |
+
|
| 225 |
+
Listener must explicitly filter out a subset of imports that apply to the given context.
|
| 226 |
+
"""
|
| 227 |
+
@spec listen() :: :ok
|
| 228 |
+
def listen() do
|
| 229 |
+
:ok = Oban.Notifier.listen([@oban_channel])
|
| 230 |
+
end
|
| 231 |
+
|
| 232 |
+
defp schedule_job(site_import, opts) do
|
| 233 |
+
{listen?, opts} = Keyword.pop(opts, :listen?, false)
|
| 234 |
+
{job_opts, opts} = Keyword.pop(opts, :job_opts, [])
|
| 235 |
+
|
| 236 |
+
if listen? do
|
| 237 |
+
:ok = listen()
|
| 238 |
+
end
|
| 239 |
+
|
| 240 |
+
if not Imported.other_imports_in_progress?(site_import) do
|
| 241 |
+
opts
|
| 242 |
+
|> Keyword.put(:import_id, site_import.id)
|
| 243 |
+
|> Map.new()
|
| 244 |
+
|> Plausible.Workers.ImportAnalytics.new(job_opts)
|
| 245 |
+
|> Oban.insert()
|
| 246 |
+
else
|
| 247 |
+
{:error, :import_in_progress}
|
| 248 |
+
end
|
| 249 |
+
end
|
| 250 |
+
|
| 251 |
+
defp mark_complete(site_import, extra_data, on_success_fun) do
|
| 252 |
+
site_import =
|
| 253 |
+
site_import
|
| 254 |
+
|> SiteImport.complete_changeset(extra_data)
|
| 255 |
+
|> Repo.update!()
|
| 256 |
+
|
| 257 |
+
:ok = on_success_fun.(site_import, extra_data)
|
| 258 |
+
|
| 259 |
+
site_import
|
| 260 |
+
end
|
| 261 |
+
end
|
lib/plausible/imported/location.ex
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.Location do
|
| 2 |
+
@moduledoc false
|
| 3 |
+
use Ecto.Schema
|
| 4 |
+
|
| 5 |
+
@primary_key false
|
| 6 |
+
schema "imported_locations" do
|
| 7 |
+
field :site_id, Ch, type: "UInt64"
|
| 8 |
+
field :import_id, Ch, type: "UInt64"
|
| 9 |
+
field :date, :date
|
| 10 |
+
field :country, :string
|
| 11 |
+
field :region, :string
|
| 12 |
+
field :city, Ch, type: "UInt64"
|
| 13 |
+
field :visitors, Ch, type: "UInt64"
|
| 14 |
+
field :visits, Ch, type: "UInt64"
|
| 15 |
+
field :visit_duration, Ch, type: "UInt64"
|
| 16 |
+
field :pageviews, Ch, type: "UInt64"
|
| 17 |
+
field :bounces, Ch, type: "UInt32"
|
| 18 |
+
end
|
| 19 |
+
end
|
lib/plausible/imported/noop_importer.ex
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.NoopImporter do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Stub import implementation.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
use Plausible.Imported.Importer
|
| 7 |
+
|
| 8 |
+
@impl true
|
| 9 |
+
def name(), do: :noop
|
| 10 |
+
|
| 11 |
+
@impl true
|
| 12 |
+
def label(), do: "Noop"
|
| 13 |
+
|
| 14 |
+
# reusing existing template from another source
|
| 15 |
+
@impl true
|
| 16 |
+
def email_template(), do: "google_analytics_import.html"
|
| 17 |
+
|
| 18 |
+
@impl true
|
| 19 |
+
def parse_args(opts), do: opts
|
| 20 |
+
|
| 21 |
+
@impl true
|
| 22 |
+
def import_data(_site_import, %{"error" => true}), do: {:error, "Something went wrong"}
|
| 23 |
+
def import_data(_site_import, %{"crash" => true}), do: raise("boom")
|
| 24 |
+
def import_data(_site_import, _opts), do: :ok
|
| 25 |
+
|
| 26 |
+
@impl true
|
| 27 |
+
def before_start(site_import, _opts) do
|
| 28 |
+
send(self(), {:before_start, site_import.id})
|
| 29 |
+
|
| 30 |
+
{:ok, site_import}
|
| 31 |
+
end
|
| 32 |
+
|
| 33 |
+
@impl true
|
| 34 |
+
def on_success(site_import, _extra_data) do
|
| 35 |
+
send(self(), {:on_success, site_import.id})
|
| 36 |
+
|
| 37 |
+
:ok
|
| 38 |
+
end
|
| 39 |
+
|
| 40 |
+
@impl true
|
| 41 |
+
def on_failure(site_import) do
|
| 42 |
+
send(self(), {:on_failure, site_import.id})
|
| 43 |
+
|
| 44 |
+
:ok
|
| 45 |
+
end
|
| 46 |
+
end
|
lib/plausible/imported/operating_system.ex
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.OperatingSystem do
|
| 2 |
+
@moduledoc false
|
| 3 |
+
use Ecto.Schema
|
| 4 |
+
|
| 5 |
+
@primary_key false
|
| 6 |
+
schema "imported_operating_systems" do
|
| 7 |
+
field :site_id, Ch, type: "UInt64"
|
| 8 |
+
field :import_id, Ch, type: "UInt64"
|
| 9 |
+
field :date, :date
|
| 10 |
+
field :operating_system, :string
|
| 11 |
+
field :operating_system_version, :string
|
| 12 |
+
field :visitors, Ch, type: "UInt64"
|
| 13 |
+
field :visits, Ch, type: "UInt64"
|
| 14 |
+
field :visit_duration, Ch, type: "UInt64"
|
| 15 |
+
field :pageviews, Ch, type: "UInt64"
|
| 16 |
+
field :bounces, Ch, type: "UInt32"
|
| 17 |
+
end
|
| 18 |
+
end
|
lib/plausible/imported/page.ex
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.Page do
|
| 2 |
+
@moduledoc false
|
| 3 |
+
use Ecto.Schema
|
| 4 |
+
|
| 5 |
+
@primary_key false
|
| 6 |
+
schema "imported_pages" do
|
| 7 |
+
field :site_id, Ch, type: "UInt64"
|
| 8 |
+
field :import_id, Ch, type: "UInt64"
|
| 9 |
+
field :date, :date
|
| 10 |
+
field :hostname, :string
|
| 11 |
+
field :page, :string
|
| 12 |
+
field :visits, Ch, type: "UInt64"
|
| 13 |
+
field :visitors, Ch, type: "UInt64"
|
| 14 |
+
field :pageviews, Ch, type: "UInt64"
|
| 15 |
+
field :exits, Ch, type: "UInt64"
|
| 16 |
+
field :total_scroll_depth, Ch, type: "UInt64"
|
| 17 |
+
field :total_scroll_depth_visits, Ch, type: "UInt64"
|
| 18 |
+
field :total_time_on_page, Ch, type: "UInt64"
|
| 19 |
+
field :total_time_on_page_visits, Ch, type: "UInt64"
|
| 20 |
+
end
|
| 21 |
+
end
|
lib/plausible/imported/site_import.ex
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.SiteImport do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Site import schema.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
use Ecto.Schema
|
| 7 |
+
|
| 8 |
+
import Ecto.Changeset
|
| 9 |
+
|
| 10 |
+
alias Plausible.Auth.User
|
| 11 |
+
alias Plausible.Imported.ImportSources
|
| 12 |
+
alias Plausible.Site
|
| 13 |
+
|
| 14 |
+
@statuses [:pending, :importing, :completed, :failed]
|
| 15 |
+
|
| 16 |
+
@type t() :: %__MODULE__{}
|
| 17 |
+
|
| 18 |
+
schema "site_imports" do
|
| 19 |
+
field :start_date, :date
|
| 20 |
+
field :end_date, :date
|
| 21 |
+
field :label, :string
|
| 22 |
+
field :source, Ecto.Enum, values: ImportSources.names()
|
| 23 |
+
field :status, Ecto.Enum, values: @statuses
|
| 24 |
+
field :legacy, :boolean, default: false
|
| 25 |
+
field :has_scroll_depth, :boolean, default: false
|
| 26 |
+
|
| 27 |
+
belongs_to :site, Site
|
| 28 |
+
belongs_to :imported_by, User
|
| 29 |
+
|
| 30 |
+
timestamps()
|
| 31 |
+
end
|
| 32 |
+
|
| 33 |
+
for status <- @statuses do
|
| 34 |
+
defmacro unquote(status)(), do: unquote(status)
|
| 35 |
+
end
|
| 36 |
+
|
| 37 |
+
@spec label(t()) :: String.t()
|
| 38 |
+
def label(%{source: source, label: label}) do
|
| 39 |
+
build_label(ImportSources.by_name(source).label(), label)
|
| 40 |
+
end
|
| 41 |
+
|
| 42 |
+
@spec create_changeset(Site.t(), User.t(), map()) :: Ecto.Changeset.t()
|
| 43 |
+
def create_changeset(site, user, params) do
|
| 44 |
+
%__MODULE__{}
|
| 45 |
+
|> cast(params, [:label, :source, :start_date, :end_date, :legacy])
|
| 46 |
+
|> validate_required([:source])
|
| 47 |
+
|> put_assoc(:site, site)
|
| 48 |
+
|> put_assoc(:imported_by, user)
|
| 49 |
+
|> put_change(:status, pending())
|
| 50 |
+
end
|
| 51 |
+
|
| 52 |
+
@spec start_changeset(t()) :: Ecto.Changeset.t()
|
| 53 |
+
def start_changeset(site_import) do
|
| 54 |
+
site_import
|
| 55 |
+
|> change(status: importing())
|
| 56 |
+
end
|
| 57 |
+
|
| 58 |
+
@spec complete_changeset(t(), map()) :: Ecto.Changeset.t()
|
| 59 |
+
def complete_changeset(site_import, params \\ %{}) do
|
| 60 |
+
site_import
|
| 61 |
+
|> cast(params, [:start_date, :end_date])
|
| 62 |
+
|> put_change(:status, completed())
|
| 63 |
+
|> validate_required([:start_date, :end_date])
|
| 64 |
+
end
|
| 65 |
+
|
| 66 |
+
@spec fail_changeset(t()) :: Ecto.Changeset.t()
|
| 67 |
+
def fail_changeset(site_import) do
|
| 68 |
+
change(site_import, status: failed())
|
| 69 |
+
end
|
| 70 |
+
|
| 71 |
+
defp build_label(source, nil), do: source
|
| 72 |
+
defp build_label(source, label), do: "#{source} (#{label})"
|
| 73 |
+
end
|
lib/plausible/imported/source.ex
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.Source do
|
| 2 |
+
@moduledoc false
|
| 3 |
+
use Ecto.Schema
|
| 4 |
+
|
| 5 |
+
@primary_key false
|
| 6 |
+
schema "imported_sources" do
|
| 7 |
+
field :site_id, Ch, type: "UInt64"
|
| 8 |
+
field :import_id, Ch, type: "UInt64"
|
| 9 |
+
field :date, :date
|
| 10 |
+
field :source, :string
|
| 11 |
+
field :channel, Ch, type: "LowCardinality(String)"
|
| 12 |
+
field :referrer, :string
|
| 13 |
+
field :utm_source, :string
|
| 14 |
+
field :utm_medium, :string
|
| 15 |
+
field :utm_campaign, :string
|
| 16 |
+
field :utm_content, :string
|
| 17 |
+
field :utm_term, :string
|
| 18 |
+
field :visitors, Ch, type: "UInt64"
|
| 19 |
+
field :visits, Ch, type: "UInt64"
|
| 20 |
+
field :visit_duration, Ch, type: "UInt64"
|
| 21 |
+
field :pageviews, Ch, type: "UInt64"
|
| 22 |
+
field :bounces, Ch, type: "UInt32"
|
| 23 |
+
end
|
| 24 |
+
end
|
lib/plausible/imported/universal_analytics.ex
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.UniversalAnalytics do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Import implementation for Universal Analytics.
|
| 4 |
+
|
| 5 |
+
NOTE: As importing from UA is no longer supported, this module
|
| 6 |
+
is only used to support rendering existing imports.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
use Plausible.Imported.Importer
|
| 10 |
+
|
| 11 |
+
@impl true
|
| 12 |
+
def name(), do: :universal_analytics
|
| 13 |
+
|
| 14 |
+
@impl true
|
| 15 |
+
def label(), do: "Google Analytics"
|
| 16 |
+
|
| 17 |
+
@impl true
|
| 18 |
+
def email_template(), do: "google_analytics_import.html"
|
| 19 |
+
|
| 20 |
+
@impl true
|
| 21 |
+
def parse_args(_args) do
|
| 22 |
+
raise "Importing data not supported"
|
| 23 |
+
end
|
| 24 |
+
|
| 25 |
+
@impl true
|
| 26 |
+
def import_data(_site_import, _opts) do
|
| 27 |
+
raise "Importing data not supported"
|
| 28 |
+
end
|
| 29 |
+
end
|
lib/plausible/imported/visitor.ex
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Imported.Visitor do
|
| 2 |
+
@moduledoc false
|
| 3 |
+
use Ecto.Schema
|
| 4 |
+
|
| 5 |
+
@primary_key false
|
| 6 |
+
schema "imported_visitors" do
|
| 7 |
+
field :site_id, Ch, type: "UInt64"
|
| 8 |
+
field :import_id, Ch, type: "UInt64"
|
| 9 |
+
field :date, :date
|
| 10 |
+
field :visitors, Ch, type: "UInt64"
|
| 11 |
+
field :pageviews, Ch, type: "UInt64"
|
| 12 |
+
field :bounces, Ch, type: "UInt64"
|
| 13 |
+
field :visits, Ch, type: "UInt64"
|
| 14 |
+
field :visit_duration, Ch, type: "UInt64"
|
| 15 |
+
end
|
| 16 |
+
end
|
lib/plausible/ingest_repo.ex
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.IngestRepo do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
Write-centric Clickhouse access interface
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
use Ecto.Repo,
|
| 7 |
+
otp_app: :plausible,
|
| 8 |
+
adapter: Ecto.Adapters.ClickHouse
|
| 9 |
+
|
| 10 |
+
defmacro __using__(_) do
|
| 11 |
+
quote do
|
| 12 |
+
alias Plausible.IngestRepo
|
| 13 |
+
import Ecto
|
| 14 |
+
import Ecto.Query, only: [from: 1, from: 2]
|
| 15 |
+
end
|
| 16 |
+
end
|
| 17 |
+
|
| 18 |
+
def clustered_table?(table) do
|
| 19 |
+
replica_count(table) > 1
|
| 20 |
+
end
|
| 21 |
+
|
| 22 |
+
def replica_count(table) do
|
| 23 |
+
{:ok, %{rows: [[count]]}} =
|
| 24 |
+
query("SELECT sum(active_replicas) FROM system.replicas WHERE table = '#{table}'")
|
| 25 |
+
|
| 26 |
+
count
|
| 27 |
+
end
|
| 28 |
+
end
|
lib/plausible/ingestion/acquisition.ex
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Ingestion.Acquisition do
|
| 2 |
+
@moduledoc """
|
| 3 |
+
This module is responsible for figuring out acquisition channel from event referrer_source.
|
| 4 |
+
|
| 5 |
+
Acquisition channel is the marketing channel where people come from and convert and help
|
| 6 |
+
users to understand and improve their marketing flow.
|
| 7 |
+
|
| 8 |
+
Note it uses priv/ga4-source-categories.csv as a source, which comes from https://support.google.com/analytics/answer/9756891?hl=en.
|
| 9 |
+
|
| 10 |
+
Notable differences from GA4 that have been implemented just for Plausible:
|
| 11 |
+
1. The @custom_source_categories module attribute contains a list of custom source categories that we have manually
|
| 12 |
+
added based on our own judgement and user feedback. For example we group AI assistants (ChatGPT, Claude, Gemini, etc.)
|
| 13 |
+
into their own "AI Assistants" channel.
|
| 14 |
+
2. Google is in a privileged position to analyze paid traffic from within their own network. The biggest use-case is auto-tagged adwords campaigns.
|
| 15 |
+
We do our best by categorizing as paid search when source is Google and the url has `gclid` parameter. Same for source Bing and `msclkid` url parameter.
|
| 16 |
+
3. The @paid_sources module attribute in Plausible.Ingestion.Source contains a list of utm_sources that we will automatically categorize as paid traffic
|
| 17 |
+
regardless of the medium. Examples are `yt-ads`, `facebook_ad`, `adwords`, etc. See also: Plausible.Ingestion.Source.paid_source?/1
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
@external_resource "priv/ga4-source-categories.csv"
|
| 21 |
+
@custom_source_categories [
|
| 22 |
+
{"hacker news", "SOURCE_CATEGORY_SOCIAL"},
|
| 23 |
+
{"yahoo!", "SOURCE_CATEGORY_SEARCH"},
|
| 24 |
+
{"gmail", "SOURCE_CATEGORY_EMAIL"},
|
| 25 |
+
{"telegram", "SOURCE_CATEGORY_SOCIAL"},
|
| 26 |
+
{"slack", "SOURCE_CATEGORY_SOCIAL"},
|
| 27 |
+
{"producthunt", "SOURCE_CATEGORY_SOCIAL"},
|
| 28 |
+
{"github", "SOURCE_CATEGORY_SOCIAL"},
|
| 29 |
+
{"steamcommunity.com", "SOURCE_CATEGORY_SOCIAL"},
|
| 30 |
+
{"statics.teams.cdn.office.net", "SOURCE_CATEGORY_SOCIAL"},
|
| 31 |
+
{"vkontakte", "SOURCE_CATEGORY_SOCIAL"},
|
| 32 |
+
{"threads", "SOURCE_CATEGORY_SOCIAL"},
|
| 33 |
+
{"ecosia", "SOURCE_CATEGORY_SEARCH"},
|
| 34 |
+
{"kagi", "SOURCE_CATEGORY_SEARCH"},
|
| 35 |
+
{"brave", "SOURCE_CATEGORY_SEARCH"},
|
| 36 |
+
{"temu.com", "SOURCE_CATEGORY_SHOPPING"},
|
| 37 |
+
{"discord", "SOURCE_CATEGORY_SOCIAL"},
|
| 38 |
+
{"sogou", "SOURCE_CATEGORY_SEARCH"},
|
| 39 |
+
{"microsoft teams", "SOURCE_CATEGORY_SOCIAL"},
|
| 40 |
+
{"bluesky", "SOURCE_CATEGORY_SOCIAL"},
|
| 41 |
+
{"mastodon", "SOURCE_CATEGORY_SOCIAL"},
|
| 42 |
+
{"chatgpt", "SOURCE_CATEGORY_AI_ASSISTANTS"},
|
| 43 |
+
{"claude", "SOURCE_CATEGORY_AI_ASSISTANTS"},
|
| 44 |
+
{"phind", "SOURCE_CATEGORY_AI_ASSISTANTS"},
|
| 45 |
+
{"deepseek", "SOURCE_CATEGORY_AI_ASSISTANTS"},
|
| 46 |
+
{"microsoft copilot", "SOURCE_CATEGORY_AI_ASSISTANTS"},
|
| 47 |
+
{"grok", "SOURCE_CATEGORY_AI_ASSISTANTS"},
|
| 48 |
+
{"google gemini", "SOURCE_CATEGORY_AI_ASSISTANTS"},
|
| 49 |
+
{"perplexity", "SOURCE_CATEGORY_AI_ASSISTANTS"},
|
| 50 |
+
{"x (twitter)", "SOURCE_CATEGORY_SOCIAL"}
|
| 51 |
+
]
|
| 52 |
+
@source_categories Application.app_dir(:plausible, "priv/ga4-source-categories.csv")
|
| 53 |
+
|> File.read!()
|
| 54 |
+
|> NimbleCSV.RFC4180.parse_string(skip_headers: false)
|
| 55 |
+
|> Enum.map(fn [source, category] -> {source, category} end)
|
| 56 |
+
|> then(&(@custom_source_categories ++ &1))
|
| 57 |
+
|> Enum.into(%{})
|
| 58 |
+
|
| 59 |
+
def source_categories(), do: @source_categories
|
| 60 |
+
|
| 61 |
+
def get_channel(source, utm_medium, utm_campaign, utm_source, click_id_param) do
|
| 62 |
+
get_channel_lowered(
|
| 63 |
+
String.downcase(source || ""),
|
| 64 |
+
String.downcase(utm_medium || ""),
|
| 65 |
+
String.downcase(utm_campaign || ""),
|
| 66 |
+
String.downcase(utm_source || ""),
|
| 67 |
+
click_id_param
|
| 68 |
+
)
|
| 69 |
+
end
|
| 70 |
+
|
| 71 |
+
defp get_channel_lowered(source, utm_medium, utm_campaign, utm_source, click_id_param) do
|
| 72 |
+
cond do
|
| 73 |
+
cross_network?(utm_campaign) -> "Cross-network"
|
| 74 |
+
paid_shopping?(source, utm_campaign, utm_medium) -> "Paid Shopping"
|
| 75 |
+
paid_search?(source, utm_medium, utm_source, click_id_param) -> "Paid Search"
|
| 76 |
+
paid_social?(source, utm_medium, utm_source) -> "Paid Social"
|
| 77 |
+
paid_video?(source, utm_medium, utm_source) -> "Paid Video"
|
| 78 |
+
display?(utm_medium) -> "Display"
|
| 79 |
+
paid_other?(utm_medium) -> "Paid Other"
|
| 80 |
+
organic_shopping?(source, utm_campaign) -> "Organic Shopping"
|
| 81 |
+
organic_social?(source, utm_medium) -> "Organic Social"
|
| 82 |
+
organic_video?(source, utm_medium) -> "Organic Video"
|
| 83 |
+
ai_assistants?(source) -> "AI Assistants"
|
| 84 |
+
search_source?(source) -> "Organic Search"
|
| 85 |
+
email?(source, utm_source, utm_medium) -> "Email"
|
| 86 |
+
affiliates?(utm_medium) -> "Affiliates"
|
| 87 |
+
audio?(utm_medium) -> "Audio"
|
| 88 |
+
sms?(utm_source, utm_medium) -> "SMS"
|
| 89 |
+
mobile_push_notifications?(source, utm_medium) -> "Mobile Push Notifications"
|
| 90 |
+
referral?(source, utm_medium) -> "Referral"
|
| 91 |
+
true -> "Direct"
|
| 92 |
+
end
|
| 93 |
+
end
|
| 94 |
+
|
| 95 |
+
defp cross_network?(utm_campaign) do
|
| 96 |
+
String.contains?(utm_campaign, "cross-network")
|
| 97 |
+
end
|
| 98 |
+
|
| 99 |
+
defp paid_shopping?(source, utm_campaign, utm_medium) do
|
| 100 |
+
(shopping_source?(source) or shopping_campaign?(utm_campaign)) and paid_medium?(utm_medium)
|
| 101 |
+
end
|
| 102 |
+
|
| 103 |
+
defp paid_search?(source, utm_medium, utm_source, click_id_param) do
|
| 104 |
+
(search_source?(source) and paid_medium?(utm_medium)) or
|
| 105 |
+
(search_source?(source) and paid_source?(utm_source)) or
|
| 106 |
+
(source == "google" and click_id_param == "gclid") or
|
| 107 |
+
(source == "bing" and click_id_param == "msclkid")
|
| 108 |
+
end
|
| 109 |
+
|
| 110 |
+
defp paid_social?(source, utm_medium, utm_source) do
|
| 111 |
+
(social_source?(source) and paid_medium?(utm_medium)) or
|
| 112 |
+
(social_source?(source) and paid_source?(utm_source))
|
| 113 |
+
end
|
| 114 |
+
|
| 115 |
+
defp paid_video?(source, utm_medium, utm_source) do
|
| 116 |
+
(video_source?(source) and paid_medium?(utm_medium)) or
|
| 117 |
+
(video_source?(source) and paid_source?(utm_source))
|
| 118 |
+
end
|
| 119 |
+
|
| 120 |
+
defp display?(utm_medium) do
|
| 121 |
+
utm_medium in [
|
| 122 |
+
"display",
|
| 123 |
+
"banner",
|
| 124 |
+
"expandable",
|
| 125 |
+
"interstitial",
|
| 126 |
+
"cpm"
|
| 127 |
+
]
|
| 128 |
+
end
|
| 129 |
+
|
| 130 |
+
defp paid_other?(utm_medium) do
|
| 131 |
+
paid_medium?(utm_medium)
|
| 132 |
+
end
|
| 133 |
+
|
| 134 |
+
defp organic_shopping?(source, utm_campaign) do
|
| 135 |
+
shopping_source?(source) or shopping_campaign?(utm_campaign)
|
| 136 |
+
end
|
| 137 |
+
|
| 138 |
+
defp organic_social?(source, utm_medium) do
|
| 139 |
+
social_source?(source) or
|
| 140 |
+
utm_medium in [
|
| 141 |
+
"social",
|
| 142 |
+
"social-network",
|
| 143 |
+
"social-media",
|
| 144 |
+
"sm",
|
| 145 |
+
"social network",
|
| 146 |
+
"social media"
|
| 147 |
+
]
|
| 148 |
+
end
|
| 149 |
+
|
| 150 |
+
defp organic_video?(source, utm_medium) do
|
| 151 |
+
video_source?(source) or String.contains?(utm_medium, "video")
|
| 152 |
+
end
|
| 153 |
+
|
| 154 |
+
defp referral?(source, utm_medium) do
|
| 155 |
+
utm_medium in ["referral", "app", "link"] or source !== ""
|
| 156 |
+
end
|
| 157 |
+
|
| 158 |
+
@email_tags ["email", "e-mail", "e_mail", "e mail", "newsletter"]
|
| 159 |
+
defp email?(source, utm_source, utm_medium) do
|
| 160 |
+
email_source?(source) or
|
| 161 |
+
String.contains?(utm_source, @email_tags) or
|
| 162 |
+
String.contains?(utm_medium, @email_tags)
|
| 163 |
+
end
|
| 164 |
+
|
| 165 |
+
defp affiliates?(utm_medium) do
|
| 166 |
+
utm_medium == "affiliate"
|
| 167 |
+
end
|
| 168 |
+
|
| 169 |
+
defp audio?(utm_medium) do
|
| 170 |
+
utm_medium == "audio"
|
| 171 |
+
end
|
| 172 |
+
|
| 173 |
+
defp sms?(utm_source, utm_medium) do
|
| 174 |
+
utm_source == "sms" or utm_medium == "sms"
|
| 175 |
+
end
|
| 176 |
+
|
| 177 |
+
defp mobile_push_notifications?(source, utm_medium) do
|
| 178 |
+
String.ends_with?(utm_medium, "push") or
|
| 179 |
+
String.contains?(utm_medium, ["mobile", "notification"]) or
|
| 180 |
+
source == "firebase"
|
| 181 |
+
end
|
| 182 |
+
|
| 183 |
+
defp shopping_source?(source) do
|
| 184 |
+
@source_categories[source] == "SOURCE_CATEGORY_SHOPPING"
|
| 185 |
+
end
|
| 186 |
+
|
| 187 |
+
defp search_source?(source) do
|
| 188 |
+
@source_categories[source] == "SOURCE_CATEGORY_SEARCH"
|
| 189 |
+
end
|
| 190 |
+
|
| 191 |
+
defp social_source?(source) do
|
| 192 |
+
@source_categories[source] == "SOURCE_CATEGORY_SOCIAL"
|
| 193 |
+
end
|
| 194 |
+
|
| 195 |
+
defp video_source?(source) do
|
| 196 |
+
@source_categories[source] == "SOURCE_CATEGORY_VIDEO"
|
| 197 |
+
end
|
| 198 |
+
|
| 199 |
+
defp email_source?(source) do
|
| 200 |
+
@source_categories[source] == "SOURCE_CATEGORY_EMAIL"
|
| 201 |
+
end
|
| 202 |
+
|
| 203 |
+
defp ai_assistants?(source) do
|
| 204 |
+
@source_categories[source] == "SOURCE_CATEGORY_AI_ASSISTANTS"
|
| 205 |
+
end
|
| 206 |
+
|
| 207 |
+
defp shopping_campaign?(utm_campaign) do
|
| 208 |
+
Regex.match?(~r/^(.*(([^a-df-z]|^)shop|shopping).*)$/, utm_campaign)
|
| 209 |
+
end
|
| 210 |
+
|
| 211 |
+
defp paid_medium?(utm_medium) do
|
| 212 |
+
Regex.match?(~r/^(.*cp.*|ppc|retargeting|paid.*)$/, utm_medium)
|
| 213 |
+
end
|
| 214 |
+
|
| 215 |
+
defp paid_source?(utm_source) do
|
| 216 |
+
Plausible.Ingestion.Source.paid_source?(utm_source)
|
| 217 |
+
end
|
| 218 |
+
end
|
lib/plausible/ingestion/city_overrides.ex
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
defmodule Plausible.Ingestion.CityOverrides do
|
| 2 |
+
@moduledoc false
|
| 3 |
+
|
| 4 |
+
@overrides %{
|
| 5 |
+
# Austria
|
| 6 |
+
# Gemeindebezirk Floridsdorf -> Vienna
|
| 7 |
+
2_779_467 => 2_761_369,
|
| 8 |
+
# Gemeindebezirk Leopoldstadt -> Vienna
|
| 9 |
+
2_772_614 => 2_761_369,
|
| 10 |
+
# Gemeindebezirk Landstrasse -> Vienna
|
| 11 |
+
2_773_040 => 2_761_369,
|
| 12 |
+
# Gemeindebezirk Donaustadt -> Vienna
|
| 13 |
+
2_780_851 => 2_761_369,
|
| 14 |
+
# Gemeindebezirk Favoriten -> Vienna
|
| 15 |
+
2_779_776 => 2_761_369,
|
| 16 |
+
# Gemeindebezirk Währing -> Vienna
|
| 17 |
+
2_762_091 => 2_761_369,
|
| 18 |
+
# Gemeindebezirk Wieden -> Vienna
|
| 19 |
+
2_761_393 => 2_761_369,
|
| 20 |
+
# Gemeindebezirk Innere Stadt -> Vienna
|
| 21 |
+
2_775_259 => 2_761_369,
|
| 22 |
+
# Gemeindebezirk Alsergrund -> Vienna
|
| 23 |
+
2_782_729 => 2_761_369,
|
| 24 |
+
# Gemeindebezirk Liesing -> Vienna
|
| 25 |
+
2_772_484 => 2_761_369,
|
| 26 |
+
# Urfahr -> Linz
|
| 27 |
+
2_762_518 => 2_772_400,
|
| 28 |
+
|
| 29 |
+
# Canada
|
| 30 |
+
# Old Toronto -> Toronto
|
| 31 |
+
8_436_019 => 6_167_865,
|
| 32 |
+
# Etobicoke -> Toronto
|
| 33 |
+
5_950_267 => 6_167_865,
|
| 34 |
+
# East York -> Toronto
|
| 35 |
+
5_946_235 => 6_167_865,
|
| 36 |
+
# Scarborough -> Toronto
|
| 37 |
+
6_948_711 => 6_167_865,
|
| 38 |
+
# North York -> Toronto
|
| 39 |
+
6_091_104 => 6_167_865,
|
| 40 |
+
|
| 41 |
+
# Czech republic
|
| 42 |
+
# Praha 5 -> Prague
|
| 43 |
+
11_951_220 => 3_067_696,
|
| 44 |
+
# Praha 4 -> Prague
|
| 45 |
+
11_951_218 => 3_067_696,
|
| 46 |
+
# Praha 11 -> Prague
|
| 47 |
+
11_951_232 => 3_067_696,
|
| 48 |
+
# Praha 10 -> Prague
|
| 49 |
+
11_951_210 => 3_067_696,
|
| 50 |
+
# Praha 4 -> Prague
|
| 51 |
+
8_378_772 => 3_067_696,
|
| 52 |
+
|
| 53 |
+
# Denmark
|
| 54 |
+
# København SV -> Copenhagen
|
| 55 |
+
11_747_123 => 2_618_425,
|
| 56 |
+
# København NV -> Copenhagen
|
| 57 |
+
11_746_894 => 2_618_425,
|
| 58 |
+
# Odense S -> Odense
|
| 59 |
+
11_746_825 => 2_615_876,
|
| 60 |
+
# Odense M -> Odense
|
| 61 |
+
11_746_974 => 2_615_876,
|
| 62 |
+
# Odense SØ -> Odense
|
| 63 |
+
11_746_888 => 2_615_876,
|
| 64 |
+
# Aarhus C -> Aarhus
|
| 65 |
+
11_746_746 => 2_624_652,
|
| 66 |
+
# Aarhus N -> Aarhus
|
| 67 |
+
11_746_890 => 2_624_652,
|
| 68 |
+
|
| 69 |
+
# Estonia
|
| 70 |
+
# Kristiine linnaosa -> Tallinn
|
| 71 |
+
11_050_530 => 588_409,
|
| 72 |
+
# Kesklinna linnaosa -> Tallinn
|
| 73 |
+
11_053_706 => 588_409,
|
| 74 |
+
# Lasnamäe linnaosa -> Tallinn
|
| 75 |
+
11_050_526 => 588_409,
|
| 76 |
+
# Põhja-Tallinna linnaosa -> Tallinn
|
| 77 |
+
11_049_594 => 588_409,
|
| 78 |
+
# Mustamäe linnaosa -> Tallinn
|
| 79 |
+
11_050_531 => 588_409,
|
| 80 |
+
# Haabersti linnaosa -> Tallinn
|
| 81 |
+
11_053_707 => 588_409,
|
| 82 |
+
# Viimsi -> Tallinn
|
| 83 |
+
587_629 => 588_409,
|
| 84 |
+
|
| 85 |
+
# Germany
|
| 86 |
+
# Bezirk Tempelhof-Schöneberg -> Berlin
|
| 87 |
+
3_336_297 => 2_950_159,
|
| 88 |
+
# Bezirk Mitte -> Berlin
|
| 89 |
+
2_870_912 => 2_950_159,
|
| 90 |
+
# Bezirk Charlottenburg-Wilmersdorf -> Berlin
|
| 91 |
+
3_336_294 => 2_950_159,
|
| 92 |
+
# Bezirk Friedrichshain-Kreuzberg -> Berlin
|
| 93 |
+
3_336_295 => 2_950_159,
|
| 94 |
+
# Moosach -> Munich
|
| 95 |
+
8_351_447 => 2_867_714,
|
| 96 |
+
# Schwabing-Freimann -> Munich
|
| 97 |
+
8_351_448 => 2_867_714,
|
| 98 |
+
# Stadtbezirk 06 -> Düsseldorf
|
| 99 |
+
6_947_276 => 2_934_246,
|
| 100 |
+
# Stadtbezirk 04 -> Düsseldorf
|
| 101 |
+
6_947_274 => 2_934_246,
|
| 102 |
+
# Köln-Ehrenfeld -> Köln
|
| 103 |
+
6_947_479 => 2_886_242,
|
| 104 |
+
# Köln-Lindenthal- -> Köln
|
| 105 |
+
6_947_481 => 2_886_242,
|
| 106 |
+
# Beuel -> Bonn
|
| 107 |
+
2_949_619 => 2_946_447,
|
| 108 |
+
# Innenstadt I -> Frankfurt am Main
|
| 109 |
+
6_946_225 => 2_925_533,
|
| 110 |
+
# Innenstadt II -> Frankfurt am Main
|
| 111 |
+
6_946_226 => 2_925_533,
|
| 112 |
+
# Innenstadt III -> Frankfurt am Main
|
| 113 |
+
6_946_227 => 2_925_533,
|
| 114 |
+
|
| 115 |
+
# India
|
| 116 |
+
# Navi Mumbai -> Mumbai
|
| 117 |
+
6_619_347 => 1_275_339,
|
| 118 |
+
|
| 119 |
+
# Mexico
|
| 120 |
+
# Miguel Hidalgo Villa Olímpica -> Mexico city
|
| 121 |
+
11_561_026 => 3_530_597,
|
| 122 |
+
# Zedec Santa Fe -> Mexico city
|
| 123 |
+
3_517_471 => 3_530_597,
|
| 124 |
+
# Fuentes del Pedregal-> Mexico city
|
| 125 |
+
11_562_596 => 3_530_597,
|
| 126 |
+
# Centro -> Mexico city
|
| 127 |
+
9_179_691 => 3_530_597,
|
| 128 |
+
# Cuauhtémoc-> Mexico city
|
| 129 |
+
12_266_959 => 3_530_597,
|
| 130 |
+
|
| 131 |
+
# Netherlands
|
| 132 |
+
# Schiphol-Rijk -> Amsterdam
|
| 133 |
+
10_173_838 => 2_759_794,
|
| 134 |
+
# Westpoort -> Amsterdam
|
| 135 |
+
11_525_047 => 2_759_794,
|
| 136 |
+
# Amsterdam-Zuidoost -> Amsterdam
|
| 137 |
+
6_544_881 => 2_759_794,
|
| 138 |
+
# Loosduinen -> The Hague
|
| 139 |
+
11_525_037 => 2_747_373,
|
| 140 |
+
# Laak -> The Hague
|
| 141 |
+
11_525_042 => 2_747_373,
|
| 142 |
+
|
| 143 |
+
# Norway
|
| 144 |
+
# Nordre Aker District -> Oslo
|
| 145 |
+
6_940_981 => 3_143_244,
|
| 146 |
+
|
| 147 |
+
# Romania
|
| 148 |
+
# Sector 1 -> Bucharest,
|
| 149 |
+
11_055_041 => 683_506,
|
| 150 |
+
# Sector 2 -> Bucharest
|
| 151 |
+
11_055_040 => 683_506,
|
| 152 |
+
# Sector 3 -> Bucharest
|
| 153 |
+
11_055_044 => 683_506,
|
| 154 |
+
# Sector 4 -> Bucharest
|
| 155 |
+
11_055_042 => 683_506,
|
| 156 |
+
# Sector 5 -> Bucharest
|
| 157 |
+
11_055_043 => 683_506,
|
| 158 |
+
# Sector 6 -> Bucharest
|
| 159 |
+
11_055_039 => 683_506,
|
| 160 |
+
# Bucuresti -> Bucharest
|
| 161 |
+
6_691_781 => 683_506,
|
| 162 |
+
|
| 163 |
+
# Slovakia
|
| 164 |
+
# Bratislava -> Bratislava
|
| 165 |
+
3_343_955 => 3_060_972,
|
| 166 |
+
|
| 167 |
+
# Sweden
|
| 168 |
+
# Södermalm -> Stockholm
|
| 169 |
+
2_676_209 => 2_673_730,
|
| 170 |
+
|
| 171 |
+
# Switzerland
|
| 172 |
+
# Vorstädte -> Basel
|
| 173 |
+
11_789_440 => 2_661_604,
|
| 174 |
+
# Zürich (Kreis 11) / Oerlikon -> Zürich
|
| 175 |
+
2_659_310 => 2_657_896,
|
| 176 |
+
# Zürich (Kreis 3) / Alt-Wiedikon -> Zürich
|
| 177 |
+
2_658_007 => 2_657_896,
|
| 178 |
+
# Zürich (Kreis 5) -> Zürich
|
| 179 |
+
6_295_521 => 2_657_896,
|
| 180 |
+
# Zürich (Kreis 1) / Hochschulen -> Zürich
|
| 181 |
+
6_295_489 => 2_657_896,
|
| 182 |
+
|
| 183 |
+
# UK
|
| 184 |
+
# Shadwell -> London
|
| 185 |
+
6_690_595 => 2_643_743,
|
| 186 |
+
# City of London -> London
|
| 187 |
+
2_643_741 => 2_643_743,
|
| 188 |
+
# South Bank -> London
|
| 189 |
+
6_545_251 => 2_643_743,
|
| 190 |
+
# Soho -> London
|
| 191 |
+
6_545_173 => 2_643_743,
|
| 192 |
+
# Whitechapel -> London
|
| 193 |
+
2_634_112 => 2_643_743,
|
| 194 |
+
# King's Cross -> London
|
| 195 |
+
6_690_589 => 2_643_743,
|
| 196 |
+
# Poplar -> London
|
| 197 |
+
2_640_091 => 2_643_743,
|
| 198 |
+
# Hackney -> London
|
| 199 |
+
2_647_694 => 2_643_743
|
| 200 |
+
}
|
| 201 |
+
def get(key, default), do: Map.get(@overrides, key, default)
|
| 202 |
+
end
|