Reapply+bugfix: goals with custom props (#5936) (#5944)

* Reapply "Goals with custom props (Stats API queries, funnels) (#5936)" (#5943)

This reverts commit 45116bda7b.

* Gracefully handle `nil` for existing goal.custom_props

* Revert "Gracefully handle `nil` for existing goal.custom_props"

This reverts commit 8e38748775.

* Migration: make `goals.custom_props` non-null

* Adjust test
This commit is contained in:
Adam Rutkowski 2025-12-11 14:09:34 +01:00 committed by GitHub
parent c78ddf6ba4
commit 38381195f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 960 additions and 100 deletions

View File

@ -19,18 +19,32 @@ defmodule Plausible.Goal do
field :funnels, {:array, :map}, virtual: true, default: []
end
field :custom_props, :map, default: %{}
belongs_to :site, Plausible.Site
timestamps()
end
@fields [:id, :site_id, :event_name, :page_path, :scroll_threshold, :display_name] ++
@fields [
:id,
:site_id,
:event_name,
:page_path,
:scroll_threshold,
:display_name,
:custom_props
] ++
on_ee(do: [:currency], else: [])
@max_event_name_length 120
def max_event_name_length(), do: @max_event_name_length
@max_custom_props_per_goal 3
def max_custom_props_per_goal(), do: @max_custom_props_per_goal
def changeset(goal, attrs \\ %{}) do
goal
|> cast(attrs, @fields)
@ -40,16 +54,18 @@ defmodule Plausible.Goal do
|> validate_event_name_and_page_path()
|> validate_page_path_for_scroll_goal()
|> maybe_put_display_name()
|> unique_constraint(:event_name, name: :goals_event_name_unique)
|> validate_change(:custom_props, fn :custom_props, custom_props ->
if map_size(custom_props) > @max_custom_props_per_goal do
[custom_props: "use at most #{@max_custom_props_per_goal} properties per goal"]
else
[]
end
end)
|> unique_constraint(:display_name, name: :goals_display_name_unique)
|> unique_constraint(:event_name, name: :goals_event_config_unique)
|> unique_constraint([:page_path, :scroll_threshold],
name: :goals_pageview_config_unique
)
|> unique_constraint([:page_path, :scroll_threshold],
name: :goals_page_path_and_scroll_threshold_unique
)
|> unique_constraint(:display_name, name: :goals_display_name_unique)
|> unique_constraint(:event_name, name: :goals_event_config_unique)
|> unique_constraint(:display_name, name: :goals_site_id_display_name_index)
|> validate_length(:event_name, max: @max_event_name_length)
|> validate_number(:scroll_threshold,
greater_than_or_equal_to: -1,

View File

@ -17,6 +17,8 @@ defmodule Plausible.Goals do
if override do
override
else
# see: config/test.exs - you can steer this limit for tests
# by providing `max_goals_per_site` option to e.g. create/3
Application.get_env(:plausible, :max_goals_per_site, @max_goals_per_site)
end
end

View File

@ -67,7 +67,9 @@ defmodule Plausible.Stats.Goals do
event_names_imports: [String.t()],
event_names_by_type: [String.t()],
page_regexes: [String.t()],
scroll_thresholds: [non_neg_integer()]
scroll_thresholds: [non_neg_integer()],
custom_props_keys: [[String.t()]],
custom_props_values: [[String.t()]]
}
@doc """
@ -77,33 +79,50 @@ defmodule Plausible.Stats.Goals do
def goal_join_data(query) do
goals = query.preloaded_goals.matching_toplevel_filters
%{
indices: Enum.with_index(goals, 1) |> Enum.map(fn {_goal, idx} -> idx end),
types: Enum.map(goals, &to_string(Plausible.Goal.type(&1))),
# :TRICKY: This will contain "" for non-event goals
event_names_imports: Enum.map(goals, &to_string(&1.event_name)),
event_names_by_type:
Enum.map(goals, fn goal ->
case Plausible.Goal.type(goal) do
:event -> goal.event_name
:page -> "pageview"
:scroll -> "engagement"
end
end),
# :TRICKY: event goals are considered to match everything for the sake of efficient queries in query_builder.ex
# See also Plausible.Stats.SQL.Expression#event_goal_join
page_regexes:
Enum.map(goals, fn goal ->
case Plausible.Goal.type(goal) do
:event -> ".?"
:page -> Filters.Utils.page_regex(goal.page_path)
:scroll -> Filters.Utils.page_regex(goal.page_path)
end
end),
scroll_thresholds: Enum.map(goals, & &1.scroll_threshold)
}
goals
|> Enum.with_index(1)
|> Enum.reduce(
%{
indices: [],
types: [],
event_names_imports: [],
event_names_by_type: [],
page_regexes: [],
scroll_thresholds: [],
custom_props_keys: [],
custom_props_values: []
},
fn {goal, idx}, acc ->
goal_type = Plausible.Goal.type(goal)
{prop_keys, prop_values} = Enum.unzip(goal.custom_props)
%{
indices: [idx | acc.indices],
types: [to_string(goal_type) | acc.types],
# This will contain "" for non-event goals
event_names_imports: [to_string(goal.event_name) | acc.event_names_imports],
event_names_by_type: [event_name_by_type(goal_type, goal) | acc.event_names_by_type],
# Event goals are considered to match everything for the sake of efficient queries in query_builder.ex
# See also Plausible.Stats.SQL.Expression.event_goal_join/1
page_regexes: [page_regex_for_goal(goal_type, goal) | acc.page_regexes],
scroll_thresholds: [goal.scroll_threshold | acc.scroll_thresholds],
custom_props_keys: [prop_keys | acc.custom_props_keys],
custom_props_values: [prop_values | acc.custom_props_values]
}
end
)
|> Enum.map(fn {key, list} -> {key, Enum.reverse(list)} end)
|> Map.new()
end
defp event_name_by_type(:event, goal), do: goal.event_name
defp event_name_by_type(:page, _goal), do: "pageview"
defp event_name_by_type(:scroll, _goal), do: "engagement"
defp page_regex_for_goal(:event, _goal), do: ".?"
defp page_regex_for_goal(:page, goal), do: Filters.Utils.page_regex(goal.page_path)
defp page_regex_for_goal(:scroll, goal), do: Filters.Utils.page_regex(goal.page_path)
def toplevel_scroll_goal_filters?(query) do
goal_filters? =
Enum.any?(query.filters, fn
@ -179,7 +198,14 @@ defmodule Plausible.Stats.Goals do
end
defp goal_condition(:event, goal, _) do
dynamic([e], e.name == ^goal.event_name)
name_condition = dynamic([e], e.name == ^goal.event_name)
if map_size(goal.custom_props) > 0 do
custom_props_condition = build_custom_props_condition(goal.custom_props)
dynamic([e], ^name_condition and ^custom_props_condition)
else
name_condition
end
end
defp goal_condition(:scroll, goal, false = _imported?) do
@ -213,6 +239,24 @@ defmodule Plausible.Stats.Goals do
end
end
defp build_custom_props_condition(custom_props) do
Enum.reduce(custom_props, true, fn {prop_key, prop_value}, acc ->
condition =
dynamic(
[e],
fragment(
"?[indexOf(?, ?)] = ?",
field(e, :"meta.value"),
field(e, :"meta.key"),
^prop_key,
^prop_value
)
)
dynamic([e], ^acc and ^condition)
end)
end
def page_path_db_field(true = _imported?), do: :page
def page_path_db_field(false = _imported?), do: :pathname
end

View File

@ -204,7 +204,7 @@ defmodule Plausible.Stats.SQL.Expression do
def select_dimension_internal(q, "visit:exit_page") do
# As exit page changes with every pageview event over the lifetime
# of a session, only the most recent value must be considered.
# of a session, only the most recent value must be considered.
select_merge_as(q, [t], %{
exit_page: fragment("argMax(?, ?)", field(t, :exit_page), field(t, :events))
})
@ -443,6 +443,15 @@ defmodule Plausible.Stats.SQL.Expression do
def session_metric(:conversion_rate, _query), do: %{}
def session_metric(:group_conversion_rate, _query), do: %{}
@doc """
The fragment matches events to goals by:
1. Checking if the pathname matches the goal's page regex pattern
2. Verifying the event name matches the expected name for the goal type
3. Validating scroll depth is within threshold (for scroll goals)
4. Ensuring all custom properties match (if any are defined on the goal)
Returns an array of goal indices that the event matches.
"""
defmacro event_goal_join(goal_join_data) do
quote do
fragment(
@ -450,7 +459,13 @@ defmodule Plausible.Stats.SQL.Expression do
arrayIntersect(
multiMatchAllIndices(?, ?),
arrayMap(
(expected_name, threshold, index) -> if(expected_name = ? and ? between threshold and 100, index, -1),
(expected_name, threshold, index, custom_props_keys, custom_props_values) -> if(
expected_name = ? and ? between threshold and 100 and
(empty(custom_props_keys) OR arrayAll((k, v) -> ?[indexOf(?, k)] = v, custom_props_keys, custom_props_values)),
index, -1
),
?,
?,
?,
?,
?
@ -461,9 +476,13 @@ defmodule Plausible.Stats.SQL.Expression do
type(^unquote(goal_join_data).page_regexes, {:array, :string}),
e.name,
e.scroll_depth,
field(e, :"meta.value"),
field(e, :"meta.key"),
type(^unquote(goal_join_data).event_names_by_type, {:array, :string}),
type(^unquote(goal_join_data).scroll_thresholds, {:array, :integer}),
type(^unquote(goal_join_data).indices, {:array, :integer})
type(^unquote(goal_join_data).indices, {:array, :integer}),
type(^unquote(goal_join_data).custom_props_keys, {:array, {:array, :string}}),
type(^unquote(goal_join_data).custom_props_values, {:array, {:array, :string}})
)
end
end

View File

@ -83,8 +83,8 @@ defmodule Plausible.Stats.SQL.QueryBuilder do
group_by: s.session_id
)
# The session-only dimension columns are explicitly selected in joined
# sessions table. This enables combining session-only dimensions (entry
# The session-only dimension columns are explicitly selected in joined
# sessions table. This enables combining session-only dimensions (entry
# and exit pages) with event-only metrics, like revenue.
sessions_q =
Enum.reduce(dimensions, sessions_q, fn dimension, acc ->

View File

@ -0,0 +1,259 @@
defmodule Plausible.FunnelsCustomPropsGoals do
use Plausible.DataCase
@moduletag :ee_only
on_ee do
alias Plausible.Goals
alias Plausible.Funnels
alias Plausible.Stats
describe "Plausible.Stats.Funnel - with custom property goals" do
setup do
site = new_site()
{:ok, site: site}
end
test "funnels with custom property filters on event goals", %{site: site} do
{:ok, g1} = Goals.create(site, %{"page_path" => "/start"})
{:ok, g2} =
Goals.create(site, %{
"event_name" => "Purchase",
"custom_props" => %{"plan" => "premium"}
})
{:ok, g3} = Goals.create(site, %{"page_path" => "/thank-you"})
{:ok, funnel} =
Funnels.create(
site,
"Premium purchase funnel",
[
%{"goal_id" => g1.id},
%{"goal_id" => g2.id},
%{"goal_id" => g3.id}
]
)
populate_stats(site, [
build(:pageview, pathname: "/start", user_id: 100),
build(:event,
name: "Purchase",
"meta.key": ["plan"],
"meta.value": ["premium"],
user_id: 100
),
build(:pageview, pathname: "/thank-you", user_id: 100),
build(:pageview, pathname: "/start", user_id: 200),
build(:event,
name: "Purchase",
"meta.key": ["plan"],
"meta.value": ["free"],
user_id: 200
),
build(:pageview, pathname: "/thank-you", user_id: 200),
build(:pageview, pathname: "/start", user_id: 300),
build(:event, name: "Purchase", user_id: 300)
])
query = Plausible.Stats.Query.from(site, %{"period" => "all"})
{:ok, funnel_data} = Stats.funnel(site, query, funnel.id)
assert funnel_data[:all_visitors] == 3
assert funnel_data[:entering_visitors] == 3
assert [step1, step2, step3] = funnel_data[:steps]
assert step1.visitors == 3
assert step2.visitors == 1
assert step3.visitors == 1
end
test "funnels with multiple custom property filters", %{site: site} do
{:ok, g1} = Goals.create(site, %{"event_name" => "Start"})
{:ok, g2} =
Goals.create(site, %{
"event_name" => "Purchase",
"custom_props" => %{"plan" => "premium", "variant" => "A"}
})
{:ok, funnel} =
Funnels.create(
site,
"Premium variant A funnel",
[
%{"goal_id" => g1.id},
%{"goal_id" => g2.id}
]
)
populate_stats(site, [
build(:event, name: "Start", user_id: 100),
build(:event,
name: "Purchase",
"meta.key": ["plan", "variant"],
"meta.value": ["premium", "A"],
user_id: 100
),
build(:event, name: "Start", user_id: 200),
build(:event,
name: "Purchase",
"meta.key": ["plan", "variant"],
"meta.value": ["premium", "B"],
user_id: 200
),
build(:event, name: "Start", user_id: 300),
build(:event,
name: "Purchase",
"meta.key": ["plan", "variant"],
"meta.value": ["free", "A"],
user_id: 300
),
build(:event, name: "Start", user_id: 400),
build(:event,
name: "Purchase",
"meta.key": ["plan"],
"meta.value": ["premium"],
user_id: 400
)
])
query = Plausible.Stats.Query.from(site, %{"period" => "all"})
{:ok, funnel_data} = Stats.funnel(site, query, funnel.id)
assert funnel_data[:all_visitors] == 4
assert funnel_data[:entering_visitors] == 4
assert [step1, step2] = funnel_data[:steps]
assert step1.visitors == 4
assert step2.visitors == 1
end
test "funnels with mixed goals (custom props and regular)", %{site: site} do
{:ok, g1} =
Goals.create(site, %{
"event_name" => "Signup",
"custom_props" => %{"method" => "email"}
})
{:ok, g2} = Goals.create(site, %{"event_name" => "Onboarding Complete"})
{:ok, g3} =
Goals.create(site, %{
"event_name" => "Purchase",
"custom_props" => %{"plan" => "premium"}
})
{:ok, funnel} =
Funnels.create(
site,
"Email signup to premium purchase",
[
%{"goal_id" => g1.id},
%{"goal_id" => g2.id},
%{"goal_id" => g3.id}
]
)
populate_stats(site, [
build(:event,
name: "Signup",
"meta.key": ["method"],
"meta.value": ["email"],
user_id: 100
),
build(:event, name: "Onboarding Complete", user_id: 100),
build(:event,
name: "Purchase",
"meta.key": ["plan"],
"meta.value": ["premium"],
user_id: 100
),
build(:event,
name: "Signup",
"meta.key": ["method"],
"meta.value": ["google"],
user_id: 200
),
build(:event, name: "Onboarding Complete", user_id: 200),
build(:event,
name: "Purchase",
"meta.key": ["plan"],
"meta.value": ["premium"],
user_id: 200
),
build(:event,
name: "Signup",
"meta.key": ["method"],
"meta.value": ["email"],
user_id: 300
),
build(:event, name: "Onboarding Complete", user_id: 300),
build(:event,
name: "Purchase",
"meta.key": ["plan"],
"meta.value": ["free"],
user_id: 300
)
])
query = Plausible.Stats.Query.from(site, %{"period" => "all"})
{:ok, funnel_data} = Stats.funnel(site, query, funnel.id)
assert funnel_data[:all_visitors] == 3
assert funnel_data[:entering_visitors] == 2
assert [step1, step2, step3] = funnel_data[:steps]
assert step1.visitors == 2
assert step2.visitors == 2
assert step3.visitors == 1
end
test "funnel with empty custom_props does not filter", %{site: site} do
{:ok, g1} =
Goals.create(site, %{
"event_name" => "Click",
"custom_props" => %{}
})
{:ok, g2} = Goals.create(site, %{"event_name" => "Convert"})
{:ok, funnel} =
Funnels.create(
site,
"Click to convert funnel",
[
%{"goal_id" => g1.id},
%{"goal_id" => g2.id}
]
)
populate_stats(site, [
build(:event, name: "Click", user_id: 100),
build(:event, name: "Convert", user_id: 100),
build(:event,
name: "Click",
"meta.key": ["button"],
"meta.value": ["cta"],
user_id: 200
),
build(:event, name: "Convert", user_id: 200)
])
query = Plausible.Stats.Query.from(site, %{"period" => "all"})
{:ok, funnel_data} = Stats.funnel(site, query, funnel.id)
assert funnel_data[:all_visitors] == 2
assert funnel_data[:entering_visitors] == 2
assert [step1, step2] = funnel_data[:steps]
assert step1.visitors == 2
assert step2.visitors == 2
end
end
end
end

View File

@ -64,7 +64,7 @@ defmodule Plausible.GoalsTest do
changeset.errors[:scroll_threshold]
end
test "create/2 validates uniqueness across page_path and scroll_threshold" do
test "create/2 fails when same pageview+scroll threshold config exists with different display name" do
site = new_site()
{:ok, _} =
@ -105,18 +105,169 @@ defmodule Plausible.GoalsTest do
test "create/2 fails to create the same custom event goal twice" do
site = new_site()
{:ok, _} = Goals.create(site, %{"event_name" => "foo bar"})
assert {:error, _changeset} = Goals.create(site, %{"event_name" => "foo bar"})
# assert {"has already been taken", _} = changeset.errors[:display_name]
assert {:error, changeset} = Goals.create(site, %{"event_name" => "foo bar"})
assert {"has already been taken", _} = changeset.errors[:display_name]
end
test "create/2 succeeds to create the same custom event goal thrice with different custom props and different display names each" do
site = new_site()
{:ok, _} =
Goals.create(site, %{
"event_name" => "Purchase",
"display_name" => "Tablet Purchase",
"custom_props" => %{"product" => "tablet"}
})
{:ok, _} =
Goals.create(site, %{
"event_name" => "Purchase",
"display_name" => "Speaker Purchase",
"custom_props" => %{"product" => "speaker"}
})
{:ok, _} =
Goals.create(site, %{
"event_name" => "Purchase",
"display_name" => "General Purchase"
})
end
test "create/2 fails to create the same custom event goal twice with different display names but no props each" do
site = new_site()
{:ok, _} =
Goals.create(site, %{
"event_name" => "Purchase",
"display_name" => "General Purchase"
})
{:error, changeset} =
Goals.create(site, %{
"event_name" => "Purchase",
"display_name" => "General Purchase 2"
})
assert {"has already been taken", _} = changeset.errors[:event_name]
end
test "create/3 fails to create a goal with more than #{Plausible.Goal.max_custom_props_per_goal()} custom props" do
site = new_site()
{:error, changeset} =
Goals.create(site, %{
"event_name" => "Purchase",
"display_name" => "General Purchase",
"custom_props" => %{
"variant" => "A",
"promo" => "true",
"product" => "tablet",
"limit" => "hit"
}
})
assert {"use at most 3 properties per goal", _} = changeset.errors[:custom_props]
end
test "create/2 succeeds to create the same custom event twice with different props and different display names each" do
site = new_site()
{:ok, _} =
Goals.create(site, %{
"event_name" => "Purchase",
"display_name" => "General Purchase",
"custom_props" => %{"variant" => "A"}
})
{:ok, _} =
Goals.create(site, %{
"event_name" => "Purchase",
"display_name" => "General Purchase 2",
"custom_props" => %{"variant" => "A", "foo" => "bar"}
})
end
test "create/2 fails to create the same currency goal twice" do
site = new_site()
{:ok, _} = Goals.create(site, %{"event_name" => "foo bar", "currency" => "EUR"})
assert {:error, _changeset} =
Goals.create(site, %{"event_name" => "foo bar", "currency" => "EUR"})
assert {:error, changeset} =
Goals.create(site, %{
"event_name" => "foo bar",
"currency" => "EUR",
"display_name" => "Purchase copy"
})
# assert {"has already been taken", _} = changeset.errors[:display_name]
assert {"has already been taken", _} = changeset.errors[:event_name]
end
test "create/2 fails to create two pageview goals with same display name" do
site = new_site()
{:ok, _} = Goals.create(site, %{"page_path" => "/index", "display_name" => "Index"})
assert {:error, changeset} =
Goals.create(site, %{"page_path" => "/index-2", "display_name" => "Index"})
assert {"has already been taken", _} =
changeset.errors[:display_name]
end
test "create/2 fails when same custom props config exists with different display name" do
site = new_site()
{:ok, _} =
Goals.create(site, %{
"event_name" => "Purchase",
"display_name" => "Purchase Event",
"custom_props" => %{"product" => "tablet"}
})
{:error, changeset} =
Goals.create(site, %{
"event_name" => "Purchase",
"display_name" => "Different Display Name",
"custom_props" => %{"product" => "tablet"}
})
assert {"has already been taken", _} = changeset.errors[:event_name]
end
test "create/2 fails when same display name exists despite different custom props config" do
site = new_site()
{:ok, _} =
Goals.create(site, %{
"event_name" => "Purchase",
"display_name" => "My Goal",
"custom_props" => %{"product" => "tablet"}
})
{:error, changeset} =
Goals.create(site, %{
"event_name" => "Purchase",
"display_name" => "My Goal",
"custom_props" => %{"product" => "speaker"}
})
assert {"has already been taken", _} = changeset.errors[:display_name]
end
test "create/2 fails when same display name exists between event and pageview goals" do
site = new_site()
{:ok, _} =
Goals.create(site, %{
"event_name" => "Signup",
"display_name" => "User Action"
})
{:error, changeset} =
Goals.create(site, %{
"page_path" => "/signup",
"display_name" => "User Action"
})
assert {"has already been taken", _} = changeset.errors[:display_name]
end
test "create/2 fails to create a goal with 'engagement' as event_name (reserved)" do
@ -127,41 +278,38 @@ defmodule Plausible.GoalsTest do
changeset.errors[:event_name]
end
@tag :ee_only
test "create/2 sets site.updated_at for revenue goal" do
site_1 = new_site(updated_at: DateTime.add(DateTime.utc_now(), -3600))
{:ok, _goal_1} = Goals.create(site_1, %{"event_name" => "Checkout", "currency" => "BRL"})
assert NaiveDateTime.compare(site_1.updated_at, Plausible.Repo.reload!(site_1).updated_at) ==
:lt
site_2 = new_site(updated_at: DateTime.add(DateTime.utc_now(), -3600))
{:ok, _goal_2} = Goals.create(site_2, %{"event_name" => "Read Article", "currency" => nil})
assert NaiveDateTime.compare(site_2.updated_at, Plausible.Repo.reload!(site_2).updated_at) ==
:eq
end
@tag :ee_only
test "create/2 creates revenue goal" do
site = new_site()
{:ok, goal} = Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
assert goal.event_name == "Purchase"
assert goal.page_path == nil
assert goal.currency == :EUR
end
@tag :ee_only
test "create/2 returns error when site does not have access to revenue goals" do
user = new_user() |> subscribe_to_growth_plan()
site = new_site(owner: user)
{:error, :upgrade_required} =
Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
end
on_ee do
test "create/2 sets site.updated_at for revenue goal" do
site_1 = new_site(updated_at: DateTime.add(DateTime.utc_now(), -3600))
{:ok, _goal_1} = Goals.create(site_1, %{"event_name" => "Checkout", "currency" => "BRL"})
assert NaiveDateTime.compare(site_1.updated_at, Plausible.Repo.reload!(site_1).updated_at) ==
:lt
site_2 = new_site(updated_at: DateTime.add(DateTime.utc_now(), -3600))
{:ok, _goal_2} = Goals.create(site_2, %{"event_name" => "Read Article", "currency" => nil})
assert NaiveDateTime.compare(site_2.updated_at, Plausible.Repo.reload!(site_2).updated_at) ==
:eq
end
test "create/2 creates revenue goal" do
site = new_site()
{:ok, goal} = Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
assert goal.event_name == "Purchase"
assert goal.page_path == nil
assert goal.currency == :EUR
end
test "create/2 returns error when site does not have access to revenue goals" do
user = new_user() |> subscribe_to_growth_plan()
site = new_site(owner: user)
{:error, :upgrade_required} =
Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
end
test "create/2 returns error when creating a revenue goal for consolidated view" do
user = new_user()
new_site(owner: user)
@ -172,16 +320,15 @@ defmodule Plausible.GoalsTest do
{:error, :revenue_goals_unavailable} =
Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
end
end
@tag :ee_only
test "create/2 fails for unknown currency code" do
site = new_site()
test "create/2 fails for unknown currency code" do
site = new_site()
assert {:error, changeset} =
Goals.create(site, %{"event_name" => "Purchase", "currency" => "Euro"})
assert {:error, changeset} =
Goals.create(site, %{"event_name" => "Purchase", "currency" => "Euro"})
assert [currency: {"is invalid", _}] = changeset.errors
assert [currency: {"is invalid", _}] = changeset.errors
end
end
test "update/2 updates a goal" do
@ -240,22 +387,23 @@ defmodule Plausible.GoalsTest do
}
end
@tag :ee_only
test "list_revenue_goals/1 lists event_names and currencies for each revenue goal" do
site = new_site()
on_ee do
test "list_revenue_goals/1 lists event_names and currencies for each revenue goal" do
site = new_site()
Goals.create(site, %{"event_name" => "One", "currency" => "EUR"})
Goals.create(site, %{"event_name" => "Two", "currency" => "EUR"})
Goals.create(site, %{"event_name" => "Three", "currency" => "USD"})
Goals.create(site, %{"event_name" => "Four"})
Goals.create(site, %{"page_path" => "/some-page"})
Goals.create(site, %{"event_name" => "One", "currency" => "EUR"})
Goals.create(site, %{"event_name" => "Two", "currency" => "EUR"})
Goals.create(site, %{"event_name" => "Three", "currency" => "USD"})
Goals.create(site, %{"event_name" => "Four"})
Goals.create(site, %{"page_path" => "/some-page"})
revenue_goals = Goals.list_revenue_goals(site)
revenue_goals = Goals.list_revenue_goals(site)
assert length(revenue_goals) == 3
assert %{display_name: "One", currency: :EUR} in revenue_goals
assert %{display_name: "Two", currency: :EUR} in revenue_goals
assert %{display_name: "Three", currency: :USD} in revenue_goals
assert length(revenue_goals) == 3
assert %{display_name: "One", currency: :EUR} in revenue_goals
assert %{display_name: "Two", currency: :EUR} in revenue_goals
assert %{display_name: "Three", currency: :USD} in revenue_goals
end
end
test "create/2 clears currency for pageview goals" do

View File

@ -0,0 +1,372 @@
defmodule PlausibleWeb.Api.ExternalStatsController.QueryGoalCustomPropsTest do
use PlausibleWeb.ConnCase
@user_id Enum.random(1000..9999)
setup [:create_user, :create_site, :create_api_key, :use_api_key]
alias Plausible.Goals
describe "goals with custom property filters" do
test "filters custom event goals by custom properties", %{conn: conn, site: site} do
{:ok, _goal} =
Goals.create(site, %{
"event_name" => "Purchase",
"custom_props" => %{"variant" => "A"}
})
populate_stats(site, [
build(:event,
name: "Purchase",
"meta.key": ["variant"],
"meta.value": ["A"],
timestamp: ~N[2021-01-01 00:00:01]
),
build(:event,
name: "Purchase",
"meta.key": ["variant"],
"meta.value": ["A"],
timestamp: ~N[2021-01-01 00:00:02]
),
build(:event,
name: "Purchase",
"meta.key": ["variant"],
"meta.value": ["B"],
timestamp: ~N[2021-01-01 00:00:03]
),
build(:event,
name: "Purchase",
timestamp: ~N[2021-01-01 00:00:04]
)
])
conn =
post(conn, "/api/v2/query", %{
"site_id" => site.domain,
"date_range" => "all",
"metrics" => ["visitors", "events"],
"filters" => [["is", "event:goal", ["Purchase"]]]
})
resp = json_response(conn, 200)
assert resp["results"] == [
%{"dimensions" => [], "metrics" => [2, 2]}
]
end
test "filters with multiple custom properties (AND logic)", %{conn: conn, site: site} do
{:ok, _goal} =
Goals.create(site, %{
"event_name" => "Purchase",
"custom_props" => %{"variant" => "A", "plan" => "premium"}
})
populate_stats(site, [
build(:event,
name: "Purchase",
"meta.key": ["variant", "plan"],
"meta.value": ["A", "premium"]
),
build(:event,
name: "Purchase",
"meta.key": ["variant", "plan"],
"meta.value": ["A", "free"],
user_id: @user_id
),
build(:event,
name: "Purchase",
"meta.key": ["variant", "plan"],
"meta.value": ["B", "premium"],
user_id: @user_id + 1
),
build(:event,
name: "Purchase",
"meta.key": ["variant"],
"meta.value": ["A"],
user_id: @user_id + 2
)
])
conn =
post(conn, "/api/v2/query", %{
"site_id" => site.domain,
"date_range" => "all",
"metrics" => ["visitors", "events"],
"filters" => [["is", "event:goal", ["Purchase"]]]
})
assert json_response(conn, 200)["results"] == [
%{"dimensions" => [], "metrics" => [1, 1]}
]
end
test "goals without custom_props filter match all events", %{conn: conn, site: site} do
{:ok, _goal} = Goals.create(site, %{"event_name" => "Signup"})
populate_stats(site, [
build(:event,
name: "Signup",
"meta.key": ["source"],
"meta.value": ["google"]
),
build(:event,
name: "Signup",
"meta.key": ["source"],
"meta.value": ["twitter"]
),
build(:event, name: "Signup")
])
conn =
post(conn, "/api/v2/query", %{
"site_id" => site.domain,
"date_range" => "all",
"metrics" => ["visitors", "events"],
"filters" => [["is", "event:goal", ["Signup"]]]
})
assert json_response(conn, 200)["results"] == [
%{"dimensions" => [], "metrics" => [3, 3]}
]
end
test "breakdown by event:goal with custom property filters", %{conn: conn, site: site} do
{:ok, _g1} =
Goals.create(site, %{
"event_name" => "Purchase A",
"custom_props" => %{"variant" => "A"}
})
{:ok, _g2} =
Goals.create(site, %{
"event_name" => "Purchase B",
"custom_props" => %{"variant" => "B"}
})
populate_stats(site, [
build(:event,
name: "Purchase A",
"meta.key": ["variant"],
"meta.value": ["A"],
user_id: @user_id
),
build(:event,
name: "Purchase A",
"meta.key": ["variant"],
"meta.value": ["A"],
user_id: @user_id
),
build(:event,
name: "Purchase B",
"meta.key": ["variant"],
"meta.value": ["B"],
user_id: @user_id
)
])
conn =
post(conn, "/api/v2/query", %{
"site_id" => site.domain,
"date_range" => "all",
"metrics" => ["visitors", "events"],
"dimensions" => ["event:goal"]
})
assert json_response(conn, 200)["results"] == [
%{"dimensions" => ["Purchase A"], "metrics" => [1, 2]},
%{"dimensions" => ["Purchase B"], "metrics" => [1, 1]}
]
end
test "custom property filters work with conversion_rate metric", %{conn: conn, site: site} do
{:ok, _goal} =
Goals.create(site, %{
"event_name" => "Signup",
"custom_props" => %{"method" => "email"}
})
populate_stats(site, [
build(:pageview, user_id: @user_id),
build(:event,
name: "Signup",
"meta.key": ["method"],
"meta.value": ["email"],
user_id: @user_id
),
build(:pageview, user_id: @user_id + 1),
build(:event,
name: "Signup",
"meta.key": ["method"],
"meta.value": ["google"],
user_id: @user_id + 1
),
build(:pageview, user_id: @user_id + 2)
])
conn =
post(conn, "/api/v2/query", %{
"site_id" => site.domain,
"date_range" => "all",
"metrics" => ["visitors", "events", "conversion_rate"],
"filters" => [["is", "event:goal", ["Signup"]]]
})
assert json_response(conn, 200)["results"] == [
%{"dimensions" => [], "metrics" => [1, 1, 33.33]}
]
end
test "custom property filters work with multi-dimensional breakdown", %{
conn: conn,
site: site
} do
{:ok, _goal} =
Goals.create(site, %{
"event_name" => "Purchase",
"custom_props" => %{"variant" => "A"}
})
populate_stats(site, [
build(:event,
name: "Purchase",
"meta.key": ["variant", "plan"],
"meta.value": ["A", "premium"]
),
build(:event,
name: "Purchase",
"meta.key": ["variant", "plan"],
"meta.value": ["A", "free"],
user_id: @user_id + 1
),
build(:event,
name: "Purchase",
"meta.key": ["variant", "plan"],
"meta.value": ["B", "premium"],
user_id: @user_id + 2
)
])
conn =
post(conn, "/api/v2/query", %{
"site_id" => site.domain,
"date_range" => "all",
"metrics" => ["visitors", "events"],
"dimensions" => ["event:goal", "event:props:plan"],
"filters" => [["is", "event:goal", ["Purchase"]]]
})
results = json_response(conn, 200)["results"]
assert length(results) == 2
assert Enum.member?(results, %{"dimensions" => ["Purchase", "free"], "metrics" => [1, 1]})
assert Enum.member?(results, %{"dimensions" => ["Purchase", "premium"], "metrics" => [1, 1]})
end
test "custom property filters work with time series queries", %{conn: conn, site: site} do
{:ok, _goal} =
Goals.create(site, %{
"event_name" => "Click",
"custom_props" => %{"button" => "cta"}
})
populate_stats(site, [
build(:event,
name: "Click",
"meta.key": ["button"],
"meta.value": ["cta"],
timestamp: ~N[2021-01-01 00:00:00]
),
build(:event,
name: "Click",
"meta.key": ["button"],
"meta.value": ["nav"],
timestamp: ~N[2021-01-01 00:00:00]
),
build(:event,
name: "Click",
"meta.key": ["button"],
"meta.value": ["cta"],
timestamp: ~N[2021-01-02 00:00:00]
)
])
conn =
post(conn, "/api/v2/query", %{
"site_id" => site.domain,
"date_range" => ["2021-01-01", "2021-01-02"],
"metrics" => ["visitors"],
"dimensions" => ["time:day"],
"filters" => [["is", "event:goal", ["Click"]]]
})
assert json_response(conn, 200)["results"] == [
%{"dimensions" => ["2021-01-01"], "metrics" => [1]},
%{"dimensions" => ["2021-01-02"], "metrics" => [1]}
]
end
test "different goals with same event name but different custom props are distinguished", %{
conn: conn,
site: site
} do
{:ok, _g1} =
Goals.create(site, %{
"event_name" => "Button Click",
"display_name" => "Red Button",
"custom_props" => %{"color" => "red"}
})
{:ok, _g2} =
Goals.create(site, %{
"event_name" => "Button Click",
"display_name" => "Blue Button",
"custom_props" => %{"color" => "blue"}
})
populate_stats(site, [
build(:event,
name: "Button Click",
"meta.key": ["color"],
"meta.value": ["red"],
user_id: @user_id
),
build(:event,
name: "Button Click",
"meta.key": ["color"],
"meta.value": ["red"],
user_id: @user_id
),
build(:event,
name: "Button Click",
"meta.key": ["color"],
"meta.value": ["blue"],
user_id: @user_id + 1
),
build(:event, name: "Button Click", user_id: @user_id + 2)
])
conn =
post(conn, "/api/v2/query", %{
"site_id" => site.domain,
"date_range" => "all",
"metrics" => ["visitors", "events"],
"dimensions" => ["event:goal"]
})
results = json_response(conn, 200)["results"]
assert Enum.find(results, &(&1["dimensions"] == ["Red Button"])) == %{
"dimensions" => ["Red Button"],
"metrics" => [1, 2]
}
assert Enum.find(results, &(&1["dimensions"] == ["Blue Button"])) == %{
"dimensions" => ["Blue Button"],
"metrics" => [1, 1]
}
refute Enum.find(results, &(&1["dimensions"] == ["Button Click"]))
end
end
end