From 49651644d71b70179add46d3b578819c97128574 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Wed, 4 Feb 2026 16:48:28 +0100 Subject: [PATCH 1/4] Add a failing test for mixed goals --- .../api/stats_controller/conversions_test.exs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/test/plausible_web/controllers/api/stats_controller/conversions_test.exs b/test/plausible_web/controllers/api/stats_controller/conversions_test.exs index 5a2f8cc82965..1339d9f72108 100644 --- a/test/plausible_web/controllers/api/stats_controller/conversions_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/conversions_test.exs @@ -523,6 +523,38 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do assert hd(results)["visitors"] == 2 end + @tag :ee_only + test "handles mixed goals with and without custom props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, name: "Signup"), + build(:event, name: "Purchase", "meta.key": ["product"], "meta.value": ["Shirt"]) + ]) + + {:ok, _goal_with_props} = + Plausible.Goals.create( + site, + %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt"} + } + ) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = get(conn, "/api/stats/#{site.domain}/conversions?period=day") + response = json_response(conn, 200) + results = response["results"] + + assert [ + %{"conversion_rate" => 50.0, "events" => 1, "name" => "Purchase", "visitors" => 1}, + %{"conversion_rate" => 50.0, "events" => 1, "name" => "Signup", "visitors" => 1} + ] = + results + end + @tag :ee_only test "returns revenue metrics as nil for non-revenue goals", %{ conn: conn, From 6d2ddcb2512e6771f9a0b5ca3c5449f6c9ce8874 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Wed, 4 Feb 2026 16:58:27 +0100 Subject: [PATCH 2/4] Ensure custom props goals are first when typed into an array --- lib/plausible/stats/goals.ex | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/plausible/stats/goals.ex b/lib/plausible/stats/goals.ex index 806635f3509e..84960a6f58a4 100644 --- a/lib/plausible/stats/goals.ex +++ b/lib/plausible/stats/goals.ex @@ -82,6 +82,10 @@ defmodule Plausible.Stats.Goals do goals = query.preloaded_goals.matching_toplevel_filters goals + # Sort goals, so that those with custom_props come first + # This ensures the first element in custom_props_keys/values arrays is non-empty + # preventing ClickHouse Array(Nothing) type inference error + |> Enum.sort_by(fn goal -> if goal.custom_props == %{}, do: :last, else: :first end) |> Enum.with_index(1) |> Enum.reduce( %{ From d8d271c56d82c622d30c3b90032b9a727ae6c4d9 Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Wed, 4 Feb 2026 19:02:47 +0200 Subject: [PATCH 3/4] Failing test --- .../api/stats_controller/conversions_test.exs | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/test/plausible_web/controllers/api/stats_controller/conversions_test.exs b/test/plausible_web/controllers/api/stats_controller/conversions_test.exs index 1339d9f72108..4200827f29d5 100644 --- a/test/plausible_web/controllers/api/stats_controller/conversions_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/conversions_test.exs @@ -529,28 +529,62 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do site: site } do populate_stats(site, [ - build(:event, name: "Signup"), - build(:event, name: "Purchase", "meta.key": ["product"], "meta.value": ["Shirt"]) + build(:event, name: "Purchase", "meta.key": ["product"], "meta.value": ["Shirt"]), + build(:event, name: "Purchase", "meta.key": ["product"], "meta.value": ["Jacket"]) ]) - {:ok, _goal_with_props} = + {:ok, _} = Plausible.Goals.create( site, %{ "event_name" => "Purchase", + "display_name" => "Purchase - Shirt", "custom_props" => %{"product" => "Shirt"} } ) - insert(:goal, %{site: site, event_name: "Signup"}) + {:ok, _} = + Plausible.Goals.create( + site, + %{ + "event_name" => "Purchase", + "display_name" => "Purchase - Jacket", + "custom_props" => %{"product" => "Jacket"} + } + ) + + {:ok, _} = + Plausible.Goals.create( + site, + %{ + "event_name" => "Purchase", + "display_name" => "Purchase - All" + } + ) conn = get(conn, "/api/stats/#{site.domain}/conversions?period=day") response = json_response(conn, 200) results = response["results"] assert [ - %{"conversion_rate" => 50.0, "events" => 1, "name" => "Purchase", "visitors" => 1}, - %{"conversion_rate" => 50.0, "events" => 1, "name" => "Signup", "visitors" => 1} + %{ + "conversion_rate" => 100.0, + "events" => 2, + "name" => "Purchase - All", + "visitors" => 2 + }, + %{ + "conversion_rate" => 50.0, + "events" => 1, + "name" => "Purchase - Shirt", + "visitors" => 1 + }, + %{ + "conversion_rate" => 50.0, + "events" => 1, + "name" => "Purchase - Jacket", + "visitors" => 1 + } ] = results end From df98377523c78e840862efb84bed0f4bf90c0875 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 5 Feb 2026 13:01:02 +0100 Subject: [PATCH 4/4] Apply brute force fix Tracking the core issue at https://github.com/plausible/ecto_ch/issues/262 --- lib/plausible/stats/goals.ex | 4 --- lib/plausible/stats/sql/expression.ex | 16 ++++++--- .../api/stats_controller/conversions_test.exs | 34 ++++++++++++++++++- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/lib/plausible/stats/goals.ex b/lib/plausible/stats/goals.ex index 84960a6f58a4..806635f3509e 100644 --- a/lib/plausible/stats/goals.ex +++ b/lib/plausible/stats/goals.ex @@ -82,10 +82,6 @@ defmodule Plausible.Stats.Goals do goals = query.preloaded_goals.matching_toplevel_filters goals - # Sort goals, so that those with custom_props come first - # This ensures the first element in custom_props_keys/values arrays is non-empty - # preventing ClickHouse Array(Nothing) type inference error - |> Enum.sort_by(fn goal -> if goal.custom_props == %{}, do: :last, else: :first end) |> Enum.with_index(1) |> Enum.reduce( %{ diff --git a/lib/plausible/stats/sql/expression.ex b/lib/plausible/stats/sql/expression.ex index 1db9dcb386e2..0b00347c1cb0 100644 --- a/lib/plausible/stats/sql/expression.ex +++ b/lib/plausible/stats/sql/expression.ex @@ -467,8 +467,8 @@ defmodule Plausible.Stats.SQL.Expression do ?, ?, ?, - ?, - ? + arraySlice(?, 2), + arraySlice(?, 2) ) ) """, @@ -481,8 +481,16 @@ defmodule Plausible.Stats.SQL.Expression do 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).custom_props_keys, {:array, {:array, :string}}), - type(^unquote(goal_join_data).custom_props_values, {:array, {:array, :string}}) + # this is temporary until https://github.com/plausible/ecto_ch/issues/262 + # is resolved + type( + ^[["__TRICK_ECTO_CH__"] | unquote(goal_join_data).custom_props_keys], + {:array, {:array, :string}} + ), + type( + ^[["__TRICK_ECTO_CH__"] | unquote(goal_join_data).custom_props_values], + {:array, {:array, :string}} + ) ) end end diff --git a/test/plausible_web/controllers/api/stats_controller/conversions_test.exs b/test/plausible_web/controllers/api/stats_controller/conversions_test.exs index 4200827f29d5..f95e092a7f77 100644 --- a/test/plausible_web/controllers/api/stats_controller/conversions_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/conversions_test.exs @@ -524,7 +524,7 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do end @tag :ee_only - test "handles mixed goals with and without custom props", %{ + test "returns correct conversion stats for goals with and without custom properties", %{ conn: conn, site: site } do @@ -589,6 +589,38 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do results end + @tag :ee_only + test "handles mixed goals with and without custom props (2)", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, name: "Signup"), + build(:event, name: "Purchase", "meta.key": ["product"], "meta.value": ["Shirt"]) + ]) + + {:ok, _goal_with_props} = + Plausible.Goals.create( + site, + %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt"} + } + ) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = get(conn, "/api/stats/#{site.domain}/conversions?period=day") + response = json_response(conn, 200) + results = response["results"] + + assert [ + %{"conversion_rate" => 50.0, "events" => 1, "name" => "Purchase", "visitors" => 1}, + %{"conversion_rate" => 50.0, "events" => 1, "name" => "Signup", "visitors" => 1} + ] = + results + end + @tag :ee_only test "returns revenue metrics as nil for non-revenue goals", %{ conn: conn,