Leon4gr45 commited on
Commit
c27e67a
·
verified ·
1 Parent(s): 88211d3

Upload folder using huggingface_hub (part 4)

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_web.ex +152 -0
  2. lib/plausible_web/views/stats_view.ex +80 -0
  3. lib/plausible_web/views/text_helpers.ex +53 -0
  4. lib/plausible_web/views/unsubscribe_view.ex +3 -0
  5. lib/sentry_filter.ex +69 -0
  6. lib/workers/accept_traffic_until_notification.ex +106 -0
  7. lib/workers/check_usage.ex +170 -0
  8. lib/workers/clean_invitations.ex +25 -0
  9. lib/workers/clean_user_sessions.ex +27 -0
  10. lib/workers/clickhouse_clean_sites.ex +81 -0
  11. lib/workers/expire_domain_change_transitions.ex +22 -0
  12. lib/workers/export_analytics.ex +121 -0
  13. lib/workers/import_analytics.ex +96 -0
  14. lib/workers/local_import_analytics_cleaner.ex +32 -0
  15. lib/workers/locations_sync.ex +15 -0
  16. lib/workers/lock_sites.ex +24 -0
  17. lib/workers/notify_annual_renewal.ex +76 -0
  18. lib/workers/notify_exported_analytics.ex +43 -0
  19. lib/workers/purge_cdn_cache.ex +71 -0
  20. lib/workers/rotate_salts.ex +9 -0
  21. lib/workers/schedule_email_reports.ex +100 -0
  22. lib/workers/send_check_stats_emails.ex +55 -0
  23. lib/workers/send_email_report.ex +210 -0
  24. lib/workers/send_site_setup_emails.ex +121 -0
  25. lib/workers/send_trial_notifications.ex +85 -0
  26. lib/workers/set_legacy_time_on_page_cutoff.ex +96 -0
  27. lib/workers/traffic_change_notifier.ex +181 -0
  28. mix.exs +246 -0
  29. mix.lock +171 -0
  30. priv/custom_sources.json +84 -0
  31. priv/data_migrations/AcquisitionChannel/sql/acquisition_channel_add_materialized_column.sql.eex +4 -0
  32. priv/data_migrations/AcquisitionChannel/sql/acquisition_channel_backfill_materialized_column.sql.eex +2 -0
  33. priv/data_migrations/AcquisitionChannel/sql/acquisition_channel_functions.sql.eex +235 -0
  34. priv/data_migrations/AcquisitionChannel/sql/acquisition_channel_update_materialized_column.sql.eex +4 -0
  35. priv/data_migrations/FunnelDupeGoals/sql/list-funnels-with-dupe-goal-ids.sql.eex +62 -0
  36. priv/data_migrations/LocationsSync/sql/add-alias-column.sql.eex +5 -0
  37. priv/data_migrations/LocationsSync/sql/create-location-data-table.sql.eex +13 -0
  38. priv/data_migrations/LocationsSync/sql/get-location-data-table-comment.sql.eex +1 -0
  39. priv/data_migrations/LocationsSync/sql/truncate-location-data-table.sql.eex +1 -0
  40. priv/data_migrations/LocationsSync/sql/update-location-data-dictionary.sql.eex +11 -0
  41. priv/data_migrations/LocationsSync/sql/update-location-data-table-comment.sql.eex +3 -0
  42. priv/data_migrations/NumericIDs/sql/attach-tmp-events-v2.sql.eex +1 -0
  43. priv/data_migrations/NumericIDs/sql/attach-tmp-sessions-v2.sql.eex +1 -0
  44. priv/data_migrations/NumericIDs/sql/check-replicas.sql.eex +1 -0
  45. priv/data_migrations/NumericIDs/sql/create-domains-lookup.sql.eex +1 -0
  46. priv/data_migrations/NumericIDs/sql/create-events-v2.sql.eex +39 -0
  47. priv/data_migrations/NumericIDs/sql/create-sessions-v2.sql.eex +45 -0
  48. priv/data_migrations/NumericIDs/sql/create-tmp-events-v2.sql.eex +6 -0
  49. priv/data_migrations/NumericIDs/sql/create-tmp-sessions-v2.sql.eex +6 -0
  50. priv/data_migrations/NumericIDs/sql/drop-domains-lookup.sql.eex +1 -0
lib/plausible_web.ex ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule PlausibleWeb do
2
+ @moduledoc false
3
+
4
+ use Plausible
5
+
6
+ def live_view(opts \\ []) do
7
+ quote do
8
+ use Plausible
9
+ use Phoenix.LiveView, global_prefixes: ~w(x-)
10
+ use PlausibleWeb.Live.Flash
11
+
12
+ use PlausibleWeb.Live.AuthContext
13
+
14
+ unless :no_sentry_context in unquote(opts) do
15
+ use PlausibleWeb.Live.SentryContext
16
+ end
17
+
18
+ on_ee do
19
+ use Plausible.Audit.LiveContext
20
+ end
21
+
22
+ alias PlausibleWeb.Router.Helpers, as: Routes
23
+ alias Phoenix.LiveView.JS
24
+
25
+ import PlausibleWeb.Components.Generic
26
+ import PlausibleWeb.Live.Components.Form
27
+ end
28
+ end
29
+
30
+ def live_component do
31
+ quote do
32
+ use Phoenix.LiveComponent, global_prefixes: ~w(x-)
33
+ import PlausibleWeb.Components.Generic
34
+ import PlausibleWeb.Live.Components.Form
35
+ alias Phoenix.LiveView.JS
36
+ alias PlausibleWeb.Router.Helpers, as: Routes
37
+ end
38
+ end
39
+
40
+ def component do
41
+ quote do
42
+ use Phoenix.Component, global_prefixes: ~w(x-)
43
+ import PlausibleWeb.Components.Generic
44
+ import PlausibleWeb.Live.Components.Form
45
+ alias Phoenix.LiveView.JS
46
+ alias PlausibleWeb.Router.Helpers, as: Routes
47
+ end
48
+ end
49
+
50
+ def controller do
51
+ quote do
52
+ use Phoenix.Controller, formats: [html: "View", json: "View"]
53
+
54
+ import Plug.Conn
55
+ import PlausibleWeb.ControllerHelpers
56
+ alias PlausibleWeb.Router.Helpers, as: Routes
57
+ end
58
+ end
59
+
60
+ def view do
61
+ quote do
62
+ use Phoenix.View,
63
+ root: "lib/plausible_web/templates"
64
+
65
+ # Import convenience functions from controllers
66
+ import Phoenix.Controller, only: [view_module: 1]
67
+
68
+ use Phoenix.Component, global_prefixes: ~w(x-)
69
+
70
+ import PlausibleWeb.Components.Generic
71
+ import PlausibleWeb.Live.Components.Form
72
+ alias PlausibleWeb.Router.Helpers, as: Routes
73
+ end
74
+ end
75
+
76
+ on_ee do
77
+ def extra_view do
78
+ quote do
79
+ use Phoenix.View,
80
+ root: "extra/lib/plausible_web/templates"
81
+
82
+ # Import convenience functions from controllers
83
+ import Phoenix.Controller, only: [view_module: 1]
84
+
85
+ use Phoenix.Component
86
+
87
+ import PlausibleWeb.Components.Generic
88
+ import PlausibleWeb.Live.Components.Form
89
+ alias PlausibleWeb.Router.Helpers, as: Routes
90
+ end
91
+ end
92
+ end
93
+
94
+ def router do
95
+ quote do
96
+ use Phoenix.Router
97
+ import Plug.Conn
98
+ import Phoenix.Controller
99
+ end
100
+ end
101
+
102
+ def channel do
103
+ quote do
104
+ use Phoenix.Channel
105
+ end
106
+ end
107
+
108
+ def plugins_api_controller do
109
+ quote do
110
+ use Phoenix.Controller, formats: [:json]
111
+ import Plug.Conn
112
+ import PlausibleWeb.Router.Helpers
113
+
114
+ alias PlausibleWeb.Plugins.API.Schemas
115
+ alias PlausibleWeb.Plugins.API.Views
116
+ alias PlausibleWeb.Plugins.API.Errors
117
+ alias Plausible.Plugins.API
118
+
119
+ plug(OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true, replace_params: false)
120
+
121
+ use OpenApiSpex.ControllerSpecs
122
+ end
123
+ end
124
+
125
+ def plugins_api_view do
126
+ quote do
127
+ use Phoenix.View, root: ""
128
+
129
+ alias PlausibleWeb.Router.Helpers
130
+ import PlausibleWeb.Plugins.API.Views.Pagination, only: [render_metadata_links: 4]
131
+ end
132
+ end
133
+
134
+ def open_api_schema do
135
+ quote do
136
+ require OpenApiSpex
137
+ alias OpenApiSpex.Schema
138
+ alias PlausibleWeb.Plugins.API.Schemas
139
+ end
140
+ end
141
+
142
+ @doc """
143
+ When used, dispatch to the appropriate controller/view/etc.
144
+ """
145
+ defmacro __using__(which) when is_atom(which) do
146
+ apply(__MODULE__, which, [])
147
+ end
148
+
149
+ defmacro __using__([{which, opts}]) when is_atom(which) do
150
+ apply(__MODULE__, which, [List.wrap(opts)])
151
+ end
152
+ end
lib/plausible_web/views/stats_view.ex ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule PlausibleWeb.StatsView do
2
+ use PlausibleWeb, :view
3
+ use Plausible
4
+
5
+ require Plausible.Billing
6
+
7
+ def plausible_url do
8
+ PlausibleWeb.Endpoint.url()
9
+ end
10
+
11
+ def large_number_format(n, opts \\ []) do
12
+ k = if Keyword.get(opts, :capitalize_k?, false), do: "K", else: "k"
13
+
14
+ cond do
15
+ n >= 1_000 && n < 1_000_000 ->
16
+ thousands = trunc(n / 100) / 10
17
+
18
+ if thousands == trunc(thousands) || n >= 100_000 do
19
+ "#{trunc(thousands)}" <> k
20
+ else
21
+ "#{thousands}" <> k
22
+ end
23
+
24
+ n >= 1_000_000 && n < 1_000_000_000 ->
25
+ millions = trunc(n / 100_000) / 10
26
+
27
+ if millions == trunc(millions) || n > 100_000_000 do
28
+ "#{trunc(millions)}M"
29
+ else
30
+ "#{millions}M"
31
+ end
32
+
33
+ n >= 1_000_000_000 && n < 1_000_000_000_000 ->
34
+ billions = trunc(n / 100_000_000) / 10
35
+
36
+ if billions == trunc(billions) || n > 100_000_000_000 do
37
+ "#{trunc(billions)}B"
38
+ else
39
+ "#{billions}B"
40
+ end
41
+
42
+ is_integer(n) ->
43
+ Integer.to_string(n)
44
+ end
45
+ end
46
+
47
+ def stats_container_class(conn) do
48
+ cond do
49
+ conn.assigns[:embedded] && conn.params["width"] == "manual" -> "px-6"
50
+ conn.assigns[:embedded] -> "max-w-screen-xl mx-auto px-6"
51
+ !conn.assigns[:embedded] -> "container print:max-w-full"
52
+ end
53
+ end
54
+
55
+ @doc """
56
+ Returns a readable stats URL.
57
+
58
+ Native Phoenix router functions percent-encode all diacritics, resulting in
59
+ ugly URLs, e.g. `https://plausible.io/café.com` transforms into
60
+ `https://plausible.io/caf%C3%A9.com`.
61
+
62
+ This function encodes only the slash (`/`) character from the site's domain.
63
+
64
+ ## Examples
65
+
66
+ iex> PlausibleWeb.StatsView.pretty_stats_url(%Plausible.Site{domain: "user.gittea.io/repo"})
67
+ "http://localhost:8000/user.gittea.io%2Frepo"
68
+
69
+ iex> PlausibleWeb.StatsView.pretty_stats_url(%Plausible.Site{domain: "anakin.test"})
70
+ "http://localhost:8000/anakin.test"
71
+
72
+ iex> PlausibleWeb.StatsView.pretty_stats_url(%Plausible.Site{domain: "café.test"})
73
+ "http://localhost:8000/café.test"
74
+
75
+ """
76
+ def pretty_stats_url(%Plausible.Site{domain: domain}) when is_binary(domain) do
77
+ pretty_domain = String.replace(domain, "/", "%2F")
78
+ "#{plausible_url()}/#{pretty_domain}"
79
+ end
80
+ end
lib/plausible_web/views/text_helpers.ex ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule PlausibleWeb.TextHelpers do
2
+ @moduledoc false
3
+
4
+ @spec pretty_join([String.t()]) :: String.t()
5
+
6
+ @doc """
7
+ Turns a list of strings into a string and replaces the last comma
8
+ with the word "and".
9
+
10
+ ### Examples:
11
+
12
+ iex> ["one"] |> PlausibleWeb.TextHelpers.pretty_join()
13
+ "one"
14
+
15
+ iex> ["one", "two"] |> PlausibleWeb.TextHelpers.pretty_join()
16
+ "one and two"
17
+
18
+ iex> ["one", "two", "three"] |> PlausibleWeb.TextHelpers.pretty_join()
19
+ "one, two and three"
20
+ """
21
+ def pretty_join([str]), do: str
22
+
23
+ def pretty_join(list) do
24
+ [last_string | rest] = Enum.reverse(list)
25
+
26
+ rest_string =
27
+ rest
28
+ |> Enum.reverse()
29
+ |> Enum.join(", ")
30
+
31
+ "#{rest_string} and #{last_string}"
32
+ end
33
+
34
+ def pretty_list(list) do
35
+ list
36
+ |> Enum.map(&String.replace("#{&1}", "_", " "))
37
+ |> pretty_join()
38
+ end
39
+
40
+ def format_date_range(date_range) do
41
+ "#{format_date(date_range.first)} - #{format_date(date_range.last)}"
42
+ end
43
+
44
+ def format_date(date) do
45
+ Calendar.strftime(date, "%b %-d, %Y")
46
+ end
47
+
48
+ def number_format(number) when is_integer(number) do
49
+ Cldr.Number.to_string!(number)
50
+ end
51
+
52
+ def number_format(other), do: other
53
+ end
lib/plausible_web/views/unsubscribe_view.ex ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ defmodule PlausibleWeb.UnsubscribeView do
2
+ use PlausibleWeb, :view
3
+ end
lib/sentry_filter.ex ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.SentryFilter do
2
+ @moduledoc """
3
+ Sentry callbacks for filtering and grouping events
4
+ """
5
+
6
+ @spec before_send(Sentry.Event.t()) :: Sentry.Event.t()
7
+ def before_send(event)
8
+
9
+ def before_send(%{original_exception: %Bamboo.PostmarkAdapter.Error{} = e} = event) do
10
+ if Bamboo.PostmarkAdapter.Error.is_hard_bounce(e), do: false, else: event
11
+ end
12
+
13
+ def before_send(%{original_exception: %Phoenix.NotAcceptableError{}}), do: false
14
+ def before_send(%{original_exception: %Plug.CSRFProtection.InvalidCSRFTokenError{}}), do: false
15
+ def before_send(%{original_exception: %Plug.Static.InvalidPathError{}}), do: false
16
+
17
+ def before_send(
18
+ %{
19
+ exception: [%{type: "DBConnection.ConnectionError"}],
20
+ original_exception: %{reason: reason}
21
+ } = event
22
+ ) do
23
+ %{event | fingerprint: ["db_connection", reason]}
24
+ end
25
+
26
+ def before_send(
27
+ %{
28
+ exception: [%{type: "Mint.TransportError"}],
29
+ original_exception: %{reason: reason}
30
+ } = event
31
+ ) do
32
+ %{event | fingerprint: ["mint_transport", reason]}
33
+ end
34
+
35
+ def before_send(
36
+ %{
37
+ exception: [%{type: "Finch.TransportError"}],
38
+ original_exception: %{reason: reason}
39
+ } = event
40
+ ) do
41
+ %{event | fingerprint: ["mint_transport", reason]}
42
+ end
43
+
44
+ def before_send(
45
+ %{source: :logger, message: %{formatted: "Ch.Connection (#PID<" <> rest}} = event
46
+ ) do
47
+ if String.ends_with?(rest, ")) disconnected: ** (Mint.HTTPError) the connection is closed") do
48
+ false
49
+ else
50
+ event
51
+ end
52
+ end
53
+
54
+ def before_send(%{extra: %{request: %Plausible.Ingestion.Request{}}} = event) do
55
+ %{event | fingerprint: ["ingestion_request"]}
56
+ end
57
+
58
+ def before_send(%{source: :logger, message: %{formatted: "Ranch listener" <> rest}} = event) do
59
+ if String.contains?(rest, "had its request process") do
60
+ false
61
+ else
62
+ event
63
+ end
64
+ end
65
+
66
+ def before_send(event) do
67
+ event
68
+ end
69
+ end
lib/workers/accept_traffic_until_notification.ex ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.AcceptTrafficUntil do
2
+ @moduledoc """
3
+ A worker meant to be run once a day that sends out e-mail notifications to site
4
+ owners assuming:
5
+ - their sites still receive traffic (i.e. have stats for yesterday)
6
+ - `site.accept_traffic_until` is approaching either tomorrow or exactly in 7 days
7
+
8
+ Users having no sites or sites that receive no traffic, won't be notified.
9
+ We make a tiny effort here to make sure we send the same notification at most once a day.
10
+ """
11
+ use Oban.Worker, queue: :check_accept_traffic_until
12
+ import Ecto.Query
13
+
14
+ alias Plausible.Repo
15
+ alias Plausible.ClickhouseRepo
16
+
17
+ def dry_run(date) do
18
+ perform(nil, date, true)
19
+ end
20
+
21
+ @impl Oban.Worker
22
+ def perform(_job, today \\ Date.utc_today(), dry_run? \\ false) do
23
+ tomorrow = today |> Date.add(+1)
24
+ next_week = today |> Date.add(+7)
25
+
26
+ # send at most one notification per user, per day
27
+ sent_today_query =
28
+ from s in "sent_accept_traffic_until_notifications",
29
+ where: s.user_id == parent_as(:users).id and s.sent_on == ^today,
30
+ select: true
31
+
32
+ notifications =
33
+ Repo.all(
34
+ from t in Plausible.Teams.Team,
35
+ inner_join: u in assoc(t, :owners),
36
+ as: :users,
37
+ inner_join: s in assoc(t, :sites),
38
+ where: t.accept_traffic_until == ^tomorrow or t.accept_traffic_until == ^next_week,
39
+ where: not exists(sent_today_query),
40
+ select: %{
41
+ id: u.id,
42
+ email: u.email,
43
+ deadline: t.accept_traffic_until,
44
+ site_ids: fragment("array_agg(?.id)", s),
45
+ name: u.name,
46
+ team: t
47
+ },
48
+ group_by: [u.id, t.id]
49
+ )
50
+
51
+ for notification <- notifications do
52
+ case {has_stats?(notification.site_ids, today), notification.deadline} do
53
+ {true, ^tomorrow} ->
54
+ if dry_run? do
55
+ IO.puts("Will send final notification to #{notification.email}")
56
+ else
57
+ notification
58
+ |> store_sent(today)
59
+ |> PlausibleWeb.Email.approaching_accept_traffic_until_tomorrow()
60
+ |> Plausible.Mailer.send()
61
+ end
62
+
63
+ {true, ^next_week} ->
64
+ if dry_run? do
65
+ IO.puts("Will send weekly notification to #{notification.email}")
66
+ else
67
+ notification
68
+ |> store_sent(today)
69
+ |> PlausibleWeb.Email.approaching_accept_traffic_until()
70
+ |> Plausible.Mailer.send()
71
+ end
72
+
73
+ _ ->
74
+ nil
75
+ end
76
+ end
77
+
78
+ {:ok, Enum.count(notifications)}
79
+ end
80
+
81
+ defp has_stats?(site_ids, today) do
82
+ ago_2d = Date.add(today, -2)
83
+
84
+ ClickhouseRepo.exists?(
85
+ from e in "events_v2",
86
+ where: fragment("toDate(?) >= ?", e.timestamp, ^ago_2d),
87
+ where: e.site_id in ^site_ids
88
+ )
89
+ end
90
+
91
+ defp store_sent(notification, today) do
92
+ Repo.insert_all(
93
+ "sent_accept_traffic_until_notifications",
94
+ [
95
+ %{
96
+ user_id: notification.id,
97
+ sent_on: today
98
+ }
99
+ ],
100
+ on_conflict: :nothing,
101
+ conflict_target: [:user_id, :sent_on]
102
+ )
103
+
104
+ notification
105
+ end
106
+ end
lib/workers/check_usage.ex ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.CheckUsage do
2
+ use Plausible.Repo
3
+ use Oban.Worker, queue: :check_usage
4
+ require Plausible.Billing.Subscription.Status
5
+ alias Plausible.Billing.{Subscription, Quota}
6
+ alias Plausible.Teams
7
+
8
+ defmacro yesterday() do
9
+ quote do
10
+ fragment("now() - INTERVAL '1 day'")
11
+ end
12
+ end
13
+
14
+ defmacro last_day_of_month(day) do
15
+ quote do
16
+ fragment(
17
+ "(date_trunc('month', ?::date) + interval '1 month' - interval '1 day')::date",
18
+ unquote(day)
19
+ )
20
+ end
21
+ end
22
+
23
+ defmacro day_of_month(date) do
24
+ quote do
25
+ fragment("EXTRACT(day from ?::date)", unquote(date))
26
+ end
27
+ end
28
+
29
+ defmacro least(left, right) do
30
+ quote do
31
+ fragment("least(?, ?)", unquote(left), unquote(right))
32
+ end
33
+ end
34
+
35
+ @impl Oban.Worker
36
+ def perform(_job, usage_mod \\ Teams.Billing, today \\ Date.utc_today()) do
37
+ yesterday = today |> Date.shift(day: -1)
38
+
39
+ active_subscribers =
40
+ Repo.all(
41
+ from(t in Teams.Team,
42
+ as: :team,
43
+ inner_join: o in assoc(t, :owners),
44
+ left_join: bm in assoc(t, :billing_members),
45
+ inner_lateral_join: s in subquery(Teams.last_subscription_join_query()),
46
+ on: true,
47
+ left_join: ep in Plausible.Billing.EnterprisePlan,
48
+ on: ep.team_id == t.id and ep.paddle_plan_id == s.paddle_plan_id,
49
+ where:
50
+ s.status in [
51
+ ^Subscription.Status.active(),
52
+ ^Subscription.Status.past_due(),
53
+ ^Subscription.Status.deleted()
54
+ ],
55
+ where: not is_nil(s.last_bill_date),
56
+ # Accounts for situations like last_bill_date==2021-01-31 AND today==2021-03-01. Since February never reaches the 31st day, the account is checked on 2021-03-01.
57
+ where: s.next_bill_date >= ^today,
58
+ where:
59
+ least(day_of_month(s.last_bill_date), day_of_month(last_day_of_month(^yesterday))) ==
60
+ day_of_month(^yesterday),
61
+ order_by: t.id,
62
+ preload: [subscription: s, enterprise_plan: ep, owners: o, billing_members: bm]
63
+ )
64
+ )
65
+
66
+ for subscriber <- active_subscribers do
67
+ case {subscriber.grace_period, subscriber.enterprise_plan} do
68
+ {nil, nil} ->
69
+ check_regular_subscriber(subscriber, usage_mod)
70
+
71
+ {nil, _} ->
72
+ check_enterprise_subscriber(subscriber, usage_mod)
73
+
74
+ {_, nil} ->
75
+ maybe_remove_grace_period(subscriber, usage_mod)
76
+
77
+ _ ->
78
+ :skip
79
+ end
80
+ end
81
+
82
+ :ok
83
+ end
84
+
85
+ defp check_site_usage_for_enterprise(subscriber) do
86
+ limit = subscriber.enterprise_plan.site_limit
87
+
88
+ usage = Teams.Billing.site_usage(subscriber)
89
+
90
+ if Quota.below_limit?(usage, limit) do
91
+ {:below_limit, {usage, limit}}
92
+ else
93
+ {:over_limit, {usage, limit}}
94
+ end
95
+ end
96
+
97
+ def maybe_remove_grace_period(subscriber, usage_mod) do
98
+ case check_pageview_usage_last_cycle(subscriber, usage_mod) do
99
+ {:below_limit, _} ->
100
+ Plausible.Teams.remove_grace_period(subscriber)
101
+ :ok
102
+
103
+ _ ->
104
+ :skip
105
+ end
106
+ end
107
+
108
+ defp check_regular_subscriber(subscriber, usage_mod) do
109
+ case check_pageview_usage_two_cycles(subscriber, usage_mod) do
110
+ {:over_limit, pageview_usage} ->
111
+ suggested_volume =
112
+ Plausible.Billing.Plans.suggest_volume(subscriber, pageview_usage.last_cycle.total)
113
+
114
+ for owner <- subscriber.owners ++ subscriber.billing_members do
115
+ PlausibleWeb.Email.over_limit_email(owner, subscriber, pageview_usage, suggested_volume)
116
+ |> Plausible.Mailer.send()
117
+ end
118
+
119
+ Plausible.Teams.start_grace_period(subscriber)
120
+
121
+ _ ->
122
+ nil
123
+ end
124
+ end
125
+
126
+ def check_enterprise_subscriber(subscriber, usage_mod) do
127
+ pageview_usage = check_pageview_usage_two_cycles(subscriber, usage_mod)
128
+ site_usage = check_site_usage_for_enterprise(subscriber)
129
+
130
+ case {pageview_usage, site_usage} do
131
+ {{:below_limit, _}, {:below_limit, _}} ->
132
+ nil
133
+
134
+ {{_, pageview_usage}, {_, {site_usage, site_allowance}}} ->
135
+ for owner <- subscriber.owners ++ subscriber.billing_members do
136
+ PlausibleWeb.Email.enterprise_over_limit_internal_email(
137
+ owner,
138
+ pageview_usage,
139
+ site_usage,
140
+ site_allowance
141
+ )
142
+ |> Plausible.Mailer.send()
143
+ end
144
+
145
+ Plausible.Teams.start_manual_lock_grace_period(subscriber)
146
+ end
147
+ end
148
+
149
+ def check_pageview_usage_two_cycles(subscriber, usage_mod) do
150
+ usage = usage_mod.monthly_pageview_usage(subscriber)
151
+ limit = Teams.Billing.monthly_pageview_limit(subscriber.subscription)
152
+
153
+ if Quota.exceeds_last_two_usage_cycles?(usage, limit) do
154
+ {:over_limit, usage}
155
+ else
156
+ {:below_limit, usage}
157
+ end
158
+ end
159
+
160
+ defp check_pageview_usage_last_cycle(subscriber, usage_mod) do
161
+ usage = usage_mod.monthly_pageview_usage(subscriber)
162
+ limit = Teams.Billing.monthly_pageview_limit(subscriber.subscription)
163
+
164
+ if :last_cycle in Quota.exceeded_cycles(usage, limit) do
165
+ {:over_limit, usage}
166
+ else
167
+ {:below_limit, usage}
168
+ end
169
+ end
170
+ end
lib/workers/clean_invitations.ex ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.CleanInvitations do
2
+ use Plausible.Repo
3
+ use Oban.Worker, queue: :clean_invitations
4
+
5
+ @cutoff Duration.new!(hour: -48)
6
+
7
+ @impl Oban.Worker
8
+ def perform(_job) do
9
+ cutoff_time =
10
+ NaiveDateTime.utc_now(:second)
11
+ |> NaiveDateTime.shift(@cutoff)
12
+
13
+ Repo.delete_all(
14
+ from ti in Plausible.Teams.Invitation,
15
+ where: ti.inserted_at < ^cutoff_time
16
+ )
17
+
18
+ Repo.delete_all(
19
+ from ti in Plausible.Teams.SiteTransfer,
20
+ where: ti.inserted_at < ^cutoff_time
21
+ )
22
+
23
+ :ok
24
+ end
25
+ end
lib/workers/clean_user_sessions.ex ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.CleanUserSessions do
2
+ @moduledoc """
3
+ Job removing expired user sessions. A grace period is applied.
4
+ """
5
+
6
+ use Plausible.Repo
7
+ use Oban.Worker, queue: :clean_user_sessions
8
+
9
+ @grace_period Duration.new!(day: -7)
10
+
11
+ @spec grace_period_duration() :: Duration.t()
12
+ def grace_period_duration(), do: @grace_period
13
+
14
+ @impl Oban.Worker
15
+ def perform(_job) do
16
+ grace_cutoff =
17
+ NaiveDateTime.utc_now(:second)
18
+ |> NaiveDateTime.shift(@grace_period)
19
+
20
+ Repo.delete_all(
21
+ from us in Plausible.Auth.UserSession,
22
+ where: us.timeout_at < ^grace_cutoff
23
+ )
24
+
25
+ :ok
26
+ end
27
+ end
lib/workers/clickhouse_clean_sites.ex ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.ClickhouseCleanSites do
2
+ @moduledoc """
3
+ Cleans deleted site data from ClickHouse asynchronously.
4
+
5
+ We batch up data deletions from ClickHouse as deleting a single site is
6
+ just as expensive as deleting many.
7
+ """
8
+
9
+ use Plausible.Repo
10
+ use Plausible.ClickhouseRepo
11
+ use Plausible.IngestRepo
12
+ use Oban.Worker, queue: :clickhouse_clean_sites
13
+
14
+ import Ecto.Query
15
+
16
+ require Logger
17
+
18
+ @tables_to_clear [
19
+ "events_v2",
20
+ "sessions_v2",
21
+ "ingest_counters",
22
+ "imported_browsers",
23
+ "imported_devices",
24
+ "imported_entry_pages",
25
+ "imported_exit_pages",
26
+ "imported_locations",
27
+ "imported_operating_systems",
28
+ "imported_pages",
29
+ "imported_custom_events",
30
+ "imported_sources",
31
+ "imported_visitors"
32
+ ]
33
+
34
+ @settings if Mix.env() in [:test, :ce_test, :e2e_test], do: [mutations_sync: 2], else: []
35
+
36
+ def perform(_job) do
37
+ deleted_sites = get_deleted_sites_with_clickhouse_data()
38
+
39
+ if not Enum.empty?(deleted_sites) do
40
+ Logger.notice(
41
+ "Clearing ClickHouse data for the following #{length(deleted_sites)} sites which have been deleted: #{inspect(deleted_sites)}"
42
+ )
43
+
44
+ for table <- @tables_to_clear do
45
+ IngestRepo.query!(
46
+ "ALTER TABLE {$0:Identifier} DELETE WHERE site_id IN {$1:Array(UInt64)}",
47
+ [table, deleted_sites],
48
+ settings: @settings
49
+ )
50
+ end
51
+ end
52
+
53
+ :ok
54
+ end
55
+
56
+ def get_deleted_sites_with_clickhouse_data() do
57
+ pg_sites =
58
+ from(s in Plausible.Site.regular(), select: s.id)
59
+ |> Plausible.Repo.all()
60
+ |> MapSet.new()
61
+
62
+ {:ok, ch} =
63
+ Plausible.ClickhouseRepo.get_config_without_ch_query_execution_timeout()
64
+ |> Ch.start_link()
65
+
66
+ %Ch.Result{columns: ["site_id"], rows: rows} =
67
+ DBConnection.run(
68
+ ch,
69
+ fn conn ->
70
+ Ch.query!(conn, "FROM events_v2 SELECT site_id GROUP BY site_id", [],
71
+ timeout: :infinity
72
+ )
73
+ end,
74
+ timeout: :infinity
75
+ )
76
+
77
+ ch_sites = rows |> MapSet.new(fn [site_id] -> site_id end)
78
+
79
+ MapSet.difference(ch_sites, pg_sites) |> MapSet.to_list()
80
+ end
81
+ end
lib/workers/expire_domain_change_transitions.ex ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.ExpireDomainChangeTransitions do
2
+ @moduledoc """
3
+ Periodic worker that expires domain change transition period.
4
+ Old domains are frozen for a given time, so users can still access them
5
+ before redeploying their scripts and integrations.
6
+ """
7
+ use Plausible.Repo
8
+ use Oban.Worker, queue: :domain_change_transition
9
+
10
+ require Logger
11
+
12
+ @impl Oban.Worker
13
+ def perform(_job) do
14
+ {:ok, n} = Plausible.Site.Domain.expire_change_transitions()
15
+
16
+ if n > 0 do
17
+ Logger.warning("Expired #{n} from the domain change transition period.")
18
+ end
19
+
20
+ :ok
21
+ end
22
+ end
lib/workers/export_analytics.ex ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.ExportAnalytics do
2
+ @moduledoc """
3
+ Worker for running CSV export jobs. Supports S3 and local storage.
4
+ To avoid blocking the queue, a timeout of 15 minutes is enforced.
5
+ """
6
+
7
+ use Oban.Worker,
8
+ queue: :analytics_exports,
9
+ max_attempts: 3
10
+
11
+ alias Plausible.Exports
12
+
13
+ @doc "This base query filters export jobs for a site"
14
+ def base_query(site_id) do
15
+ import Ecto.Query, only: [from: 2]
16
+
17
+ from j in Oban.Job,
18
+ where: j.worker == ^Oban.Worker.to_string(__MODULE__),
19
+ where: j.args["site_id"] == ^site_id
20
+ end
21
+
22
+ @impl true
23
+ def timeout(_job), do: :timer.minutes(15)
24
+
25
+ @impl true
26
+ def perform(%Oban.Job{args: args} = job) do
27
+ %{
28
+ "storage" => storage,
29
+ "site_id" => site_id
30
+ } = args
31
+
32
+ site = Plausible.Repo.get!(Plausible.Site, site_id)
33
+ true = Plausible.Sites.regular?(site)
34
+ %Date.Range{} = date_range = Exports.date_range(site.id, site.timezone)
35
+
36
+ queries =
37
+ Exports.export_queries(site,
38
+ date_range: date_range,
39
+ timezone: site.timezone,
40
+ extname: ".csv"
41
+ )
42
+
43
+ # since each worker / `perform` attempt runs in a separate process
44
+ # it's ok to use start_link to keep connection lifecycle
45
+ # bound to that of the worker
46
+ {:ok, ch} =
47
+ Plausible.ClickhouseRepo.get_config_without_ch_query_execution_timeout()
48
+ |> Ch.start_link()
49
+
50
+ try do
51
+ case storage do
52
+ "s3" -> perform_s3_export(ch, site, queries, args)
53
+ "local" -> perform_local_export(ch, queries, args)
54
+ end
55
+ after
56
+ Exports.oban_notify(site_id)
57
+ end
58
+
59
+ email_success(job.args)
60
+
61
+ :ok
62
+ catch
63
+ class, reason ->
64
+ if job.attempt >= job.max_attempts, do: email_failure(job.args)
65
+ :erlang.raise(class, reason, __STACKTRACE__)
66
+ end
67
+
68
+ defp perform_s3_export(ch, site, queries, args) do
69
+ %{
70
+ "s3_bucket" => s3_bucket,
71
+ "s3_path" => s3_path
72
+ } = args
73
+
74
+ created_on = Plausible.Timezones.to_date_in_timezone(DateTime.utc_now(), site.timezone)
75
+ filename = Exports.archive_filename(site.domain, created_on)
76
+
77
+ DBConnection.run(
78
+ ch,
79
+ fn conn ->
80
+ conn
81
+ |> Exports.stream_archive(queries, format: "CSVWithNames", timeout: :infinity)
82
+ |> Plausible.S3.export_upload_multipart(s3_bucket, s3_path, filename)
83
+ end,
84
+ timeout: :infinity
85
+ )
86
+ end
87
+
88
+ defp perform_local_export(ch, queries, args) do
89
+ %{"local_path" => local_path} = args
90
+ tmp_path = Plug.Upload.random_file!("tmp-plausible-export")
91
+
92
+ DBConnection.run(
93
+ ch,
94
+ fn conn ->
95
+ Exports.stream_archive(conn, queries, format: "CSVWithNames")
96
+ |> Stream.into(File.stream!(tmp_path))
97
+ |> Stream.run()
98
+ end,
99
+ timeout: :infinity
100
+ )
101
+
102
+ File.mkdir_p!(Path.dirname(local_path))
103
+ if File.exists?(local_path), do: File.rm!(local_path)
104
+ Plausible.File.mv!(tmp_path, local_path)
105
+ end
106
+
107
+ defp email_failure(args) do
108
+ args |> Map.put("status", "failure") |> email()
109
+ end
110
+
111
+ defp email_success(args) do
112
+ args |> Map.put("status", "success") |> email()
113
+ end
114
+
115
+ defp email(args) do
116
+ # email delivery can potentially fail and cause already successful
117
+ # export to be repeated which is costly, hence email is delivered
118
+ # in a separate job
119
+ Oban.insert!(Plausible.Workers.NotifyExportedAnalytics.new(args))
120
+ end
121
+ end
lib/workers/import_analytics.ex ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.ImportAnalytics do
2
+ @moduledoc """
3
+ Worker for running analytics import jobs.
4
+ """
5
+
6
+ use Plausible.Repo
7
+
8
+ use Oban.Worker,
9
+ queue: :analytics_imports,
10
+ max_attempts: 3,
11
+ unique: [fields: [:args], keys: [:import_id], period: 60]
12
+
13
+ alias Plausible.Imported.ImportSources
14
+ alias Plausible.Imported.Importer
15
+ alias Plausible.Imported.SiteImport
16
+
17
+ @impl Oban.Worker
18
+ def perform(%Oban.Job{
19
+ args: %{"import_id" => import_id} = args
20
+ }) do
21
+ site_import =
22
+ SiteImport
23
+ |> Repo.get!(import_id)
24
+ |> Repo.preload(:site)
25
+
26
+ import_api = ImportSources.by_name(site_import.source)
27
+
28
+ case import_api.run_import(site_import, args) do
29
+ {:ok, site_import} ->
30
+ import_complete(site_import)
31
+
32
+ :ok
33
+
34
+ {:error, error, error_opts} ->
35
+ Sentry.capture_message("Failed to import from #{site_import.source}",
36
+ extra: %{
37
+ import_id: site_import.id,
38
+ site: site_import.site.domain,
39
+ error: inspect(error)
40
+ }
41
+ )
42
+
43
+ import_fail(site_import, error_opts)
44
+
45
+ {:discard, error}
46
+ end
47
+ end
48
+
49
+ @impl Oban.Worker
50
+ def backoff(_job) do
51
+ # 5 minutes
52
+ 300
53
+ end
54
+
55
+ def import_complete(site_import) do
56
+ site_import = Repo.preload(site_import, [:imported_by, site: :team])
57
+
58
+ PlausibleWeb.Email.import_success(site_import, site_import.imported_by)
59
+ |> Plausible.Mailer.send()
60
+
61
+ Plausible.Sites.clear_stats_start_date!(site_import.site)
62
+
63
+ Importer.notify(site_import, :complete)
64
+
65
+ :ok
66
+ end
67
+
68
+ def import_fail_transient(site_import) do
69
+ Plausible.Purge.delete_imported_stats!(site_import)
70
+
71
+ Importer.notify(site_import, :transient_fail)
72
+ end
73
+
74
+ def import_fail(site_import, opts) do
75
+ skip_purge? = Keyword.get(opts, :skip_purge?, false)
76
+ skip_mark_failed? = Keyword.get(opts, :skip_mark_failed?, false)
77
+
78
+ if not skip_purge? do
79
+ Plausible.Purge.delete_imported_stats!(site_import)
80
+ end
81
+
82
+ if not skip_mark_failed? do
83
+ import_api = ImportSources.by_name(site_import.source)
84
+
85
+ site_import =
86
+ site_import
87
+ |> import_api.mark_failed()
88
+ |> Repo.preload([:site, :imported_by])
89
+
90
+ Importer.notify(site_import, :fail)
91
+
92
+ PlausibleWeb.Email.import_failure(site_import, site_import.imported_by)
93
+ |> Plausible.Mailer.send()
94
+ end
95
+ end
96
+ end
lib/workers/local_import_analytics_cleaner.ex ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.LocalImportAnalyticsCleaner do
2
+ @moduledoc """
3
+ Worker for cleaning local files left after analytics import jobs.
4
+ """
5
+
6
+ use Oban.Worker, queue: :analytics_imports, unique: [period: 3600]
7
+
8
+ @impl Oban.Worker
9
+ def perform(%Oban.Job{args: args}) do
10
+ %{"import_id" => import_id, "paths" => paths} = args
11
+
12
+ if import_in_progress?(import_id) do
13
+ {:snooze, _one_hour = 3600}
14
+ else
15
+ Enum.each(paths, fn path ->
16
+ # credo:disable-for-next-line Credo.Check.Refactor.Nesting
17
+ if File.exists?(path), do: File.rm!(path)
18
+ end)
19
+ end
20
+ end
21
+
22
+ defp import_in_progress?(import_id) do
23
+ import Ecto.Query
24
+ require Plausible.Imported.SiteImport
25
+ alias Plausible.Imported.SiteImport
26
+
27
+ SiteImport
28
+ |> where(id: ^import_id)
29
+ |> where([i], i.status in ^[SiteImport.pending(), SiteImport.importing()])
30
+ |> Plausible.Repo.exists?()
31
+ end
32
+ end
lib/workers/locations_sync.ex ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.LocationsSync do
2
+ @moduledoc false
3
+
4
+ use Plausible.Repo
5
+ use Oban.Worker, queue: :locations_sync
6
+
7
+ @impl Oban.Worker
8
+ def perform(_job) do
9
+ if Plausible.DataMigration.LocationsSync.out_of_date?() do
10
+ Plausible.DataMigration.LocationsSync.run()
11
+ end
12
+
13
+ :ok
14
+ end
15
+ end
lib/workers/lock_sites.ex ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.LockSites do
2
+ use Plausible.Repo
3
+ use Oban.Worker, queue: :lock_sites
4
+
5
+ alias Plausible.Teams
6
+
7
+ @impl Oban.Worker
8
+ def perform(_job) do
9
+ teams =
10
+ Repo.all(
11
+ from t in Teams.Team,
12
+ as: :team,
13
+ left_lateral_join: s in subquery(Teams.last_subscription_join_query()),
14
+ on: true,
15
+ preload: [subscription: s]
16
+ )
17
+
18
+ for team <- teams do
19
+ Plausible.Billing.SiteLocker.update_for(team)
20
+ end
21
+
22
+ :ok
23
+ end
24
+ end
lib/workers/notify_annual_renewal.ex ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.NotifyAnnualRenewal do
2
+ use Plausible.Repo
3
+ use Oban.Worker, queue: :notify_annual_renewal
4
+
5
+ require Plausible.Billing.Subscription.Status
6
+
7
+ alias Money.Subscription
8
+ alias Plausible.Billing.Subscription
9
+ alias Plausible.Teams
10
+
11
+ @yearly_plans Plausible.Billing.Plans.yearly_product_ids()
12
+
13
+ @impl Oban.Worker
14
+ @doc """
15
+ Sends a notification at most 7 days and at least 1 day before the renewal of an annual subscription
16
+ """
17
+ def perform(_job) do
18
+ sent_notification =
19
+ from(
20
+ s in "sent_renewal_notifications",
21
+ where: s.timestamp > fragment("now() - INTERVAL '1 month'")
22
+ )
23
+
24
+ teams =
25
+ Repo.all(
26
+ from t in Teams.Team,
27
+ as: :team,
28
+ inner_join: o in assoc(t, :owners),
29
+ left_join: bm in assoc(t, :billing_members),
30
+ inner_lateral_join: s in subquery(Teams.last_subscription_join_query()),
31
+ on: true,
32
+ left_join: sent in ^sent_notification,
33
+ on: o.id == sent.user_id,
34
+ where: is_nil(sent.id),
35
+ where: s.paddle_plan_id in @yearly_plans,
36
+ where:
37
+ s.next_bill_date > fragment("now()::date") and
38
+ s.next_bill_date <= fragment("now()::date + INTERVAL '7 days'"),
39
+ preload: [owners: o, subscription: s, billing_members: bm]
40
+ )
41
+
42
+ for team <- teams do
43
+ recipients = team.owners ++ team.billing_members
44
+
45
+ case team.subscription.status do
46
+ Subscription.Status.active() ->
47
+ for recipient <- recipients do
48
+ template = PlausibleWeb.Email.yearly_renewal_notification(team, recipient)
49
+ Plausible.Mailer.send(template)
50
+ end
51
+
52
+ Subscription.Status.deleted() ->
53
+ for recipient <- recipients do
54
+ template = PlausibleWeb.Email.yearly_expiration_notification(team, recipient)
55
+ Plausible.Mailer.send(template)
56
+ end
57
+
58
+ _ ->
59
+ Sentry.capture_message("Invalid subscription for renewal",
60
+ team: team
61
+ )
62
+ end
63
+
64
+ for recipient <- recipients do
65
+ Repo.insert_all("sent_renewal_notifications", [
66
+ %{
67
+ user_id: recipient.id,
68
+ timestamp: NaiveDateTime.utc_now()
69
+ }
70
+ ])
71
+ end
72
+ end
73
+
74
+ :ok
75
+ end
76
+ end
lib/workers/notify_exported_analytics.ex ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.NotifyExportedAnalytics do
2
+ @moduledoc "This worker delivers emails for successful and failed exports"
3
+
4
+ use Oban.Worker,
5
+ queue: :notify_exported_analytics,
6
+ max_attempts: 5
7
+
8
+ @impl true
9
+ def perform(%Oban.Job{args: args}) do
10
+ %{
11
+ "status" => status,
12
+ "storage" => storage,
13
+ "email_to" => email_to,
14
+ "site_id" => site_id
15
+ } = args
16
+
17
+ user = Plausible.Repo.get_by!(Plausible.Auth.User, email: email_to)
18
+
19
+ site =
20
+ Plausible.Site
21
+ |> Plausible.Repo.get!(site_id)
22
+ |> Plausible.Repo.preload(:team)
23
+
24
+ email =
25
+ case status do
26
+ "success" ->
27
+ case storage do
28
+ "s3" ->
29
+ %{expires_at: expires_at} = Plausible.Exports.get_s3_export!(site_id, _retries = 10)
30
+ PlausibleWeb.Email.export_success(user, site, expires_at)
31
+
32
+ "local" ->
33
+ PlausibleWeb.Email.export_success(user, site, _expires_at = nil)
34
+ end
35
+
36
+ "failure" ->
37
+ PlausibleWeb.Email.export_failure(user, site)
38
+ end
39
+
40
+ Plausible.Mailer.deliver_now!(email)
41
+ :ok
42
+ end
43
+ end
lib/workers/purge_cdn_cache.ex ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.PurgeCDNCache do
2
+ @moduledoc """
3
+ Worker for purging CDN cache for tracker scripts on cloud.
4
+
5
+ Uses Bunny CDN's API to purge cache by tag.
6
+ Docs ref: https://docs.bunny.net/reference/pullzonepublic_purgecachepostbytag
7
+
8
+ Note that purging by id "*" is equivalent to purging ALL cache.
9
+ """
10
+
11
+ use Oban.Worker,
12
+ queue: :purge_cdn_cache,
13
+ max_attempts: 5,
14
+ # To avoid running into API rate limits, we:
15
+ # - Schedule jobs with a delay
16
+ # - Bump the scheduled time every time a new one is scheduled with the same args
17
+ unique: [
18
+ states: [:scheduled],
19
+ fields: [:args]
20
+ ]
21
+
22
+ require Logger
23
+
24
+ @impl Oban.Worker
25
+ def perform(%Oban.Job{args: %{"id" => id}}) do
26
+ pullzone_id = Application.get_env(:plausible, __MODULE__, [])[:pullzone_id]
27
+ api_key = Application.get_env(:plausible, __MODULE__, [])[:api_key]
28
+
29
+ purge_cache(id, pullzone_id, api_key)
30
+ end
31
+
32
+ @impl Oban.Worker
33
+ def backoff(%Oban.Job{attempt: attempt}) do
34
+ # Exponential backoff starting at 3 minutes
35
+ trunc(:math.pow(2, attempt - 1) * 180)
36
+ end
37
+
38
+ defp purge_cache(id, pullzone_id, api_key) when is_nil(pullzone_id) or is_nil(api_key) do
39
+ Logger.warning("Ignoring purge CDN cache for tracker script #{id}: Configuration missing")
40
+ {:discard, "Configuration missing"}
41
+ end
42
+
43
+ defp purge_cache(id, pullzone_id, api_key) do
44
+ options =
45
+ [
46
+ headers: [
47
+ {"content-type", "application/json"},
48
+ {"AccessKey", api_key}
49
+ ],
50
+ body: Jason.encode!(%{"CacheTag" => "tracker_script::#{id}"})
51
+ ]
52
+ |> Keyword.merge(Application.get_env(:plausible, __MODULE__)[:req_opts] || [])
53
+
54
+ case Req.post("https://api.bunny.net/pullzone/#{pullzone_id}/purgeCache", options) do
55
+ {:ok, %{status: 204}} ->
56
+ Logger.notice("Successfully purged CDN cache for tracker script #{id}")
57
+ {:ok, :success}
58
+
59
+ {:ok, %{status: status}} ->
60
+ Logger.warning(
61
+ "Failed to purge CDN cache for tracker script #{id}: Unexpected status: #{status}"
62
+ )
63
+
64
+ {:error, "Unexpected status: #{status}"}
65
+
66
+ {:error, reason} ->
67
+ Logger.warning("Failed to purge CDN cache for tracker script #{id}: #{inspect(reason)}")
68
+ {:error, reason}
69
+ end
70
+ end
71
+ end
lib/workers/rotate_salts.ex ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.RotateSalts do
2
+ use Plausible.Repo
3
+ use Oban.Worker, queue: :rotate_salts
4
+
5
+ @impl Oban.Worker
6
+ def perform(_job) do
7
+ Plausible.Session.Salts.rotate()
8
+ end
9
+ end
lib/workers/schedule_email_reports.ex ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.ScheduleEmailReports do
2
+ @moduledoc """
3
+ Job scheduling dispatch of weekly and monthly email reports.
4
+ """
5
+ use Plausible.Repo
6
+ use Oban.Worker, queue: :schedule_email_reports
7
+ alias Plausible.Workers.SendEmailReport
8
+
9
+ @impl Oban.Worker
10
+ @doc """
11
+ Email reports should be sent on Monday at 9am according to the timezone
12
+ of a site.
13
+ """
14
+ def perform(_job) do
15
+ schedule_weekly_emails()
16
+ schedule_monthly_emails()
17
+ end
18
+
19
+ defp schedule_weekly_emails() do
20
+ weekly_jobs =
21
+ from(
22
+ j in Oban.Job,
23
+ where:
24
+ j.worker == "Plausible.Workers.SendEmailReport" and
25
+ fragment("(? ->> 'interval')", j.args) == "weekly"
26
+ )
27
+
28
+ sites =
29
+ Repo.all(
30
+ from s in Plausible.Site,
31
+ inner_join: t in assoc(s, :team),
32
+ join: wr in Plausible.Site.WeeklyReport,
33
+ on: wr.site_id == s.id,
34
+ left_join: job in subquery(weekly_jobs),
35
+ on:
36
+ fragment("(? -> 'site_id')::int", job.args) == s.id and
37
+ job.state not in ["completed", "discarded"],
38
+ where: is_nil(job),
39
+ where: not t.locked,
40
+ preload: [weekly_report: wr]
41
+ )
42
+
43
+ for site <- sites do
44
+ SendEmailReport.new(%{site_id: site.id, interval: "weekly"},
45
+ scheduled_at: monday_9am(site.timezone)
46
+ )
47
+ |> Oban.insert!()
48
+ end
49
+
50
+ :ok
51
+ end
52
+
53
+ def monday_9am(timezone) do
54
+ DateTime.now!(timezone)
55
+ |> DateTime.shift(week: 1)
56
+ |> Plausible.Times.beginning_of_week()
57
+ |> DateTime.shift(hour: 9)
58
+ end
59
+
60
+ defp schedule_monthly_emails() do
61
+ monthly_jobs =
62
+ from(
63
+ j in Oban.Job,
64
+ where:
65
+ j.worker == "Plausible.Workers.SendEmailReport" and
66
+ fragment("(? ->> 'interval')", j.args) == "monthly"
67
+ )
68
+
69
+ sites =
70
+ Repo.all(
71
+ from s in Plausible.Site,
72
+ inner_join: t in assoc(s, :team),
73
+ join: mr in Plausible.Site.MonthlyReport,
74
+ on: mr.site_id == s.id,
75
+ left_join: job in subquery(monthly_jobs),
76
+ on:
77
+ fragment("(? -> 'site_id')::int", job.args) == s.id and
78
+ job.state not in ["completed", "discarded"],
79
+ where: is_nil(job),
80
+ where: not t.locked,
81
+ preload: [monthly_report: mr]
82
+ )
83
+
84
+ for site <- sites do
85
+ SendEmailReport.new(%{site_id: site.id, interval: "monthly"},
86
+ scheduled_at: first_of_month_9am(site.timezone)
87
+ )
88
+ |> Oban.insert!()
89
+ end
90
+
91
+ :ok
92
+ end
93
+
94
+ def first_of_month_9am(timezone) do
95
+ DateTime.now!(timezone)
96
+ |> DateTime.shift(month: 1)
97
+ |> Plausible.Times.beginning_of_month()
98
+ |> DateTime.shift(hour: 9)
99
+ end
100
+ end
lib/workers/send_check_stats_emails.ex ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.SendCheckStatsEmails do
2
+ use Plausible.Repo
3
+ use Oban.Worker, queue: :check_stats_emails
4
+
5
+ @impl Oban.Worker
6
+ def perform(_job) do
7
+ q =
8
+ from(u in Plausible.Auth.User,
9
+ left_join: ce in "check_stats_emails",
10
+ on: ce.user_id == u.id,
11
+ where: is_nil(ce.id),
12
+ where:
13
+ u.inserted_at > fragment("(now() at time zone 'utc') - '14 days'::interval") and
14
+ u.inserted_at < fragment("(now() at time zone 'utc') - '7 days'::interval") and
15
+ u.last_seen < fragment("(now() at time zone 'utc') - '7 days'::interval")
16
+ )
17
+
18
+ for user <- Repo.all(q) do
19
+ if eligible_for_check_stats_email?(user) do
20
+ send_check_stats_email(user)
21
+ end
22
+ end
23
+
24
+ :ok
25
+ end
26
+
27
+ defp eligible_for_check_stats_email?(user) do
28
+ sites =
29
+ from(tm in Plausible.Teams.Membership,
30
+ inner_join: t in assoc(tm, :team),
31
+ inner_join: s in assoc(t, :sites),
32
+ left_join: gm in assoc(tm, :guest_memberships),
33
+ where: tm.user_id == ^user.id,
34
+ where: tm.role != :guest or gm.site_id == s.id,
35
+ select: s
36
+ )
37
+ |> Repo.all()
38
+ |> Repo.preload(:weekly_report)
39
+
40
+ not Enum.any?(sites, fn site -> site.weekly_report end) and
41
+ Enum.any?(sites, &Plausible.Sites.has_stats?/1)
42
+ end
43
+
44
+ defp send_check_stats_email(user) do
45
+ PlausibleWeb.Email.check_stats_email(user)
46
+ |> Plausible.Mailer.send()
47
+
48
+ Repo.insert_all("check_stats_emails", [
49
+ %{
50
+ user_id: user.id,
51
+ timestamp: NaiveDateTime.utc_now()
52
+ }
53
+ ])
54
+ end
55
+ end
lib/workers/send_email_report.ex ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.SendEmailReport do
2
+ use Plausible
3
+ use Plausible.Repo
4
+ use Oban.Worker, queue: :send_email_reports, max_attempts: 1
5
+
6
+ alias Plausible.Stats.{QueryResult, QueryBuilder, ParsedQueryParams, QueryInclude}
7
+
8
+ import Ecto.Query, only: [from: 2]
9
+
10
+ @weekly "weekly"
11
+ @monthly "monthly"
12
+
13
+ def perform(%Oban.Job{args: %{"interval" => interval, "site_id" => site_id}})
14
+ when interval in [@weekly, @monthly] do
15
+ report_type = report_type(interval)
16
+
17
+ site =
18
+ from(s in Plausible.Site,
19
+ where: s.id == ^site_id,
20
+ inner_join: r in assoc(s, ^report_type),
21
+ inner_join: t in assoc(s, :team),
22
+ preload: [{^report_type, r}, {:team, t}]
23
+ )
24
+ |> Repo.one()
25
+
26
+ with %Plausible.Site{} <- site,
27
+ %{} = report <- Map.fetch!(site, report_type),
28
+ true <- ok_to_send?(site) do
29
+ date_range = date_range(site, interval)
30
+ report_name = report_name(interval, date_range.first)
31
+ date_label = Calendar.strftime(date_range.last, "%-d %b %Y")
32
+ stats = stats(site, date_range)
33
+
34
+ report
35
+ |> Map.fetch!(:recipients)
36
+ |> Enum.each(fn email ->
37
+ assigns = %{
38
+ site: site,
39
+ report_name: report_name,
40
+ date_label: date_label,
41
+ unsubscribe_link: unsubscribe_link(site, email, interval),
42
+ site_member?: site_member?(site, email),
43
+ interval: interval,
44
+ stats: stats
45
+ }
46
+
47
+ email
48
+ |> PlausibleWeb.Email.stats_report(assigns)
49
+ |> Plausible.Mailer.send()
50
+ end)
51
+ else
52
+ _ -> :discard
53
+ end
54
+ end
55
+
56
+ defp report_type(@weekly), do: :weekly_report
57
+ defp report_type(@monthly), do: :monthly_report
58
+
59
+ defp site_member?(site, email) do
60
+ user = Plausible.Auth.find_user_by(email: email)
61
+ user && Plausible.Teams.Memberships.site_member?(site, user)
62
+ end
63
+
64
+ defp unsubscribe_link(site, email, interval) do
65
+ PlausibleWeb.Endpoint.url() <>
66
+ "/sites/#{URI.encode_www_form(site.domain)}/#{interval}-report/unsubscribe?email=#{email}"
67
+ end
68
+
69
+ defp report_name(@weekly, _), do: "Weekly"
70
+ defp report_name(@monthly, first), do: Calendar.strftime(first, "%B")
71
+
72
+ defp stats(site, date_range) do
73
+ shared_params = %ParsedQueryParams{
74
+ metrics: [:visitors],
75
+ input_date_range: {:date_range, date_range.first, date_range.last},
76
+ pagination: %{limit: 5, offset: 0}
77
+ }
78
+
79
+ stats = stats_aggregates(site, shared_params)
80
+ pages = pages(site, shared_params)
81
+ sources = sources(site, shared_params)
82
+ goals = goals(site, shared_params)
83
+
84
+ stats
85
+ |> Map.put(:pages, pages)
86
+ |> Map.put(:sources, sources)
87
+ |> Map.put(:goals, goals)
88
+ end
89
+
90
+ defp stats_aggregates(site, %ParsedQueryParams{} = shared_params) do
91
+ query =
92
+ QueryBuilder.build!(
93
+ site,
94
+ struct!(shared_params,
95
+ metrics: [:pageviews, :visitors, :bounce_rate],
96
+ include: %QueryInclude{compare: :previous_period}
97
+ )
98
+ )
99
+
100
+ %QueryResult{
101
+ results: [
102
+ %{
103
+ metrics: [pageviews, visitors, bounce_rate],
104
+ comparison: %{
105
+ change: [pageviews_change, visitors_change, bounce_rate_change]
106
+ }
107
+ }
108
+ ]
109
+ } = Plausible.Stats.query(site, query)
110
+
111
+ %{
112
+ pageviews: %{value: pageviews, change: pageviews_change},
113
+ visitors: %{value: visitors, change: visitors_change},
114
+ bounce_rate: %{value: bounce_rate, change: bounce_rate_change}
115
+ }
116
+ end
117
+
118
+ defp pages(site, %ParsedQueryParams{} = shared_params) do
119
+ query = QueryBuilder.build!(site, struct!(shared_params, dimensions: ["event:page"]))
120
+
121
+ site
122
+ |> Plausible.Stats.query(query)
123
+ |> Map.fetch!(:results)
124
+ |> Enum.map(fn %{metrics: [visitors], dimensions: [page]} ->
125
+ %{
126
+ page: page,
127
+ visitors: visitors
128
+ }
129
+ end)
130
+ end
131
+
132
+ defp sources(site, %ParsedQueryParams{} = shared_params) do
133
+ query =
134
+ QueryBuilder.build!(
135
+ site,
136
+ struct!(shared_params,
137
+ dimensions: ["visit:source"],
138
+ filters: [[:is_not, "visit:source", ["Direct / None"]]]
139
+ )
140
+ )
141
+
142
+ site
143
+ |> Plausible.Stats.query(query)
144
+ |> Map.fetch!(:results)
145
+ |> Enum.map(fn %{metrics: [visitors], dimensions: [source]} ->
146
+ %{
147
+ source: source,
148
+ visitors: visitors
149
+ }
150
+ end)
151
+ end
152
+
153
+ defp goals(site, %ParsedQueryParams{} = shared_params) do
154
+ query = QueryBuilder.build!(site, struct!(shared_params, dimensions: ["event:goal"]))
155
+
156
+ site
157
+ |> Plausible.Stats.query(query)
158
+ |> Map.fetch!(:results)
159
+ |> Enum.map(fn %{metrics: [visitors], dimensions: [goal_name]} ->
160
+ %{
161
+ goal: goal_name,
162
+ visitors: visitors
163
+ }
164
+ end)
165
+ end
166
+
167
+ defp date_range(site, @weekly) do
168
+ first =
169
+ site.timezone
170
+ |> DateTime.now!()
171
+ |> Date.shift(day: -7)
172
+ |> Date.beginning_of_week()
173
+
174
+ last =
175
+ site.timezone
176
+ |> DateTime.now!()
177
+ |> DateTime.to_date()
178
+ |> Date.shift(day: -7)
179
+ |> Date.end_of_week()
180
+
181
+ Date.range(first, last)
182
+ end
183
+
184
+ defp date_range(site, @monthly) do
185
+ first =
186
+ site.timezone
187
+ |> DateTime.now!()
188
+ |> Date.shift(month: -1)
189
+ |> Date.beginning_of_month()
190
+
191
+ last =
192
+ site.timezone
193
+ |> DateTime.now!()
194
+ |> DateTime.shift(month: -1)
195
+ |> DateTime.to_date()
196
+ |> Date.end_of_month()
197
+
198
+ Date.range(first, last)
199
+ end
200
+
201
+ on_ee do
202
+ defp ok_to_send?(site) do
203
+ Plausible.Sites.regular?(site) or
204
+ (Plausible.Sites.consolidated?(site) and
205
+ Plausible.ConsolidatedView.ok_to_display?(site.team))
206
+ end
207
+ else
208
+ defp ok_to_send?(_site), do: always(true)
209
+ end
210
+ end
lib/workers/send_site_setup_emails.ex ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.SendSiteSetupEmails do
2
+ @moduledoc """
3
+ Job sending site setup emails.
4
+ """
5
+
6
+ use Plausible.Repo
7
+ use Oban.Worker, queue: :site_setup_emails
8
+
9
+ @impl Oban.Worker
10
+ def perform(_job) do
11
+ send_create_site_emails()
12
+ send_setup_help_emails()
13
+ send_setup_success_emails()
14
+
15
+ :ok
16
+ end
17
+
18
+ defp send_create_site_emails() do
19
+ q =
20
+ from u in Plausible.Auth.User,
21
+ as: :user,
22
+ where:
23
+ not exists(
24
+ from tm in Plausible.Teams.Membership,
25
+ where: tm.user_id == parent_as(:user).id,
26
+ select: true
27
+ ),
28
+ where:
29
+ not exists(
30
+ from se in "create_site_emails",
31
+ where: se.user_id == parent_as(:user).id,
32
+ select: true
33
+ ),
34
+ where:
35
+ u.inserted_at > fragment("(now() at time zone 'utc') - '72 hours'::interval") and
36
+ u.inserted_at < fragment("(now() at time zone 'utc') - '48 hours'::interval")
37
+
38
+ for user <- Repo.all(q) do
39
+ send_create_site_email(user)
40
+ end
41
+ end
42
+
43
+ defp send_setup_help_emails() do
44
+ q =
45
+ from(s in Plausible.Site.regular(),
46
+ left_join: se in "setup_help_emails",
47
+ on: se.site_id == s.id,
48
+ where: is_nil(se.id),
49
+ where: s.inserted_at > fragment("(now() at time zone 'utc') - '72 hours'::interval"),
50
+ preload: [:owners, :team]
51
+ )
52
+
53
+ for site <- Repo.all(q) do
54
+ owners = site.owners
55
+ setup_completed = Plausible.Sites.has_stats?(site)
56
+ hours_passed = NaiveDateTime.diff(DateTime.utc_now(), site.inserted_at, :hour)
57
+
58
+ if !setup_completed && hours_passed > 47 do
59
+ send_setup_help_email(owners, site)
60
+ end
61
+ end
62
+ end
63
+
64
+ defp send_setup_success_emails() do
65
+ q =
66
+ from(s in Plausible.Site.regular(),
67
+ left_join: se in "setup_success_emails",
68
+ on: se.site_id == s.id,
69
+ where: is_nil(se.id),
70
+ inner_join: t in assoc(s, :team),
71
+ where: s.inserted_at > fragment("(now() at time zone 'utc') - '72 hours'::interval"),
72
+ preload: [:owners, team: t]
73
+ )
74
+
75
+ for site <- Repo.all(q) do
76
+ if Plausible.Sites.has_stats?(site) do
77
+ send_setup_success_email(site)
78
+ end
79
+ end
80
+ end
81
+
82
+ defp send_create_site_email(user) do
83
+ PlausibleWeb.Email.create_site_email(user)
84
+ |> Plausible.Mailer.send()
85
+
86
+ Repo.insert_all("create_site_emails", [
87
+ %{
88
+ user_id: user.id,
89
+ timestamp: NaiveDateTime.utc_now()
90
+ }
91
+ ])
92
+ end
93
+
94
+ defp send_setup_success_email(site) do
95
+ for owner <- site.owners do
96
+ PlausibleWeb.Email.site_setup_success(owner, site)
97
+ |> Plausible.Mailer.send()
98
+ end
99
+
100
+ Repo.insert_all("setup_success_emails", [
101
+ %{
102
+ site_id: site.id,
103
+ timestamp: NaiveDateTime.utc_now()
104
+ }
105
+ ])
106
+ end
107
+
108
+ defp send_setup_help_email(users, site) do
109
+ for user <- users do
110
+ PlausibleWeb.Email.site_setup_help(user, site.team, site)
111
+ |> Plausible.Mailer.send()
112
+ end
113
+
114
+ Repo.insert_all("setup_help_emails", [
115
+ %{
116
+ site_id: site.id,
117
+ timestamp: NaiveDateTime.utc_now()
118
+ }
119
+ ])
120
+ end
121
+ end
lib/workers/send_trial_notifications.ex ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.SendTrialNotifications do
2
+ @moduledoc """
3
+ Job sending trial end notification emails.
4
+ """
5
+ use Plausible.Repo
6
+
7
+ use Oban.Worker,
8
+ queue: :trial_notification_emails,
9
+ max_attempts: 1
10
+
11
+ alias Plausible.Teams
12
+
13
+ @impl Oban.Worker
14
+ def perform(_job) do
15
+ teams =
16
+ Repo.all(
17
+ from t in Teams.Team,
18
+ inner_join: o in assoc(t, :owners),
19
+ left_join: bm in assoc(t, :billing_members),
20
+ left_join: s in assoc(t, :subscription),
21
+ where: not is_nil(t.trial_expiry_date),
22
+ where: is_nil(s.id),
23
+ order_by: t.inserted_at,
24
+ preload: [owners: o, billing_members: bm]
25
+ )
26
+ |> Enum.filter(&Teams.has_active_sites?/1)
27
+
28
+ for team <- teams do
29
+ recipients = team.owners ++ team.billing_members
30
+
31
+ case Date.diff(team.trial_expiry_date, Date.utc_today()) do
32
+ 7 ->
33
+ send_one_week_reminder(recipients, team)
34
+
35
+ 1 ->
36
+ send_tomorrow_reminder(recipients, team)
37
+
38
+ 0 ->
39
+ send_today_reminder(recipients, team)
40
+
41
+ -1 ->
42
+ send_over_reminder(recipients, team)
43
+
44
+ _ ->
45
+ nil
46
+ end
47
+ end
48
+
49
+ :ok
50
+ end
51
+
52
+ defp send_one_week_reminder(users, team) do
53
+ for user <- users do
54
+ PlausibleWeb.Email.trial_one_week_reminder(user, team)
55
+ |> Plausible.Mailer.send()
56
+ end
57
+ end
58
+
59
+ defp send_tomorrow_reminder(users, team) do
60
+ usage = Plausible.Teams.Billing.usage_cycle(team, :last_30_days)
61
+ suggested_volume = Plausible.Billing.Plans.suggest_volume(team, usage.total)
62
+
63
+ for user <- users do
64
+ PlausibleWeb.Email.trial_ending_tomorrow_email(user, team, usage, suggested_volume)
65
+ |> Plausible.Mailer.send()
66
+ end
67
+ end
68
+
69
+ defp send_today_reminder(users, team) do
70
+ usage = Plausible.Teams.Billing.usage_cycle(team, :last_30_days)
71
+ suggested_volume = Plausible.Billing.Plans.suggest_volume(team, usage.total)
72
+
73
+ for user <- users do
74
+ PlausibleWeb.Email.trial_ending_today_email(user, team, usage, suggested_volume)
75
+ |> Plausible.Mailer.send()
76
+ end
77
+ end
78
+
79
+ defp send_over_reminder(users, team) do
80
+ for user <- users do
81
+ PlausibleWeb.Email.trial_over_email(user, team)
82
+ |> Plausible.Mailer.send()
83
+ end
84
+ end
85
+ end
lib/workers/set_legacy_time_on_page_cutoff.ex ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.SetLegacyTimeOnPageCutoff do
2
+ @moduledoc """
3
+ Sets sites `legacy_time_on_page_cutoff` depending on whether they have
4
+ sent us engagement data in the past.
5
+ """
6
+
7
+ use Plausible.Repo
8
+ use Plausible.ClickhouseRepo
9
+ use Oban.Worker, queue: :legacy_time_on_page_cutoff
10
+
11
+ import Ecto.Query
12
+
13
+ require Logger
14
+
15
+ @impl Oban.Worker
16
+ def perform(%Oban.Job{args: args}) do
17
+ cutoff_date = Map.get(args, "cutoff_date", Date.utc_today())
18
+
19
+ {small_sites, large_sites} =
20
+ sites_with_engagement_data(cutoff_date)
21
+ |> filter_sites_needing_update()
22
+
23
+ to_update = small_sites ++ large_sites
24
+
25
+ if length(to_update) > 0 do
26
+ Logger.notice(
27
+ "Setting legacy_time_on_page_cutoff for #{length(to_update)} sites (#{length(small_sites)} small, #{length(large_sites)} large)"
28
+ )
29
+
30
+ {count, _} =
31
+ Repo.update_all(
32
+ from(s in Plausible.Site.regular(),
33
+ where: s.id in ^to_update,
34
+ where: is_nil(s.legacy_time_on_page_cutoff)
35
+ ),
36
+ set: [legacy_time_on_page_cutoff: cutoff_date]
37
+ )
38
+
39
+ Logger.notice(
40
+ "Successfully set legacy_time_on_page_cutoff=#{cutoff_date} for #{count} sites"
41
+ )
42
+ else
43
+ Logger.debug("No sites legacy_time_on_page_cutoff needs updating")
44
+ end
45
+
46
+ :ok
47
+ end
48
+
49
+ defp sites_with_engagement_data(cutoff_date) do
50
+ site_info_q =
51
+ from(
52
+ e in "events_v2",
53
+ where: e.timestamp >= fragment("toStartOfHour(? - toIntervalHour(48))", ^cutoff_date),
54
+ where: e.timestamp < fragment("toStartOfHour(? - toIntervalHour(24))", ^cutoff_date),
55
+ group_by: e.site_id,
56
+ select: %{
57
+ site_id: e.site_id,
58
+ hours_with_engagement:
59
+ fragment(
60
+ "uniqIf(toStartOfHour(timestamp), name = 'engagement' AND engagement_time > 0)"
61
+ ),
62
+ is_small_site: fragment("count() < 2000")
63
+ }
64
+ )
65
+
66
+ q =
67
+ from(
68
+ s in subquery(site_info_q),
69
+ select: %{
70
+ small_sites:
71
+ fragment("groupArrayIf(site_id, (is_small_site and hours_with_engagement > 0))"),
72
+ large_sites:
73
+ fragment("groupArrayIf(site_id, (not is_small_site and hours_with_engagement = 24))")
74
+ }
75
+ )
76
+
77
+ result = ClickhouseRepo.one(q)
78
+
79
+ {MapSet.new(result.small_sites), MapSet.new(result.large_sites)}
80
+ end
81
+
82
+ defp filter_sites_needing_update({small_sites, large_sites}) do
83
+ needing_update =
84
+ from(s in Plausible.Site.regular(),
85
+ where: is_nil(s.legacy_time_on_page_cutoff),
86
+ select: s.id
87
+ )
88
+ |> Repo.all()
89
+ |> MapSet.new()
90
+
91
+ {
92
+ small_sites |> MapSet.intersection(needing_update) |> MapSet.to_list(),
93
+ large_sites |> MapSet.intersection(needing_update) |> MapSet.to_list()
94
+ }
95
+ end
96
+ end
lib/workers/traffic_change_notifier.ex ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.Workers.TrafficChangeNotifier do
2
+ @moduledoc """
3
+ Oban service sending out traffic drop/spike notifications
4
+ """
5
+ use Plausible
6
+ use Plausible.Repo
7
+ alias Plausible.Stats.{Clickhouse, ParsedQueryParams, QueryBuilder}
8
+ alias Plausible.Site.TrafficChangeNotification
9
+
10
+ alias PlausibleWeb.Router.Helpers, as: Routes
11
+
12
+ use Oban.Worker, queue: :spike_notifications
13
+ @min_interval_hours 12
14
+
15
+ @impl Oban.Worker
16
+ def perform(_job, now \\ NaiveDateTime.utc_now(:second)) do
17
+ today = NaiveDateTime.to_date(now)
18
+
19
+ notifications =
20
+ Repo.all(
21
+ from sn in TrafficChangeNotification,
22
+ where:
23
+ is_nil(sn.last_sent) or
24
+ sn.last_sent < ^NaiveDateTime.add(now, -@min_interval_hours, :hour),
25
+ inner_join: s in assoc(sn, :site),
26
+ inner_join: t in assoc(s, :team),
27
+ where: not t.locked,
28
+ where: is_nil(t.accept_traffic_until) or t.accept_traffic_until > ^today,
29
+ preload: [site: {s, team: t}]
30
+ )
31
+
32
+ for notification <- notifications, ok_to_send?(notification.site) do
33
+ handle_notification(notification, now)
34
+ end
35
+
36
+ :ok
37
+ end
38
+
39
+ defp handle_notification(%TrafficChangeNotification{type: :spike} = notification, now) do
40
+ current_visitors = Clickhouse.current_visitors(notification.site)
41
+
42
+ if current_visitors >= notification.threshold do
43
+ stats =
44
+ notification.site
45
+ |> get_traffic_spike_stats()
46
+ |> Map.put(:current_visitors, current_visitors)
47
+
48
+ notify_spike(notification, stats, now)
49
+ end
50
+ end
51
+
52
+ defp handle_notification(%TrafficChangeNotification{type: :drop} = notification, now) do
53
+ current_visitors = Clickhouse.current_visitors_12h(notification.site)
54
+
55
+ if current_visitors < notification.threshold do
56
+ notify_drop(notification, current_visitors, now)
57
+ end
58
+ end
59
+
60
+ defp notify_spike(notification, stats, now) do
61
+ for recipient_email <- notification.recipients do
62
+ send_spike_notification(recipient_email, notification.site, stats)
63
+ end
64
+
65
+ notification
66
+ |> TrafficChangeNotification.was_sent(now)
67
+ |> Repo.update()
68
+ end
69
+
70
+ defp notify_drop(notification, current_visitors, now) do
71
+ for recipient_email <- notification.recipients do
72
+ send_drop_notification(recipient_email, notification.site, current_visitors)
73
+ end
74
+
75
+ notification
76
+ |> TrafficChangeNotification.was_sent(now)
77
+ |> Repo.update()
78
+ end
79
+
80
+ defp send_spike_notification(recipient_email, site, stats) do
81
+ dashboard_link =
82
+ if site_member?(site, recipient_email) do
83
+ Routes.stats_url(PlausibleWeb.Endpoint, :stats, site.domain, []) <>
84
+ "?__team=#{site.team.identifier}"
85
+ end
86
+
87
+ template =
88
+ PlausibleWeb.Email.spike_notification(
89
+ recipient_email,
90
+ site,
91
+ stats,
92
+ dashboard_link
93
+ )
94
+
95
+ Plausible.Mailer.send(template)
96
+ end
97
+
98
+ defp send_drop_notification(recipient_email, site, current_visitors) do
99
+ site_member? = site_member?(site, recipient_email)
100
+
101
+ dashboard_link =
102
+ if site_member? do
103
+ Routes.stats_url(PlausibleWeb.Endpoint, :stats, site.domain, []) <>
104
+ "?__team=#{site.team.identifier}"
105
+ end
106
+
107
+ installation_link =
108
+ if site_member? and Plausible.Sites.regular?(site) do
109
+ Routes.site_url(PlausibleWeb.Endpoint, :installation, site.domain,
110
+ flow: PlausibleWeb.Flows.review()
111
+ ) <> "&__team=#{site.team.identifier}"
112
+ end
113
+
114
+ template =
115
+ PlausibleWeb.Email.drop_notification(
116
+ recipient_email,
117
+ site,
118
+ current_visitors,
119
+ dashboard_link,
120
+ installation_link
121
+ )
122
+
123
+ Plausible.Mailer.send(template)
124
+ end
125
+
126
+ defp get_traffic_spike_stats(site) do
127
+ %{}
128
+ |> put_sources(site)
129
+ |> put_pages(site)
130
+ end
131
+
132
+ @base_query_params %ParsedQueryParams{
133
+ metrics: [:visitors],
134
+ pagination: %{limit: 3, offset: 0},
135
+ input_date_range: :realtime
136
+ }
137
+
138
+ defp put_sources(stats, site) do
139
+ query =
140
+ QueryBuilder.build!(
141
+ site,
142
+ struct!(@base_query_params,
143
+ dimensions: ["visit:source"],
144
+ filters: [[:is_not, "visit:source", ["Direct / None"]]]
145
+ )
146
+ )
147
+
148
+ %{results: sources} = Plausible.Stats.query(site, query)
149
+
150
+ Map.put(stats, :sources, sources)
151
+ end
152
+
153
+ defp put_pages(stats, site) do
154
+ query = QueryBuilder.build!(site, struct!(@base_query_params, dimensions: ["event:page"]))
155
+
156
+ %{results: pages} = Plausible.Stats.query(site, query)
157
+
158
+ Map.put(stats, :pages, pages)
159
+ end
160
+
161
+ defp site_member?(site, recipient_email) do
162
+ from(tm in Plausible.Teams.Membership,
163
+ inner_join: u in assoc(tm, :user),
164
+ left_join: gm in assoc(tm, :guest_memberships),
165
+ where: tm.team_id == ^site.team_id,
166
+ where: tm.role != :guest or gm.site_id == ^site.id,
167
+ where: u.email == ^recipient_email
168
+ )
169
+ |> Repo.exists?()
170
+ end
171
+
172
+ on_ee do
173
+ defp ok_to_send?(site) do
174
+ Plausible.Sites.regular?(site) or
175
+ (Plausible.Sites.consolidated?(site) and
176
+ Plausible.ConsolidatedView.ok_to_display?(site.team))
177
+ end
178
+ else
179
+ defp ok_to_send?(_site), do: always(true)
180
+ end
181
+ end
mix.exs ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ defmodule Plausible.MixProject do
2
+ use Mix.Project
3
+
4
+ def project do
5
+ [
6
+ name: "Plausible",
7
+ source_url: "https://github.com/plausible/analytics",
8
+ docs: docs(),
9
+ app: :plausible,
10
+ version: System.get_env("APP_VERSION", "0.0.1"),
11
+ elixir: "~> 1.18",
12
+ elixirc_paths: elixirc_paths(Mix.env()),
13
+ start_permanent: Mix.env() in [:prod, :ce, :load],
14
+ aliases: aliases(),
15
+ deps: deps(),
16
+ test_coverage: [
17
+ tool: ExCoveralls
18
+ ],
19
+ listeners: [Phoenix.CodeReloader],
20
+ releases: [
21
+ plausible: [
22
+ include_executables_for: [:unix],
23
+ config_providers: [
24
+ {Config.Reader,
25
+ path: {:system, "RELEASE_ROOT", "/import_extra_config.exs"}, imports: []}
26
+ ]
27
+ ]
28
+ ],
29
+ dialyzer: [
30
+ plt_file: {:no_warn, "priv/plts/dialyzer.plt"},
31
+ plt_add_apps: [:mix, :ex_unit]
32
+ ]
33
+ ]
34
+ end
35
+
36
+ # Configuration for the OTP application.
37
+ #
38
+ # Type `mix help compile.app` for more information.
39
+ def application do
40
+ [
41
+ mod: {Plausible.Application, []},
42
+ extra_applications:
43
+ [
44
+ :logger,
45
+ :runtime_tools,
46
+ :tls_certificate_check,
47
+ :opentelemetry_exporter
48
+ ] ++ if(Mix.env() in [:dev, :load], do: [:tools, :observer, :wx], else: [])
49
+ ]
50
+ end
51
+
52
+ def cli do
53
+ [
54
+ preferred_envs: [
55
+ "test.e2e": :e2e_test,
56
+ "test.e2e.ui": :e2e_test
57
+ ]
58
+ ]
59
+ end
60
+
61
+ # Specifies which paths to compile per environment.
62
+ defp elixirc_paths(env) when env in [:test, :e2e_test, :dev],
63
+ do: ["lib", "test/support", "extra/lib"]
64
+
65
+ defp elixirc_paths(env) when env in [:ce_test, :ce_dev],
66
+ do: ["lib", "test/support"]
67
+
68
+ defp elixirc_paths(:ce), do: ["lib"]
69
+ defp elixirc_paths(_), do: ["lib", "extra/lib"]
70
+
71
+ # Specifies your project dependencies.
72
+ #
73
+ # Type `mix help deps` for examples and options.
74
+ defp deps do
75
+ [
76
+ {:bamboo, "~> 2.3", override: true},
77
+ {:bamboo_postmark, git: "https://github.com/plausible/bamboo_postmark.git", branch: "main"},
78
+ {:bamboo_mua, "~> 0.2.0"},
79
+ {:bcrypt_elixir, "~> 3.3"},
80
+ {:bypass, "~> 2.1", only: [:dev, :test, :ce_test, :e2e_test]},
81
+ {:ecto_ch, "~> 0.8.6"},
82
+ {:cloak, "~> 1.1"},
83
+ {:cloak_ecto, "~> 1.2"},
84
+ {:combination, "~> 0.0.3"},
85
+ {:cors_plug, "~> 3.0"},
86
+ {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
87
+ {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false},
88
+ {:double, "~> 0.8.0", only: [:dev, :test, :ce_test, :ce_dev, :e2e_test]},
89
+ {:ecto, "~> 3.13.5"},
90
+ {:ecto_sql, "~> 3.13.2"},
91
+ {:envy, "~> 1.1.1"},
92
+ {:eqrcode, "~> 0.2.1"},
93
+ {:ex_machina, "~> 2.3", only: [:dev, :test, :ce_dev, :ce_test, :e2e_test]},
94
+ {:excoveralls, "~> 0.10", only: :test},
95
+ {:finch, "~> 0.23"},
96
+ {:lazy_html, "~> 0.1.8"},
97
+ {:fun_with_flags, "~> 1.13.0"},
98
+ {:fun_with_flags_ui, "~> 1.0"},
99
+ {:locus, "~> 2.3"},
100
+ {:gen_cycle, "~> 1.0.4"},
101
+ {:hackney, "~> 1.8"},
102
+ {:jason, "~> 1.3"},
103
+ {:location, git: "https://github.com/plausible/location.git"},
104
+ {:mox, "~> 1.0", only: [:test, :ce_test, :e2e_test]},
105
+ {:nanoid, "~> 2.1.0"},
106
+ {:nimble_totp, "~> 1.0"},
107
+ {:oban, "~> 2.20.1"},
108
+ {:observer_cli, "~> 1.7"},
109
+ {:opentelemetry, "~> 1.7"},
110
+ {:opentelemetry_api, "~> 1.5"},
111
+ {:opentelemetry_api_experimental,
112
+ git: "https://github.com/open-telemetry/opentelemetry-erlang.git",
113
+ ref: "b241ebf4feb7558738d009583bae8602b3c49fd0",
114
+ sparse: "apps/opentelemetry_api_experimental",
115
+ override: true},
116
+ {:opentelemetry_ecto, "~> 1.2"},
117
+ {:opentelemetry_exporter, "~> 1.10"},
118
+ {:opentelemetry_experimental,
119
+ git: "https://github.com/open-telemetry/opentelemetry-erlang.git",
120
+ ref: "b241ebf4feb7558738d009583bae8602b3c49fd0",
121
+ sparse: "apps/opentelemetry_experimental",
122
+ override: true},
123
+ {:opentelemetry_phoenix, "~> 2.0.1"},
124
+ {:opentelemetry_oban, "~> 1.1"},
125
+ {:opentelemetry_cowboy, "~> 1.0"},
126
+ # # https://github.com/open-telemetry/opentelemetry-erlang-contrib/issues/428
127
+ {:opentelemetry_semantic_conventions, "~> 1.27", override: true},
128
+ {:phoenix, "~> 1.8.2"},
129
+ {:phoenix_view, "~> 2.0"},
130
+ {:phoenix_ecto, "~> 4.5"},
131
+ {:phoenix_html, "~> 4.1"},
132
+ {:phoenix_live_reload, "~> 1.2", only: [:dev, :ce_dev]},
133
+ {:phoenix_pubsub, "~> 2.0"},
134
+ {:phoenix_live_view, "~> 1.1.17"},
135
+ {:php_serializer, "~> 2.0"},
136
+ {:plug, "~> 1.13", override: true},
137
+ {:prima, "~> 0.2.6"},
138
+ {:plug_cowboy, "~> 2.3"},
139
+ {:polymorphic_embed, "~> 5.0"},
140
+ {:postgrex, "~> 0.22.2"},
141
+ {:prom_ex, "~> 1.8"},
142
+ {:peep, "~> 3.0"},
143
+ {:public_suffix, git: "https://github.com/axelson/publicsuffix-elixir"},
144
+ {:recon, "~> 2.5"},
145
+ {:ref_inspector, "~> 2.0"},
146
+ {:referrer_blocklist, git: "https://github.com/plausible/referrer-blocklist.git"},
147
+ {:sentry, "~> 11.0.4"},
148
+ {:simple_saml, "~> 1.2"},
149
+ {:xml_builder, "~> 2.1"},
150
+ {:siphash, "~> 3.2"},
151
+ {:timex, "~> 3.7"},
152
+ {:ua_inspector, "~> 3.12"},
153
+ {:ex_doc, "~> 0.28", only: :dev, runtime: false},
154
+ {:ex_money, "~> 5.24.2"},
155
+ {:mjml_eex, "~> 0.12.0"},
156
+ {:mjml, "~> 4.0.0"},
157
+ {:heroicons, "~> 0.5.0"},
158
+ {:zxcvbn, git: "https://github.com/techgaun/zxcvbn-elixir.git"},
159
+ {:open_api_spex, "~> 3.22.1"},
160
+ {:joken, "~> 2.5"},
161
+ {:paginator, git: "https://github.com/duffelhq/paginator.git"},
162
+ {:esbuild, "~> 0.7", runtime: Mix.env() in [:dev, :ce_dev]},
163
+ {:tailwind, "~> 0.4.0", runtime: Mix.env() in [:dev, :ce_dev]},
164
+ {:ex_json_logger, "~> 1.4.0"},
165
+ {:ecto_network, "~> 1.6.0"},
166
+ {:ex_aws, "~> 2.5"},
167
+ {:ex_aws_s3, "~> 2.5"},
168
+ {:sweet_xml, "~> 0.7.4"},
169
+ {:zstream, "~> 0.6.4"},
170
+ {:con_cache,
171
+ git: "https://github.com/aerosol/con_cache", branch: "ensure-dirty-ops-emit-telemetry"},
172
+ {:req, "~> 0.6"},
173
+ {:opentelemetry_req, "~> 1.0"},
174
+ {:happy_tcp, github: "ruslandoga/happy_tcp", only: [:ce, :ce_dev, :ce_test, :e2e_test]},
175
+ {:ex_json_schema, "~> 0.11.1"},
176
+ {:odgn_json_pointer, "~> 3.1.0"},
177
+ {:phoenix_bakery, "~> 0.1.2", only: [:ce, :ce_dev, :ce_test, :e2e_test]},
178
+ {:site_encrypt, github: "sasa1977/site_encrypt", only: [:ce, :ce_dev, :ce_test, :e2e_test]},
179
+ {:phoenix_html_helpers, "~> 1.0"},
180
+ {:libcluster, "~> 3.5"},
181
+ {:decimal, "~> 3.0", override: true},
182
+ {:logger_backends, "~> 1.0.0"}
183
+ ]
184
+ end
185
+
186
+ defp aliases do
187
+ [
188
+ setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
189
+ "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
190
+ "ecto.reset": ["ecto.drop", "ecto.setup"],
191
+ test: ["ecto.create --quiet", "ecto.migrate", "test", "clean_clickhouse"],
192
+ "e2e.setup": [
193
+ "cmd npm install --prefix ./e2e",
194
+ "cmd npm exec playwright install --with-deps chromium --prefix ./e2e"
195
+ ],
196
+ # accepts Playwright CLI arguments (https://playwright.dev/docs/test-cli), for example
197
+ # mix test.e2e --ui
198
+ # mix test.e2e --debug segments.spec.ts
199
+ "test.e2e": [
200
+ "esbuild default",
201
+ "ecto.create --quiet",
202
+ "ecto.migrate",
203
+ "clean_postgres",
204
+ "clean_clickhouse",
205
+ "cmd npm run --prefix ./e2e test -- "
206
+ ],
207
+ "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
208
+ "assets.typecheck": ["cmd npm --prefix assets run typecheck"],
209
+ "assets.build": [
210
+ "tailwind default",
211
+ "esbuild default"
212
+ ],
213
+ "assets.deploy": [
214
+ "tailwind default --minify",
215
+ "esbuild default --minify",
216
+ "phx.digest"
217
+ ]
218
+ ]
219
+ end
220
+
221
+ defp docs do
222
+ [
223
+ main: "readme",
224
+ logo: "priv/static/images/ee/favicon-32x32.png",
225
+ extras:
226
+ Path.wildcard("guides/**/*.md") ++
227
+ [
228
+ "README.md": [filename: "readme", title: "Introduction"],
229
+ "CONTRIBUTING.md": [filename: "contributing", title: "Contributing"]
230
+ ],
231
+ groups_for_extras: [
232
+ Features: Path.wildcard("guides/features/*.md")
233
+ ],
234
+ before_closing_body_tag: fn
235
+ :html ->
236
+ """
237
+ <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
238
+ <script>mermaid.initialize({startOnLoad: true})</script>
239
+ """
240
+
241
+ _ ->
242
+ ""
243
+ end
244
+ ]
245
+ end
246
+ end
mix.lock ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ %{
2
+ "acceptor_pool": {:hex, :acceptor_pool, "1.0.0", "43c20d2acae35f0c2bcd64f9d2bde267e459f0f3fd23dab26485bf518c281b21", [:rebar3], [], "hexpm", "0cbcd83fdc8b9ad2eee2067ef8b91a14858a5883cb7cd800e6fcd5803e158788"},
3
+ "attrs": {:hex, :attrs, "0.6.0", "25d738b47829f964a786ef73897d2550b66f3e7d1d7c49a83bc8fd81c71bed93", [:mix], [], "hexpm", "9c30ac15255c2ba8399263db55ba32c2f4e5ec267b654ce23df99168b405c82e"},
4
+ "bamboo": {:hex, :bamboo, "2.5.0", "973f5cb1471a1d2d7d9da7b8e4f6096afb6a133f85394631184fd40be8adb8ab", [:mix], [{:hackney, ">= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.4 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "35c8635ff6677a81ab7258944ff15739280f3254a041b6f0229dddeb9b90ad3d"},
5
+ "bamboo_mua": {:hex, :bamboo_mua, "0.2.4", "1143cb64e0455bd496d4da14132308d1e44a825c2ba5e295c6c2adc33dff3af0", [:mix], [{:bamboo, "~> 2.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:mail, "~> 0.4.0", [hex: :mail, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: false]}], "hexpm", "6802c491f8585b047d7f75f0d57f44398b44d105614115a3346710c2de75cc1f"},
6
+ "bamboo_postmark": {:git, "https://github.com/plausible/bamboo_postmark.git", "c6a773d1b7a4e5c9ec802ace64b5bb526504c25a", [branch: "main"]},
7
+ "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
8
+ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
9
+ "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
10
+ "castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
11
+ "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
12
+ "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
13
+ "ch": {:hex, :ch, "0.7.1", "116c08094b30d095c3bd6a8fe4ebe19fdaaf3dce84e2413cfdd6af157baf6303", [:mix], [{:db_connection, "~> 2.9.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "3c1c900291ff9c4c077cd1dc0c265051a3f1d26320d58b37ed9e91b33d41a868"},
14
+ "chatterbox": {:hex, :ts_chatterbox, "0.15.1", "5cac4d15dd7ad61fc3c4415ce4826fc563d4643dee897a558ec4ea0b1c835c9c", [:rebar3], [{:hpack, "~> 0.3.0", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "4f75b91451338bc0da5f52f3480fa6ef6e3a2aeecfc33686d6b3d0a0948f31aa"},
15
+ "cldr_utils": {:hex, :cldr_utils, "2.29.7", "2189bc0117efe91c684558e79174b45eb43135595b7d1fe9b57f53917be195c1", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "4bddcd597fee34e2d2829ae9ef62bcfef8d97ae5f6b75f0c6ee37a3db31aa73a"},
16
+ "cloak": {:hex, :cloak, "1.1.4", "aba387b22ea4d80d92d38ab1890cc528b06e0e7ef2a4581d71c3fdad59e997e7", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "92b20527b9aba3d939fab0dd32ce592ff86361547cfdc87d74edce6f980eb3d7"},
17
+ "cloak_ecto": {:hex, :cloak_ecto, "1.3.0", "0de127c857d7452ba3c3367f53fb814b0410ff9c680a8d20fbe8b9a3c57a1118", [:mix], [{:cloak, "~> 1.1.1", [hex: :cloak, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "314beb0c123b8a800418ca1d51065b27ba3b15f085977e65c0f7b2adab2de1cc"},
18
+ "combination": {:hex, :combination, "0.0.3", "746aedca63d833293ec6e835aa1f34974868829b1486b1e1cb0685f0b2ae1f41", [:mix], [], "hexpm", "72b099f463df42ef7dc6371d250c7070b57b6c5902853f69deb894f79eda18ca"},
19
+ "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
20
+ "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
21
+ "con_cache": {:git, "https://github.com/aerosol/con_cache", "d050e133742e512cf2caa4598607e60dd0161072", [branch: "ensure-dirty-ops-emit-telemetry"]},
22
+ "cors_plug": {:hex, :cors_plug, "3.0.3", "7c3ac52b39624bc616db2e937c282f3f623f25f8d550068b6710e58d04a0e330", [:mix], [{:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "3f2d759e8c272ed3835fab2ef11b46bddab8c1ab9528167bd463b6452edf830d"},
23
+ "cowboy": {:hex, :cowboy, "2.16.1", "fa04080b602ff25c40a7700f2dc0152dbc1ba26b42093ae0fa9bb7a337d5a242", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "b8ea4dd317a043e3177ec840cfa3bcb47cfb41035d3abb24d954dc7d51def399"},
24
+ "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
25
+ "cowlib": {:hex, :cowlib, "2.18.0", "4a3ef8cb012c21c7cbb7c925f75405e26d7fcd393fd78c46187539dbc9a48310", [:make, :rebar3], [], "hexpm", "c4f89d33a61162d4cfdcb5a1af7e562d80a700b2573cc71020ce6521b152dc1a"},
26
+ "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"},
27
+ "ctx": {:hex, :ctx, "0.6.0", "8ff88b70e6400c4df90142e7f130625b82086077a45364a78d208ed3ed53c7fe", [:rebar3], [], "hexpm", "a14ed2d1b67723dbebbe423b28d7615eb0bdcba6ff28f2d1f1b0a7e1d4aa5fc2"},
28
+ "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
29
+ "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
30
+ "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
31
+ "digital_token": {:hex, :digital_token, "2.0.0", "fb3c74411790994895c4a61387ab322b64a682995ef2f54a4627c92d46be5d62", [:mix], [], "hexpm", "cbd2fff52770284a8251540a4b4e529e9738c6fe052d7f3c3428eb5c817385cd"},
32
+ "double": {:hex, :double, "0.8.2", "8e1cfcccdaef76c18846bc08e555555a2a699b806fa207b6468572a60513cc6a", [:mix], [], "hexpm", "90287642b2ec86125e0457aaba2ab0e80f7d7050cc80a0cef733e59bd70aa67c"},
33
+ "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"},
34
+ "ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
35
+ "ecto_ch": {:hex, :ecto_ch, "0.8.6", "f31b507e86690c003f46e75d6e742e6b5d8ce34b6b10a86604b1c3aa785e0b56", [:mix], [{:ch, "~> 0.5.0 or ~> 0.6.0 or ~> 0.7.0", [hex: :ch, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "6ca9f1cf9680452b1925c6a3a7b5e3d8b12e38ee134b03c6a45a8b26434fad97"},
36
+ "ecto_network": {:hex, :ecto_network, "1.6.0", "f3159c0adb280321b72dc711b3fc7bcc48e2772f71db292a1c3529c587603611", [:mix], [{:ecto_sql, ">= 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 0.0.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.14.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "000411a6d3a585845f6809383f20afd418c090e104f862241124ac9518ac1073"},
37
+ "ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
38
+ "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
39
+ "envy": {:hex, :envy, "1.1.1", "0bc9bd654dec24fcdf203f7c5aa1b8f30620f12cfb28c589d5e9c38fe1b07475", [:mix], [], "hexpm", "7061eb1a47415fd757145d8dec10dc0b1e48344960265cb108f194c4252c3a89"},
40
+ "eqrcode": {:hex, :eqrcode, "0.2.1", "d12838813e8fc87b8940cc05f9baadb189031f6009facdc56ff074375ec73b6e", [:mix], [], "hexpm", "d5828a222b904c68360e7dc2a40c3ef33a1328b7c074583898040f389f928025"},
41
+ "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
42
+ "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
43
+ "ex_aws": {:hex, :ex_aws, "2.5.1", "7418917974ea42e9e84b25e88b9f3d21a861d5f953ad453e212f48e593d8d39f", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1b95431f70c446fa1871f0eb9b183043c5a625f75f9948a42d25f43ae2eff12b"},
44
+ "ex_aws_s3": {:hex, :ex_aws_s3, "2.5.3", "422468e5c3e1a4da5298e66c3468b465cfd354b842e512cb1f6fbbe4e2f5bdaf", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: false]}, {:sweet_xml, ">= 0.0.0", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm", "4f09dd372cc386550e484808c5ac5027766c8d0cd8271ccc578b82ee6ef4f3b8"},
45
+ "ex_cldr": {:hex, :ex_cldr, "2.47.4", "64e1efda73d6c5fa0342d5b6554f39b740fa02ed540dc03997facf573101ad9e", [:mix], [{:cldr_utils, "~> 2.29", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "918aabc032955f3eac70abbdf2c5469433132edfaaaccee55451f074ee1ccdba"},
46
+ "ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.17.2", "4892b5255a205e8133297b702abbe13c9b51d0281fa8c2ea100f51df9657c3c7", [:mix], [{:ex_cldr, "~> 2.38", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "797095c106a2fe6632981531e29cfb1d2f8ee7de626f4d6243f974d6f74a0112"},
47
+ "ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.38.3", "64a99531df09397174b7f477785c43016f223d01856725b541a19d36b8149523", [:mix], [{:decimal, "~> 1.6 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 1.0 or ~> 2.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.45", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.17", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "3a0d87ef2747c66d78ae8967023d415d3d76aa310981047ad601e3287e8fe73c"},
48
+ "ex_doc": {:hex, :ex_doc, "0.35.1", "de804c590d3df2d9d5b8aec77d758b00c814b356119b3d4455e4b8a8687aecaf", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2121c6402c8d44b05622677b761371a759143b958c6c19f6558ff64d0aed40df"},
49
+ "ex_json_logger": {:hex, :ex_json_logger, "1.4.0", "ad1dcc1cfe6940ee1d9d489b20757c89769626ce34c4957548d6fbe155cd96f1", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7548a1ecba290746e06214d2b3d8783c76760c779a8903a8e44bfd23a7340444"},
50
+ "ex_json_schema": {:hex, :ex_json_schema, "0.11.1", "b593f92937a095f66054bb318681397dfe7304e7d2b6b1a7534ea3aa40024f8c", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "32d651a575a6ce2fd613f140b0fef8dd0acc7cf8e8bcd29a3a1be5c945700dd5"},
51
+ "ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"},
52
+ "ex_money": {:hex, :ex_money, "5.24.2", "131eda8a404b654dbf787ca71a8b782e11112e64726d85c27540856f94d4aee5", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.46", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.38", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:ex_cldr_units, "~> 3.19", [hex: :ex_cldr_units, repo: "hexpm", optional: true]}, {:gringotts, "~> 1.1", [hex: :gringotts, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.0 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm", "707bf02c99537d6cd93e5e5fd7f4faedde3ea2a7a932032b48e5ba0173307920"},
53
+ "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"},
54
+ "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
55
+ "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
56
+ "finch": {:hex, :finch, "0.23.0", "e3f9287ac25a8832f848b144c2b57346aac65b205e2e0629a52adfe6507fd837", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "80e58d3f936f57e3fdf404f83a3642897ae6d9fb642934e46da4d8fe761b99d5"},
57
+ "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
58
+ "fun_with_flags": {:hex, :fun_with_flags, "1.13.0", "8e8eddd6b723691211387923b160bc779fa593f59485209a058c90ea4bbb1cd5", [:mix], [{:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: true]}, {:redix, "~> 1.0", [hex: :redix, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dbbbff607e6f096d3857c88530d1a4e178d9eeec5dc00118e2cc1d076578710a"},
59
+ "fun_with_flags_ui": {:hex, :fun_with_flags_ui, "1.1.0", "61a394cd7d80c09943a4b02e07c9ab8899781f3392a157b45527d22731bf871a", [:mix], [{:cowboy, ">= 2.0.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:fun_with_flags, "~> 1.12", [hex: :fun_with_flags, repo: "hexpm", optional: false]}, {:plug, "~> 1.12", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 2.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "99b578a250141537ff7774abb832427f3406a4faff3cfc4f074788beeef38f5d"},
60
+ "gen_cycle": {:hex, :gen_cycle, "1.0.4", "2315199f1256116328fad6a9d4371c2fb0bc39c7f5111b88d2de582300c931cc", [:rebar3], [], "hexpm", "67817e31b352bb00715f80a5571b3c069e26e994b2ebafa376acf76a2d0f66d8"},
61
+ "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
62
+ "gproc": {:hex, :gproc, "0.9.1", "f1df0364423539cf0b80e8201c8b1839e229e5f9b3ccb944c5834626998f5b8c", [:rebar3], [], "hexpm", "905088e32e72127ed9466f0bac0d8e65704ca5e73ee5a62cb073c3117916d507"},
63
+ "grpcbox": {:hex, :grpcbox, "0.17.1", "6e040ab3ef16fe699ffb513b0ef8e2e896da7b18931a1ef817143037c454bcce", [:rebar3], [{:acceptor_pool, "~> 1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~> 0.15.1", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~> 0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~> 0.9.1", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "4a3b5d7111daabc569dc9cbd9b202a3237d81c80bf97212fbc676832cb0ceb17"},
64
+ "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
65
+ "happy_tcp": {:git, "https://github.com/ruslandoga/happy_tcp.git", "d639436bf8e7b456abc86bac13b722a2b6d60725", []},
66
+ "heroicons": {:hex, :heroicons, "0.5.6", "95d730e7179c633df32d95c1fdaaecdf81b0da11010b89b737b843ac176a7eb5", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "ca267f02a5fa695a4178a737b649fb6644a2e399639d4ba7964c18e8a58c2352"},
67
+ "hpack": {:hex, :hpack_erl, "0.3.0", "2461899cc4ab6a0ef8e970c1661c5fc6a52d3c25580bc6dd204f84ce94669926", [:rebar3], [], "hexpm", "d6137d7079169d8c485c6962dfe261af5b9ef60fbc557344511c1e65e3d95fb0"},
68
+ "hpax": {:hex, :hpax, "1.0.4", "777de5d433b0fbdc7c418159c8055910faa8047ffdb3d6b31098d2a46cd7685c", [:mix], [], "hexpm", "afc7cb142ebcc2d01ce7816190b98ce5dd49e799111b24249f3443d730f377ca"},
69
+ "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"},
70
+ "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
71
+ "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
72
+ "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
73
+ "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
74
+ "lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"},
75
+ "libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"},
76
+ "location": {:git, "https://github.com/plausible/location.git", "a89bf79985c3c3d0830477ae587001156a646ce8", []},
77
+ "locus": {:hex, :locus, "2.3.12", "aa3992023363a523e5c6d0d104da1cd1026626d16b32a480903083201057f281", [:rebar3], [{:tls_certificate_check, "~> 1.9", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "e9bacd644127c5aaac478461e8b4bcd49479e7bb9f665b48ab4ebac2b7adc1ec"},
78
+ "logger_backends": {:hex, :logger_backends, "1.0.1", "08a9bd09eed271eac41ff35dd7603b6abf9e0f8b6ffe40359846cb0bfd37704f", [:mix], [], "hexpm", "d7903244fe8d84eeb3d3d42f8d7de080fd0409b613040df54c78b3591672d652"},
79
+ "mail": {:hex, :mail, "0.4.4", "2ed92e3b2dedb2013ba8eef9fb7ace3892e0eae1f45c0658738a72c195f7649b", [:mix], [], "hexpm", "bd44bf3e253d8be9c7f2e59b3253aff1efc1c9fa7d8ab4430c96780683faa8e2"},
80
+ "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
81
+ "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
82
+ "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"},
83
+ "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
84
+ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
85
+ "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
86
+ "mint": {:hex, :mint, "1.9.1", "3bc120b743ed2e99ad920910f2613e9faebabb2257731b0e2ea4d8ccd9eceede", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "831101bd560b086316fab5f7adb21a4f3455717d8e4bc8368b052e09aa9163e0"},
87
+ "mjml": {:hex, :mjml, "4.0.0", "3418c1a975112766f92b8786a17d53640a4a1c8c7dedb621016a08e1e1447d20", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7.0", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "0886a411f9a850bc4ac6b878c9c0dbea168669176fde1f6583acf55fa8b628dd"},
88
+ "mjml_eex": {:hex, :mjml_eex, "0.12.0", "ee7e58a0b2f361812c024ddd2dd57ccd4107e486632016fcf707d4d0eaa04d62", [:mix], [{:erlexec, "~> 2.0.7", [hex: :erlexec, repo: "hexpm", optional: true]}, {:mjml, "~> 4.0", [hex: :mjml, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.2 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6ca41f6252669b709d31d8b7c74db00c8bc3784dea1913b406a68c12763c5bcb"},
89
+ "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"},
90
+ "mua": {:hex, :mua, "0.2.5", "e99aa9646964a0109a2efcc8e684c6f8d90c60fb0191f52e1784cea296584daf", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "0e2b18024d0db8943a68e84fb5e2253d3225c8f61d8387cbfc581d66e34d8493"},
91
+ "nanoid": {:hex, :nanoid, "2.1.0", "d192a5bf1d774258bc49762b480fca0e3128178fa6d35a464af2a738526607fd", [:mix], [], "hexpm", "ebc7a342d02d213534a7f93a091d569b9fea7f26fcd3a638dc655060fc1f76ac"},
92
+ "nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"},
93
+ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
94
+ "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"},
95
+ "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
96
+ "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
97
+ "nimble_totp": {:hex, :nimble_totp, "1.0.0", "79753bae6ce59fd7cacdb21501a1dbac249e53a51c4cd22b34fa8438ee067283", [:mix], [], "hexpm", "6ce5e4c068feecdb782e85b18237f86f66541523e6bad123e02ee1adbe48eda9"},
98
+ "oban": {:hex, :oban, "2.20.1", "39d0b68787e5cf251541c0d657a698f6142a24d8744e1e40b2cf045d4fa232a6", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17a45277dbeb41a455040b41dd8c467163fad685d1366f2f59207def3bcdd1d8"},
99
+ "observer_cli": {:hex, :observer_cli, "1.7.4", "3c1bfb6d91bf68f6a3d15f46ae20da0f7740d363ee5bc041191ce8722a6c4fae", [:mix, :rebar3], [{:recon, "~> 2.5.1", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "50de6d95d814f447458bd5d72666a74624eddb0ef98bdcee61a0153aae0865ff"},
100
+ "octo_fetch": {:hex, :octo_fetch, "0.4.0", "074b5ecbc08be10b05b27e9db08bc20a3060142769436242702931c418695b19", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "cf8be6f40cd519d7000bb4e84adcf661c32e59369ca2827c4e20042eda7a7fc6"},
101
+ "odgn_json_pointer": {:hex, :odgn_json_pointer, "3.1.0", "be81198ad8c9bce01fc872d6400d88a4a55ce711f7c8838e1166bd21f736d29a", [:mix], [], "hexpm", "85730306bb2e5796c993813f35e9bafeb561526f5e80a517793c33d1635ade94"},
102
+ "open_api_spex": {:hex, :open_api_spex, "3.22.1", "38c99d8bf107dc7ffb112dc669f33e0a287396c6c9fd6bb7eeb27e3fe8dbba0e", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", [hex: :poison, repo: "hexpm", optional: true]}, {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", [hex: :ymlr, repo: "hexpm", optional: true]}], "hexpm", "fa51ecd04ececbad89a8ede55ebd9db7aa9e55cc7ddbb46455522e0f3c098290"},
103
+ "opentelemetry": {:hex, :opentelemetry, "1.7.0", "20d0f12d3d1c398d3670fd44fd1a7c495dd748ab3e5b692a7906662e2fb1a38a", [:rebar3], [{:opentelemetry_api, "~> 1.5.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "a9173b058c4549bf824cbc2f1d2fa2adc5cdedc22aa3f0f826951187bbd53131"},
104
+ "opentelemetry_api": {:hex, :opentelemetry_api, "1.5.0", "1a676f3e3340cab81c763e939a42e11a70c22863f645aa06aafefc689b5550cf", [:mix, :rebar3], [], "hexpm", "f53ec8a1337ae4a487d43ac89da4bd3a3c99ddf576655d071deed8b56a2d5dda"},
105
+ "opentelemetry_api_experimental": {:git, "https://github.com/open-telemetry/opentelemetry-erlang.git", "b241ebf4feb7558738d009583bae8602b3c49fd0", [ref: "b241ebf4feb7558738d009583bae8602b3c49fd0", sparse: "apps/opentelemetry_api_experimental"]},
106
+ "opentelemetry_cowboy": {:hex, :opentelemetry_cowboy, "1.0.0", "786c7cde66a2493323c79d2c94e679ff501d459a9b403d8b60b9bef116333117", [:rebar3], [{:cowboy_telemetry, "~> 0.4", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7575716eaccacd0eddc3e7e61403aecb5d0a6397183987d6049094aeb0b87a7c"},
107
+ "opentelemetry_ecto": {:hex, :opentelemetry_ecto, "1.2.0", "2382cb47ddc231f953d3b8263ed029d87fbf217915a1da82f49159d122b64865", [:mix], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "70dfa2e79932e86f209df00e36c980b17a32f82d175f0068bf7ef9a96cf080cf"},
108
+ "opentelemetry_experimental": {:git, "https://github.com/open-telemetry/opentelemetry-erlang.git", "b241ebf4feb7558738d009583bae8602b3c49fd0", [ref: "b241ebf4feb7558738d009583bae8602b3c49fd0", sparse: "apps/opentelemetry_experimental"]},
109
+ "opentelemetry_exporter": {:hex, :opentelemetry_exporter, "1.10.0", "972e142392dbfa679ec959914664adefea38399e4f56ceba5c473e1cabdbad79", [:rebar3], [{:grpcbox, ">= 0.0.0", [hex: :grpcbox, repo: "hexpm", optional: false]}, {:opentelemetry, "~> 1.7.0", [hex: :opentelemetry, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.5.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:tls_certificate_check, "~> 1.18", [hex: :tls_certificate_check, repo: "hexpm", optional: false]}], "hexpm", "33a116ed7304cb91783f779dec02478f887c87988077bfd72840f760b8d4b952"},
110
+ "opentelemetry_oban": {:hex, :opentelemetry_oban, "1.1.1", "519e9ba60d3dc3483ad2df3fade131d47056e0dae74f0724c8a40b9718f089d1", [:mix], [{:oban, "~> 2.0", [hex: :oban, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.2", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 0.2", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ae6aed431626a94a4bb6bf5b268247ced687ec8f99eced6887e3754f9d3a2089"},
111
+ "opentelemetry_phoenix": {:hex, :opentelemetry_phoenix, "2.0.1", "c664cdef205738cffcd409b33599439a4ffb2035ef6e21a77927ac1da90463cb", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:opentelemetry_telemetry, "~> 1.1", [hex: :opentelemetry_telemetry, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:plug, ">= 1.11.0", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a24fdccdfa6b890c8892c6366beab4a15a27ec0c692b0f77ec2a862e7b235f6e"},
112
+ "opentelemetry_process_propagator": {:hex, :opentelemetry_process_propagator, "0.3.0", "ef5b2059403a1e2b2d2c65914e6962e56371570b8c3ab5323d7a8d3444fb7f84", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.0", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}], "hexpm", "7243cb6de1523c473cba5b1aefa3f85e1ff8cc75d08f367104c1e11919c8c029"},
113
+ "opentelemetry_req": {:hex, :opentelemetry_req, "1.0.0", "f8ef479a05311bdc030925a07be869ede45e8158cc2c80d9855e35a7d1efc89c", [:mix], [{:jason, "~> 1.3", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:opentelemetry_api, "~> 1.4", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:opentelemetry_semantic_conventions, "~> 1.27", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: false]}, {:otel_http, "~> 0.2", [hex: :otel_http, repo: "hexpm", optional: false]}, {:req, ">= 0.3.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "6404ae802d694f7c3e5b43bf41af99364ecf20c3f5fea144fff8b7f873b22bf7"},
114
+ "opentelemetry_semantic_conventions": {:hex, :opentelemetry_semantic_conventions, "1.27.0", "acd0194a94a1e57d63da982ee9f4a9f88834ae0b31b0bd850815fe9be4bbb45f", [:mix, :rebar3], [], "hexpm", "9681ccaa24fd3d810b4461581717661fd85ff7019b082c2dff89c7d5b1fc2864"},
115
+ "opentelemetry_telemetry": {:hex, :opentelemetry_telemetry, "1.1.2", "410ab4d76b0921f42dbccbe5a7c831b8125282850be649ee1f70050d3961118a", [:mix, :rebar3], [{:opentelemetry_api, "~> 1.3", [hex: :opentelemetry_api, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "641ab469deb181957ac6d59bce6e1321d5fe2a56df444fc9c19afcad623ab253"},
116
+ "otel_http": {:hex, :otel_http, "0.2.0", "b17385986c7f1b862f5d577f72614ecaa29de40392b7618869999326b9a61d8a", [:rebar3], [], "hexpm", "f2beadf922c8cfeb0965488dd736c95cc6ea8b9efce89466b3904d317d7cc717"},
117
+ "paginator": {:git, "https://github.com/duffelhq/paginator.git", "3508d6ad77a95ac1faf15d5fd7f959fab3e17da2", []},
118
+ "parent": {:hex, :parent, "0.12.1", "495c4386f06de0df492e0a7a7199c10323a55e9e933b27222060dd86dccd6d62", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2ab589ef1f37bfcedbfb5ecfbab93354972fb7391201b8907a866dadd20b39d1"},
119
+ "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
120
+ "peep": {:hex, :peep, "3.4.2", "49d4ca116d994779351959dfee971fb2c7d6506f1821374f3cc4fd39a3d3fadb", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "cae224b09e224bf5584f5375108becf71e2288572a099a122e66735289cd33f4"},
121
+ "phoenix": {:hex, :phoenix, "1.8.9", "a63ed0962ed5b903b146dab0ae8eb8387fe478f8171a5e26d56a165f35996fe1", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "3477e2dd5a4f61820341169031bdfe21275f659923bea9c5c0ea2aa1c3fcc046"},
122
+ "phoenix_bakery": {:hex, :phoenix_bakery, "0.1.2", "ca57673caea1a98f1cc763f94032796a015774d27eaa3ce5feef172195470452", [:mix], [{:brotli, "~> 0.3.0", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "45cc8cecc5c3002b922447c16389761718c07c360432328b04680034e893ea5b"},
123
+ "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
124
+ "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
125
+ "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
126
+ "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"},
127
+ "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"},
128
+ "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
129
+ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
130
+ "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
131
+ "php_serializer": {:hex, :php_serializer, "2.0.0", "b43f31aca22ed7321f32da2b94fe2ddf9b6739a965cb51541969119e572e821d", [:mix], [], "hexpm", "61e402e99d9062c0225a3f4fcf7e43b4cba1b8654944c0e7c139c3ca9de481da"},
132
+ "plug": {:hex, :plug, "1.20.2", "adbee2441232412e37fbb357fd5e4cd533fdd253b29f2e1992262b0f1fb01462", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b16baf55877d60891002ffc1ce0b3ff7d6f30a38a23e02e4d4293c4ac266f136"},
133
+ "plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"},
134
+ "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"},
135
+ "polymorphic_embed": {:hex, :polymorphic_embed, "5.0.3", "37444e0af941026a2c29b0539b6471bdd6737a6492a19264bf2bb0118e3ac242", [:mix], [{:attrs, "~> 0.6", [hex: :attrs, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "2fed44f57abf0a0fc7642e0eb0807a55b65de1562712cc0620772cbbb80e49c1"},
136
+ "postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
137
+ "prima": {:hex, :prima, "0.2.6", "a40993d108760a711c2da183cff20234497e977f6155aad1ee5e17a3b00a46b6", [:mix], [{:esbuild, "~> 0.7", [hex: :esbuild, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.2", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "00ec378bd7b0c58d58043e59581f9aa5ba35a0d5031e011e36c449d3a0b7418c"},
138
+ "prom_ex": {:hex, :prom_ex, "1.11.0", "1f6d67f2dead92224cb4f59beb3e4d319257c5728d9638b4a5e8ceb51a4f9c7e", [:mix], [{:absinthe, ">= 1.7.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.11.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "76b074bc3730f0802978a7eb5c7091a65473eaaf07e99ec9e933138dcc327805"},
139
+ "public_suffix": {:git, "https://github.com/axelson/publicsuffix-elixir", "fa40c243d4b5d8598b90cff268bc4e33f3bb63f1", []},
140
+ "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"},
141
+ "recon": {:hex, :recon, "2.5.4", "05dd52a119ee4059fa9daa1ab7ce81bc7a8161a2f12e9d42e9d551ffd2ba901c", [:mix, :rebar3], [], "hexpm", "e9ab01ac7fc8572e41eb59385efeb3fb0ff5bf02103816535bacaedf327d0263"},
142
+ "ref_inspector": {:hex, :ref_inspector, "2.0.0", "f3e97e51d9782de4c792f56eed26c80903bc39174c878285392ce76d5e67fe98", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:yamerl, "~> 0.7", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "bf62f3f1a87d6b8b30f457a480668f965373e64f184611282b5e89d8dd81fd33"},
143
+ "referrer_blocklist": {:git, "https://github.com/plausible/referrer-blocklist.git", "d6f52c225cccb4f04b80e3a5d588868ec234139d", []},
144
+ "req": {:hex, :req, "0.6.2", "b9b2024f35bcf60a92cc8cad2eaaf9d4e7aace463ff74be1afe5986830184413", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cc9cd30a2ddd04989929b887178e1610c940456d962c6c3a52df6146d2eef9bf"},
145
+ "rustler_precompiled": {:hex, :rustler_precompiled, "0.7.3", "42cb9449785cd86c87453e39afdd27a0bdfa5c77a4ec5dc5ce45112e06b9f89b", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "cbc4b3777682e5f6f43ed39b0e0b4a42dccde8053aba91b4514e8f5ff9a5ac6d"},
146
+ "saxy": {:hex, :saxy, "1.6.0", "02cb4e9bd045f25ac0c70fae8164754878327ee393c338a090288210b02317ee", [:mix], [], "hexpm", "ef42eb4ac983ca77d650fbdb68368b26570f6cc5895f0faa04d34a6f384abad3"},
147
+ "sentry": {:hex, :sentry, "11.0.4", "60371c96cefd247e0fc98840bba2648f64f19aa0b8db8e938f5a98421f55b619", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 0.3.0 or ~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:opentelemetry, ">= 0.0.0", [hex: :opentelemetry, repo: "hexpm", optional: true]}, {:opentelemetry_api, ">= 0.0.0", [hex: :opentelemetry_api, repo: "hexpm", optional: true]}, {:opentelemetry_exporter, ">= 0.0.0", [hex: :opentelemetry_exporter, repo: "hexpm", optional: true]}, {:opentelemetry_semantic_conventions, ">= 0.0.0", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "feaafc284dc204c82aadaddc884227aeaa3480decb274d30e184b9d41a700c66"},
148
+ "simple_saml": {:hex, :simple_saml, "1.2.0", "d875939e21cd73cdb3f747e701e4d2d5f0f4e35610d37df50629087454fa43f9", [:mix], [{:simple_xml, "~> 1.3", [hex: :simple_xml, repo: "hexpm", optional: false]}, {:x509, "~> 0.9.0", [hex: :x509, repo: "hexpm", optional: false]}], "hexpm", "24663665e53a91bfc38359ed9f26f575a389bde9fc94ec369f9f2259046feaf0"},
149
+ "simple_xml": {:hex, :simple_xml, "1.3.2", "84b892527b5183748694583a658c729c84bb89fc8ac650d7025ebc2c49e74018", [:mix], [{:saxy, "~> 1.5", [hex: :saxy, repo: "hexpm", optional: false]}, {:x509, "~> 0.9.0", [hex: :x509, repo: "hexpm", optional: false]}], "hexpm", "8c15fa49955a8c82b50f3053617e8a4cd771fcd0a502d660e56993c38ac86ca5"},
150
+ "siphash": {:hex, :siphash, "3.2.0", "ec03fd4066259218c85e2a4b8eec4bb9663bc02b127ea8a0836db376ba73f2ed", [:make, :mix], [], "hexpm", "ba3810701c6e95637a745e186e8a4899087c3b079ba88fb8f33df054c3b0b7c3"},
151
+ "site_encrypt": {:git, "https://github.com/sasa1977/site_encrypt.git", "8d47801c51eeea11ac385cb73eef4ed9a6565367", []},
152
+ "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
153
+ "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
154
+ "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
155
+ "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
156
+ "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
157
+ "telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"},
158
+ "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
159
+ "timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"},
160
+ "tls_certificate_check": {:hex, :tls_certificate_check, "1.30.0", "ef9bdfcb5b551b747cad231a65ebd449623628bb72471c1d2aefcbbc5730683d", [:rebar3], [{:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "dfbada8fddb80ae19b8aeaac34d95cfce98c91393b0db9c3ee079b488fd68d81"},
161
+ "tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"},
162
+ "ua_inspector": {:hex, :ua_inspector, "3.12.0", "c8c5cfb0ce19b5cd609efb577bf75e3ed60deef8ed26bc0577aa2ff60ea49bcd", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:yamerl, "~> 0.7", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "81fa0e28effb50ae9fd74ffe51622af3846149aee32c11d60b16ceab62b45682"},
163
+ "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
164
+ "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
165
+ "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
166
+ "x509": {:hex, :x509, "0.9.2", "a75aa605348abd905990f3d2dc1b155fcde4e030fa2f90c4a91534405dce0f6e", [:mix], [], "hexpm", "4c5ede75697e565d4b0f5be04c3b71bb1fd3a090ea243af4bd7dae144e48cfc7"},
167
+ "xml_builder": {:hex, :xml_builder, "2.4.0", "b20d23077266c81f593360dc037ea398461dddb6638a329743da6c73afa56725", [:mix], [], "hexpm", "833e325bb997f032b5a1b740d2fd6feed3c18ca74627f9f5f30513a9ae1a232d"},
168
+ "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
169
+ "zstream": {:hex, :zstream, "0.6.7", "ae74c7544f4e0563ffbe71324bf1c9bbc0ab33bb580a0ae718da511fb8a5c9d6", [:mix], [], "hexpm", "48c43ae0f00cfcda1ccb69c1d044755663d43b2ee8a0a65763648bf2078d634d"},
170
+ "zxcvbn": {:git, "https://github.com/techgaun/zxcvbn-elixir.git", "5c9684590f4ecf00af2d391f90db9ce93bd4e996", []},
171
+ }
priv/custom_sources.json ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "bsky.app":"Bluesky",
3
+ "go.bsky.app":"Bluesky",
4
+ "android-app://com.reddit.frontpage":"Reddit",
5
+ "baidu.com":"Baidu",
6
+ "chatgpt.com":"ChatGPT",
7
+ "chat.openai.com":"ChatGPT",
8
+ "claude.ai":"Claude",
9
+ "copilot.microsoft.com":"Microsoft Copilot",
10
+ "copilot.com":"Microsoft Copilot",
11
+ "copilot":"Microsoft Copilot",
12
+ "deepseek.com":"DeepSeek",
13
+ "gemini.google.com":"Google Gemini",
14
+ "grok.com":"Grok",
15
+ "x.ai":"Grok",
16
+ "phind.com":"Phind",
17
+ "chaos.social":"Mastodon",
18
+ "fosstodon.org":"Mastodon",
19
+ "hachyderm.io":"Mastodon",
20
+ "indieweb.social":"Mastodon",
21
+ "infosec.exchange":"Mastodon",
22
+ "mas.to":"Mastodon",
23
+ "mastodon.online":"Mastodon",
24
+ "mastodon.social":"Mastodon",
25
+ "mastodon.world":"Mastodon",
26
+ "mathstodon.xyz":"Mastodon",
27
+ "mstdn.jp":"Mastodon",
28
+ "mstdn.social":"Mastodon",
29
+ "scholar.social":"Mastodon",
30
+ "sigmoid.social":"Mastodon",
31
+ "social.tchncs.de":"Mastodon",
32
+ "android-app://org.joinmastodon.android": "Mastodon",
33
+ "discord.com":"Discord",
34
+ "discordapp.com":"Discord",
35
+ "linktr.ee":"Linktree",
36
+ "m.sogou.com":"Sogou",
37
+ "kagi.com":"Kagi",
38
+ "l.threads.com":"Threads",
39
+ "ntp.msn.com":"Bing",
40
+ "perplexity.ai":"Perplexity",
41
+ "pplx.ai":"Perplexity",
42
+ "ptb.discord.com":"Discord",
43
+ "search.brave.com":"Brave",
44
+ "sogou.com":"Sogou",
45
+ "statics.teams.cdn.office.net":"Microsoft Teams",
46
+ "teams.microsoft.com":"Microsoft Teams",
47
+ "t.me":"Telegram",
48
+ "wap.sogou.com":"Sogou",
49
+ "ya.ru":"Yandex",
50
+ "yandex.com.tr":"Yandex",
51
+ "yandex.eu":"Yandex",
52
+ "yandex.fr":"Yandex",
53
+ "yandex.kz":"Yandex",
54
+ "yandex.tm":"Yandex",
55
+ "yandex.uz":"Yandex",
56
+ "fb": "Facebook",
57
+ "fb-ads": "Facebook",
58
+ "fbads": "Facebook",
59
+ "fbad": "Facebook",
60
+ "facebook-ads": "Facebook",
61
+ "facebook_ads": "Facebook",
62
+ "fcb": "Facebook",
63
+ "facebook_ad": "Facebook",
64
+ "facebook_feed_ad": "Facebook",
65
+ "ig": "Instagram",
66
+ "yt": "Youtube",
67
+ "yt-ads": "Youtube",
68
+ "reddit-ads": "Reddit",
69
+ "google_ads": "Google",
70
+ "google-ads": "Google",
71
+ "googleads": "Google",
72
+ "gads": "Google",
73
+ "google ads": "Google",
74
+ "adwords": "Google",
75
+ "x.com":"X (Twitter)",
76
+ "mobile.twitter.com": "X (Twitter)",
77
+ "twitter-ads": "X (Twitter)",
78
+ "twitter": "X (Twitter)",
79
+ "tiktokads": "TikTok",
80
+ "tik.tok": "TikTok",
81
+ "wikipedia": "Wikipedia",
82
+ "canary.discord.com":"Discord",
83
+ "webk.telegram.org":"Telegram"
84
+ }
priv/data_migrations/AcquisitionChannel/sql/acquisition_channel_add_materialized_column.sql.eex ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ ALTER TABLE <%= @table %>
2
+ <%= @on_cluster_statement %>
3
+ ADD COLUMN IF NOT EXISTS acquisition_channel LowCardinality(String)
4
+ MATERIALIZED acquisition_channel(referrer_source, utm_medium, utm_campaign, utm_source, click_id_param)
priv/data_migrations/AcquisitionChannel/sql/acquisition_channel_backfill_materialized_column.sql.eex ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ ALTER TABLE <%= @table %>
2
+ MATERIALIZE COLUMN acquisition_channel
priv/data_migrations/AcquisitionChannel/sql/acquisition_channel_functions.sql.eex ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE TABLE IF NOT EXISTS acquisition_channel_source_category
2
+ <%= @on_cluster_statement %>
3
+ (
4
+ referrer_source String,
5
+ category LowCardinality(String)
6
+ )
7
+ <%= if @on_cluster_statement != "" do %>
8
+ ENGINE = ReplicatedMergeTree('/clickhouse/{cluster}/tables/{shard}/plausible_prod/acquisition_channel_source_category', '{replica}')
9
+ <% else %>
10
+ ENGINE = MergeTree()
11
+ <% end %>
12
+ ORDER BY referrer_source
13
+ <%= @table_settings %>;
14
+
15
+ TRUNCATE TABLE acquisition_channel_source_category SETTINGS alter_sync=2;
16
+
17
+ INSERT INTO acquisition_channel_source_category(referrer_source, category)
18
+ SELECT t.1 AS referrer_source, t.2 AS category
19
+ FROM (
20
+ SELECT arrayJoin({source_categories:Array(Tuple(String, String))}) AS t
21
+ )
22
+ SETTINGS insert_quorum = <%= @insert_quorum %>;
23
+
24
+ CREATE OR REPLACE DICTIONARY acquisition_channel_source_category_dict
25
+ <%= @on_cluster_statement %>
26
+ (
27
+ `referrer_source` String,
28
+ `category` String
29
+ )
30
+ PRIMARY KEY referrer_source
31
+ SOURCE(CLICKHOUSE(TABLE acquisition_channel_source_category <%= @dictionary_connection_params %>))
32
+ LIFETIME(0)
33
+ LAYOUT(hashed());
34
+
35
+ CREATE TABLE IF NOT EXISTS acquisition_channel_paid_sources
36
+ <%= @on_cluster_statement %>
37
+ (
38
+ referrer_source String
39
+ )
40
+ <%= if @on_cluster_statement != "" do %>
41
+ ENGINE = ReplicatedMergeTree('/clickhouse/{cluster}/tables/{shard}/plausible_prod/acquisition_channel_paid_sources', '{replica}')
42
+ <% else %>
43
+ ENGINE = MergeTree()
44
+ <% end %>
45
+ ORDER BY referrer_source
46
+ <%= @table_settings %>;
47
+
48
+ TRUNCATE TABLE acquisition_channel_paid_sources SETTINGS alter_sync=2;
49
+
50
+ INSERT INTO acquisition_channel_paid_sources(referrer_source)
51
+ SELECT arrayJoin({paid_sources:Array(String)}) AS referrer_source
52
+ SETTINGS insert_quorum = <%= @insert_quorum %>;
53
+
54
+ CREATE OR REPLACE DICTIONARY acquisition_channel_paid_sources_dict
55
+ <%= @on_cluster_statement %>
56
+ (
57
+ `referrer_source` String
58
+ )
59
+ PRIMARY KEY referrer_source
60
+ SOURCE(CLICKHOUSE(TABLE acquisition_channel_paid_sources <%= @dictionary_connection_params %>))
61
+ LIFETIME(0)
62
+ LAYOUT(hashed());
63
+
64
+ CREATE OR REPLACE FUNCTION acquisition_channel_has_category_shopping <%= @on_cluster_statement %> AS
65
+ (referrer_source) ->
66
+ dictGet('acquisition_channel_source_category_dict', 'category', referrer_source) = 'SOURCE_CATEGORY_SHOPPING';
67
+
68
+ CREATE OR REPLACE FUNCTION acquisition_channel_has_category_social <%= @on_cluster_statement %> AS
69
+ (referrer_source) ->
70
+ dictGet('acquisition_channel_source_category_dict', 'category', referrer_source) = 'SOURCE_CATEGORY_SOCIAL';
71
+
72
+ CREATE OR REPLACE FUNCTION acquisition_channel_has_category_video <%= @on_cluster_statement %> AS
73
+ (referrer_source) ->
74
+ dictGet('acquisition_channel_source_category_dict', 'category', referrer_source) = 'SOURCE_CATEGORY_VIDEO';
75
+
76
+ CREATE OR REPLACE FUNCTION acquisition_channel_has_category_search <%= @on_cluster_statement %> AS
77
+ (referrer_source) ->
78
+ dictGet('acquisition_channel_source_category_dict', 'category', referrer_source) = 'SOURCE_CATEGORY_SEARCH';
79
+
80
+ CREATE OR REPLACE FUNCTION acquisition_channel_has_category_email <%= @on_cluster_statement %> AS
81
+ (referrer_source) ->
82
+ dictGet('acquisition_channel_source_category_dict', 'category', referrer_source) = 'SOURCE_CATEGORY_EMAIL';
83
+
84
+ CREATE OR REPLACE FUNCTION acquisition_channel_has_category_ai_assistants <%= @on_cluster_statement %> AS
85
+ (referrer_source) ->
86
+ dictGet('acquisition_channel_source_category_dict', 'category', referrer_source) = 'SOURCE_CATEGORY_AI_ASSISTANTS';
87
+
88
+ CREATE OR REPLACE FUNCTION acquisition_channel_paid_utm_source <%= @on_cluster_statement %> AS
89
+ (referrer_source) ->
90
+ dictHas('acquisition_channel_paid_sources_dict', referrer_source);
91
+
92
+ CREATE OR REPLACE FUNCTION acquisition_channel_cross_network <%= @on_cluster_statement %> AS
93
+ (utm_campaign) ->
94
+ position(utm_campaign, 'cross-network') > 0;
95
+
96
+ CREATE OR REPLACE FUNCTION acquisition_channel_paid_shopping <%= @on_cluster_statement %> AS
97
+ (referrer_source, utm_medium, utm_campaign) ->
98
+ acquisition_channel_paid_medium(utm_medium) AND
99
+ (
100
+ acquisition_channel_has_category_shopping(referrer_source)
101
+ OR acquisition_channel_shopping_campaign(utm_campaign)
102
+ );
103
+
104
+ CREATE OR REPLACE FUNCTION acquisition_channel_paid_search <%= @on_cluster_statement %> AS
105
+ (referrer_source, utm_medium, utm_source, click_id_param) ->
106
+ (
107
+ acquisition_channel_has_category_search(referrer_source)
108
+ AND (
109
+ acquisition_channel_paid_medium(utm_medium)
110
+ OR acquisition_channel_paid_utm_source(utm_source)
111
+ )
112
+ ) OR (
113
+ referrer_source == 'google'
114
+ AND click_id_param == 'gclid'
115
+ ) OR (
116
+ referrer_source == 'bing'
117
+ AND click_id_param == 'msclkid'
118
+ );
119
+
120
+ CREATE OR REPLACE FUNCTION acquisition_channel_paid_social <%= @on_cluster_statement %> AS
121
+ (referrer_source, utm_medium, utm_source) ->
122
+ acquisition_channel_has_category_social(referrer_source)
123
+ AND (
124
+ acquisition_channel_paid_medium(utm_medium)
125
+ OR acquisition_channel_paid_utm_source(utm_source)
126
+ );
127
+
128
+ CREATE OR REPLACE FUNCTION acquisition_channel_paid_video <%= @on_cluster_statement %> AS
129
+ (referrer_source, utm_medium, utm_source) ->
130
+ acquisition_channel_has_category_video(referrer_source)
131
+ AND (
132
+ acquisition_channel_paid_medium(utm_medium)
133
+ OR acquisition_channel_paid_utm_source(utm_source)
134
+ );
135
+
136
+ CREATE OR REPLACE FUNCTION acquisition_channel_display <%= @on_cluster_statement %> AS
137
+ (utm_medium) ->
138
+ utm_medium IN ('display', 'banner', 'expandable', 'interstitial', 'cpm');
139
+
140
+ CREATE OR REPLACE FUNCTION acquisition_channel_paid_medium <%= @on_cluster_statement %> AS
141
+ (utm_medium) ->
142
+ match(utm_medium, '^(.*cp.*|ppc|retargeting|paid.*)$');
143
+
144
+ CREATE OR REPLACE FUNCTION acquisition_channel_shopping_campaign <%= @on_cluster_statement %> AS
145
+ (utm_campaign) ->
146
+ match(utm_campaign, '^(.*(([^a-df-z]|^)shop|shopping).*)$');
147
+
148
+ CREATE OR REPLACE FUNCTION acquisition_channel_organic_shopping <%= @on_cluster_statement %> AS
149
+ (referrer_source, utm_campaign) ->
150
+ acquisition_channel_has_category_shopping(referrer_source)
151
+ OR acquisition_channel_shopping_campaign(utm_campaign);
152
+
153
+ CREATE OR REPLACE FUNCTION acquisition_channel_organic_social <%= @on_cluster_statement %> AS
154
+ (referrer_source, utm_medium) ->
155
+ acquisition_channel_has_category_social(referrer_source)
156
+ OR utm_medium IN (
157
+ 'social',
158
+ 'social-network',
159
+ 'social-media',
160
+ 'sm',
161
+ 'social network',
162
+ 'social media'
163
+ );
164
+
165
+ CREATE OR REPLACE FUNCTION acquisition_channel_organic_video <%= @on_cluster_statement %> AS
166
+ (referrer_source, utm_medium) ->
167
+ acquisition_channel_has_category_video(referrer_source) OR position(utm_medium, 'video') > 0;
168
+
169
+ CREATE OR REPLACE FUNCTION acquisition_channel_email <%= @on_cluster_statement %> AS
170
+ (referrer_source, utm_source, utm_medium) ->
171
+ acquisition_channel_has_category_email(referrer_source)
172
+ OR acquisition_channel_contains_email(utm_source)
173
+ OR acquisition_channel_contains_email(utm_medium);
174
+
175
+ CREATE OR REPLACE FUNCTION acquisition_channel_affiliates <%= @on_cluster_statement %> AS
176
+ (utm_medium) ->
177
+ utm_medium == 'affiliate';
178
+
179
+ CREATE OR REPLACE FUNCTION acquisition_channel_audio <%= @on_cluster_statement %> AS
180
+ (utm_medium) ->
181
+ utm_medium == 'audio';
182
+
183
+ CREATE OR REPLACE FUNCTION acquisition_channel_sms <%= @on_cluster_statement %> AS
184
+ (column) ->
185
+ column == 'sms';
186
+
187
+ CREATE OR REPLACE FUNCTION acquisition_channel_mobile_push_notifications <%= @on_cluster_statement %> AS
188
+ (utm_medium, referrer_source) ->
189
+ endsWith(utm_medium, 'push') OR
190
+ multiSearchAny(utm_medium, ['mobile', 'notification']) OR
191
+ referrer_source == 'firebase';
192
+
193
+ CREATE OR REPLACE FUNCTION acquisition_channel_referral <%= @on_cluster_statement %> AS
194
+ (utm_medium, referrer_source) ->
195
+ utm_medium IN ('referral', 'app', 'link') OR
196
+ not empty(referrer_source);
197
+
198
+ CREATE OR REPLACE FUNCTION acquisition_channel_contains_email <%= @on_cluster_statement %> AS
199
+ (column) ->
200
+ match(column, 'e[-_ ]?mail|newsletter');
201
+
202
+ CREATE OR REPLACE FUNCTION acquisition_channel <%= @on_cluster_statement %> AS
203
+ (referrer_source, utm_medium, utm_campaign, utm_source, click_id_param) ->
204
+ acquisition_channel_lowered(
205
+ lower(referrer_source),
206
+ lower(utm_medium),
207
+ lower(utm_campaign),
208
+ lower(utm_source),
209
+ click_id_param
210
+ );
211
+
212
+ CREATE OR REPLACE FUNCTION acquisition_channel_lowered <%= @on_cluster_statement %> AS
213
+ (referrer_source, utm_medium, utm_campaign, utm_source, click_id_param) ->
214
+ multiIf(
215
+ acquisition_channel_cross_network(utm_campaign), 'Cross-network',
216
+ acquisition_channel_display(utm_medium), 'Display',
217
+ acquisition_channel_paid_shopping(referrer_source, utm_medium, utm_campaign), 'Paid Shopping',
218
+ acquisition_channel_paid_search(referrer_source, utm_medium, utm_source, click_id_param), 'Paid Search',
219
+ acquisition_channel_paid_social(referrer_source, utm_medium, utm_source), 'Paid Social',
220
+ acquisition_channel_paid_video(referrer_source, utm_medium, utm_source), 'Paid Video',
221
+ acquisition_channel_paid_medium(utm_medium), 'Paid Other',
222
+ acquisition_channel_organic_shopping(referrer_source, utm_campaign), 'Organic Shopping',
223
+ acquisition_channel_organic_social(referrer_source, utm_medium), 'Organic Social',
224
+ acquisition_channel_organic_video(referrer_source, utm_medium), 'Organic Video',
225
+ acquisition_channel_has_category_ai_assistants(referrer_source), 'AI Assistants',
226
+ acquisition_channel_has_category_search(referrer_source), 'Organic Search',
227
+ acquisition_channel_email(referrer_source, utm_source, utm_medium), 'Email',
228
+ acquisition_channel_affiliates(utm_medium), 'Affiliates',
229
+ acquisition_channel_audio(utm_medium), 'Audio',
230
+ acquisition_channel_sms(utm_source), 'SMS',
231
+ acquisition_channel_sms(utm_medium), 'SMS',
232
+ acquisition_channel_mobile_push_notifications(utm_medium, referrer_source), 'Mobile Push Notifications',
233
+ acquisition_channel_referral(utm_medium, referrer_source), 'Referral',
234
+ 'Direct'
235
+ );
priv/data_migrations/AcquisitionChannel/sql/acquisition_channel_update_materialized_column.sql.eex ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ ALTER TABLE <%= @table %>
2
+ <%= @on_cluster_statement %>
3
+ MODIFY COLUMN acquisition_channel LowCardinality(String)
4
+ MATERIALIZED acquisition_channel(referrer_source, utm_medium, utm_campaign, utm_source, click_id_param)
priv/data_migrations/FunnelDupeGoals/sql/list-funnels-with-dupe-goal-ids.sql.eex ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ WITH DuplicateGoals AS (
2
+ SELECT
3
+ g.site_id,
4
+ CASE
5
+ WHEN g.event_name IS NOT NULL THEN 'Event: ' || g.event_name
6
+ WHEN g.page_path IS NOT NULL THEN 'Page: ' || g.page_path
7
+ END AS goal_name,
8
+ ARRAY_AGG(g.id) AS duplicate_ids
9
+ FROM
10
+ goals g
11
+ WHERE
12
+ g.site_id IS NOT NULL
13
+ GROUP BY
14
+ g.site_id,
15
+ goal_name
16
+ HAVING COUNT(*) > 1
17
+ ),
18
+ FunnelStepsWithDuplicateGoals AS (
19
+ SELECT
20
+ fs.id,
21
+ fs.goal_id,
22
+ fs.funnel_id,
23
+ f.name,
24
+ CASE
25
+ WHEN g.event_name IS NOT NULL THEN 'Event: ' || g.event_name
26
+ WHEN g.page_path IS NOT NULL THEN 'Page: ' || g.page_path
27
+ END AS goal_name,
28
+ dg.duplicate_ids
29
+ FROM
30
+ funnel_steps fs
31
+ JOIN goals g ON fs.goal_id = g.id
32
+ JOIN funnels f ON fs.funnel_id = f.id
33
+ JOIN DuplicateGoals dg ON f.site_id = dg.site_id
34
+ WHERE
35
+ fs.goal_id = ANY(dg.duplicate_ids) -- Ch
36
+ )
37
+ SELECT
38
+ s.domain,
39
+ f.site_id,
40
+ f.name as funnel_name,
41
+ f.id,
42
+ fs.id,
43
+ fs.goal_id,
44
+ fs.goal_name,
45
+ fs.duplicate_ids,
46
+ array_agg(fs2.goal_id)
47
+ FROM
48
+ funnels f
49
+ JOIN FunnelStepsWithDuplicateGoals fs ON f.id = fs.funnel_id
50
+ JOIN funnel_steps fs2 ON f.id = fs2.funnel_id
51
+ JOIN sites s ON s.id = f.site_id
52
+ GROUP BY
53
+ s.domain,
54
+ f.site_id,
55
+ f.name,
56
+ f.id,
57
+ fs.id,
58
+ fs.goal_id,
59
+ fs.goal_name,
60
+ fs.duplicate_ids
61
+ ORDER BY fs.id ASC
62
+ ;
priv/data_migrations/LocationsSync/sql/add-alias-column.sql.eex ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ ALTER TABLE <%= @table %>
2
+ <%= if @cluster? do %>ON CLUSTER '{cluster}'<% end %>
3
+ ADD COLUMN IF NOT EXISTS
4
+ <%= @column_name %> String
5
+ ALIAS dictGet('location_data_dict', 'name', tuple('<%= @type %>', <%= @input_column %>))
priv/data_migrations/LocationsSync/sql/create-location-data-table.sql.eex ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE TABLE IF NOT EXISTS location_data <%= if @cluster? do %>ON CLUSTER '{cluster}'<% end %>
2
+ (
3
+ `type` LowCardinality(String),
4
+ `id` String,
5
+ `name` String
6
+ )
7
+ <%= if @cluster? do %>
8
+ ENGINE = ReplicatedMergeTree('/clickhouse/{cluster}/tables/{shard}/plausible_prod/location_data', '{replica}')
9
+ <% else %>
10
+ ENGINE = MergeTree()
11
+ <% end %>
12
+ ORDER BY (type, id)
13
+ SETTINGS index_granularity = 128 <%= @table_settings %>
priv/data_migrations/LocationsSync/sql/get-location-data-table-comment.sql.eex ADDED
@@ -0,0 +1 @@
 
 
1
+ select comment from system.tables where database = currentDatabase() and table = 'location_data'
priv/data_migrations/LocationsSync/sql/truncate-location-data-table.sql.eex ADDED
@@ -0,0 +1 @@
 
 
1
+ TRUNCATE TABLE IF EXISTS location_data SETTINGS alter_sync=2
priv/data_migrations/LocationsSync/sql/update-location-data-dictionary.sql.eex ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE OR REPLACE DICTIONARY location_data_dict
2
+ <%= if @cluster? do %>ON CLUSTER '{cluster}'<% end %>
3
+ (
4
+ `type` String,
5
+ `id` String,
6
+ `name` String
7
+ )
8
+ PRIMARY KEY type, id
9
+ SOURCE(CLICKHOUSE(TABLE location_data <%= @dictionary_connection_params %>))
10
+ LIFETIME(0)
11
+ LAYOUT(complex_key_cache(size_in_cells 500000))
priv/data_migrations/LocationsSync/sql/update-location-data-table-comment.sql.eex ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ ALTER TABLE location_data
2
+ <%= if @cluster? do %>ON CLUSTER '{cluster}'<% end %>
3
+ MODIFY COMMENT '<%= @version %>'
priv/data_migrations/NumericIDs/sql/attach-tmp-events-v2.sql.eex ADDED
@@ -0,0 +1 @@
 
 
1
+ ALTER TABLE events_v2 ATTACH PARTITION ID '<%= @partition %>' FROM tmp_events_v2
priv/data_migrations/NumericIDs/sql/attach-tmp-sessions-v2.sql.eex ADDED
@@ -0,0 +1 @@
 
 
1
+ ALTER TABLE sessions_v2 ATTACH PARTITION ID '<%= @partition %>' FROM tmp_sessions_v2
priv/data_migrations/NumericIDs/sql/check-replicas.sql.eex ADDED
@@ -0,0 +1 @@
 
 
1
+ SELECT 1 FROM system.replicas WHERE table = 'events'
priv/data_migrations/NumericIDs/sql/create-domains-lookup.sql.eex ADDED
@@ -0,0 +1 @@
 
 
1
+ CREATE TABLE IF NOT EXISTS domains_lookup(site_id UInt64, domain String) ENGINE Memory;
priv/data_migrations/NumericIDs/sql/create-events-v2.sql.eex ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE TABLE IF NOT EXISTS events_v2 <%= if @cluster? do %>ON CLUSTER '{cluster}'<% end %>
2
+ (
3
+ `timestamp` DateTime CODEC(Delta(4), LZ4),
4
+ `name` LowCardinality(String),
5
+ `site_id` UInt64,
6
+ `user_id` UInt64,
7
+ `session_id` UInt64,
8
+ `hostname` String,
9
+ `pathname` String CODEC(ZSTD(3)),
10
+ `referrer` String,
11
+ `referrer_source` String,
12
+ `country_code` FixedString(2),
13
+ `screen_size` LowCardinality(String),
14
+ `operating_system` LowCardinality(String),
15
+ `browser` LowCardinality(String),
16
+ `utm_medium` String,
17
+ `utm_source` String,
18
+ `utm_campaign` String,
19
+ `meta.key` Array(String),
20
+ `meta.value` Array(String),
21
+ `browser_version` LowCardinality(String),
22
+ `operating_system_version` LowCardinality(String),
23
+ `subdivision1_code` LowCardinality(String),
24
+ `subdivision2_code` LowCardinality(String),
25
+ `city_geoname_id` UInt32,
26
+ `utm_content` String,
27
+ `utm_term` String,
28
+ `transferred_from` String
29
+ )
30
+ <%= if @cluster? do %>
31
+ ENGINE = ReplicatedMergeTree('/clickhouse/{cluster}/tables/{shard}/{database}/events_v2', '{replica}')
32
+ <% else %>
33
+ ENGINE = MergeTree()
34
+ <% end %>
35
+ PARTITION BY toYYYYMM(timestamp)
36
+ PRIMARY KEY (site_id, toDate(timestamp), name, user_id)
37
+ ORDER BY (site_id, toDate(timestamp), name, user_id, timestamp)
38
+ SAMPLE BY user_id
39
+ <%= @table_settings %>
priv/data_migrations/NumericIDs/sql/create-sessions-v2.sql.eex ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ CREATE TABLE IF NOT EXISTS sessions_v2 <%= if @cluster? do %>ON CLUSTER '{cluster}'<% end %>
2
+ (
3
+ `session_id` UInt64,
4
+ `sign` Int8,
5
+ `site_id` UInt64,
6
+ `user_id` UInt64,
7
+ `hostname` String,
8
+ `timestamp` DateTime CODEC(DoubleDelta, LZ4),
9
+ `start` DateTime CODEC(DoubleDelta, LZ4),
10
+ `is_bounce` UInt8,
11
+ `entry_page` String,
12
+ `exit_page` String,
13
+ `pageviews` Int32,
14
+ `events` Int32,
15
+ `duration` UInt32,
16
+ `referrer` String,
17
+ `referrer_source` String,
18
+ `country_code` LowCardinality(FixedString(2)),
19
+ `screen_size` LowCardinality(String),
20
+ `operating_system` LowCardinality(String),
21
+ `browser` LowCardinality(String),
22
+ `utm_medium` String,
23
+ `utm_source` String,
24
+ `utm_campaign` String,
25
+ `browser_version` LowCardinality(String),
26
+ `operating_system_version` LowCardinality(String),
27
+ `subdivision1_code` LowCardinality(String),
28
+ `subdivision2_code` LowCardinality(String),
29
+ `city_geoname_id` UInt32,
30
+ `utm_content` String,
31
+ `utm_term` String,
32
+ `transferred_from` String,
33
+ `entry_meta.key` Array(String),
34
+ `entry_meta.value` Array(String)
35
+ )
36
+ <%= if @cluster? do %>
37
+ ENGINE = ReplicatedCollapsingMergeTree('/clickhouse/{cluster}/tables/{shard}/plausible_prod/sessions_v2', '{replica}', sign)
38
+ <% else %>
39
+ ENGINE = CollapsingMergeTree(sign)
40
+ <% end %>
41
+ PARTITION BY toYYYYMM(start)
42
+ PRIMARY KEY (site_id, toDate(start), user_id, session_id)
43
+ ORDER BY (site_id, toDate(start), user_id, session_id)
44
+ SAMPLE BY user_id
45
+ <%= @table_settings %>
priv/data_migrations/NumericIDs/sql/create-tmp-events-v2.sql.eex ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ CREATE TABLE IF NOT EXISTS
2
+ tmp_events_v2 AS events_v2 ENGINE = MergeTree()
3
+ ORDER BY (site_id, toDate(timestamp), name, user_id, timestamp)
4
+ PRIMARY KEY (site_id, toDate(timestamp), name, user_id)
5
+ PARTITION BY toYYYYMM(timestamp)
6
+ <%= @table_settings %>
priv/data_migrations/NumericIDs/sql/create-tmp-sessions-v2.sql.eex ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ CREATE TABLE IF NOT EXISTS
2
+ tmp_sessions_v2 AS sessions_v2 ENGINE = CollapsingMergeTree(sign)
3
+ PARTITION BY toYYYYMM(start)
4
+ PRIMARY KEY (site_id, toDate(start), user_id, session_id)
5
+ ORDER BY (site_id, toDate(start), user_id, session_id)
6
+ <%= @table_settings %>
priv/data_migrations/NumericIDs/sql/drop-domains-lookup.sql.eex ADDED
@@ -0,0 +1 @@
 
 
1
+ DROP TABLE IF EXISTS domains_lookup SYNC;