Leon4gr45 commited on
Commit
8da2481
·
verified ·
1 Parent(s): 3e21b19

Upload folder using huggingface_hub (part 2)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. lib/plausible.ex +82 -0
  2. lib/plausible/data_migration.ex +127 -0
  3. lib/plausible/data_migration/postgres_repo.ex +22 -0
  4. lib/plausible/data_migration/prefix_tracker_script_configuration_id.ex +96 -0
  5. lib/plausible/data_migration/site_imports.ex +270 -0
  6. lib/plausible/data_migration/versioned_sessions.ex +80 -0
  7. lib/plausible/dns_lookup.ex +31 -0
  8. lib/plausible/ecto/types/compiled_regex.ex +14 -0
  9. lib/plausible/ecto/types/nanoid.ex +51 -0
  10. lib/plausible/event/system_events.ex +101 -0
  11. lib/plausible/event/write_buffer.ex +38 -0
  12. lib/plausible/exports.ex +715 -0
  13. lib/plausible/file.ex +27 -0
  14. lib/plausible/funnel/const.ex +26 -0
  15. lib/plausible/geo.ex +207 -0
  16. lib/plausible/goal.ex +317 -0
  17. lib/plausible/goals/goals.ex +490 -0
  18. lib/plausible/google/api.ex +201 -0
  19. lib/plausible/google/ga4/api.ex +215 -0
  20. lib/plausible/google/ga4/http.ex +330 -0
  21. lib/plausible/google/ga4/report_request.ex +168 -0
  22. lib/plausible/google/http.ex +110 -0
  23. lib/plausible/google/search_console/filters.ex +77 -0
  24. lib/plausible/helpers/changeset.ex +34 -0
  25. lib/plausible/helpers/config.ex +53 -0
  26. lib/plausible/helpers/json.ex +15 -0
  27. lib/plausible/http_client.ex +189 -0
  28. lib/plausible/import_deletion_repo.ex +17 -0
  29. lib/plausible/imported.ex +212 -0
  30. lib/plausible/imported/browser.ex +18 -0
  31. lib/plausible/imported/buffer.ex +115 -0
  32. lib/plausible/imported/csv_importer.ex +372 -0
  33. lib/plausible/imported/custom_event.ex +16 -0
  34. lib/plausible/imported/device.ex +17 -0
  35. lib/plausible/imported/entry_page.ex +17 -0
  36. lib/plausible/imported/exit_page.ex +17 -0
  37. lib/plausible/imported/google_analytics4.ex +357 -0
  38. lib/plausible/imported/import_sources.ex +24 -0
  39. lib/plausible/imported/importer.ex +261 -0
  40. lib/plausible/imported/location.ex +19 -0
  41. lib/plausible/imported/noop_importer.ex +46 -0
  42. lib/plausible/imported/operating_system.ex +18 -0
  43. lib/plausible/imported/page.ex +21 -0
  44. lib/plausible/imported/site_import.ex +73 -0
  45. lib/plausible/imported/source.ex +24 -0
  46. lib/plausible/imported/universal_analytics.ex +29 -0
  47. lib/plausible/imported/visitor.ex +16 -0
  48. lib/plausible/ingest_repo.ex +28 -0
  49. lib/plausible/ingestion/acquisition.ex +218 -0
  50. 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