Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ All notable changes to this project will be documented in this file.
- Fixed issue with site guests in Editor role and team members in Editor role not being able to change the domain of site
- Fixed direct dashboard links that use legacy dashboard filters containing URL encoded special characters (e.g. character `ê` in the legacy filter `?page=%C3%AA`)
- Fix bug with tracker script config cache that made requests for certain cached scripts give error 500
- Fix issue with all non-interactive events being counted as interactive

## v3.1.0 - 2025-11-13

Expand Down
14 changes: 12 additions & 2 deletions lib/mix/tasks/send_pageview.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ defmodule Mix.Tasks.SendPageview do
@default_event "pageview"
@default_props "{}"
@default_queryparams ""
@default_interactive true
@options [
ip: :string,
user_agent: :string,
Expand All @@ -30,7 +31,8 @@ defmodule Mix.Tasks.SendPageview do
props: :string,
revenue_currency: :string,
revenue_amount: :string,
queryparams: :string
queryparams: :string,
interactive: :string
]

def run(opts) do
Expand Down Expand Up @@ -92,6 +94,13 @@ defmodule Mix.Tasks.SendPageview do
hostname = Keyword.get(opts, :hostname, domain)
queryparams = Keyword.get(opts, :queryparams, @default_queryparams)

interactive =
if Keyword.get(opts, :interactive) == "false" do
false
else
@default_interactive
end

revenue =
if Keyword.get(opts, :revenue_currency) do
%{
Expand All @@ -106,7 +115,8 @@ defmodule Mix.Tasks.SendPageview do
domain: domain,
referrer: referrer,
props: props,
revenue: revenue
revenue: revenue,
interactive: interactive
}
end

Expand Down
3 changes: 2 additions & 1 deletion lib/plausible/clickhouse_event_v2.ex
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ defmodule Plausible.ClickhouseEventV2 do
:revenue_source_amount,
:revenue_source_currency,
:revenue_reporting_amount,
:revenue_reporting_currency
:revenue_reporting_currency,
:interactive?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This here was the root cause. We correctly derived what the user wanted to set the interactive? value to, but we didn't allow it to pass to the event we built. It was ignored by ClickhouseEventV2.new/1 and the event was built with the default true value. This meant that logic to handle non-interactive events down the line never kicked in.

]
)
|> validate_required([:name, :site_id, :hostname, :pathname, :user_id, :timestamp])
Expand Down
101 changes: 101 additions & 0 deletions lib/plausible/event/system_events.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
defmodule Plausible.Event.SystemEvents do
@moduledoc """
System events are events that require at least one form of special treatment by the application.
They are distinguished by event name.

For some system events, the tracking API may reject events that don't match the expected format
(e.g. engagement events without scroll depth or engagement time defined).

For other system events, it may accept the event (e.g. "Outbound Link: Click" with the url prop not set).

System events may have corresponding system-managed goals, in which case the goal name and event name will be the same
(e.g. "Outbound Link: Click" goal is managed by the application). The system will only be able to manage these goals properly
if they are not renamed by the user.
"""
@pageview_event_name "pageview"
@engagement_event_name "engagement"

@outbound_link_click_event_name "Outbound Link: Click"
@cloaked_link_click_event_name "Cloaked Link: Click"
@file_download_link_click_event_name "File Download"

@error_404_event_name "404"
@wordpress_form_completions_event_name "WP Form Completions"
@form_submission_event_name "Form: Submission"

@all_system_events [
@pageview_event_name,
@engagement_event_name,
@outbound_link_click_event_name,
@cloaked_link_click_event_name,
@file_download_link_click_event_name,
@error_404_event_name,
@wordpress_form_completions_event_name,
@form_submission_event_name
]

@interactive_events @all_system_events

@events_with_url_prop [
@outbound_link_click_event_name,
@cloaked_link_click_event_name,
@file_download_link_click_event_name
]

@events_with_path_prop [
@error_404_event_name,
@wordpress_form_completions_event_name,
@form_submission_event_name
]

@events_with_engagement_props [
@engagement_event_name
]

def events() do
@all_system_events
end

def events_with_interactive_always_true() do
@interactive_events
end

def events_with_url_prop() do
@events_with_url_prop
end

def events_with_path_prop() do
@events_with_path_prop
end

def events_with_engagement_props() do
@events_with_engagement_props
end

@spec special_events_for_prop_key(String.t()) :: [String.t()]
def special_events_for_prop_key("url"), do: events_with_url_prop()
def special_events_for_prop_key("path"), do: events_with_path_prop()

@doc """
Checks if the event name is for a system event / system goal that should have the event.props.path synced with the event.pathname property.

### Examples
iex> sync_props_path_with_pathname?("404", [{"path", "/foo"}])
false

Note: Should not override event.props.path if it is set deliberately to nil
iex> sync_props_path_with_pathname?("404", [{"path", nil}])
false

iex> sync_props_path_with_pathname?("404", [{"other", "value"}])
true

iex> sync_props_path_with_pathname?("404", [])
true
"""
@spec sync_props_path_with_pathname?(String.t(), [{String.t(), String.t()}]) :: boolean()
def sync_props_path_with_pathname?(event_name, props_in_request) do
event_name in events_with_path_prop() and
not Enum.any?(props_in_request, fn {k, _} -> k == "path" end)
end
end
4 changes: 2 additions & 2 deletions lib/plausible/exports.ex
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,7 @@ defmodule Plausible.Exports do
fragment(
"if(? in ?, ?, '')",
e.name,
^Plausible.Goals.SystemGoals.goals_with_url(),
^Plausible.Event.SystemEvents.events_with_url_prop(),
get_by_key(e, :meta, "url")
),
:link_url
Expand All @@ -573,7 +573,7 @@ defmodule Plausible.Exports do
fragment(
"if(? in ?, ?, '')",
e.name,
^Plausible.Goals.SystemGoals.goals_with_path(),
^Plausible.Event.SystemEvents.events_with_path_prop(),
get_by_key(e, :meta, "path")
),
:path
Expand Down
48 changes: 0 additions & 48 deletions lib/plausible/goals/system_goals.ex

This file was deleted.

15 changes: 9 additions & 6 deletions lib/plausible/ingestion/request.ex
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ defmodule Plausible.Ingestion.Request do
end

defp maybe_set_props_path_to_pathname(props_in_request, changeset) do
if Plausible.Goals.SystemGoals.sync_props_path_with_pathname?(
if Plausible.Event.SystemEvents.sync_props_path_with_pathname?(
Changeset.get_field(changeset, :event_name),
props_in_request
) do
Expand Down Expand Up @@ -259,12 +259,15 @@ defmodule Plausible.Ingestion.Request do
end

defp put_interactive(changeset, %{} = request_body) do
case request_body["i"] || request_body["interactive"] do
interactive? when is_boolean(interactive?) ->
Changeset.put_change(changeset, :interactive?, interactive?)
can_be_marked_as_non_interactive? =
Changeset.get_field(changeset, :event_name) not in Plausible.Event.SystemEvents.events_with_interactive_always_true()

_ ->
changeset
if can_be_marked_as_non_interactive? and
(request_body["i"] == false or
request_body["interactive"] == false) do
Changeset.put_change(changeset, :interactive?, false)
else
changeset
end
end

Expand Down
15 changes: 10 additions & 5 deletions lib/plausible/stats/imported/base.ex
Original file line number Diff line number Diff line change
Expand Up @@ -123,21 +123,26 @@ defmodule Plausible.Stats.Imported.Base do
end

defp do_decide_custom_prop_table(query, property) do
has_required_name_filter? =
"event:props:" <> prop_key = property

has_required_event_or_goal_name_filter? =
query.filters
|> Enum.flat_map(fn
[:is, "event:name", names | _rest] -> names
[:is, "event:goal", names | _rest] -> names
[:is, "event:name", event_names | _rest] -> event_names
[:is, "event:goal", goal_names | _rest] -> goal_names
_ -> []
end)
|> Enum.any?(&(&1 in Plausible.Goals.SystemGoals.special_goals_for(property)))
|> Enum.any?(fn event_or_goal_name ->
event_or_goal_name in Plausible.Event.SystemEvents.special_events_for_prop_key(prop_key)
end)

has_unsupported_filters? =
query.filters
|> dimensions_used_in_filters()
|> Enum.any?(&(&1 not in [property, "event:name", "event:goal"]))

if has_required_name_filter? and not has_unsupported_filters? do
if has_required_event_or_goal_name_filter? and
not has_unsupported_filters? do
["imported_custom_events"]
else
[]
Expand Down
4 changes: 2 additions & 2 deletions lib/plausible/stats/imported/imported.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ defmodule Plausible.Stats.Imported do
Usually, when no filters are used, the imported schema supports the
query. There is one exception though - breakdown by a custom property.
We are currently importing only two custom properties - `url` and `path`.
Both these properties can only be used with their special goal filter
(see Plausible.Goals.SystemGoals).
Both these properties can only be used with their particular `event:name`
filter or the corresponding goal filter (see Plausible.Event.SystemEvents).
"""
def schema_supports_query?(query) do
length(Imported.Base.decide_tables(query)) > 0
Expand Down
5 changes: 5 additions & 0 deletions test/plausible/event/system_events_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
defmodule Plausible.Event.SystemEventsTest do
use ExUnit.Case, async: true

doctest Plausible.Event.SystemEvents, import: true
end
2 changes: 0 additions & 2 deletions test/plausible/goals_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ defmodule Plausible.GoalsTest do
use Plausible.DataCase
alias Plausible.Goals

doctest Plausible.Goals.SystemGoals, import: true

test "create/2 creates goals and trims input" do
site = new_site()
{:ok, goal} = Goals.create(site, %{"page_path" => "/foo bar "})
Expand Down
24 changes: 24 additions & 0 deletions test/plausible/ingestion/event_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,30 @@ defmodule Plausible.Ingestion.EventTest do
assert dropped.drop_reason == :no_session_for_engagement
end

for {input, expected} <- [
{0, true},
{nil, true},
{"invalid", true},
{true, true},
{false, false}
] do
test "parses events interactive value #{inspect(input)} to #{inspect(expected)}" do
site = new_site()

payload = %{
name: "ping",
url: "http://#{site.domain}",
interactive: unquote(input)
}

conn = build_conn(:post, "/api/events", payload)
assert {:ok, request, _conn} = Request.build(conn)

assert {:ok, %{buffered: [event], dropped: []}} = Event.build_and_buffer(request)
assert event.clickhouse_event.interactive? == unquote(expected)
end
end

@tag :ee_only
test "saves revenue amount" do
site = new_site()
Expand Down
Loading
Loading