Skip to content

Commit 6ade93b

Browse files
authored
ScriptV2: Self hosted tracker script cache (#5502)
* Allow caching tracker script on CE Open questions with this approach: - `ingestion_url`: Using `PlausibleWeb.Endpoint.url()` requires that endpoint has started, but we want to pre-warm the cache _before_ the endpoint starts. To work around this, a different approach is used to get the right url. - caching: Other caches currently cache database models, this caches a string. Will this cause issues? * Slightly better workaround * Lazier timers
1 parent 4387d42 commit 6ade93b

File tree

7 files changed

+176
-12
lines changed

7 files changed

+176
-12
lines changed

config/runtime.exs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,8 @@ config :plausible, PlausibleWeb.Endpoint,
357357
http: [port: http_port, ip: listen_ip] ++ default_http_opts,
358358
secret_key_base: secret_key_base,
359359
websocket_url: websocket_url,
360-
secure_cookie: secure_cookie
360+
secure_cookie: secure_cookie,
361+
base_url: base_url
361362

362363
# maybe enable HTTPS in CE
363364
if config_env() in [:ce, :ce_dev, :ce_test] do

lib/plausible/application.ex

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,22 @@ defmodule Plausible.Application do
128128
]
129129
)
130130
end,
131+
on_ce do
132+
warmed_cache(PlausibleWeb.TrackerScriptCache,
133+
adapter_opts: [
134+
n_lock_partitions: 1,
135+
ttl_check_interval: false,
136+
ets_options: [:bag, read_concurrency: true]
137+
],
138+
warmers: [
139+
refresh_all:
140+
{PlausibleWeb.TrackerScriptCache.All,
141+
interval: :timer.minutes(180) + Enum.random(1..:timer.seconds(10))},
142+
refresh_updated_recently:
143+
{PlausibleWeb.TrackerScriptCache.RecentlyUpdated, interval: :timer.seconds(120)}
144+
]
145+
)
146+
end,
131147
Plausible.Ingestion.Counters,
132148
Plausible.Session.Salts,
133149
Supervisor.child_spec(Plausible.Event.WriteBuffer, id: Plausible.Event.WriteBuffer),

lib/plausible/cache.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ defmodule Plausible.Cache do
107107
@spec refresh_updated_recently(Keyword.t()) :: :ok
108108
def refresh_updated_recently(opts \\ []) do
109109
recently_updated_query =
110-
from [s, _rg] in base_db_query(),
110+
from [s, ...] in base_db_query(),
111111
order_by: [asc: s.updated_at],
112112
where: s.updated_at > ago(^15, "minute")
113113

lib/plausible_web/plugs/tracker_plug.ex

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ defmodule PlausibleWeb.TrackerPlug do
44
"""
55

66
import Plug.Conn
7-
import Ecto.Query
87
use Agent
8+
use Plausible
99

1010
base_variants = [
1111
"hash",
@@ -62,14 +62,9 @@ defmodule PlausibleWeb.TrackerPlug do
6262
def telemetry_event(name), do: [:plausible, :tracker_script, :request, name]
6363

6464
defp request_tracker_script(tag, conn) do
65-
tracker_script_configuration =
66-
Plausible.Repo.one(
67-
from s in Plausible.Site.TrackerScriptConfiguration, where: s.id == ^tag, preload: [:site]
68-
)
69-
70-
if tracker_script_configuration do
71-
script_tag = PlausibleWeb.Tracker.plausible_main_script_tag(tracker_script_configuration)
65+
script_tag = get_plausible_web_script_tag(tag)
7266

67+
if script_tag do
7368
:telemetry.execute(
7469
telemetry_event(:v2),
7570
%{},
@@ -84,7 +79,7 @@ defmodule PlausibleWeb.TrackerPlug do
8479
|> put_resp_header("cache-control", "public, max-age=60, no-transform")
8580
# CDN-Tag is used by BunnyCDN to tag cached resources. This allows us to purge
8681
# specific tracker scripts from the CDN cache.
87-
|> put_resp_header("cdn-tag", "tracker_script::#{tracker_script_configuration.id}")
82+
|> put_resp_header("cdn-tag", "tracker_script::#{tag}")
8883
|> send_resp(200, script_tag)
8984
|> halt()
9085
else
@@ -100,6 +95,16 @@ defmodule PlausibleWeb.TrackerPlug do
10095
end
10196
end
10297

98+
defp get_plausible_web_script_tag(tag) do
99+
on_ee do
100+
# On cloud, we generate the script always on the fly relying on CDN caching
101+
PlausibleWeb.TrackerScriptCache.get_from_source(tag)
102+
else
103+
# On self-hosted, we have a pre-warmed cache for the script
104+
PlausibleWeb.TrackerScriptCache.get(tag)
105+
end
106+
end
107+
103108
defp legacy_request_file(filename, files_available, conn) do
104109
if filename && MapSet.member?(files_available, filename) do
105110
location = Application.app_dir(:plausible, "priv/tracker/js/" <> filename)

lib/plausible_web/tracker.ex

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ defmodule PlausibleWeb.Tracker do
3535
def plausible_main_config(tracker_script_configuration) do
3636
%{
3737
domain: tracker_script_configuration.site.domain,
38-
endpoint: "#{PlausibleWeb.Endpoint.url()}/api/event",
38+
endpoint: tracker_ingestion_endpoint(),
3939
hashBasedRouting: tracker_script_configuration.hash_based_routing,
4040
outboundLinks: tracker_script_configuration.outbound_links,
4141
fileDownloads: tracker_script_configuration.file_downloads,
@@ -119,4 +119,14 @@ defmodule PlausibleWeb.Tracker do
119119
defp changeset(tracker_script_configuration, config_update, :plugins_api) do
120120
TrackerScriptConfiguration.plugins_api_changeset(tracker_script_configuration, config_update)
121121
end
122+
123+
defp tracker_ingestion_endpoint() do
124+
# :TRICKY: Normally we would use PlausibleWeb.Endpoint.url() here, but
125+
# that requires the endpoint to be started. We start the TrackerScriptCache
126+
# before the endpoint is started, so we need to use the base_url directly.
127+
128+
endpoint_config = Application.fetch_env!(:plausible, PlausibleWeb.Endpoint)
129+
base_url = Keyword.get(endpoint_config, :base_url)
130+
"#{base_url}/api/event"
131+
end
122132
end
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
defmodule PlausibleWeb.TrackerScriptCache do
2+
@moduledoc """
3+
Cache for tracker script(s) for self-hosted Plausible instances.
4+
"""
5+
alias Plausible.Site.TrackerScriptConfiguration
6+
7+
import Ecto.Query
8+
use Plausible.Cache
9+
10+
@cache_name :tracker_script_cache
11+
12+
@impl true
13+
def name(), do: @cache_name
14+
15+
@impl true
16+
def child_id(), do: :cache_tracker_script
17+
18+
@impl true
19+
def count_all() do
20+
Plausible.Repo.aggregate(TrackerScriptConfiguration, :count)
21+
end
22+
23+
@impl true
24+
def base_db_query() do
25+
from(
26+
t in TrackerScriptConfiguration,
27+
join: s in assoc(t, :site),
28+
preload: [site: s]
29+
)
30+
end
31+
32+
@impl true
33+
def get_from_source(id) do
34+
query =
35+
base_db_query()
36+
|> where([t], t.id == ^id)
37+
38+
case Plausible.Repo.one(query) do
39+
%TrackerScriptConfiguration{} = tracker_script_configuration ->
40+
PlausibleWeb.Tracker.plausible_main_script_tag(tracker_script_configuration)
41+
42+
_ ->
43+
nil
44+
end
45+
end
46+
47+
@impl true
48+
def unwrap_cache_keys(items) do
49+
Enum.reduce(items, [], fn
50+
tracker_script_configuration, acc ->
51+
[
52+
{tracker_script_configuration.id,
53+
PlausibleWeb.Tracker.plausible_main_script_tag(tracker_script_configuration)}
54+
| acc
55+
]
56+
end)
57+
end
58+
end
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
defmodule PlausibleWeb.TrackerScriptCacheTest do
2+
use Plausible.DataCase, async: true
3+
use Plausible.Teams.Test
4+
5+
alias Plausible.Site.TrackerScriptConfiguration
6+
alias PlausibleWeb.TrackerScriptCache
7+
8+
describe "public cache interface" do
9+
test "cache caches tracker script configurations", %{test: test} do
10+
{:ok, _} =
11+
Supervisor.start_link(
12+
[{TrackerScriptCache, [cache_name: test, child_id: :test_cache_tracker_script]}],
13+
strategy: :one_for_one,
14+
name: :"cache_supervisor_#{test}"
15+
)
16+
17+
site = new_site(domain: "site1.example.com")
18+
config = create_config(site)
19+
20+
:ok = TrackerScriptCache.refresh_all(cache_name: test)
21+
22+
{:ok, _} = Plausible.Repo.delete(config)
23+
24+
assert TrackerScriptCache.size(test) == 1
25+
26+
assert script_tag = TrackerScriptCache.get(config.id, force?: true, cache_name: test)
27+
assert is_binary(script_tag)
28+
29+
refute TrackerScriptCache.get("nonexistent", cache_name: test, force?: true)
30+
end
31+
32+
test "refreshes only recently added configurations", %{test: test} do
33+
{:ok, _} = start_test_cache(test)
34+
35+
site1 = new_site()
36+
site2 = new_site()
37+
38+
past_date = ~N[2021-01-01 00:00:00]
39+
old_config = create_config(site1, inserted_at: past_date, updated_at: past_date)
40+
new_config = create_config(site2)
41+
42+
cache_opts = [cache_name: test, force?: true]
43+
44+
assert TrackerScriptCache.get(old_config.id, cache_opts) == nil
45+
assert TrackerScriptCache.get(new_config.id, cache_opts) == nil
46+
47+
assert :ok = TrackerScriptCache.refresh_updated_recently(cache_opts)
48+
49+
refute TrackerScriptCache.get(old_config.id, cache_opts)
50+
assert TrackerScriptCache.get(new_config.id, cache_opts)
51+
end
52+
end
53+
54+
defp start_test_cache(cache_name) do
55+
%{start: {m, f, a}} = TrackerScriptCache.child_spec(cache_name: cache_name)
56+
apply(m, f, a)
57+
end
58+
59+
defp create_config(site, opts \\ []) do
60+
config = %TrackerScriptConfiguration{
61+
site_id: site.id,
62+
installation_type: :manual,
63+
hash_based_routing: true,
64+
outbound_links: true,
65+
file_downloads: true,
66+
form_submissions: true
67+
}
68+
69+
config
70+
|> Ecto.Changeset.change(opts)
71+
|> Repo.insert!()
72+
|> Repo.preload(:site)
73+
end
74+
end

0 commit comments

Comments
 (0)