200 lines
6.7 KiB
Elixir
200 lines
6.7 KiB
Elixir
defmodule Plausible.Props do
|
|
@moduledoc """
|
|
Context module for handling custom event props.
|
|
"""
|
|
use Plausible
|
|
import Ecto.Query
|
|
|
|
@type prop :: String.t()
|
|
|
|
@max_props 300
|
|
def max_props, do: @max_props
|
|
|
|
@max_prop_key_length 300
|
|
def max_prop_key_length, do: @max_prop_key_length
|
|
|
|
@max_prop_value_length 2000
|
|
def max_prop_value_length, do: @max_prop_value_length
|
|
|
|
# NOTE: Keep up to date with `Plausible.Imported.imported_custom_props/0`.
|
|
@internal_keys ~w(url path search_query)
|
|
|
|
@doc """
|
|
Lists prop keys used internally.
|
|
|
|
These props should be allowed by default, and should not be displayed in the
|
|
props settings page. For example, `url` is a special prop key used for file
|
|
downloads and outbound links. It doesn't make sense to remove this prop key
|
|
from the allow list, or to suggest users to add this prop key.
|
|
"""
|
|
def internal_keys, do: @internal_keys
|
|
|
|
@doc """
|
|
Returns the custom props allowed in queries for the given site. There are
|
|
two factors deciding whether a custom property is allowed for a site.
|
|
|
|
### 1. Subscription plan including the props feature.
|
|
|
|
Internally used keys (i.e. `#{inspect(@internal_keys)}`) are always allowed,
|
|
even for plans that don't include props. For any other props, access to the
|
|
Custom Properties feature is required.
|
|
|
|
### 2. The site having an `allowed_event_props` list configured.
|
|
|
|
For customers with a configured `allowed_event_props` list, this function
|
|
returns that list (+ internally used keys). That helps to filter out garbage
|
|
props which people might not want to see in their dashboards.
|
|
|
|
With the `bypass_setup?` boolean option you can override the requirement of
|
|
the site having set up props in the `allowed_event_props` list. For example,
|
|
this is currently used for fetching allowed properties in Stats API queries
|
|
in order to ensure the props feature access.
|
|
|
|
Since `allowed_event_props` was added after the props feature had already
|
|
been used for a while, there are sites with `allowed_event_props = nil`. For
|
|
those sites, all custom properties that exist in the database are allowed to
|
|
be queried.
|
|
"""
|
|
@spec allowed_for(Plausible.Site.t()) :: [prop()] | :all
|
|
def allowed_for(site, opts \\ []) do
|
|
site = Plausible.Repo.preload(site, :team)
|
|
internal_keys = Plausible.Props.internal_keys()
|
|
props_enabled? = Plausible.Billing.Feature.Props.check_availability(site.team) == :ok
|
|
bypass_setup? = Keyword.get(opts, :bypass_setup?)
|
|
|
|
cond do
|
|
props_enabled? && is_nil(site.allowed_event_props) -> :all
|
|
props_enabled? && bypass_setup? -> :all
|
|
props_enabled? -> site.allowed_event_props ++ internal_keys
|
|
true -> internal_keys
|
|
end
|
|
end
|
|
|
|
@spec allow(Plausible.Site.t(), [prop()] | prop()) ::
|
|
{:ok, Plausible.Site.t()} | {:error, Ecto.Changeset.t()} | {:error, :upgrade_required}
|
|
@doc """
|
|
Allows a prop key or a list of props keys to be included in ClickHouse
|
|
queries. Allowing prop keys does not affect ingestion, as we don't want any
|
|
data to be dropped or lost.
|
|
"""
|
|
def allow(site, prop_or_props) do
|
|
with site <- Plausible.Repo.preload(site, :team),
|
|
:ok <- ensure_prop_key_accessible(prop_or_props, site.team) do
|
|
site
|
|
|> allow_changeset(prop_or_props)
|
|
|> Plausible.Repo.update()
|
|
end
|
|
end
|
|
|
|
def allow_changeset(site, prop_or_props) do
|
|
old_props = site.allowed_event_props || []
|
|
new_props = List.wrap(prop_or_props) ++ old_props
|
|
|
|
changeset(site, new_props)
|
|
end
|
|
|
|
@spec disallow(Plausible.Site.t(), [prop()] | prop()) ::
|
|
{:ok, Plausible.Site.t()} | {:error, Ecto.Changeset.t()}
|
|
@doc """
|
|
Removes previously allowed prop key(s) from the allow list. This means this
|
|
prop key won't be included in ClickHouse queries. This doesn't drop any
|
|
ClickHouse data, nor affects ingestion.
|
|
"""
|
|
def disallow(site, prop_or_props) do
|
|
allowed_event_props = site.allowed_event_props || []
|
|
|
|
site
|
|
|> changeset(allowed_event_props -- List.wrap(prop_or_props))
|
|
|> Plausible.Repo.update()
|
|
end
|
|
|
|
defp changeset(site, props) do
|
|
props =
|
|
props
|
|
|> Enum.map(&String.trim/1)
|
|
|> Enum.uniq()
|
|
|
|
site
|
|
|> Ecto.Changeset.change(allowed_event_props: props)
|
|
|> Ecto.Changeset.validate_length(:allowed_event_props, max: @max_props)
|
|
|> Ecto.Changeset.validate_change(:allowed_event_props, fn field, allowed_props ->
|
|
if Enum.all?(allowed_props, &valid?/1),
|
|
do: [],
|
|
else: [
|
|
{field,
|
|
{"must be between 1 and #{@max_prop_key_length} characters",
|
|
validation: :length, type: :string}}
|
|
]
|
|
end)
|
|
end
|
|
|
|
@spec allow_existing_props(Plausible.Site.t()) ::
|
|
{:ok, Plausible.Site.t()} | {:error, :upgrade_required}
|
|
@doc """
|
|
Allows the #{@max_props} most frequent props keys for a specific site over
|
|
the past 6 months.
|
|
"""
|
|
def allow_existing_props(%Plausible.Site{} = site) do
|
|
props_to_allow =
|
|
site
|
|
|> suggest_keys_to_allow()
|
|
|> Enum.filter(&valid?/1)
|
|
|
|
allow(site, props_to_allow)
|
|
end
|
|
|
|
def ensure_prop_key_accessible(prop_keys, team) when is_list(prop_keys) do
|
|
Enum.reduce_while(prop_keys, :ok, fn prop_key, :ok ->
|
|
case ensure_prop_key_accessible(prop_key, team) do
|
|
:ok -> {:cont, :ok}
|
|
error -> {:halt, error}
|
|
end
|
|
end)
|
|
end
|
|
|
|
def ensure_prop_key_accessible(prop_key, team) do
|
|
if prop_key in @internal_keys do
|
|
:ok
|
|
else
|
|
Plausible.Billing.Feature.Props.check_availability(team)
|
|
end
|
|
end
|
|
|
|
@spec suggest_keys_to_allow(Plausible.Site.t(), non_neg_integer()) :: [String.t()]
|
|
@doc """
|
|
Queries the events table to fetch the #{@max_props} most frequent prop keys
|
|
for a specific site over the past 6 months, excluding keys that are already
|
|
allowed.
|
|
"""
|
|
def suggest_keys_to_allow(%Plausible.Site{} = site, limit \\ @max_props) do
|
|
allowed_event_props = site.allowed_event_props || []
|
|
|
|
unnested_keys =
|
|
from e in Plausible.ClickhouseEventV2,
|
|
where: ^Plausible.Sites.site_id_query_filter(site),
|
|
where: fragment("? > (NOW() - INTERVAL 6 MONTH)", e.timestamp),
|
|
select: %{key: fragment("arrayJoin(?)", field(e, :"meta.key"))}
|
|
|
|
Plausible.ClickhouseRepo.all(
|
|
from uk in subquery(unnested_keys),
|
|
where: uk.key not in ^allowed_event_props,
|
|
where: uk.key not in ^@internal_keys,
|
|
group_by: uk.key,
|
|
select: uk.key,
|
|
order_by: {:desc, count(uk.key)},
|
|
limit: ^limit
|
|
)
|
|
end
|
|
|
|
defp valid?(key) do
|
|
String.length(key) in 1..@max_prop_key_length
|
|
end
|
|
|
|
@doc """
|
|
Returns whether the site has configured custom props or not.
|
|
"""
|
|
def configured?(%Plausible.Site{allowed_event_props: allowed_event_props}) do
|
|
is_list(allowed_event_props) && length(allowed_event_props) > 0
|
|
end
|
|
end
|